Skip to content

Commit 5f067b6

Browse files
feat: add workspace apps (#136)
Closes #94. <img width="311" alt="Screenshot 2025-04-22 at 2 10 32 pm" src="https://github.com/user-attachments/assets/36e20e2e-49b4-4cbd-8bcc-e41840fdc45c" /> https://github.com/user-attachments/assets/0777d1c9-6183-487d-b24a-b2ad9639d75b The cursor does not change to a pointing hand as it should when screen-recording, and the display name of the app is also shown on hover: <img width="255" alt="image" src="https://github.com/user-attachments/assets/95c1f06b-b14a-457c-85a6-5a514b017def" /> As per the linked issue, this only shows the first five apps. If there's less than 5 apps, they won't be centered (I think this looks a bit better): <img width="325" alt="image" src="https://github.com/user-attachments/assets/348c1b46-f8d5-4a32-8ba6-eb03d8125344" /> Later designs will likely include a Workspace window where all the apps can be viewed, and potentially reordered to control what is shown on the tray. EDIT: Web apps have been filtered out of the above examples, as we don't currently have a way to determine whether they will work properly via Coder Connect.
1 parent 681a9a6 commit 5f067b6

File tree

10 files changed

+680
-37
lines changed

10 files changed

+680
-37
lines changed

Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift

+5
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import FluidMenuBarExtra
22
import NetworkExtension
3+
import SDWebImageSVGCoder
4+
import SDWebImageSwiftUI
35
import SwiftUI
46
import VPNLib
57

@@ -66,6 +68,9 @@ class AppDelegate: NSObject, NSApplicationDelegate {
6668
}
6769

6870
func applicationDidFinishLaunching(_: Notification) {
71+
// Init SVG loader
72+
SDImageCodersManager.shared.addCoder(SDImageSVGCoder.shared)
73+
6974
menuBar = .init(menuBarExtra: FluidMenuBarExtra(
7075
title: "Coder Desktop",
7176
image: "MenuBarIcon",

Coder-Desktop/Coder-Desktop/State.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ class AppState: ObservableObject {
3737
}
3838
}
3939

40-
private var client: Client?
40+
public var client: Client?
4141

4242
@Published var useLiteralHeaders: Bool = UserDefaults.standard.bool(forKey: Keys.useLiteralHeaders) {
4343
didSet {

Coder-Desktop/Coder-Desktop/Theme.swift

+4
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ enum Theme {
77
static let trayInset: CGFloat = trayMargin + trayPadding
88

99
static let rectCornerRadius: CGFloat = 4
10+
11+
static let appIconWidth: CGFloat = 30
12+
static let appIconHeight: CGFloat = 30
13+
static let appIconSize: CGSize = .init(width: appIconWidth, height: appIconHeight)
1014
}
1115

1216
static let defaultVisibleAgents = 5

Coder-Desktop/Coder-Desktop/Views/ResponsiveLink.swift

+1-6
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,8 @@ struct ResponsiveLink: View {
1313
.font(.subheadline)
1414
.foregroundColor(isPressed ? .red : .blue)
1515
.underline(isHovered, color: isPressed ? .red : .blue)
16-
.onHover { hovering in
16+
.onHoverWithPointingHand { hovering in
1717
isHovered = hovering
18-
if hovering {
19-
NSCursor.pointingHand.push()
20-
} else {
21-
NSCursor.pop()
22-
}
2318
}
2419
.simultaneousGesture(
2520
DragGesture(minimumDistance: 0)

Coder-Desktop/Coder-Desktop/Views/Util.swift

+13
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,16 @@ extension UUID {
3131
self.init(uuid: uuid)
3232
}
3333
}
34+
35+
public extension View {
36+
@inlinable nonisolated func onHoverWithPointingHand(perform action: @escaping (Bool) -> Void) -> some View {
37+
onHover { hovering in
38+
if hovering {
39+
NSCursor.pointingHand.push()
40+
} else {
41+
NSCursor.pop()
42+
}
43+
action(hovering)
44+
}
45+
}
46+
}

Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift

+98-30
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import CoderSDK
2+
import os
13
import 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 {
4453
struct 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

Comments
 (0)