@@ -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,23 @@ 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 ?
69
+ @Binding var userInteracted : Bool
60
70
61
71
@State private var nameIsSelected : Bool = false
62
- @State private var copyIsSelected : Bool = false
63
72
64
- private let defaultVisibleApps = 5
65
73
@State private var apps : [ WorkspaceApp ] = [ ]
66
74
75
+ var hasApps : Bool { !apps. isEmpty }
76
+
67
77
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)
72
79
73
80
var formattedName = AttributedString ( name)
74
81
formattedName. foregroundColor = . primary
@@ -79,17 +86,34 @@ struct MenuItemView: View {
79
86
return formattedName
80
87
}
81
88
89
+ private var isExpanded : Bool {
90
+ expandedItem == item. id
91
+ }
92
+
82
93
private var wsURL : URL {
83
94
// TODO: CoderVPN currently only supports owned workspaces
84
95
baseAccessURL. appending ( path: " @me " ) . appending ( path: item. wsName)
85
96
}
86
97
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
+
87
111
var body : some View {
88
112
VStack ( spacing: 0 ) {
89
- HStack ( spacing: 0 ) {
90
- Link ( destination : wsURL ) {
113
+ HStack ( spacing: 3 ) {
114
+ Button ( action : toggleExpanded ) {
91
115
HStack ( spacing: Theme . Size. trayPadding) {
92
- StatusDot ( color: item . status . color )
116
+ AnimatedChevron ( isExpanded : isExpanded , color: . secondary )
93
117
Text ( itemName) . lineLimit ( 1 ) . truncationMode ( . tail)
94
118
Spacer ( )
95
119
} . padding ( . horizontal, Theme . Size. trayPadding)
@@ -98,42 +122,24 @@ struct MenuItemView: View {
98
122
. foregroundStyle ( nameIsSelected ? . white : . primary)
99
123
. background ( nameIsSelected ? Color . accentColor. opacity ( 0.8 ) : . clear)
100
124
. clipShape ( . rect( cornerRadius: Theme . Size. rectCornerRadius) )
101
- . onHoverWithPointingHand { hovering in
125
+ . onHover { hovering in
102
126
nameIsSelected = hovering
103
127
}
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)
123
130
}
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 )
132
141
}
133
142
}
134
- . padding ( . leading, apps. count < defaultVisibleApps ? 14 : 0 )
135
- . padding ( . bottom, 5 )
136
- . padding ( . top, 10 )
137
143
}
138
144
}
139
145
. task { await loadApps ( ) }
@@ -172,3 +178,83 @@ struct MenuItemView: View {
172
178
}
173
179
}
174
180
}
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