Skip to content

Commit 25ad797

Browse files
1 parent 6210775 commit 25ad797

File tree

5 files changed

+153
-45
lines changed

5 files changed

+153
-45
lines changed

Coder-Desktop/Coder-Desktop/Theme.swift

+4
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,9 @@ enum Theme {
1313
static let appIconSize: CGSize = .init(width: appIconWidth, height: appIconHeight)
1414
}
1515

16+
enum Animation {
17+
static let collapsibleDuration = 0.2
18+
}
19+
1620
static let defaultVisibleAgents = 5
1721
}

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

+20-2
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ struct Agents<VPN: VPNService>: View {
44
@EnvironmentObject var vpn: VPN
55
@EnvironmentObject var state: AppState
66
@State private var viewAll = false
7+
@State private var expandedItem: VPNMenuItem.ID?
8+
@State private var hasToggledExpansion: Bool = false
79
private let defaultVisibleRows = 5
810

911
let inspection = Inspection<Self>()
@@ -15,8 +17,24 @@ struct Agents<VPN: VPNService>: View {
1517
let items = vpn.menuState.sorted
1618
let visibleItems = viewAll ? items[...] : items.prefix(defaultVisibleRows)
1719
ForEach(visibleItems, id: \.id) { agent in
18-
MenuItemView(item: agent, baseAccessURL: state.baseAccessURL!)
19-
.padding(.horizontal, Theme.Size.trayMargin)
20+
MenuItemView(
21+
item: agent,
22+
baseAccessURL: state.baseAccessURL!,
23+
expandedItem: $expandedItem,
24+
userInteracted: $hasToggledExpansion
25+
)
26+
.padding(.horizontal, Theme.Size.trayMargin)
27+
}.onChange(of: visibleItems) {
28+
// If no workspaces are online, we should expand the first one to come online
29+
if visibleItems.filter({ $0.status != .off }).isEmpty {
30+
hasToggledExpansion = false
31+
return
32+
}
33+
if hasToggledExpansion {
34+
return
35+
}
36+
expandedItem = visibleItems.first?.id
37+
hasToggledExpansion = true
2038
}
2139
if items.count == 0 {
2240
Text("No workspaces!")

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

+126-40
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,13 @@ enum VPNMenuItem: Equatable, Comparable, Identifiable {
3535
}
3636
}
3737

38+
func primaryHost(hostnameSuffix: String) -> String {
39+
switch self {
40+
case let .agent(agent): agent.primaryHost
41+
case .offlineWorkspace: "\(wsName).\(hostnameSuffix)"
42+
}
43+
}
44+
3845
static func < (lhs: VPNMenuItem, rhs: VPNMenuItem) -> Bool {
3946
switch (lhs, rhs) {
4047
case let (.agent(lhsAgent), .agent(rhsAgent)):
@@ -52,23 +59,23 @@ enum VPNMenuItem: Equatable, Comparable, Identifiable {
5259

5360
struct MenuItemView: View {
5461
@EnvironmentObject var state: AppState
62+
@Environment(\.openURL) private var openURL
5563

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

5866
let item: VPNMenuItem
5967
let baseAccessURL: URL
68+
@Binding var expandedItem: VPNMenuItem.ID?
69+
@Binding var userInteracted: Bool
6070

6171
@State private var nameIsSelected: Bool = false
62-
@State private var copyIsSelected: Bool = false
6372

64-
private let defaultVisibleApps = 5
6573
@State private var apps: [WorkspaceApp] = []
6674

75+
var hasApps: Bool { !apps.isEmpty }
76+
6777
private var itemName: AttributedString {
68-
let name = switch item {
69-
case let .agent(agent): agent.primaryHost
70-
case .offlineWorkspace: "\(item.wsName).\(state.hostnameSuffix)"
71-
}
78+
let name = item.primaryHost(hostnameSuffix: state.hostnameSuffix)
7279

7380
var formattedName = AttributedString(name)
7481
formattedName.foregroundColor = .primary
@@ -79,17 +86,34 @@ struct MenuItemView: View {
7986
return formattedName
8087
}
8188

89+
private var isExpanded: Bool {
90+
expandedItem == item.id
91+
}
92+
8293
private var wsURL: URL {
8394
// TODO: CoderVPN currently only supports owned workspaces
8495
baseAccessURL.appending(path: "@me").appending(path: item.wsName)
8596
}
8697

98+
private func toggleExpanded() {
99+
userInteracted = true
100+
if isExpanded {
101+
withAnimation(.snappy(duration: Theme.Animation.collapsibleDuration)) {
102+
expandedItem = nil
103+
}
104+
} else {
105+
withAnimation(.snappy(duration: Theme.Animation.collapsibleDuration)) {
106+
expandedItem = item.id
107+
}
108+
}
109+
}
110+
87111
var body: some View {
88112
VStack(spacing: 0) {
89-
HStack(spacing: 0) {
90-
Link(destination: wsURL) {
113+
HStack(spacing: 3) {
114+
Button(action: toggleExpanded) {
91115
HStack(spacing: Theme.Size.trayPadding) {
92-
StatusDot(color: item.status.color)
116+
AnimatedChevron(isExpanded: isExpanded, color: .secondary)
93117
Text(itemName).lineLimit(1).truncationMode(.tail)
94118
Spacer()
95119
}.padding(.horizontal, Theme.Size.trayPadding)
@@ -98,42 +122,24 @@ struct MenuItemView: View {
98122
.foregroundStyle(nameIsSelected ? .white : .primary)
99123
.background(nameIsSelected ? Color.accentColor.opacity(0.8) : .clear)
100124
.clipShape(.rect(cornerRadius: Theme.Size.rectCornerRadius))
101-
.onHoverWithPointingHand { hovering in
125+
.onHover { hovering in
102126
nameIsSelected = hovering
103127
}
104-
Spacer()
105-
}.buttonStyle(.plain)
106-
if case let .agent(agent) = item {
107-
Button {
108-
NSPasteboard.general.clearContents()
109-
NSPasteboard.general.setString(agent.primaryHost, 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-
}
128+
}.buttonStyle(.plain).padding(.trailing, 3)
129+
MenuItemIcons(item: item, wsURL: wsURL)
123130
}
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()
131+
if isExpanded {
132+
if hasApps {
133+
MenuItemCollapsibleView(apps: apps)
134+
} else {
135+
HStack {
136+
Text(item.status == .off ? "Workspace is offline." : "No apps available.")
137+
.font(.body)
138+
.foregroundColor(.secondary)
139+
.padding(.horizontal, Theme.Size.trayInset)
140+
.padding(.top, 7)
132141
}
133142
}
134-
.padding(.leading, apps.count < defaultVisibleApps ? 14 : 0)
135-
.padding(.bottom, 5)
136-
.padding(.top, 10)
137143
}
138144
}
139145
.task { await loadApps() }
@@ -172,3 +178,83 @@ struct MenuItemView: View {
172178
}
173179
}
174180
}
181+
182+
struct MenuItemCollapsibleView: View {
183+
private let defaultVisibleApps = 5
184+
let apps: [WorkspaceApp]
185+
186+
var body: some View {
187+
HStack(spacing: 17) {
188+
ForEach(apps.prefix(defaultVisibleApps), id: \.id) { app in
189+
WorkspaceAppIcon(app: app)
190+
.frame(width: Theme.Size.appIconWidth, height: Theme.Size.appIconHeight)
191+
}
192+
if apps.count < defaultVisibleApps {
193+
Spacer()
194+
}
195+
}
196+
.padding(.leading, apps.count < defaultVisibleApps ? 14 : 0)
197+
.padding(.bottom, 5)
198+
.padding(.top, 10)
199+
}
200+
}
201+
202+
struct MenuItemIcons: View {
203+
@EnvironmentObject var state: AppState
204+
@Environment(\.openURL) private var openURL
205+
206+
let item: VPNMenuItem
207+
let wsURL: URL
208+
209+
@State private var copyIsSelected: Bool = false
210+
@State private var webIsSelected: Bool = false
211+
212+
func copyToClipboard() {
213+
let primaryHost = item.primaryHost(hostnameSuffix: state.hostnameSuffix)
214+
NSPasteboard.general.clearContents()
215+
NSPasteboard.general.setString(primaryHost, forType: .string)
216+
}
217+
218+
var body: some View {
219+
StatusDot(color: item.status.color)
220+
.padding(.trailing, 3)
221+
.padding(.top, 1)
222+
MenuItemIconButton(systemName: "doc.on.doc", action: copyToClipboard)
223+
.font(.system(size: 9))
224+
.symbolVariant(.fill)
225+
MenuItemIconButton(systemName: "globe", action: { openURL(wsURL) })
226+
.contentShape(Rectangle())
227+
.font(.system(size: 12))
228+
.padding(.trailing, Theme.Size.trayMargin)
229+
}
230+
}
231+
232+
struct MenuItemIconButton: View {
233+
let systemName: String
234+
@State var isSelected: Bool = false
235+
let action: @MainActor () -> Void
236+
237+
var body: some View {
238+
Button(action: action) {
239+
Image(systemName: systemName)
240+
.padding(3)
241+
.contentShape(Rectangle())
242+
}.foregroundStyle(isSelected ? .white : .primary)
243+
.background(isSelected ? Color.accentColor.opacity(0.8) : .clear)
244+
.clipShape(.rect(cornerRadius: Theme.Size.rectCornerRadius))
245+
.onHover { hovering in isSelected = hovering }
246+
.buttonStyle(.plain)
247+
}
248+
}
249+
250+
struct AnimatedChevron: View {
251+
let isExpanded: Bool
252+
let color: Color
253+
254+
var body: some View {
255+
Image(systemName: "chevron.right")
256+
.font(.system(size: 12, weight: .semibold))
257+
.foregroundColor(color)
258+
.rotationEffect(.degrees(isExpanded ? 90 : 0))
259+
}
260+
}

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

+1-1
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ struct WorkspaceAppIcon: View {
3737
RoundedRectangle(cornerRadius: Theme.Size.rectCornerRadius * 2)
3838
.stroke(.secondary, lineWidth: 1)
3939
.opacity(isHovering && !isPressed ? 0.6 : 0.3)
40-
).onHoverWithPointingHand { hovering in isHovering = hovering }
40+
).onHover { hovering in isHovering = hovering }
4141
.simultaneousGesture(
4242
DragGesture(minimumDistance: 0)
4343
.onChanged { _ in

Coder-Desktop/Coder-DesktopTests/AgentsTests.swift

+2-2
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ struct AgentsTests {
6262
let forEach = try view.inspect().find(ViewType.ForEach.self)
6363
#expect(forEach.count == Theme.defaultVisibleAgents)
6464
// Agents are sorted by status, and then by name in alphabetical order
65-
#expect(throws: Never.self) { try view.inspect().find(link: "a1.coder") }
65+
#expect(throws: Never.self) { try view.inspect().find(text: "a1.coder") }
6666
}
6767

6868
@Test
@@ -115,7 +115,7 @@ struct AgentsTests {
115115
try await sut.inspection.inspect { view in
116116
let forEach = try view.find(ViewType.ForEach.self)
117117
#expect(forEach.count == Theme.defaultVisibleAgents)
118-
#expect(throws: Never.self) { try view.find(link: "offline.coder") }
118+
#expect(throws: Never.self) { try view.find(text: "offline.coder") }
119119
}
120120
}
121121
}

0 commit comments

Comments
 (0)