1+ import CoderSDK
2+ import os
13import SwiftUI
24
35// Each row in the workspaces list is an agent or an offline workspace
@@ -26,6 +28,13 @@ enum VPNMenuItem: Equatable, Comparable, Identifiable {
2628 }
2729 }
2830
31+ var workspaceID : UUID {
32+ switch self {
33+ case let . agent( agent) : agent. wsID
34+ case let . offlineWorkspace( workspace) : workspace. id
35+ }
36+ }
37+
2938 static func < ( lhs: VPNMenuItem , rhs: VPNMenuItem ) -> Bool {
3039 switch ( lhs, rhs) {
3140 case let ( . agent( lhsAgent) , . agent( rhsAgent) ) :
@@ -44,11 +53,17 @@ enum VPNMenuItem: Equatable, Comparable, Identifiable {
4453struct MenuItemView : View {
4554 @EnvironmentObject var state : AppState
4655
56+ private let logger = Logger ( subsystem: Bundle . main. bundleIdentifier!, category: " VPNMenu " )
57+
4758 let item : VPNMenuItem
4859 let baseAccessURL : URL
60+
4961 @State private var nameIsSelected : Bool = false
5062 @State private var copyIsSelected : Bool = false
5163
64+ private let defaultVisibleApps = 5
65+ @State private var apps : [ WorkspaceApp ] = [ ]
66+
5267 private var itemName : AttributedString {
5368 let name = switch item {
5469 case let . agent( agent) : agent. primaryHost ?? " \( item. wsName) . \( state. hostnameSuffix) "
@@ -70,37 +85,90 @@ struct MenuItemView: View {
7085 }
7186
7287 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+ }
78104 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 " )
104172 }
105173 }
106174 }
0 commit comments