Skip to content

feat: add workspace apps #136

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import FluidMenuBarExtra
import NetworkExtension
import SDWebImageSVGCoder
import SDWebImageSwiftUI
import SwiftUI
import VPNLib

Expand Down Expand Up @@ -66,6 +68,9 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}

func applicationDidFinishLaunching(_: Notification) {
// Init SVG loader
SDImageCodersManager.shared.addCoder(SDImageSVGCoder.shared)
Copy link
Member Author

@ethanndickson ethanndickson Apr 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SwiftUI has a great AsyncImage View, but it doesn't support svgs. FWICT there's not even a public macOS API for rendering them.

I also found this https://gist.github.com/erezhod/6e8e6af3c940d88a706a9d936c8838e6, but it doesn't have a license attached, and I didn't feel like reaching out to the two separate authors to ask.


menuBar = .init(menuBarExtra: FluidMenuBarExtra(
title: "Coder Desktop",
image: "MenuBarIcon",
Expand Down
2 changes: 1 addition & 1 deletion Coder-Desktop/Coder-Desktop/State.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ class AppState: ObservableObject {
}
}

private var client: Client?
public var client: Client?

@Published var useLiteralHeaders: Bool = UserDefaults.standard.bool(forKey: Keys.useLiteralHeaders) {
didSet {
Expand Down
4 changes: 4 additions & 0 deletions Coder-Desktop/Coder-Desktop/Theme.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ enum Theme {
static let trayInset: CGFloat = trayMargin + trayPadding

static let rectCornerRadius: CGFloat = 4

static let appIconWidth: CGFloat = 30
static let appIconHeight: CGFloat = 30
static let appIconSize: CGSize = .init(width: appIconWidth, height: appIconHeight)
}

static let defaultVisibleAgents = 5
Expand Down
7 changes: 1 addition & 6 deletions Coder-Desktop/Coder-Desktop/Views/ResponsiveLink.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,8 @@ struct ResponsiveLink: View {
.font(.subheadline)
.foregroundColor(isPressed ? .red : .blue)
.underline(isHovered, color: isPressed ? .red : .blue)
.onHover { hovering in
.onHoverWithPointingHand { hovering in
isHovered = hovering
if hovering {
NSCursor.pointingHand.push()
} else {
NSCursor.pop()
}
}
.simultaneousGesture(
DragGesture(minimumDistance: 0)
Expand Down
13 changes: 13 additions & 0 deletions Coder-Desktop/Coder-Desktop/Views/Util.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,16 @@ extension UUID {
self.init(uuid: uuid)
}
}

public extension View {
@inlinable nonisolated func onHoverWithPointingHand(perform action: @escaping (Bool) -> Void) -> some View {
onHover { hovering in
if hovering {
NSCursor.pointingHand.push()
} else {
NSCursor.pop()
}
action(hovering)
}
}
}
128 changes: 98 additions & 30 deletions Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import CoderSDK
import os
import SwiftUI

// Each row in the workspaces list is an agent or an offline workspace
Expand Down Expand Up @@ -26,6 +28,13 @@ enum VPNMenuItem: Equatable, Comparable, Identifiable {
}
}

var workspaceID: UUID {
switch self {
case let .agent(agent): agent.wsID
case let .offlineWorkspace(workspace): workspace.id
}
}

static func < (lhs: VPNMenuItem, rhs: VPNMenuItem) -> Bool {
switch (lhs, rhs) {
case let (.agent(lhsAgent), .agent(rhsAgent)):
Expand All @@ -44,11 +53,17 @@ enum VPNMenuItem: Equatable, Comparable, Identifiable {
struct MenuItemView: View {
@EnvironmentObject var state: AppState

private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "VPNMenu")

let item: VPNMenuItem
let baseAccessURL: URL

@State private var nameIsSelected: Bool = false
@State private var copyIsSelected: Bool = false

private let defaultVisibleApps = 5
@State private var apps: [WorkspaceApp] = []

private var itemName: AttributedString {
let name = switch item {
case let .agent(agent): agent.primaryHost ?? "\(item.wsName).\(state.hostnameSuffix)"
Expand All @@ -70,37 +85,90 @@ struct MenuItemView: View {
}

var body: some View {
HStack(spacing: 0) {
Link(destination: wsURL) {
HStack(spacing: Theme.Size.trayPadding) {
StatusDot(color: item.status.color)
Text(itemName).lineLimit(1).truncationMode(.tail)
VStack(spacing: 0) {
HStack(spacing: 0) {
Link(destination: wsURL) {
HStack(spacing: Theme.Size.trayPadding) {
StatusDot(color: item.status.color)
Text(itemName).lineLimit(1).truncationMode(.tail)
Spacer()
}.padding(.horizontal, Theme.Size.trayPadding)
.frame(minHeight: 22)
.frame(maxWidth: .infinity, alignment: .leading)
.foregroundStyle(nameIsSelected ? .white : .primary)
.background(nameIsSelected ? Color.accentColor.opacity(0.8) : .clear)
.clipShape(.rect(cornerRadius: Theme.Size.rectCornerRadius))
.onHoverWithPointingHand { hovering in
nameIsSelected = hovering
}
Spacer()
}.padding(.horizontal, Theme.Size.trayPadding)
.frame(minHeight: 22)
.frame(maxWidth: .infinity, alignment: .leading)
.foregroundStyle(nameIsSelected ? .white : .primary)
.background(nameIsSelected ? Color.accentColor.opacity(0.8) : .clear)
.clipShape(.rect(cornerRadius: Theme.Size.rectCornerRadius))
.onHover { hovering in nameIsSelected = hovering }
Spacer()
}.buttonStyle(.plain)
if case let .agent(agent) = item, let copyableDNS = agent.primaryHost {
Button {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(copyableDNS, forType: .string)
} label: {
Image(systemName: "doc.on.doc")
.symbolVariant(.fill)
.padding(3)
.contentShape(Rectangle())
}.foregroundStyle(copyIsSelected ? .white : .primary)
.imageScale(.small)
.background(copyIsSelected ? Color.accentColor.opacity(0.8) : .clear)
.clipShape(.rect(cornerRadius: Theme.Size.rectCornerRadius))
.onHover { hovering in copyIsSelected = hovering }
.buttonStyle(.plain)
.padding(.trailing, Theme.Size.trayMargin)
}.buttonStyle(.plain)
if case let .agent(agent) = item, let copyableDNS = agent.primaryHost {
Button {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(copyableDNS, forType: .string)
} label: {
Image(systemName: "doc.on.doc")
.symbolVariant(.fill)
.padding(3)
.contentShape(Rectangle())
}.foregroundStyle(copyIsSelected ? .white : .primary)
.imageScale(.small)
.background(copyIsSelected ? Color.accentColor.opacity(0.8) : .clear)
.clipShape(.rect(cornerRadius: Theme.Size.rectCornerRadius))
.onHoverWithPointingHand { hovering in copyIsSelected = hovering }
.buttonStyle(.plain)
.padding(.trailing, Theme.Size.trayMargin)
}
}
if !apps.isEmpty {
HStack(spacing: 17) {
ForEach(apps.prefix(defaultVisibleApps), id: \.id) { app in
WorkspaceAppIcon(app: app)
.frame(width: Theme.Size.appIconWidth, height: Theme.Size.appIconHeight)
}
if apps.count < defaultVisibleApps {
Spacer()
}
}
.padding(.leading, apps.count < defaultVisibleApps ? 14 : 0)
.padding(.bottom, 5)
.padding(.top, 10)
}
}
.task { await loadApps() }
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.task on a view ensures for each instance of the view, only one copy of the task is running at any given time, and if the view re-renders (such as a workspace going offline), the existing task will be cancelled, and a new one created, which is exactly what we want.

}

func loadApps() async {
// If this menu item is an agent, and the user is logged in
if case let .agent(agent) = item,
let client = state.client,
let host = agent.primaryHost,
let baseAccessURL = state.baseAccessURL,
// Like the CLI, we'll re-use the existing session token to populate the URL
let sessionToken = state.sessionToken
{
let workspace: CoderSDK.Workspace
do {
workspace = try await retry(floor: .milliseconds(100), ceil: .seconds(10)) {
do {
return try await client.workspace(item.workspaceID)
} catch {
logger.error("Failed to load apps for workspace \(item.wsName): \(error.localizedDescription)")
throw error
}
}
} catch { return } // Task cancelled

if let wsAgent = workspace
.latest_build.resources
.compactMap(\.agents)
.flatMap(\.self)
.first(where: { $0.id == agent.id })
{
apps = agentToApps(logger, wsAgent, host, baseAccessURL, sessionToken)
} else {
logger.error("Could not find agent '\(agent.id)' in workspace '\(item.wsName)' resources")
}
}
}
Expand Down
Loading
Loading