Skip to content

Commit c24fa6b

Browse files
committed
feat: make workspace apps collapsible
1 parent 101baae commit c24fa6b

File tree

5 files changed

+149
-47
lines changed

5 files changed

+149
-47
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/VPN/MenuState.swift

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,9 @@ struct Agent: Identifiable, Equatable, Comparable, Hashable {
1010
let wsName: String
1111
let wsID: UUID
1212

13-
// Agents are sorted by status, and then by name
13+
// Agents are sorted by name
1414
static func < (lhs: Agent, rhs: Agent) -> Bool {
15-
if lhs.status != rhs.status {
16-
return lhs.status < rhs.status
17-
}
18-
return lhs.wsName.localizedCompare(rhs.wsName) == .orderedAscending
15+
lhs.wsName.localizedCompare(rhs.wsName) == .orderedAscending
1916
}
2017

2118
let primaryHost: String

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ 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?
78
private let defaultVisibleRows = 5
89

910
let inspection = Inspection<Self>()
@@ -15,7 +16,7 @@ struct Agents<VPN: VPNService>: View {
1516
let items = vpn.menuState.sorted
1617
let visibleItems = viewAll ? items[...] : items.prefix(defaultVisibleRows)
1718
ForEach(visibleItems, id: \.id) { agent in
18-
MenuItemView(item: agent, baseAccessURL: state.baseAccessURL!)
19+
MenuItemView(item: agent, baseAccessURL: state.baseAccessURL!, expandedItem: $expandedItem)
1920
.padding(.horizontal, Theme.Size.trayMargin)
2021
}
2122
if items.count == 0 {

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

Lines changed: 140 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,22 @@ 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?
6069

6170
@State private var nameIsSelected: Bool = false
62-
@State private var copyIsSelected: Bool = false
6371

64-
private let defaultVisibleApps = 5
6572
@State private var apps: [WorkspaceApp] = []
6673

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

7379
var formattedName = AttributedString(name)
7480
formattedName.foregroundColor = .primary
@@ -79,17 +85,33 @@ struct MenuItemView: View {
7985
return formattedName
8086
}
8187

88+
private var isExpanded: Bool {
89+
expandedItem == item.id
90+
}
91+
8292
private var wsURL: URL {
8393
// TODO: CoderVPN currently only supports owned workspaces
8494
baseAccessURL.appending(path: "@me").appending(path: item.wsName)
8595
}
8696

97+
private func toggleExpanded() {
98+
if isExpanded {
99+
withAnimation(.snappy(duration: Theme.Animation.collapsibleDuration)) {
100+
expandedItem = nil
101+
}
102+
} else {
103+
withAnimation(.snappy(duration: Theme.Animation.collapsibleDuration)) {
104+
expandedItem = item.id
105+
}
106+
}
107+
}
108+
87109
var body: some View {
88110
VStack(spacing: 0) {
89-
HStack(spacing: 0) {
90-
Link(destination: wsURL) {
111+
HStack(spacing: 3) {
112+
Button(action: toggleExpanded) {
91113
HStack(spacing: Theme.Size.trayPadding) {
92-
StatusDot(color: item.status.color)
114+
AnimatedChevron(isExpanded: isExpanded, color: .secondary)
93115
Text(itemName).lineLimit(1).truncationMode(.tail)
94116
Spacer()
95117
}.padding(.horizontal, Theme.Size.trayPadding)
@@ -98,42 +120,24 @@ struct MenuItemView: View {
98120
.foregroundStyle(nameIsSelected ? .white : .primary)
99121
.background(nameIsSelected ? Color.accentColor.opacity(0.8) : .clear)
100122
.clipShape(.rect(cornerRadius: Theme.Size.rectCornerRadius))
101-
.onHoverWithPointingHand { hovering in
123+
.onHover { hovering in
102124
nameIsSelected = hovering
103125
}
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-
}
126+
}.buttonStyle(.plain).padding(.trailing, 3)
127+
MenuItemIcons(item: item, wsURL: wsURL)
123128
}
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()
129+
if isExpanded {
130+
if hasApps {
131+
MenuItemCollapsibleView(apps: apps)
132+
} else {
133+
HStack {
134+
Text(item.status == .off ? "Workspace is offline." : "No apps available.")
135+
.font(.body)
136+
.foregroundColor(.secondary)
137+
.padding(.horizontal, Theme.Size.trayInset)
138+
.padding(.top, 7)
132139
}
133140
}
134-
.padding(.leading, apps.count < defaultVisibleApps ? 14 : 0)
135-
.padding(.bottom, 5)
136-
.padding(.top, 10)
137141
}
138142
}
139143
.task { await loadApps() }
@@ -172,3 +176,99 @@ struct MenuItemView: View {
172176
}
173177
}
174178
}
179+
180+
struct MenuItemCollapsibleView: View {
181+
private let defaultVisibleApps = 5
182+
let apps: [WorkspaceApp]
183+
184+
var body: some View {
185+
HStack(spacing: 17) {
186+
ForEach(apps.prefix(defaultVisibleApps), id: \.id) { app in
187+
WorkspaceAppIcon(app: app)
188+
.frame(width: Theme.Size.appIconWidth, height: Theme.Size.appIconHeight)
189+
}
190+
if apps.count < defaultVisibleApps {
191+
Spacer()
192+
}
193+
}
194+
.padding(.leading, apps.count < defaultVisibleApps ? 14 : 0)
195+
.padding(.bottom, 5)
196+
.padding(.top, 10)
197+
}
198+
}
199+
200+
struct MenuItemIcons: View {
201+
@EnvironmentObject var state: AppState
202+
@Environment(\.openURL) private var openURL
203+
204+
let item: VPNMenuItem
205+
let wsURL: URL
206+
207+
@State private var copyIsSelected: Bool = false
208+
@State private var webIsSelected: Bool = false
209+
210+
func copyToClipboard() {
211+
let primaryHost = item.primaryHost(hostnameSuffix: state.hostnameSuffix)
212+
NSPasteboard.general.clearContents()
213+
NSPasteboard.general.setString(primaryHost, forType: .string)
214+
}
215+
216+
var body: some View {
217+
StatusDot(color: item.status.color)
218+
.padding(.trailing, 3)
219+
.padding(.top, 1)
220+
MenuItemIconButton(systemName: "doc.on.doc", action: copyToClipboard)
221+
.font(.system(size: 9))
222+
.symbolVariant(.fill)
223+
MenuItemIconButton(systemName: "globe", action: { openURL(wsURL) })
224+
.contentShape(Rectangle())
225+
.font(.system(size: 12))
226+
.padding(.trailing, Theme.Size.trayMargin)
227+
}
228+
}
229+
230+
struct MenuItemIconButton: View {
231+
let systemName: String
232+
@State var isSelected: Bool = false
233+
let action: @MainActor () -> Void
234+
235+
var body: some View {
236+
Button {
237+
action()
238+
} label: {
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+
.animation(.easeInOut(duration: Theme.Animation.collapsibleDuration), value: isExpanded)
260+
}
261+
}
262+
263+
#if DEBUG
264+
#Preview {
265+
let appState = AppState(persistent: false)
266+
appState.login(baseAccessURL: URL(string: "http://127.0.0.1:8080")!, sessionToken: "")
267+
// appState.clearSession()
268+
269+
return VPNMenu<PreviewVPN, PreviewFileSync>().frame(width: 256)
270+
.environmentObject(PreviewVPN())
271+
.environmentObject(appState)
272+
.environmentObject(PreviewFileSync())
273+
}
274+
#endif

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

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