Skip to content

feat: make workspace apps collapsible #143

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 4 commits into
base: ethan/sync-progress
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 2 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
4 changes: 4 additions & 0 deletions Coder-Desktop/Coder-Desktop/Theme.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,9 @@ enum Theme {
static let appIconSize: CGSize = .init(width: appIconWidth, height: appIconHeight)
}

enum Animation {
static let collapsibleDuration = 0.2
}

static let defaultVisibleAgents = 5
}
2 changes: 1 addition & 1 deletion Coder-Desktop/Coder-Desktop/VPN/MenuState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ struct Agent: Identifiable, Equatable, Comparable, Hashable {
let wsName: String
let wsID: UUID

// Agents are sorted by status, and then by name
// Agents are sorted by stauts, and then by name
static func < (lhs: Agent, rhs: Agent) -> Bool {
if lhs.status != rhs.status {
return lhs.status < rhs.status
Expand Down
3 changes: 2 additions & 1 deletion Coder-Desktop/Coder-Desktop/Views/VPN/Agents.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ struct Agents<VPN: VPNService>: View {
@EnvironmentObject var vpn: VPN
@EnvironmentObject var state: AppState
@State private var viewAll = false
@State private var expandedItem: VPNMenuItem.ID?
Copy link
Member

Choose a reason for hiding this comment

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

It seems odd to me having individual collapsible chevrons for each item, but only one item can be expanded at a time. I think we should just allow all items to be expanded independently

Copy link
Member Author

@ethanndickson ethanndickson Apr 30, 2025

Choose a reason for hiding this comment

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

This is just a pattern I've seen in the past that I liked. I'm pretty sure designers do it to stop the user from cluttering up the UI, or making the page too long, which is why I've done it here too. A user doesn't really need to be able to view multiple at once, and having to go close them all yourself to tidy it up is annoying.

private let defaultVisibleRows = 5

let inspection = Inspection<Self>()
Expand All @@ -15,7 +16,7 @@ struct Agents<VPN: VPNService>: View {
let items = vpn.menuState.sorted
let visibleItems = viewAll ? items[...] : items.prefix(defaultVisibleRows)
ForEach(visibleItems, id: \.id) { agent in
MenuItemView(item: agent, baseAccessURL: state.baseAccessURL!)
MenuItemView(item: agent, baseAccessURL: state.baseAccessURL!, expandedItem: $expandedItem)
.padding(.horizontal, Theme.Size.trayMargin)
}
if items.count == 0 {
Expand Down
165 changes: 125 additions & 40 deletions Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ enum VPNMenuItem: Equatable, Comparable, Identifiable {
}
}

func primaryHost(hostnameSuffix: String) -> String {
switch self {
case let .agent(agent): agent.primaryHost
case .offlineWorkspace: "\(wsName).\(hostnameSuffix)"
}
}

static func < (lhs: VPNMenuItem, rhs: VPNMenuItem) -> Bool {
switch (lhs, rhs) {
case let (.agent(lhsAgent), .agent(rhsAgent)):
Expand All @@ -52,23 +59,22 @@ enum VPNMenuItem: Equatable, Comparable, Identifiable {

struct MenuItemView: View {
@EnvironmentObject var state: AppState
@Environment(\.openURL) private var openURL

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

let item: VPNMenuItem
let baseAccessURL: URL
@Binding var expandedItem: VPNMenuItem.ID?

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

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

var hasApps: Bool { !apps.isEmpty }

private var itemName: AttributedString {
let name = switch item {
case let .agent(agent): agent.primaryHost
case .offlineWorkspace: "\(item.wsName).\(state.hostnameSuffix)"
}
let name = item.primaryHost(hostnameSuffix: state.hostnameSuffix)

var formattedName = AttributedString(name)
formattedName.foregroundColor = .primary
Expand All @@ -79,17 +85,33 @@ struct MenuItemView: View {
return formattedName
}

private var isExpanded: Bool {
expandedItem == item.id
}

private var wsURL: URL {
// TODO: CoderVPN currently only supports owned workspaces
baseAccessURL.appending(path: "@me").appending(path: item.wsName)
}

private func toggleExpanded() {
if isExpanded {
withAnimation(.snappy(duration: Theme.Animation.collapsibleDuration)) {
expandedItem = nil
}
} else {
withAnimation(.snappy(duration: Theme.Animation.collapsibleDuration)) {
expandedItem = item.id
}
}
}

var body: some View {
VStack(spacing: 0) {
HStack(spacing: 0) {
Link(destination: wsURL) {
HStack(spacing: 3) {
Button(action: toggleExpanded) {
HStack(spacing: Theme.Size.trayPadding) {
StatusDot(color: item.status.color)
AnimatedChevron(isExpanded: isExpanded, color: .secondary)
Text(itemName).lineLimit(1).truncationMode(.tail)
Spacer()
}.padding(.horizontal, Theme.Size.trayPadding)
Expand All @@ -98,42 +120,24 @@ struct MenuItemView: View {
.foregroundStyle(nameIsSelected ? .white : .primary)
.background(nameIsSelected ? Color.accentColor.opacity(0.8) : .clear)
.clipShape(.rect(cornerRadius: Theme.Size.rectCornerRadius))
.onHoverWithPointingHand { hovering in
.onHover { hovering in
nameIsSelected = hovering
}
Spacer()
}.buttonStyle(.plain)
if case let .agent(agent) = item {
Button {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(agent.primaryHost, 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)
}
}.buttonStyle(.plain).padding(.trailing, 3)
MenuItemIcons(item: item, wsURL: wsURL)
}
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()
if isExpanded {
if hasApps {
MenuItemCollapsibleView(apps: apps)
} else {
HStack {
Text(item.status == .off ? "Workspace is offline." : "No apps available.")
.font(.body)
.foregroundColor(.secondary)
.padding(.horizontal, Theme.Size.trayInset)
.padding(.top, 7)
}
}
.padding(.leading, apps.count < defaultVisibleApps ? 14 : 0)
.padding(.bottom, 5)
.padding(.top, 10)
}
}
.task { await loadApps() }
Expand Down Expand Up @@ -172,3 +176,84 @@ struct MenuItemView: View {
}
}
}

struct MenuItemCollapsibleView: View {
private let defaultVisibleApps = 5
let apps: [WorkspaceApp]

var body: some View {
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)
}
}

struct MenuItemIcons: View {
@EnvironmentObject var state: AppState
@Environment(\.openURL) private var openURL

let item: VPNMenuItem
let wsURL: URL

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

func copyToClipboard() {
let primaryHost = item.primaryHost(hostnameSuffix: state.hostnameSuffix)
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(primaryHost, forType: .string)
}

var body: some View {
StatusDot(color: item.status.color)
.padding(.trailing, 3)
.padding(.top, 1)
MenuItemIconButton(systemName: "doc.on.doc", action: copyToClipboard)
.font(.system(size: 9))
.symbolVariant(.fill)
MenuItemIconButton(systemName: "globe", action: { openURL(wsURL) })
.contentShape(Rectangle())
.font(.system(size: 12))
.padding(.trailing, Theme.Size.trayMargin)
}
}

struct MenuItemIconButton: View {
let systemName: String
@State var isSelected: Bool = false
let action: @MainActor () -> Void

var body: some View {
Button(action: action) {
Image(systemName: systemName)
.padding(3)
.contentShape(Rectangle())
}.foregroundStyle(isSelected ? .white : .primary)
.background(isSelected ? Color.accentColor.opacity(0.8) : .clear)
.clipShape(.rect(cornerRadius: Theme.Size.rectCornerRadius))
.onHover { hovering in isSelected = hovering }
.buttonStyle(.plain)
}
}

struct AnimatedChevron: View {
let isExpanded: Bool
let color: Color

var body: some View {
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .semibold))
.foregroundColor(color)
.rotationEffect(.degrees(isExpanded ? 90 : 0))
.animation(.easeInOut(duration: Theme.Animation.collapsibleDuration), value: isExpanded)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ struct WorkspaceAppIcon: View {
RoundedRectangle(cornerRadius: Theme.Size.rectCornerRadius * 2)
.stroke(.secondary, lineWidth: 1)
.opacity(isHovering && !isPressed ? 0.6 : 0.3)
).onHoverWithPointingHand { hovering in isHovering = hovering }
).onHover { hovering in isHovering = hovering }
.simultaneousGesture(
DragGesture(minimumDistance: 0)
.onChanged { _ in
Expand Down
4 changes: 2 additions & 2 deletions Coder-Desktop/Coder-DesktopTests/AgentsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ struct AgentsTests {
let forEach = try view.inspect().find(ViewType.ForEach.self)
#expect(forEach.count == Theme.defaultVisibleAgents)
// Agents are sorted by status, and then by name in alphabetical order
#expect(throws: Never.self) { try view.inspect().find(link: "a1.coder") }
#expect(throws: Never.self) { try view.inspect().find(text: "a1.coder") }
}

@Test
Expand Down Expand Up @@ -115,7 +115,7 @@ struct AgentsTests {
try await sut.inspection.inspect { view in
let forEach = try view.find(ViewType.ForEach.self)
#expect(forEach.count == Theme.defaultVisibleAgents)
#expect(throws: Never.self) { try view.find(link: "offline.coder") }
#expect(throws: Never.self) { try view.find(text: "offline.coder") }
}
}
}
Expand Down
Loading