@@ -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,22 @@ 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 ?
6069
6170 @State private var nameIsSelected : Bool = false
62- @State private var copyIsSelected : Bool = false
6371
64- private let defaultVisibleApps = 5
6572 @State private var apps : [ WorkspaceApp ] = [ ]
6673
74+ var hasApps : Bool { !apps. isEmpty }
75+
6776 private var itemName : AttributedString {
68- let name = switch item {
69- case let . agent( agent) : agent. primaryHost
70- case . offlineWorkspace: " \( item. wsName) . \( state. hostnameSuffix) "
71- }
77+ let name = item. primaryHost ( hostnameSuffix: state. hostnameSuffix)
7278
7379 var formattedName = AttributedString ( name)
7480 formattedName. foregroundColor = . primary
@@ -79,17 +85,33 @@ struct MenuItemView: View {
7985 return formattedName
8086 }
8187
88+ private var isExpanded : Bool {
89+ expandedItem == item. id
90+ }
91+
8292 private var wsURL : URL {
8393 // TODO: CoderVPN currently only supports owned workspaces
8494 baseAccessURL. appending ( path: " @me " ) . appending ( path: item. wsName)
8595 }
8696
97+ private func toggleExpanded( ) {
98+ if isExpanded {
99+ withAnimation ( . snappy( duration: Theme . Animation. collapsibleDuration) ) {
100+ expandedItem = nil
101+ }
102+ } else {
103+ withAnimation ( . snappy( duration: Theme . Animation. collapsibleDuration) ) {
104+ expandedItem = item. id
105+ }
106+ }
107+ }
108+
87109 var body : some View {
88110 VStack ( spacing: 0 ) {
89- HStack ( spacing: 0 ) {
90- Link ( destination : wsURL ) {
111+ HStack ( spacing: 3 ) {
112+ Button ( action : toggleExpanded ) {
91113 HStack ( spacing: Theme . Size. trayPadding) {
92- StatusDot ( color: item . status . color )
114+ AnimatedChevron ( isExpanded : isExpanded , color: . secondary )
93115 Text ( itemName) . lineLimit ( 1 ) . truncationMode ( . tail)
94116 Spacer ( )
95117 } . padding ( . horizontal, Theme . Size. trayPadding)
@@ -98,42 +120,24 @@ struct MenuItemView: View {
98120 . foregroundStyle ( nameIsSelected ? . white : . primary)
99121 . background ( nameIsSelected ? Color . accentColor. opacity ( 0.8 ) : . clear)
100122 . clipShape ( . rect( cornerRadius: Theme . Size. rectCornerRadius) )
101- . onHoverWithPointingHand { hovering in
123+ . onHover { hovering in
102124 nameIsSelected = hovering
103125 }
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- }
126+ } . buttonStyle ( . plain) . padding ( . trailing, 3 )
127+ MenuItemIcons ( item: item, wsURL: wsURL)
123128 }
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 ( )
129+ if isExpanded {
130+ if hasApps {
131+ MenuItemCollapsibleView ( apps: apps)
132+ } else {
133+ HStack {
134+ Text ( item. status == . off ? " Workspace is offline. " : " No apps available. " )
135+ . font ( . body)
136+ . foregroundColor ( . secondary)
137+ . padding ( . horizontal, Theme . Size. trayInset)
138+ . padding ( . top, 7 )
132139 }
133140 }
134- . padding ( . leading, apps. count < defaultVisibleApps ? 14 : 0 )
135- . padding ( . bottom, 5 )
136- . padding ( . top, 10 )
137141 }
138142 }
139143 . task { await loadApps ( ) }
@@ -172,3 +176,99 @@ struct MenuItemView: View {
172176 }
173177 }
174178}
179+
180+ struct MenuItemCollapsibleView : View {
181+ private let defaultVisibleApps = 5
182+ let apps : [ WorkspaceApp ]
183+
184+ var body : some View {
185+ HStack ( spacing: 17 ) {
186+ ForEach ( apps. prefix ( defaultVisibleApps) , id: \. id) { app in
187+ WorkspaceAppIcon ( app: app)
188+ . frame ( width: Theme . Size. appIconWidth, height: Theme . Size. appIconHeight)
189+ }
190+ if apps. count < defaultVisibleApps {
191+ Spacer ( )
192+ }
193+ }
194+ . padding ( . leading, apps. count < defaultVisibleApps ? 14 : 0 )
195+ . padding ( . bottom, 5 )
196+ . padding ( . top, 10 )
197+ }
198+ }
199+
200+ struct MenuItemIcons : View {
201+ @EnvironmentObject var state : AppState
202+ @Environment ( \. openURL) private var openURL
203+
204+ let item : VPNMenuItem
205+ let wsURL : URL
206+
207+ @State private var copyIsSelected : Bool = false
208+ @State private var webIsSelected : Bool = false
209+
210+ func copyToClipboard( ) {
211+ let primaryHost = item. primaryHost ( hostnameSuffix: state. hostnameSuffix)
212+ NSPasteboard . general. clearContents ( )
213+ NSPasteboard . general. setString ( primaryHost, forType: . string)
214+ }
215+
216+ var body : some View {
217+ StatusDot ( color: item. status. color)
218+ . padding ( . trailing, 3 )
219+ . padding ( . top, 1 )
220+ MenuItemIconButton ( systemName: " doc.on.doc " , action: copyToClipboard)
221+ . font ( . system( size: 9 ) )
222+ . symbolVariant ( . fill)
223+ MenuItemIconButton ( systemName: " globe " , action: { openURL ( wsURL) } )
224+ . contentShape ( Rectangle ( ) )
225+ . font ( . system( size: 12 ) )
226+ . padding ( . trailing, Theme . Size. trayMargin)
227+ }
228+ }
229+
230+ struct MenuItemIconButton : View {
231+ let systemName : String
232+ @State var isSelected : Bool = false
233+ let action : @MainActor ( ) -> Void
234+
235+ var body : some View {
236+ Button {
237+ action ( )
238+ } label: {
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+ . animation ( . easeInOut( duration: Theme . Animation. collapsibleDuration) , value: isExpanded)
260+ }
261+ }
262+
263+ #if DEBUG
264+ #Preview {
265+ let appState = AppState ( persistent: false )
266+ appState. login ( baseAccessURL: URL ( string: " http://127.0.0.1:8080 " ) !, sessionToken: " " )
267+ // appState.clearSession()
268+
269+ return VPNMenu < PreviewVPN , PreviewFileSync > ( ) . frame ( width: 256 )
270+ . environmentObject ( PreviewVPN ( ) )
271+ . environmentObject ( appState)
272+ . environmentObject ( PreviewFileSync ( ) )
273+ }
274+ #endif
0 commit comments