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

Lines changed: 4 additions & 0 deletions
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

Lines changed: 20 additions & 2 deletions
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

Lines changed: 126 additions & 40 deletions
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

Lines changed: 1 addition & 1 deletion
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

Lines changed: 2 additions & 2 deletions
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)
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