diff --git a/Coder-Desktop/Coder-Desktop/Theme.swift b/Coder-Desktop/Coder-Desktop/Theme.swift index 1c15b086..546242c2 100644 --- a/Coder-Desktop/Coder-Desktop/Theme.swift +++ b/Coder-Desktop/Coder-Desktop/Theme.swift @@ -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 } diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/Agents.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/Agents.swift index 0ca65759..fb3928f6 100644 --- a/Coder-Desktop/Coder-Desktop/Views/VPN/Agents.swift +++ b/Coder-Desktop/Coder-Desktop/Views/VPN/Agents.swift @@ -4,6 +4,8 @@ struct Agents: View { @EnvironmentObject var vpn: VPN @EnvironmentObject var state: AppState @State private var viewAll = false + @State private var expandedItem: VPNMenuItem.ID? + @State private var hasToggledExpansion: Bool = false private let defaultVisibleRows = 5 let inspection = Inspection() @@ -15,8 +17,24 @@ struct Agents: 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!) - .padding(.horizontal, Theme.Size.trayMargin) + MenuItemView( + item: agent, + baseAccessURL: state.baseAccessURL!, + expandedItem: $expandedItem, + userInteracted: $hasToggledExpansion + ) + .padding(.horizontal, Theme.Size.trayMargin) + }.onChange(of: visibleItems) { + // If no workspaces are online, we should expand the first one to come online + if visibleItems.filter({ $0.status != .off }).isEmpty { + hasToggledExpansion = false + return + } + if hasToggledExpansion { + return + } + expandedItem = visibleItems.first?.id + hasToggledExpansion = true } if items.count == 0 { Text("No workspaces!") diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift index 1bc0b98b..d67e34ff 100644 --- a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift +++ b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift @@ -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)): @@ -52,23 +59,23 @@ 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? + @Binding var userInteracted: Bool @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 @@ -79,17 +86,34 @@ 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() { + userInteracted = true + 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) @@ -98,42 +122,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() } @@ -172,3 +178,83 @@ 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)) + } +} diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift index 70a20d8b..14a4bd0f 100644 --- a/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift +++ b/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift @@ -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 diff --git a/Coder-Desktop/Coder-DesktopTests/AgentsTests.swift b/Coder-Desktop/Coder-DesktopTests/AgentsTests.swift index 62c1607f..741b32e5 100644 --- a/Coder-Desktop/Coder-DesktopTests/AgentsTests.swift +++ b/Coder-Desktop/Coder-DesktopTests/AgentsTests.swift @@ -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 @@ -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") } } } } pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy