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

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

+2-5
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

+2-1
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

+140-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,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

+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

0 commit comments

Comments
 (0)