@@ -35,6 +35,13 @@ enum VPNMenuItem: Equatable, Comparable, Identifiable {
3535 }
3636 }
3737
38+ func primaryHost( hostnameSuffix: String ) -> String {
39+ switch self {
40+ case let . agent( agent) : agent. primaryHost
41+ case . offlineWorkspace: " \( wsName) . \( hostnameSuffix) "
42+ }
43+ }
44+
3845 static func < ( lhs: VPNMenuItem , rhs: VPNMenuItem ) -> Bool {
3946 switch ( lhs, rhs) {
4047 case let ( . agent( lhsAgent) , . agent( rhsAgent) ) :
@@ -52,23 +59,23 @@ enum VPNMenuItem: Equatable, Comparable, Identifiable {
5259
5360struct MenuItemView : View {
5461 @EnvironmentObject var state : AppState
62+ @Environment ( \. openURL) private var openURL
5563
5664 private let logger = Logger ( subsystem: Bundle . main. bundleIdentifier!, category: " VPNMenu " )
5765
5866 let item : VPNMenuItem
5967 let baseAccessURL : URL
68+ @Binding var expandedItem : VPNMenuItem . ID ?
69+ @Binding var userInteracted : Bool
6070
6171 @State private var nameIsSelected : Bool = false
62- @State private var copyIsSelected : Bool = false
6372
64- private let defaultVisibleApps = 5
6573 @State private var apps : [ WorkspaceApp ] = [ ]
6674
75+ var hasApps : Bool { !apps. isEmpty }
76+
6777 private var itemName : AttributedString {
68- let name = switch item {
69- case let . agent( agent) : agent. primaryHost
70- case . offlineWorkspace: " \( item. wsName) . \( state. hostnameSuffix) "
71- }
78+ let name = item. primaryHost ( hostnameSuffix: state. hostnameSuffix)
7279
7380 var formattedName = AttributedString ( name)
7481 formattedName. foregroundColor = . primary
@@ -79,17 +86,34 @@ struct MenuItemView: View {
7986 return formattedName
8087 }
8188
89+ private var isExpanded : Bool {
90+ expandedItem == item. id
91+ }
92+
8293 private var wsURL : URL {
8394 // TODO: CoderVPN currently only supports owned workspaces
8495 baseAccessURL. appending ( path: " @me " ) . appending ( path: item. wsName)
8596 }
8697
98+ private func toggleExpanded( ) {
99+ userInteracted = true
100+ if isExpanded {
101+ withAnimation ( . snappy( duration: Theme . Animation. collapsibleDuration) ) {
102+ expandedItem = nil
103+ }
104+ } else {
105+ withAnimation ( . snappy( duration: Theme . Animation. collapsibleDuration) ) {
106+ expandedItem = item. id
107+ }
108+ }
109+ }
110+
87111 var body : some View {
88112 VStack ( spacing: 0 ) {
89- HStack ( spacing: 0 ) {
90- Link ( destination : wsURL ) {
113+ HStack ( spacing: 3 ) {
114+ Button ( action : toggleExpanded ) {
91115 HStack ( spacing: Theme . Size. trayPadding) {
92- StatusDot ( color: item . status . color )
116+ AnimatedChevron ( isExpanded : isExpanded , color: . secondary )
93117 Text ( itemName) . lineLimit ( 1 ) . truncationMode ( . tail)
94118 Spacer ( )
95119 } . padding ( . horizontal, Theme . Size. trayPadding)
@@ -98,42 +122,24 @@ struct MenuItemView: View {
98122 . foregroundStyle ( nameIsSelected ? . white : . primary)
99123 . background ( nameIsSelected ? Color . accentColor. opacity ( 0.8 ) : . clear)
100124 . clipShape ( . rect( cornerRadius: Theme . Size. rectCornerRadius) )
101- . onHoverWithPointingHand { hovering in
125+ . onHover { hovering in
102126 nameIsSelected = hovering
103127 }
104- Spacer ( )
105- } . buttonStyle ( . plain)
106- if case let . agent( agent) = item {
107- Button {
108- NSPasteboard . general. clearContents ( )
109- NSPasteboard . general. setString ( agent. primaryHost, forType: . string)
110- } label: {
111- Image ( systemName: " doc.on.doc " )
112- . symbolVariant ( . fill)
113- . padding ( 3 )
114- . contentShape ( Rectangle ( ) )
115- } . foregroundStyle ( copyIsSelected ? . white : . primary)
116- . imageScale ( . small)
117- . background ( copyIsSelected ? Color . accentColor. opacity ( 0.8 ) : . clear)
118- . clipShape ( . rect( cornerRadius: Theme . Size. rectCornerRadius) )
119- . onHoverWithPointingHand { hovering in copyIsSelected = hovering }
120- . buttonStyle ( . plain)
121- . padding ( . trailing, Theme . Size. trayMargin)
122- }
128+ } . buttonStyle ( . plain) . padding ( . trailing, 3 )
129+ MenuItemIcons ( item: item, wsURL: wsURL)
123130 }
124- if !apps. isEmpty {
125- HStack ( spacing: 17 ) {
126- ForEach ( apps. prefix ( defaultVisibleApps) , id: \. id) { app in
127- WorkspaceAppIcon ( app: app)
128- . frame ( width: Theme . Size. appIconWidth, height: Theme . Size. appIconHeight)
129- }
130- if apps. count < defaultVisibleApps {
131- Spacer ( )
131+ if isExpanded {
132+ if hasApps {
133+ MenuItemCollapsibleView ( apps: apps)
134+ } else {
135+ HStack {
136+ Text ( item. status == . off ? " Workspace is offline. " : " No apps available. " )
137+ . font ( . body)
138+ . foregroundColor ( . secondary)
139+ . padding ( . horizontal, Theme . Size. trayInset)
140+ . padding ( . top, 7 )
132141 }
133142 }
134- . padding ( . leading, apps. count < defaultVisibleApps ? 14 : 0 )
135- . padding ( . bottom, 5 )
136- . padding ( . top, 10 )
137143 }
138144 }
139145 . task { await loadApps ( ) }
@@ -172,3 +178,83 @@ struct MenuItemView: View {
172178 }
173179 }
174180}
181+
182+ struct MenuItemCollapsibleView : View {
183+ private let defaultVisibleApps = 5
184+ let apps : [ WorkspaceApp ]
185+
186+ var body : some View {
187+ HStack ( spacing: 17 ) {
188+ ForEach ( apps. prefix ( defaultVisibleApps) , id: \. id) { app in
189+ WorkspaceAppIcon ( app: app)
190+ . frame ( width: Theme . Size. appIconWidth, height: Theme . Size. appIconHeight)
191+ }
192+ if apps. count < defaultVisibleApps {
193+ Spacer ( )
194+ }
195+ }
196+ . padding ( . leading, apps. count < defaultVisibleApps ? 14 : 0 )
197+ . padding ( . bottom, 5 )
198+ . padding ( . top, 10 )
199+ }
200+ }
201+
202+ struct MenuItemIcons : View {
203+ @EnvironmentObject var state : AppState
204+ @Environment ( \. openURL) private var openURL
205+
206+ let item : VPNMenuItem
207+ let wsURL : URL
208+
209+ @State private var copyIsSelected : Bool = false
210+ @State private var webIsSelected : Bool = false
211+
212+ func copyToClipboard( ) {
213+ let primaryHost = item. primaryHost ( hostnameSuffix: state. hostnameSuffix)
214+ NSPasteboard . general. clearContents ( )
215+ NSPasteboard . general. setString ( primaryHost, forType: . string)
216+ }
217+
218+ var body : some View {
219+ StatusDot ( color: item. status. color)
220+ . padding ( . trailing, 3 )
221+ . padding ( . top, 1 )
222+ MenuItemIconButton ( systemName: " doc.on.doc " , action: copyToClipboard)
223+ . font ( . system( size: 9 ) )
224+ . symbolVariant ( . fill)
225+ MenuItemIconButton ( systemName: " globe " , action: { openURL ( wsURL) } )
226+ . contentShape ( Rectangle ( ) )
227+ . font ( . system( size: 12 ) )
228+ . padding ( . trailing, Theme . Size. trayMargin)
229+ }
230+ }
231+
232+ struct MenuItemIconButton : View {
233+ let systemName : String
234+ @State var isSelected : Bool = false
235+ let action : @MainActor ( ) -> Void
236+
237+ var body : some View {
238+ Button ( action: action) {
239+ Image ( systemName: systemName)
240+ . padding ( 3 )
241+ . contentShape ( Rectangle ( ) )
242+ } . foregroundStyle ( isSelected ? . white : . primary)
243+ . background ( isSelected ? Color . accentColor. opacity ( 0.8 ) : . clear)
244+ . clipShape ( . rect( cornerRadius: Theme . Size. rectCornerRadius) )
245+ . onHover { hovering in isSelected = hovering }
246+ . buttonStyle ( . plain)
247+ }
248+ }
249+
250+ struct AnimatedChevron : View {
251+ let isExpanded : Bool
252+ let color : Color
253+
254+ var body : some View {
255+ Image ( systemName: " chevron.right " )
256+ . font ( . system( size: 12 , weight: . semibold) )
257+ . foregroundColor ( color)
258+ . rotationEffect ( . degrees( isExpanded ? 90 : 0 ) )
259+ }
260+ }
0 commit comments