Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

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 ourterms of service andprivacy statement. We’ll occasionally send you account related emails.

Already on GitHub?Sign in to your account

Merged
ethanndickson merged 8 commits intomainfromethan/workspace-apps
May 1, 2025
Merged
Show file tree
Hide file tree
Changes fromall 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 deletionsCoder-Desktop/Coder-Desktop/Coder_DesktopApp.swift
View file
Open in desktop
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
import FluidMenuBarExtra
import NetworkExtension
import SDWebImageSVGCoder
import SDWebImageSwiftUI
import SwiftUI
import VPNLib

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

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

@ethanndicksonethanndicksonApr 22, 2025
edited
Loading

Choose a reason for hiding this comment

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

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

I also found thishttps://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 deletionCoder-Desktop/Coder-Desktop/State.swift
View file
Open in desktop
Original file line numberDiff line numberDiff 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 deletionsCoder-Desktop/Coder-Desktop/Theme.swift
View file
Open in desktop
Original file line numberDiff line numberDiff 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 deletionsCoder-Desktop/Coder-Desktop/Views/ResponsiveLink.swift
View file
Open in desktop
Original file line numberDiff line numberDiff 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 deletionsCoder-Desktop/Coder-Desktop/Views/Util.swift
View file
Open in desktop
Original file line numberDiff line numberDiff 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 deletionsCoder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift
View file
Open in desktop
Original file line numberDiff line numberDiff 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 DownExpand 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
MemberAuthor

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

[8]ページ先頭

©2009-2025 Movatter.jp