Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit fceb8a5

Browse files
committedMar 24, 2025·
feat: add stubbed file sync UI
1 parent 66a1c04 commit fceb8a5

19 files changed

+400
-44
lines changed
 

Diff for: ‎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("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+
}

Diff for: ‎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,112 @@
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+
.frame(minWidth: 400, minHeight: 200)
33+
.padding(.bottom, 25)
34+
.overlay(alignment: .bottom) {
35+
VStack(alignment: .leading, spacing: 0) {
36+
Divider()
37+
HStack(spacing: 0) {
38+
Button {
39+
addingNewSession = true
40+
} label: {
41+
Image(systemName: "plus")
42+
.frame(width: 24, height: 24)
43+
}.disabled(vpn.menuState.agents.isEmpty)
44+
Divider()
45+
Button {
46+
Task {
47+
loading = true
48+
defer { loading = false }
49+
do throws(DaemonError) {
50+
try await fileSync.deleteSessions(ids: [selection!])
51+
} catch {
52+
deleteError = error
53+
}
54+
await fileSync.refreshSessions()
55+
selection = nil
56+
}
57+
} label: {
58+
Image(systemName: "minus").frame(width: 24, height: 24)
59+
}.disabled(selection == nil)
60+
if let selection {
61+
if let selectedSession = fileSync.sessionState.first(where: { $0.id == selection }) {
62+
Divider()
63+
Button {
64+
// TODO: Pause & Unpause
65+
} label: {
66+
switch selectedSession.status {
67+
case .paused:
68+
Image(systemName: "play").frame(width: 24, height: 24)
69+
default:
70+
Image(systemName: "pause").frame(width: 24, height: 24)
71+
}
72+
}
73+
}
74+
}
75+
}
76+
.buttonStyle(.borderless)
77+
}
78+
.background(.primary.opacity(0.04))
79+
.fixedSize(horizontal: false, vertical: true)
80+
}
81+
}.sheet(isPresented: $addingNewSession) {
82+
FileSyncSessionModal<VPN, FS>()
83+
.frame(width: 700)
84+
}.sheet(item: $editingSession) { session in
85+
FileSyncSessionModal<VPN, FS>(existingSession: session)
86+
.frame(width: 700)
87+
}.alert("Error", isPresented: Binding(
88+
get: { deleteError != nil },
89+
set: { isPresented in
90+
if !isPresented {
91+
deleteError = nil
92+
}
93+
}
94+
)) {} message: {
95+
Text(deleteError?.description ?? "An unknown error occurred. This should never happen.")
96+
}.task {
97+
while !Task.isCancelled {
98+
await fileSync.refreshSessions()
99+
try? await Task.sleep(for: .seconds(2))
100+
}
101+
}.disabled(loading)
102+
}
103+
}
104+
105+
#if DEBUG
106+
#Preview {
107+
FileSyncConfig<PreviewVPN, PreviewFileSync>()
108+
.environmentObject(AppState(persistent: false))
109+
.environmentObject(PreviewVPN())
110+
.environmentObject(PreviewFileSync())
111+
}
112+
#endif
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
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+
if loading {
56+
ProgressView()
57+
}
58+
Button("Cancel", action: { dismiss() }).keyboardShortcut(.cancelAction)
59+
Button(existingSession == nil ? "Add" : "Save") { Task { await submit() }}
60+
.keyboardShortcut(.defaultAction)
61+
}.padding(20)
62+
}.onAppear {
63+
if let existingSession {
64+
localPath = existingSession.alphaPath
65+
workspace = agents.first { $0.primaryHost == existingSession.agentHost }
66+
remotePath = existingSession.betaPath
67+
} else {
68+
// Set the picker to the first agent by default
69+
workspace = agents.first
70+
}
71+
}.disabled(loading)
72+
.alert("Error", isPresented: Binding(
73+
get: { createError != nil },
74+
set: { if $0 { createError = nil } }
75+
)) {} message: {
76+
Text(createError?.description ?? "An unknown error occurred. This should never happen.")
77+
}
78+
}
79+
80+
func submit() async {
81+
createError = nil
82+
guard let workspace else {
83+
return
84+
}
85+
loading = true
86+
defer { loading = false }
87+
do throws(DaemonError) {
88+
if let existingSession {
89+
// TODO: Support selecting & deleting multiple sessions at once
90+
try await fileSync.deleteSessions(ids: [existingSession.id])
91+
}
92+
try await fileSync.createSession(
93+
localPath: localPath,
94+
agentHost: workspace.primaryHost!,
95+
remotePath: remotePath
96+
)
97+
} catch {
98+
createError = error
99+
return
100+
}
101+
dismiss()
102+
}
103+
}

Diff for: ‎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. This should never happen.")
5553
}.disabled(loading)
5654
.frame(width: 550)
5755
.fixedSize()

Diff for: ‎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
}

Diff for: ‎Coder-Desktop/Coder-Desktop/Views/StatusDot.swift

+16
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+
}

Diff for: ‎Coder-Desktop/Coder-Desktop/Views/VPNMenu.swift renamed to ‎Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenu.swift

+20-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,21 @@ struct VPNMenu<VPN: VPNService>: View {
6062
}.buttonStyle(.plain)
6163
TrayDivider()
6264
}
65+
#if DEBUG
66+
if vpn.state == .connected {
67+
Button {
68+
openWindow(id: .fileSync)
69+
} label: {
70+
ButtonRowView {
71+
HStack {
72+
StatusDot(color: fileSync.state.color)
73+
Text("Configure file sync")
74+
}
75+
}
76+
}.buttonStyle(.plain)
77+
TrayDivider()
78+
}
79+
#endif
6380
if vpn.state == .failed(.systemExtensionError(.needsUserApproval)) {
6481
Button {
6582
openSystemExtensionSettings()
@@ -119,8 +136,9 @@ func openSystemExtensionSettings() {
119136
appState.login(baseAccessURL: URL(string: "http://127.0.0.1:8080")!, sessionToken: "")
120137
// appState.clearSession()
121138

122-
return VPNMenu<PreviewVPN>().frame(width: 256)
139+
return VPNMenu<PreviewVPN, PreviewFileSync>().frame(width: 256)
123140
.environmentObject(PreviewVPN())
124141
.environmentObject(appState)
142+
.environmentObject(PreviewFileSync())
125143
}
126144
#endif

Diff for: ‎Coder-Desktop/Coder-Desktop/Views/VPNMenuItem.swift renamed to ‎Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift

+1-8
Original file line numberDiff line numberDiff line change
@@ -70,14 +70,7 @@ struct MenuItemView: View {
7070
HStack(spacing: 0) {
7171
Link(destination: wsURL) {
7272
HStack(spacing: Theme.Size.trayPadding) {
73-
ZStack {
74-
Circle()
75-
.fill(item.status.color.opacity(0.4))
76-
.frame(width: 12, height: 12)
77-
Circle()
78-
.fill(item.status.color.opacity(1.0))
79-
.frame(width: 7, height: 7)
80-
}
73+
StatusDot(color: item.status.color)
8174
Text(itemName).lineLimit(1).truncationMode(.tail)
8275
Spacer()
8376
}.padding(.horizontal, Theme.Size.trayPadding)

Diff for: ‎Coder-Desktop/Coder-Desktop/Windows.swift

+1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import SwiftUI
33
// Window IDs
44
enum Windows: String {
55
case login
6+
case fileSync
67
}
78

89
extension OpenWindowAction {

Diff for: ‎Coder-Desktop/Coder-DesktopTests/Util.swift

+24
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import Combine
33
import NetworkExtension
44
import SwiftUI
55
import ViewInspector
6+
import VPNLib
67

78
@MainActor
89
class MockVPNService: VPNService, ObservableObject {
@@ -26,4 +27,27 @@ class MockVPNService: VPNService, ObservableObject {
2627
var startWhenReady: Bool = false
2728
}
2829

30+
@MainActor
31+
class MockFileSyncDaemon: FileSyncDaemon {
32+
var sessionState: [VPNLib.FileSyncSession] = []
33+
34+
func refreshSessions() async {}
35+
36+
func deleteSessions(ids _: [String]) async throws(VPNLib.DaemonError) {}
37+
38+
var state: VPNLib.DaemonState = .running
39+
40+
func start() async throws(VPNLib.DaemonError) {
41+
return
42+
}
43+
44+
func stop() async {}
45+
46+
func listSessions() async throws -> [VPNLib.FileSyncSession] {
47+
[]
48+
}
49+
50+
func createSession(localPath _: String, agentHost _: String, remotePath _: String) async throws(DaemonError) {}
51+
}
52+
2953
extension Inspection: @unchecked Sendable, @retroactive InspectionEmissary {}

Diff for: ‎Coder-Desktop/Coder-DesktopTests/VPNMenuTests.swift

+5-3
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,17 @@ import ViewInspector
77
@Suite(.timeLimit(.minutes(1)))
88
struct VPNMenuTests {
99
let vpn: MockVPNService
10+
let fsd: MockFileSyncDaemon
1011
let state: AppState
11-
let sut: VPNMenu<MockVPNService>
12+
let sut: VPNMenu<MockVPNService, MockFileSyncDaemon>
1213
let view: any View
1314

1415
init() {
1516
vpn = MockVPNService()
1617
state = AppState(persistent: false)
17-
sut = VPNMenu<MockVPNService>()
18-
view = sut.environmentObject(vpn).environmentObject(state)
18+
sut = VPNMenu<MockVPNService, MockFileSyncDaemon>()
19+
fsd = MockFileSyncDaemon()
20+
view = sut.environmentObject(vpn).environmentObject(state).environmentObject(fsd)
1921
}
2022

2123
@Test

Diff for: ‎Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift

+20-23
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,12 @@ import SwiftUI
99
@MainActor
1010
public protocol FileSyncDaemon: ObservableObject {
1111
var state: DaemonState { get }
12+
var sessionState: [FileSyncSession] { get }
1213
func start() async throws(DaemonError)
1314
func stop() async
14-
func listSessions() async throws -> [FileSyncSession]
15-
func createSession(with: FileSyncSession) async throws
16-
}
17-
18-
public struct FileSyncSession {
19-
public let id: String
20-
public let name: String
21-
public let localPath: URL
22-
public let workspace: String
23-
public let agent: String
24-
public let remotePath: URL
15+
func refreshSessions() async
16+
func createSession(localPath: String, agentHost: String, remotePath: String) async throws(DaemonError)
17+
func deleteSessions(ids: [String]) async throws(DaemonError)
2518
}
2619

2720
@MainActor
@@ -41,6 +34,8 @@ public class MutagenDaemon: FileSyncDaemon {
4134
}
4235
}
4336

37+
@Published public var sessionState: [FileSyncSession] = []
38+
4439
private var mutagenProcess: Subprocess?
4540
private let mutagenPath: URL!
4641
private let mutagenDataDirectory: URL
@@ -79,7 +74,7 @@ public class MutagenDaemon: FileSyncDaemon {
7974
state = .failed(error)
8075
return
8176
}
82-
await stopIfNoSessions()
77+
await refreshSessions()
8378
}
8479
}
8580

@@ -227,6 +222,7 @@ public class MutagenDaemon: FileSyncDaemon {
227222
let process = Subprocess([mutagenPath.path, "daemon", "run"])
228223
process.environment = [
229224
"MUTAGEN_DATA_DIRECTORY": mutagenDataDirectory.path,
225+
"MUTAGEN_SSH_PATH": "/usr/bin",
230226
]
231227
logger.info("setting mutagen data directory: \(self.mutagenDataDirectory.path, privacy: .public)")
232228
return process
@@ -256,27 +252,28 @@ public class MutagenDaemon: FileSyncDaemon {
256252
}
257253
}
258254

259-
public func listSessions() async throws -> [FileSyncSession] {
260-
guard case .running = state else {
261-
return []
262-
}
255+
public func refreshSessions() async {
256+
guard case .running = state else { return }
263257
// TODO: Implement
264-
return []
265258
}
266259

267-
public func createSession(with _: FileSyncSession) async throws {
260+
public func createSession(
261+
localPath _: String,
262+
agentHost _: String,
263+
remotePath _: String
264+
) async throws(DaemonError) {
268265
if case .stopped = state {
269266
do throws(DaemonError) {
270267
try await start()
271268
} catch {
272269
state = .failed(error)
273-
return
270+
throw error
274271
}
275272
}
276-
// TODO: Add Session
273+
// TODO: Add session
277274
}
278275

279-
public func deleteSession() async throws {
276+
public func deleteSessions(ids _: [String]) async throws(DaemonError) {
280277
// TODO: Delete session
281278
await stopIfNoSessions()
282279
}
@@ -346,7 +343,7 @@ public enum DaemonError: Error {
346343
case terminatedUnexpectedly
347344
case grpcFailure(Error)
348345

349-
var description: String {
346+
public var description: String {
350347
switch self {
351348
case let .daemonStartFailure(error):
352349
"Daemon start failure: \(error)"
@@ -361,5 +358,5 @@ public enum DaemonError: Error {
361358
}
362359
}
363360

364-
var localizedDescription: String { description }
361+
public var localizedDescription: String { description }
365362
}

Diff for: ‎Coder-Desktop/VPNLib/FileSync/FileSyncSession.swift

+57
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import SwiftUI
2+
3+
public struct FileSyncSession: Identifiable {
4+
public let id: String
5+
public let alphaPath: String
6+
public let agentHost: String
7+
public let betaPath: String
8+
public let status: FileSyncStatus
9+
public let size: String
10+
}
11+
12+
public enum FileSyncStatus {
13+
case unknown
14+
case error(String)
15+
case ok
16+
case paused
17+
case needsAttention(String)
18+
case working(String)
19+
20+
public var color: Color {
21+
switch self {
22+
case .ok:
23+
.white
24+
case .paused:
25+
.secondary
26+
case .unknown:
27+
.red
28+
case .error:
29+
.red
30+
case .needsAttention:
31+
.orange
32+
case .working:
33+
.white
34+
}
35+
}
36+
37+
public var description: String {
38+
switch self {
39+
case .unknown:
40+
"Unknown"
41+
case let .error(msg):
42+
msg
43+
case .ok:
44+
"Watching"
45+
case .paused:
46+
"Paused"
47+
case let .needsAttention(msg):
48+
msg
49+
case let .working(msg):
50+
msg
51+
}
52+
}
53+
54+
public var body: some View {
55+
Text(description).foregroundColor(color)
56+
}
57+
}

0 commit comments

Comments
 (0)
Please sign in to comment.