1
+ import CoderSDK
2
+ import os
1
3
import SwiftUI
2
4
3
5
// Each row in the workspaces list is an agent or an offline workspace
@@ -26,6 +28,13 @@ enum VPNMenuItem: Equatable, Comparable, Identifiable {
26
28
}
27
29
}
28
30
31
+ var workspaceID : UUID {
32
+ switch self {
33
+ case let . agent( agent) : agent. wsID
34
+ case let . offlineWorkspace( workspace) : workspace. id
35
+ }
36
+ }
37
+
29
38
static func < ( lhs: VPNMenuItem , rhs: VPNMenuItem ) -> Bool {
30
39
switch ( lhs, rhs) {
31
40
case let ( . agent( lhsAgent) , . agent( rhsAgent) ) :
@@ -44,11 +53,17 @@ enum VPNMenuItem: Equatable, Comparable, Identifiable {
44
53
struct MenuItemView : View {
45
54
@EnvironmentObject var state : AppState
46
55
56
+ private let logger = Logger ( subsystem: Bundle . main. bundleIdentifier!, category: " VPNMenu " )
57
+
47
58
let item : VPNMenuItem
48
59
let baseAccessURL : URL
60
+
49
61
@State private var nameIsSelected : Bool = false
50
62
@State private var copyIsSelected : Bool = false
51
63
64
+ private let defaultVisibleApps = 5
65
+ @State private var apps : [ WorkspaceApp ] = [ ]
66
+
52
67
private var itemName : AttributedString {
53
68
let name = switch item {
54
69
case let . agent( agent) : agent. primaryHost ?? " \( item. wsName) . \( state. hostnameSuffix) "
@@ -70,37 +85,90 @@ struct MenuItemView: View {
70
85
}
71
86
72
87
var body : some View {
73
- HStack ( spacing: 0 ) {
74
- Link ( destination: wsURL) {
75
- HStack ( spacing: Theme . Size. trayPadding) {
76
- StatusDot ( color: item. status. color)
77
- Text ( itemName) . lineLimit ( 1 ) . truncationMode ( . tail)
88
+ VStack ( spacing: 0 ) {
89
+ HStack ( spacing: 0 ) {
90
+ Link ( destination: wsURL) {
91
+ HStack ( spacing: Theme . Size. trayPadding) {
92
+ StatusDot ( color: item. status. color)
93
+ Text ( itemName) . lineLimit ( 1 ) . truncationMode ( . tail)
94
+ Spacer ( )
95
+ } . padding ( . horizontal, Theme . Size. trayPadding)
96
+ . frame ( minHeight: 22 )
97
+ . frame ( maxWidth: . infinity, alignment: . leading)
98
+ . foregroundStyle ( nameIsSelected ? . white : . primary)
99
+ . background ( nameIsSelected ? Color . accentColor. opacity ( 0.8 ) : . clear)
100
+ . clipShape ( . rect( cornerRadius: Theme . Size. rectCornerRadius) )
101
+ . onHoverWithPointingHand { hovering in
102
+ nameIsSelected = hovering
103
+ }
78
104
Spacer ( )
79
- } . padding ( . horizontal, Theme . Size. trayPadding)
80
- . frame ( minHeight: 22 )
81
- . frame ( maxWidth: . infinity, alignment: . leading)
82
- . foregroundStyle ( nameIsSelected ? . white : . primary)
83
- . background ( nameIsSelected ? Color . accentColor. opacity ( 0.8 ) : . clear)
84
- . clipShape ( . rect( cornerRadius: Theme . Size. rectCornerRadius) )
85
- . onHover { hovering in nameIsSelected = hovering }
86
- Spacer ( )
87
- } . buttonStyle ( . plain)
88
- if case let . agent( agent) = item, let copyableDNS = agent. primaryHost {
89
- Button {
90
- NSPasteboard . general. clearContents ( )
91
- NSPasteboard . general. setString ( copyableDNS, forType: . string)
92
- } label: {
93
- Image ( systemName: " doc.on.doc " )
94
- . symbolVariant ( . fill)
95
- . padding ( 3 )
96
- . contentShape ( Rectangle ( ) )
97
- } . foregroundStyle ( copyIsSelected ? . white : . primary)
98
- . imageScale ( . small)
99
- . background ( copyIsSelected ? Color . accentColor. opacity ( 0.8 ) : . clear)
100
- . clipShape ( . rect( cornerRadius: Theme . Size. rectCornerRadius) )
101
- . onHover { hovering in copyIsSelected = hovering }
102
- . buttonStyle ( . plain)
103
- . padding ( . trailing, Theme . Size. trayMargin)
105
+ } . buttonStyle ( . plain)
106
+ if case let . agent( agent) = item, let copyableDNS = agent. primaryHost {
107
+ Button {
108
+ NSPasteboard . general. clearContents ( )
109
+ NSPasteboard . general. setString ( copyableDNS, 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
+ }
123
+ }
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 ( )
132
+ }
133
+ }
134
+ . padding ( . leading, apps. count < defaultVisibleApps ? 14 : 0 )
135
+ . padding ( . bottom, 5 )
136
+ . padding ( . top, 10 )
137
+ }
138
+ }
139
+ . task { await loadApps ( ) }
140
+ }
141
+
142
+ func loadApps( ) async {
143
+ // If this menu item is an agent, and the user is logged in
144
+ if case let . agent( agent) = item,
145
+ let client = state. client,
146
+ let host = agent. primaryHost,
147
+ let baseAccessURL = state. baseAccessURL,
148
+ // Like the CLI, we'll re-use the existing session token to populate the URL
149
+ let sessionToken = state. sessionToken
150
+ {
151
+ let workspace : CoderSDK . Workspace
152
+ do {
153
+ workspace = try await retry ( floor: . milliseconds( 100 ) , ceil: . seconds( 10 ) ) {
154
+ do {
155
+ return try await client. workspace ( item. workspaceID)
156
+ } catch {
157
+ logger. error ( " Failed to load apps for workspace \( item. wsName) : \( error. localizedDescription) " )
158
+ throw error
159
+ }
160
+ }
161
+ } catch { return } // Task cancelled
162
+
163
+ if let wsAgent = workspace
164
+ . latest_build. resources
165
+ . compactMap ( \. agents)
166
+ . flatMap ( \. self)
167
+ . first ( where: { $0. id == agent. id } )
168
+ {
169
+ apps = agentToApps ( logger, wsAgent, host, baseAccessURL, sessionToken)
170
+ } else {
171
+ logger. error ( " Could not find agent ' \( agent. id) ' in workspace ' \( item. wsName) ' resources " )
104
172
}
105
173
}
106
174
}
0 commit comments