@@ -35,6 +35,13 @@ enum VPNMenuItem: Equatable, Comparable, Identifiable {
35
35
}
36
36
}
37
37
38
+ func primaryHost( hostnameSuffix: String ) -> String {
39
+ switch self {
40
+ case let . agent( agent) : agent. primaryHost
41
+ case . offlineWorkspace: " \( wsName) . \( hostnameSuffix) "
42
+ }
43
+ }
44
+
38
45
static func < ( lhs: VPNMenuItem , rhs: VPNMenuItem ) -> Bool {
39
46
switch ( lhs, rhs) {
40
47
case let ( . agent( lhsAgent) , . agent( rhsAgent) ) :
@@ -52,23 +59,22 @@ enum VPNMenuItem: Equatable, Comparable, Identifiable {
52
59
53
60
struct MenuItemView : View {
54
61
@EnvironmentObject var state : AppState
62
+ @Environment ( \. openURL) private var openURL
55
63
56
64
private let logger = Logger ( subsystem: Bundle . main. bundleIdentifier!, category: " VPNMenu " )
57
65
58
66
let item : VPNMenuItem
59
67
let baseAccessURL : URL
68
+ @Binding var expandedItem : VPNMenuItem . ID ?
60
69
61
70
@State private var nameIsSelected : Bool = false
62
- @State private var copyIsSelected : Bool = false
63
71
64
- private let defaultVisibleApps = 5
65
72
@State private var apps : [ WorkspaceApp ] = [ ]
66
73
74
+ var hasApps : Bool { !apps. isEmpty }
75
+
67
76
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)
72
78
73
79
var formattedName = AttributedString ( name)
74
80
formattedName. foregroundColor = . primary
@@ -79,17 +85,33 @@ struct MenuItemView: View {
79
85
return formattedName
80
86
}
81
87
88
+ private var isExpanded : Bool {
89
+ expandedItem == item. id
90
+ }
91
+
82
92
private var wsURL : URL {
83
93
// TODO: CoderVPN currently only supports owned workspaces
84
94
baseAccessURL. appending ( path: " @me " ) . appending ( path: item. wsName)
85
95
}
86
96
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
+
87
109
var body : some View {
88
110
VStack ( spacing: 0 ) {
89
- HStack ( spacing: 0 ) {
90
- Link ( destination : wsURL ) {
111
+ HStack ( spacing: 3 ) {
112
+ Button ( action : toggleExpanded ) {
91
113
HStack ( spacing: Theme . Size. trayPadding) {
92
- StatusDot ( color: item . status . color )
114
+ AnimatedChevron ( isExpanded : isExpanded , color: . secondary )
93
115
Text ( itemName) . lineLimit ( 1 ) . truncationMode ( . tail)
94
116
Spacer ( )
95
117
} . padding ( . horizontal, Theme . Size. trayPadding)
@@ -98,42 +120,24 @@ struct MenuItemView: View {
98
120
. foregroundStyle ( nameIsSelected ? . white : . primary)
99
121
. background ( nameIsSelected ? Color . accentColor. opacity ( 0.8 ) : . clear)
100
122
. clipShape ( . rect( cornerRadius: Theme . Size. rectCornerRadius) )
101
- . onHoverWithPointingHand { hovering in
123
+ . onHover { hovering in
102
124
nameIsSelected = hovering
103
125
}
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)
123
128
}
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 )
132
139
}
133
140
}
134
- . padding ( . leading, apps. count < defaultVisibleApps ? 14 : 0 )
135
- . padding ( . bottom, 5 )
136
- . padding ( . top, 10 )
137
141
}
138
142
}
139
143
. task { await loadApps ( ) }
@@ -172,3 +176,99 @@ struct MenuItemView: View {
172
176
}
173
177
}
174
178
}
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