Skip to content

Commit f0cf155

Browse files
feat: add stubbed file sync UI (#116)
Closes #66 Relates to #63 The UI differs a fair bit from the wireframes & figma designs in the interest of being able to use the stock SwiftUI Table view. The biggest difference is that a modal is used to insert new file syncs, as opposed to creating them inline. This was done as it's a lot harder to do that within a SwiftUI table. This design is also consistent with tables used in Apple's own settings pages, and the HTTP header table in app settings. https://github.com/user-attachments/assets/7c3d98b9-36c4-430b-ac6f-7064b6b8dc31 The UI is mostly non-functional, it still needs to be wired up over gRPC, including conversions from Mutagen data types. As a result, the file sync button on the menu will not appear unless the file sync feature flag is enabled in settings. Right now, the workspace dropdown menu is populated from the online agents (any row with a coloured dot on the menubar menu) There's no tests for this since ViewInspector still does not support Tables.
1 parent d95289b commit f0cf155

19 files changed

+415
-44
lines changed

Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift

+8-1
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ struct DesktopApp: App {
2323
.environmentObject(appDelegate.state)
2424
}
2525
.windowResizability(.contentSize)
26+
Window("Coder File Sync", id: Windows.fileSync.rawValue) {
27+
FileSyncConfig<CoderVPNService, MutagenDaemon>()
28+
.environmentObject(appDelegate.state)
29+
.environmentObject(appDelegate.fileSyncDaemon)
30+
.environmentObject(appDelegate.vpn)
31+
}
2632
}
2733
}
2834

@@ -61,9 +67,10 @@ class AppDelegate: NSObject, NSApplicationDelegate {
6167
await self.state.handleTokenExpiry()
6268
}
6369
}, content: {
64-
VPNMenu<CoderVPNService>().frame(width: 256)
70+
VPNMenu<CoderVPNService, MutagenDaemon>().frame(width: 256)
6571
.environmentObject(self.vpn)
6672
.environmentObject(self.state)
73+
.environmentObject(self.fileSyncDaemon)
6774
}
6875
))
6976
// Subscribe to system VPN updates
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import VPNLib
2+
3+
@MainActor
4+
final class PreviewFileSync: FileSyncDaemon {
5+
var sessionState: [VPNLib.FileSyncSession] = []
6+
7+
var state: DaemonState = .running
8+
9+
init() {}
10+
11+
func refreshSessions() async {}
12+
13+
func start() async throws(DaemonError) {
14+
state = .running
15+
}
16+
17+
func stop() async {
18+
state = .stopped
19+
}
20+
21+
func createSession(localPath _: String, agentHost _: String, remotePath _: String) async throws(DaemonError) {}
22+
23+
func deleteSessions(ids _: [String]) async throws(VPNLib.DaemonError) {}
24+
}

Coder-Desktop/Coder-Desktop/VPN/MenuState.swift

+5-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import Foundation
22
import SwiftUI
33
import VPNLib
44

5-
struct Agent: Identifiable, Equatable, Comparable {
5+
struct Agent: Identifiable, Equatable, Comparable, Hashable {
66
let id: UUID
77
let name: String
88
let status: AgentStatus
@@ -135,6 +135,10 @@ struct VPNMenuState {
135135
return items.sorted()
136136
}
137137

138+
var onlineAgents: [Agent] {
139+
agents.map(\.value).filter { $0.primaryHost != nil }
140+
}
141+
138142
mutating func clear() {
139143
agents.removeAll()
140144
workspaces.removeAll()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import SwiftUI
2+
import VPNLib
3+
4+
struct FileSyncConfig<VPN: VPNService, FS: FileSyncDaemon>: View {
5+
@EnvironmentObject var vpn: VPN
6+
@EnvironmentObject var fileSync: FS
7+
8+
@State private var selection: FileSyncSession.ID?
9+
@State private var addingNewSession: Bool = false
10+
@State private var editingSession: FileSyncSession?
11+
12+
@State private var loading: Bool = false
13+
@State private var deleteError: DaemonError?
14+
15+
var body: some View {
16+
Group {
17+
Table(fileSync.sessionState, selection: $selection) {
18+
TableColumn("Local Path") {
19+
Text($0.alphaPath).help($0.alphaPath)
20+
}.width(min: 200, ideal: 240)
21+
TableColumn("Workspace", value: \.agentHost)
22+
.width(min: 100, ideal: 120)
23+
TableColumn("Remote Path", value: \.betaPath)
24+
.width(min: 100, ideal: 120)
25+
TableColumn("Status") { $0.status.body }
26+
.width(min: 80, ideal: 100)
27+
TableColumn("Size") { item in
28+
Text(item.size)
29+
}
30+
.width(min: 60, ideal: 80)
31+
}
32+
.contextMenu(forSelectionType: FileSyncSession.ID.self, menu: { _ in },
33+
primaryAction: { selectedSessions in
34+
if let session = selectedSessions.first {
35+
editingSession = fileSync.sessionState.first(where: { $0.id == session })
36+
}
37+
})
38+
.frame(minWidth: 400, minHeight: 200)
39+
.padding(.bottom, 25)
40+
.overlay(alignment: .bottom) {
41+
VStack(alignment: .leading, spacing: 0) {
42+
Divider()
43+
HStack(spacing: 0) {
44+
Button {
45+
addingNewSession = true
46+
} label: {
47+
Image(systemName: "plus")
48+
.frame(width: 24, height: 24)
49+
}.disabled(vpn.menuState.agents.isEmpty)
50+
Divider()
51+
Button {
52+
Task {
53+
loading = true
54+
defer { loading = false }
55+
do throws(DaemonError) {
56+
try await fileSync.deleteSessions(ids: [selection!])
57+
} catch {
58+
deleteError = error
59+
}
60+
await fileSync.refreshSessions()
61+
selection = nil
62+
}
63+
} label: {
64+
Image(systemName: "minus").frame(width: 24, height: 24)
65+
}.disabled(selection == nil)
66+
if let selection {
67+
if let selectedSession = fileSync.sessionState.first(where: { $0.id == selection }) {
68+
Divider()
69+
Button {
70+
// TODO: Pause & Unpause
71+
} label: {
72+
switch selectedSession.status {
73+
case .paused:
74+
Image(systemName: "play").frame(width: 24, height: 24)
75+
default:
76+
Image(systemName: "pause").frame(width: 24, height: 24)
77+
}
78+
}
79+
}
80+
}
81+
}
82+
.buttonStyle(.borderless)
83+
}
84+
.background(.primary.opacity(0.04))
85+
.fixedSize(horizontal: false, vertical: true)
86+
}
87+
}.sheet(isPresented: $addingNewSession) {
88+
FileSyncSessionModal<VPN, FS>()
89+
.frame(width: 700)
90+
}.sheet(item: $editingSession) { session in
91+
FileSyncSessionModal<VPN, FS>(existingSession: session)
92+
.frame(width: 700)
93+
}.alert("Error", isPresented: Binding(
94+
get: { deleteError != nil },
95+
set: { isPresented in
96+
if !isPresented {
97+
deleteError = nil
98+
}
99+
}
100+
)) {} message: {
101+
Text(deleteError?.description ?? "An unknown error occurred.")
102+
}.task {
103+
while !Task.isCancelled {
104+
await fileSync.refreshSessions()
105+
try? await Task.sleep(for: .seconds(2))
106+
}
107+
}.disabled(loading)
108+
}
109+
}
110+
111+
#if DEBUG
112+
#Preview {
113+
FileSyncConfig<PreviewVPN, PreviewFileSync>()
114+
.environmentObject(AppState(persistent: false))
115+
.environmentObject(PreviewVPN())
116+
.environmentObject(PreviewFileSync())
117+
}
118+
#endif
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import SwiftUI
2+
import VPNLib
3+
4+
struct FileSyncSessionModal<VPN: VPNService, FS: FileSyncDaemon>: View {
5+
var existingSession: FileSyncSession?
6+
@Environment(\.dismiss) private var dismiss
7+
@EnvironmentObject private var vpn: VPN
8+
@EnvironmentObject private var fileSync: FS
9+
10+
@State private var localPath: String = ""
11+
@State private var workspace: Agent?
12+
@State private var remotePath: String = ""
13+
14+
@State private var loading: Bool = false
15+
@State private var createError: DaemonError?
16+
17+
var body: some View {
18+
let agents = vpn.menuState.onlineAgents
19+
VStack(spacing: 0) {
20+
Form {
21+
Section {
22+
HStack(spacing: 5) {
23+
TextField("Local Path", text: $localPath)
24+
Spacer()
25+
Button {
26+
let panel = NSOpenPanel()
27+
panel.directoryURL = FileManager.default.homeDirectoryForCurrentUser
28+
panel.allowsMultipleSelection = false
29+
panel.canChooseDirectories = true
30+
panel.canChooseFiles = false
31+
if panel.runModal() == .OK {
32+
localPath = panel.url?.path(percentEncoded: false) ?? "<none>"
33+
}
34+
} label: {
35+
Image(systemName: "folder")
36+
}
37+
}
38+
}
39+
Section {
40+
Picker("Workspace", selection: $workspace) {
41+
ForEach(agents, id: \.id) { agent in
42+
Text(agent.primaryHost!).tag(agent)
43+
}
44+
// HACK: Silence error logs for no-selection.
45+
Divider().tag(nil as Agent?)
46+
}
47+
}
48+
Section {
49+
TextField("Remote Path", text: $remotePath)
50+
}
51+
}.formStyle(.grouped).scrollDisabled(true).padding(.horizontal)
52+
Divider()
53+
HStack {
54+
Spacer()
55+
Button("Cancel", action: { dismiss() }).keyboardShortcut(.cancelAction)
56+
Button(existingSession == nil ? "Add" : "Save") { Task { await submit() }}
57+
.keyboardShortcut(.defaultAction)
58+
}.padding(20)
59+
}.onAppear {
60+
if let existingSession {
61+
localPath = existingSession.alphaPath
62+
workspace = agents.first { $0.primaryHost == existingSession.agentHost }
63+
remotePath = existingSession.betaPath
64+
} else {
65+
// Set the picker to the first agent by default
66+
workspace = agents.first
67+
}
68+
}.disabled(loading)
69+
.alert("Error", isPresented: Binding(
70+
get: { createError != nil },
71+
set: { if $0 { createError = nil } }
72+
)) {} message: {
73+
Text(createError?.description ?? "An unknown error occurred.")
74+
}
75+
}
76+
77+
func submit() async {
78+
createError = nil
79+
guard let workspace else {
80+
return
81+
}
82+
loading = true
83+
defer { loading = false }
84+
do throws(DaemonError) {
85+
if let existingSession {
86+
// TODO: Support selecting & deleting multiple sessions at once
87+
try await fileSync.deleteSessions(ids: [existingSession.id])
88+
}
89+
try await fileSync.createSession(
90+
localPath: localPath,
91+
agentHost: workspace.primaryHost!,
92+
remotePath: remotePath
93+
)
94+
} catch {
95+
createError = error
96+
return
97+
}
98+
dismiss()
99+
}
100+
}

Coder-Desktop/Coder-Desktop/Views/LoginForm.swift

+2-4
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,8 @@ struct LoginForm: View {
4848
loginError = nil
4949
}
5050
}
51-
)) {
52-
Button("OK", role: .cancel) {}.keyboardShortcut(.defaultAction)
53-
} message: {
54-
Text(loginError?.description ?? "")
51+
)) {} message: {
52+
Text(loginError?.description ?? "An unknown error occurred.")
5553
}.disabled(loading)
5654
.frame(width: 550)
5755
.fixedSize()

Coder-Desktop/Coder-Desktop/Views/Settings/LiteralHeadersSection.swift

+2-2
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ struct LiteralHeadersSection<VPN: VPNService>: View {
1515
Toggle(isOn: $state.useLiteralHeaders) {
1616
Text("HTTP Headers")
1717
Text("When enabled, these headers will be included on all outgoing HTTP requests.")
18-
if vpn.state != .disabled { Text("Cannot be modified while Coder Connect is enabled.") }
18+
if !vpn.state.canBeStarted { Text("Cannot be modified while Coder Connect is enabled.") }
1919
}
2020
.controlSize(.large)
2121

@@ -65,7 +65,7 @@ struct LiteralHeadersSection<VPN: VPNService>: View {
6565
LiteralHeaderModal(existingHeader: header)
6666
}.onTapGesture {
6767
selectedHeader = nil
68-
}.disabled(vpn.state != .disabled)
68+
}.disabled(!vpn.state.canBeStarted)
6969
.onReceive(inspection.notice) { inspection.visit(self, $0) } // ViewInspector
7070
}
7171
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import SwiftUI
2+
3+
struct StatusDot: View {
4+
let color: Color
5+
6+
var body: some View {
7+
ZStack {
8+
Circle()
9+
.fill(color.opacity(0.4))
10+
.frame(width: 12, height: 12)
11+
Circle()
12+
.fill(color.opacity(1.0))
13+
.frame(width: 7, height: 7)
14+
}
15+
}
16+
}

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

+23-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import SwiftUI
2+
import VPNLib
23

3-
struct VPNMenu<VPN: VPNService>: View {
4+
struct VPNMenu<VPN: VPNService, FS: FileSyncDaemon>: View {
45
@EnvironmentObject var vpn: VPN
6+
@EnvironmentObject var fileSync: FS
57
@EnvironmentObject var state: AppState
68
@Environment(\.openSettings) private var openSettings
79
@Environment(\.openWindow) private var openWindow
@@ -60,6 +62,24 @@ struct VPNMenu<VPN: VPNService>: View {
6062
}.buttonStyle(.plain)
6163
TrayDivider()
6264
}
65+
if vpn.state == .connected {
66+
Button {
67+
openWindow(id: .fileSync)
68+
} label: {
69+
ButtonRowView {
70+
HStack {
71+
// TODO: A future PR will provide users a way to recover from a daemon failure without
72+
// needing to restart the app
73+
if case .failed = fileSync.state, sessionsHaveError(fileSync.sessionState) {
74+
Image(systemName: "exclamationmark.arrow.trianglehead.2.clockwise.rotate.90")
75+
.frame(width: 12, height: 12).help("One or more sync sessions have errors")
76+
}
77+
Text("File sync")
78+
}
79+
}
80+
}.buttonStyle(.plain)
81+
TrayDivider()
82+
}
6383
if vpn.state == .failed(.systemExtensionError(.needsUserApproval)) {
6484
Button {
6585
openSystemExtensionSettings()
@@ -119,8 +139,9 @@ func openSystemExtensionSettings() {
119139
appState.login(baseAccessURL: URL(string: "http://127.0.0.1:8080")!, sessionToken: "")
120140
// appState.clearSession()
121141

122-
return VPNMenu<PreviewVPN>().frame(width: 256)
142+
return VPNMenu<PreviewVPN, PreviewFileSync>().frame(width: 256)
123143
.environmentObject(PreviewVPN())
124144
.environmentObject(appState)
145+
.environmentObject(PreviewFileSync())
125146
}
126147
#endif

0 commit comments

Comments
 (0)