From 91d773b09d8094a3d2032aa90f3c4336b6d1e965 Mon Sep 17 00:00:00 2001
From: Ethan Dickson <ethan@coder.com>
Date: Thu, 20 Mar 2025 12:55:20 +1100
Subject: [PATCH 1/3] feat: add stubbed file sync UI

---
 .../Coder-Desktop/Coder_DesktopApp.swift      |   9 +-
 ...ntroller.swift => MenuBarController.swift} |   0
 .../Preview Content/PreviewFileSync.swift     |  24 ++++
 .../Coder-Desktop/VPN/MenuState.swift         |   6 +-
 .../Views/FileSync/FileSyncConfig.swift       | 112 ++++++++++++++++++
 .../Views/FileSync/FileSyncSessionModal.swift | 103 ++++++++++++++++
 .../Coder-Desktop/Views/LoginForm.swift       |   6 +-
 .../Settings/LiteralHeadersSection.swift      |   4 +-
 .../Coder-Desktop/Views/StatusDot.swift       |  16 +++
 .../Views/{ => VPN}/Agents.swift              |   0
 .../Views/{ => VPN}/InvalidAgents.swift       |   0
 .../Views/{ => VPN}/VPNMenu.swift             |  25 +++-
 .../Views/{ => VPN}/VPNMenuItem.swift         |   9 +-
 .../Views/{ => VPN}/VPNState.swift            |   0
 Coder-Desktop/Coder-Desktop/Windows.swift     |   1 +
 Coder-Desktop/Coder-DesktopTests/Util.swift   |  24 ++++
 .../Coder-DesktopTests/VPNMenuTests.swift     |   8 +-
 .../VPNLib/FileSync/FileSyncDaemon.swift      |  43 ++++---
 .../VPNLib/FileSync/FileSyncSession.swift     |  66 +++++++++++
 19 files changed, 412 insertions(+), 44 deletions(-)
 rename Coder-Desktop/Coder-Desktop/{MenuBarIconController.swift => MenuBarController.swift} (100%)
 create mode 100644 Coder-Desktop/Coder-Desktop/Preview Content/PreviewFileSync.swift
 create mode 100644 Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift
 create mode 100644 Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncSessionModal.swift
 create mode 100644 Coder-Desktop/Coder-Desktop/Views/StatusDot.swift
 rename Coder-Desktop/Coder-Desktop/Views/{ => VPN}/Agents.swift (100%)
 rename Coder-Desktop/Coder-Desktop/Views/{ => VPN}/InvalidAgents.swift (100%)
 rename Coder-Desktop/Coder-Desktop/Views/{ => VPN}/VPNMenu.swift (80%)
 rename Coder-Desktop/Coder-Desktop/Views/{ => VPN}/VPNMenuItem.swift (91%)
 rename Coder-Desktop/Coder-Desktop/Views/{ => VPN}/VPNState.swift (100%)
 create mode 100644 Coder-Desktop/VPNLib/FileSync/FileSyncSession.swift

diff --git a/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift b/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift
index 29b0910c..334c2f10 100644
--- a/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift
+++ b/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift
@@ -23,6 +23,12 @@ struct DesktopApp: App {
                 .environmentObject(appDelegate.state)
         }
         .windowResizability(.contentSize)
+        Window("File Sync", id: Windows.fileSync.rawValue) {
+            FileSyncConfig<CoderVPNService, MutagenDaemon>()
+                .environmentObject(appDelegate.state)
+                .environmentObject(appDelegate.fileSyncDaemon)
+                .environmentObject(appDelegate.vpn)
+        }
     }
 }
 
@@ -61,9 +67,10 @@ class AppDelegate: NSObject, NSApplicationDelegate {
                     await self.state.handleTokenExpiry()
                 }
             }, content: {
-                VPNMenu<CoderVPNService>().frame(width: 256)
+                VPNMenu<CoderVPNService, MutagenDaemon>().frame(width: 256)
                     .environmentObject(self.vpn)
                     .environmentObject(self.state)
+                    .environmentObject(self.fileSyncDaemon)
             }
         ))
         // Subscribe to system VPN updates
diff --git a/Coder-Desktop/Coder-Desktop/MenuBarIconController.swift b/Coder-Desktop/Coder-Desktop/MenuBarController.swift
similarity index 100%
rename from Coder-Desktop/Coder-Desktop/MenuBarIconController.swift
rename to Coder-Desktop/Coder-Desktop/MenuBarController.swift
diff --git a/Coder-Desktop/Coder-Desktop/Preview Content/PreviewFileSync.swift b/Coder-Desktop/Coder-Desktop/Preview Content/PreviewFileSync.swift
new file mode 100644
index 00000000..8db30e3c
--- /dev/null
+++ b/Coder-Desktop/Coder-Desktop/Preview Content/PreviewFileSync.swift	
@@ -0,0 +1,24 @@
+import VPNLib
+
+@MainActor
+final class PreviewFileSync: FileSyncDaemon {
+    var sessionState: [VPNLib.FileSyncSession] = []
+
+    var state: DaemonState = .running
+
+    init() {}
+
+    func refreshSessions() async {}
+
+    func start() async throws(DaemonError) {
+        state = .running
+    }
+
+    func stop() async {
+        state = .stopped
+    }
+
+    func createSession(localPath _: String, agentHost _: String, remotePath _: String) async throws(DaemonError) {}
+
+    func deleteSessions(ids _: [String]) async throws(VPNLib.DaemonError) {}
+}
diff --git a/Coder-Desktop/Coder-Desktop/VPN/MenuState.swift b/Coder-Desktop/Coder-Desktop/VPN/MenuState.swift
index 69817e89..9c15aca3 100644
--- a/Coder-Desktop/Coder-Desktop/VPN/MenuState.swift
+++ b/Coder-Desktop/Coder-Desktop/VPN/MenuState.swift
@@ -2,7 +2,7 @@ import Foundation
 import SwiftUI
 import VPNLib
 
-struct Agent: Identifiable, Equatable, Comparable {
+struct Agent: Identifiable, Equatable, Comparable, Hashable {
     let id: UUID
     let name: String
     let status: AgentStatus
@@ -135,6 +135,10 @@ struct VPNMenuState {
         return items.sorted()
     }
 
+    var onlineAgents: [Agent] {
+        agents.map(\.value).filter { $0.primaryHost != nil }
+    }
+
     mutating func clear() {
         agents.removeAll()
         workspaces.removeAll()
diff --git a/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift b/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift
new file mode 100644
index 00000000..ce289869
--- /dev/null
+++ b/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift
@@ -0,0 +1,112 @@
+import SwiftUI
+import VPNLib
+
+struct FileSyncConfig<VPN: VPNService, FS: FileSyncDaemon>: View {
+    @EnvironmentObject var vpn: VPN
+    @EnvironmentObject var fileSync: FS
+
+    @State private var selection: FileSyncSession.ID?
+    @State private var addingNewSession: Bool = false
+    @State private var editingSession: FileSyncSession?
+
+    @State private var loading: Bool = false
+    @State private var deleteError: DaemonError?
+
+    var body: some View {
+        Group {
+            Table(fileSync.sessionState, selection: $selection) {
+                TableColumn("Local Path") {
+                    Text($0.alphaPath).help($0.alphaPath)
+                }.width(min: 200, ideal: 240)
+                TableColumn("Workspace", value: \.agentHost)
+                    .width(min: 100, ideal: 120)
+                TableColumn("Remote Path", value: \.betaPath)
+                    .width(min: 100, ideal: 120)
+                TableColumn("Status") { $0.status.body }
+                    .width(min: 80, ideal: 100)
+                TableColumn("Size") { item in
+                    Text(item.size)
+                }
+                .width(min: 60, ideal: 80)
+            }
+            .frame(minWidth: 400, minHeight: 200)
+            .padding(.bottom, 25)
+            .overlay(alignment: .bottom) {
+                VStack(alignment: .leading, spacing: 0) {
+                    Divider()
+                    HStack(spacing: 0) {
+                        Button {
+                            addingNewSession = true
+                        } label: {
+                            Image(systemName: "plus")
+                                .frame(width: 24, height: 24)
+                        }.disabled(vpn.menuState.agents.isEmpty)
+                        Divider()
+                        Button {
+                            Task {
+                                loading = true
+                                defer { loading = false }
+                                do throws(DaemonError) {
+                                    try await fileSync.deleteSessions(ids: [selection!])
+                                } catch {
+                                    deleteError = error
+                                }
+                                await fileSync.refreshSessions()
+                                selection = nil
+                            }
+                        } label: {
+                            Image(systemName: "minus").frame(width: 24, height: 24)
+                        }.disabled(selection == nil)
+                        if let selection {
+                            if let selectedSession = fileSync.sessionState.first(where: { $0.id == selection }) {
+                                Divider()
+                                Button {
+                                    // TODO: Pause & Unpause
+                                } label: {
+                                    switch selectedSession.status {
+                                    case .paused:
+                                        Image(systemName: "play").frame(width: 24, height: 24)
+                                    default:
+                                        Image(systemName: "pause").frame(width: 24, height: 24)
+                                    }
+                                }
+                            }
+                        }
+                    }
+                    .buttonStyle(.borderless)
+                }
+                .background(.primary.opacity(0.04))
+                .fixedSize(horizontal: false, vertical: true)
+            }
+        }.sheet(isPresented: $addingNewSession) {
+            FileSyncSessionModal<VPN, FS>()
+                .frame(width: 700)
+        }.sheet(item: $editingSession) { session in
+            FileSyncSessionModal<VPN, FS>(existingSession: session)
+                .frame(width: 700)
+        }.alert("Error", isPresented: Binding(
+            get: { deleteError != nil },
+            set: { isPresented in
+                if !isPresented {
+                    deleteError = nil
+                }
+            }
+        )) {} message: {
+            Text(deleteError?.description ?? "An unknown error occurred. This should never happen.")
+        }.task {
+            while !Task.isCancelled {
+                await fileSync.refreshSessions()
+                try? await Task.sleep(for: .seconds(2))
+            }
+        }.disabled(loading)
+    }
+}
+
+#if DEBUG
+    #Preview {
+        FileSyncConfig<PreviewVPN, PreviewFileSync>()
+            .environmentObject(AppState(persistent: false))
+            .environmentObject(PreviewVPN())
+            .environmentObject(PreviewFileSync())
+    }
+#endif
diff --git a/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncSessionModal.swift b/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncSessionModal.swift
new file mode 100644
index 00000000..18df85c8
--- /dev/null
+++ b/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncSessionModal.swift
@@ -0,0 +1,103 @@
+import SwiftUI
+import VPNLib
+
+struct FileSyncSessionModal<VPN: VPNService, FS: FileSyncDaemon>: View {
+    var existingSession: FileSyncSession?
+    @Environment(\.dismiss) private var dismiss
+    @EnvironmentObject private var vpn: VPN
+    @EnvironmentObject private var fileSync: FS
+
+    @State private var localPath: String = ""
+    @State private var workspace: Agent?
+    @State private var remotePath: String = ""
+
+    @State private var loading: Bool = false
+    @State private var createError: DaemonError?
+
+    var body: some View {
+        let agents = vpn.menuState.onlineAgents
+        VStack(spacing: 0) {
+            Form {
+                Section {
+                    HStack(spacing: 5) {
+                        TextField("Local Path", text: $localPath)
+                        Spacer()
+                        Button {
+                            let panel = NSOpenPanel()
+                            panel.directoryURL = FileManager.default.homeDirectoryForCurrentUser
+                            panel.allowsMultipleSelection = false
+                            panel.canChooseDirectories = true
+                            panel.canChooseFiles = false
+                            if panel.runModal() == .OK {
+                                localPath = panel.url?.path(percentEncoded: false) ?? "<none>"
+                            }
+                        } label: {
+                            Image(systemName: "folder")
+                        }
+                    }
+                }
+                Section {
+                    Picker("Workspace", selection: $workspace) {
+                        ForEach(agents, id: \.id) { agent in
+                            Text(agent.primaryHost!).tag(agent)
+                        }
+                        // HACK: Silence error logs for no-selection.
+                        Divider().tag(nil as Agent?)
+                    }
+                }
+                Section {
+                    TextField("Remote Path", text: $remotePath)
+                }
+            }.formStyle(.grouped).scrollDisabled(true).padding(.horizontal)
+            Divider()
+            HStack {
+                Spacer()
+                if loading {
+                    ProgressView()
+                }
+                Button("Cancel", action: { dismiss() }).keyboardShortcut(.cancelAction)
+                Button(existingSession == nil ? "Add" : "Save") { Task { await submit() }}
+                    .keyboardShortcut(.defaultAction)
+            }.padding(20)
+        }.onAppear {
+            if let existingSession {
+                localPath = existingSession.alphaPath
+                workspace = agents.first { $0.primaryHost == existingSession.agentHost }
+                remotePath = existingSession.betaPath
+            } else {
+                // Set the picker to the first agent by default
+                workspace = agents.first
+            }
+        }.disabled(loading)
+            .alert("Error", isPresented: Binding(
+                get: { createError != nil },
+                set: { if $0 { createError = nil } }
+            )) {} message: {
+                Text(createError?.description ?? "An unknown error occurred. This should never happen.")
+            }
+    }
+
+    func submit() async {
+        createError = nil
+        guard let workspace else {
+            return
+        }
+        loading = true
+        defer { loading = false }
+        do throws(DaemonError) {
+            if let existingSession {
+                // TODO: Support selecting & deleting multiple sessions at once
+                try await fileSync.deleteSessions(ids: [existingSession.id])
+            }
+            try await fileSync.createSession(
+                localPath: localPath,
+                agentHost: workspace.primaryHost!,
+                remotePath: remotePath
+            )
+        } catch {
+            createError = error
+            return
+        }
+        dismiss()
+    }
+}
diff --git a/Coder-Desktop/Coder-Desktop/Views/LoginForm.swift b/Coder-Desktop/Coder-Desktop/Views/LoginForm.swift
index 14b37f73..ee8b98fe 100644
--- a/Coder-Desktop/Coder-Desktop/Views/LoginForm.swift
+++ b/Coder-Desktop/Coder-Desktop/Views/LoginForm.swift
@@ -48,10 +48,8 @@ struct LoginForm: View {
                     loginError = nil
                 }
             }
-        )) {
-            Button("OK", role: .cancel) {}.keyboardShortcut(.defaultAction)
-        } message: {
-            Text(loginError?.description ?? "")
+        )) {} message: {
+            Text(loginError?.description ?? "An unknown error occurred. This should never happen.")
         }.disabled(loading)
         .frame(width: 550)
         .fixedSize()
diff --git a/Coder-Desktop/Coder-Desktop/Views/Settings/LiteralHeadersSection.swift b/Coder-Desktop/Coder-Desktop/Views/Settings/LiteralHeadersSection.swift
index e9a9b056..c0705c03 100644
--- a/Coder-Desktop/Coder-Desktop/Views/Settings/LiteralHeadersSection.swift
+++ b/Coder-Desktop/Coder-Desktop/Views/Settings/LiteralHeadersSection.swift
@@ -15,7 +15,7 @@ struct LiteralHeadersSection<VPN: VPNService>: View {
             Toggle(isOn: $state.useLiteralHeaders) {
                 Text("HTTP Headers")
                 Text("When enabled, these headers will be included on all outgoing HTTP requests.")
-                if vpn.state != .disabled { Text("Cannot be modified while Coder Connect is enabled.") }
+                if !vpn.state.canBeStarted { Text("Cannot be modified while Coder Connect is enabled.") }
             }
             .controlSize(.large)
 
@@ -65,7 +65,7 @@ struct LiteralHeadersSection<VPN: VPNService>: View {
             LiteralHeaderModal(existingHeader: header)
         }.onTapGesture {
             selectedHeader = nil
-        }.disabled(vpn.state != .disabled)
+        }.disabled(!vpn.state.canBeStarted)
         .onReceive(inspection.notice) { inspection.visit(self, $0) } // ViewInspector
     }
 }
diff --git a/Coder-Desktop/Coder-Desktop/Views/StatusDot.swift b/Coder-Desktop/Coder-Desktop/Views/StatusDot.swift
new file mode 100644
index 00000000..4de6041c
--- /dev/null
+++ b/Coder-Desktop/Coder-Desktop/Views/StatusDot.swift
@@ -0,0 +1,16 @@
+import SwiftUI
+
+struct StatusDot: View {
+    let color: Color
+
+    var body: some View {
+        ZStack {
+            Circle()
+                .fill(color.opacity(0.4))
+                .frame(width: 12, height: 12)
+            Circle()
+                .fill(color.opacity(1.0))
+                .frame(width: 7, height: 7)
+        }
+    }
+}
diff --git a/Coder-Desktop/Coder-Desktop/Views/Agents.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/Agents.swift
similarity index 100%
rename from Coder-Desktop/Coder-Desktop/Views/Agents.swift
rename to Coder-Desktop/Coder-Desktop/Views/VPN/Agents.swift
diff --git a/Coder-Desktop/Coder-Desktop/Views/InvalidAgents.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/InvalidAgents.swift
similarity index 100%
rename from Coder-Desktop/Coder-Desktop/Views/InvalidAgents.swift
rename to Coder-Desktop/Coder-Desktop/Views/VPN/InvalidAgents.swift
diff --git a/Coder-Desktop/Coder-Desktop/Views/VPNMenu.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenu.swift
similarity index 80%
rename from Coder-Desktop/Coder-Desktop/Views/VPNMenu.swift
rename to Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenu.swift
index c3c44dba..b3fa74e2 100644
--- a/Coder-Desktop/Coder-Desktop/Views/VPNMenu.swift
+++ b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenu.swift
@@ -1,7 +1,9 @@
 import SwiftUI
+import VPNLib
 
-struct VPNMenu<VPN: VPNService>: View {
+struct VPNMenu<VPN: VPNService, FS: FileSyncDaemon>: View {
     @EnvironmentObject var vpn: VPN
+    @EnvironmentObject var fileSync: FS
     @EnvironmentObject var state: AppState
     @Environment(\.openSettings) private var openSettings
     @Environment(\.openWindow) private var openWindow
@@ -60,6 +62,24 @@ struct VPNMenu<VPN: VPNService>: View {
                     }.buttonStyle(.plain)
                     TrayDivider()
                 }
+                if vpn.state == .connected {
+                    Button {
+                        openWindow(id: .fileSync)
+                    } label: {
+                        ButtonRowView {
+                            HStack {
+                                // TODO: A future PR will provide users a way to recover from a daemon failure without
+                                // needing to restart the app
+                                if case .failed = fileSync.state, sessionsHaveError(fileSync.sessionState) {
+                                    Image(systemName: "exclamationmark.arrow.trianglehead.2.clockwise.rotate.90")
+                                        .frame(width: 12, height: 12).help("One or more sync sessions have errors")
+                                }
+                                Text("File sync")
+                            }
+                        }
+                    }.buttonStyle(.plain)
+                    TrayDivider()
+                }
                 if vpn.state == .failed(.systemExtensionError(.needsUserApproval)) {
                     Button {
                         openSystemExtensionSettings()
@@ -119,8 +139,9 @@ func openSystemExtensionSettings() {
         appState.login(baseAccessURL: URL(string: "http://127.0.0.1:8080")!, sessionToken: "")
         // appState.clearSession()
 
-        return VPNMenu<PreviewVPN>().frame(width: 256)
+        return VPNMenu<PreviewVPN, PreviewFileSync>().frame(width: 256)
             .environmentObject(PreviewVPN())
             .environmentObject(appState)
+            .environmentObject(PreviewFileSync())
     }
 #endif
diff --git a/Coder-Desktop/Coder-Desktop/Views/VPNMenuItem.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift
similarity index 91%
rename from Coder-Desktop/Coder-Desktop/Views/VPNMenuItem.swift
rename to Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift
index d66150e5..af7e6bb8 100644
--- a/Coder-Desktop/Coder-Desktop/Views/VPNMenuItem.swift
+++ b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift
@@ -70,14 +70,7 @@ struct MenuItemView: View {
         HStack(spacing: 0) {
             Link(destination: wsURL) {
                 HStack(spacing: Theme.Size.trayPadding) {
-                    ZStack {
-                        Circle()
-                            .fill(item.status.color.opacity(0.4))
-                            .frame(width: 12, height: 12)
-                        Circle()
-                            .fill(item.status.color.opacity(1.0))
-                            .frame(width: 7, height: 7)
-                    }
+                    StatusDot(color: item.status.color)
                     Text(itemName).lineLimit(1).truncationMode(.tail)
                     Spacer()
                 }.padding(.horizontal, Theme.Size.trayPadding)
diff --git a/Coder-Desktop/Coder-Desktop/Views/VPNState.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNState.swift
similarity index 100%
rename from Coder-Desktop/Coder-Desktop/Views/VPNState.swift
rename to Coder-Desktop/Coder-Desktop/Views/VPN/VPNState.swift
diff --git a/Coder-Desktop/Coder-Desktop/Windows.swift b/Coder-Desktop/Coder-Desktop/Windows.swift
index 61ac4ef6..24a5a9cc 100644
--- a/Coder-Desktop/Coder-Desktop/Windows.swift
+++ b/Coder-Desktop/Coder-Desktop/Windows.swift
@@ -3,6 +3,7 @@ import SwiftUI
 // Window IDs
 enum Windows: String {
     case login
+    case fileSync
 }
 
 extension OpenWindowAction {
diff --git a/Coder-Desktop/Coder-DesktopTests/Util.swift b/Coder-Desktop/Coder-DesktopTests/Util.swift
index c41f5c19..e38fe330 100644
--- a/Coder-Desktop/Coder-DesktopTests/Util.swift
+++ b/Coder-Desktop/Coder-DesktopTests/Util.swift
@@ -3,6 +3,7 @@ import Combine
 import NetworkExtension
 import SwiftUI
 import ViewInspector
+import VPNLib
 
 @MainActor
 class MockVPNService: VPNService, ObservableObject {
@@ -26,4 +27,27 @@ class MockVPNService: VPNService, ObservableObject {
     var startWhenReady: Bool = false
 }
 
+@MainActor
+class MockFileSyncDaemon: FileSyncDaemon {
+    var sessionState: [VPNLib.FileSyncSession] = []
+
+    func refreshSessions() async {}
+
+    func deleteSessions(ids _: [String]) async throws(VPNLib.DaemonError) {}
+
+    var state: VPNLib.DaemonState = .running
+
+    func start() async throws(VPNLib.DaemonError) {
+        return
+    }
+
+    func stop() async {}
+
+    func listSessions() async throws -> [VPNLib.FileSyncSession] {
+        []
+    }
+
+    func createSession(localPath _: String, agentHost _: String, remotePath _: String) async throws(DaemonError) {}
+}
+
 extension Inspection: @unchecked Sendable, @retroactive InspectionEmissary {}
diff --git a/Coder-Desktop/Coder-DesktopTests/VPNMenuTests.swift b/Coder-Desktop/Coder-DesktopTests/VPNMenuTests.swift
index 616e3c53..46c780ca 100644
--- a/Coder-Desktop/Coder-DesktopTests/VPNMenuTests.swift
+++ b/Coder-Desktop/Coder-DesktopTests/VPNMenuTests.swift
@@ -7,15 +7,17 @@ import ViewInspector
 @Suite(.timeLimit(.minutes(1)))
 struct VPNMenuTests {
     let vpn: MockVPNService
+    let fsd: MockFileSyncDaemon
     let state: AppState
-    let sut: VPNMenu<MockVPNService>
+    let sut: VPNMenu<MockVPNService, MockFileSyncDaemon>
     let view: any View
 
     init() {
         vpn = MockVPNService()
         state = AppState(persistent: false)
-        sut = VPNMenu<MockVPNService>()
-        view = sut.environmentObject(vpn).environmentObject(state)
+        sut = VPNMenu<MockVPNService, MockFileSyncDaemon>()
+        fsd = MockFileSyncDaemon()
+        view = sut.environmentObject(vpn).environmentObject(state).environmentObject(fsd)
     }
 
     @Test
diff --git a/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift b/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift
index 68446940..00633744 100644
--- a/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift
+++ b/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift
@@ -9,19 +9,12 @@ import SwiftUI
 @MainActor
 public protocol FileSyncDaemon: ObservableObject {
     var state: DaemonState { get }
+    var sessionState: [FileSyncSession] { get }
     func start() async throws(DaemonError)
     func stop() async
-    func listSessions() async throws -> [FileSyncSession]
-    func createSession(with: FileSyncSession) async throws
-}
-
-public struct FileSyncSession {
-    public let id: String
-    public let name: String
-    public let localPath: URL
-    public let workspace: String
-    public let agent: String
-    public let remotePath: URL
+    func refreshSessions() async
+    func createSession(localPath: String, agentHost: String, remotePath: String) async throws(DaemonError)
+    func deleteSessions(ids: [String]) async throws(DaemonError)
 }
 
 @MainActor
@@ -41,6 +34,8 @@ public class MutagenDaemon: FileSyncDaemon {
         }
     }
 
+    @Published public var sessionState: [FileSyncSession] = []
+
     private var mutagenProcess: Subprocess?
     private let mutagenPath: URL!
     private let mutagenDataDirectory: URL
@@ -79,7 +74,7 @@ public class MutagenDaemon: FileSyncDaemon {
                 state = .failed(error)
                 return
             }
-            await stopIfNoSessions()
+            await refreshSessions()
         }
     }
 
@@ -227,6 +222,7 @@ public class MutagenDaemon: FileSyncDaemon {
         let process = Subprocess([mutagenPath.path, "daemon", "run"])
         process.environment = [
             "MUTAGEN_DATA_DIRECTORY": mutagenDataDirectory.path,
+            "MUTAGEN_SSH_PATH": "/usr/bin",
         ]
         logger.info("setting mutagen data directory: \(self.mutagenDataDirectory.path, privacy: .public)")
         return process
@@ -256,27 +252,28 @@ public class MutagenDaemon: FileSyncDaemon {
         }
     }
 
-    public func listSessions() async throws -> [FileSyncSession] {
-        guard case .running = state else {
-            return []
-        }
+    public func refreshSessions() async {
+        guard case .running = state else { return }
         // TODO: Implement
-        return []
     }
 
-    public func createSession(with _: FileSyncSession) async throws {
+    public func createSession(
+        localPath _: String,
+        agentHost _: String,
+        remotePath _: String
+    ) async throws(DaemonError) {
         if case .stopped = state {
             do throws(DaemonError) {
                 try await start()
             } catch {
                 state = .failed(error)
-                return
+                throw error
             }
         }
-        // TODO: Add Session
+        // TODO: Add session
     }
 
-    public func deleteSession() async throws {
+    public func deleteSessions(ids _: [String]) async throws(DaemonError) {
         // TODO: Delete session
         await stopIfNoSessions()
     }
@@ -346,7 +343,7 @@ public enum DaemonError: Error {
     case terminatedUnexpectedly
     case grpcFailure(Error)
 
-    var description: String {
+    public var description: String {
         switch self {
         case let .daemonStartFailure(error):
             "Daemon start failure: \(error)"
@@ -361,5 +358,5 @@ public enum DaemonError: Error {
         }
     }
 
-    var localizedDescription: String { description }
+    public var localizedDescription: String { description }
 }
diff --git a/Coder-Desktop/VPNLib/FileSync/FileSyncSession.swift b/Coder-Desktop/VPNLib/FileSync/FileSyncSession.swift
new file mode 100644
index 00000000..e251b1a5
--- /dev/null
+++ b/Coder-Desktop/VPNLib/FileSync/FileSyncSession.swift
@@ -0,0 +1,66 @@
+import SwiftUI
+
+public struct FileSyncSession: Identifiable {
+    public let id: String
+    public let alphaPath: String
+    public let agentHost: String
+    public let betaPath: String
+    public let status: FileSyncStatus
+    public let size: String
+}
+
+public enum FileSyncStatus {
+    case unknown
+    case error(String)
+    case ok
+    case paused
+    case needsAttention(String)
+    case working(String)
+
+    public var color: Color {
+        switch self {
+        case .ok:
+            .white
+        case .paused:
+            .secondary
+        case .unknown:
+            .red
+        case .error:
+            .red
+        case .needsAttention:
+            .orange
+        case .working:
+            .white
+        }
+    }
+
+    public var description: String {
+        switch self {
+        case .unknown:
+            "Unknown"
+        case let .error(msg):
+            msg
+        case .ok:
+            "Watching"
+        case .paused:
+            "Paused"
+        case let .needsAttention(msg):
+            msg
+        case let .working(msg):
+            msg
+        }
+    }
+
+    public var body: some View {
+        Text(description).foregroundColor(color)
+    }
+}
+
+public func sessionsHaveError(_ sessions: [FileSyncSession]) -> Bool {
+    for session in sessions {
+        if case .error = session.status {
+            return true
+        }
+    }
+    return false
+}

From a1fd9711bec3b82774d089d1ddef8e09c69e96b5 Mon Sep 17 00:00:00 2001
From: Ethan Dickson <ethan@coder.com>
Date: Tue, 25 Mar 2025 12:33:37 +1100
Subject: [PATCH 2/3] support editing sessions

---
 .../Coder-Desktop/Views/FileSync/FileSyncConfig.swift       | 6 ++++++
 1 file changed, 6 insertions(+)

diff --git a/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift b/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift
index ce289869..f16cf4bd 100644
--- a/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift
+++ b/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift
@@ -29,6 +29,12 @@ struct FileSyncConfig<VPN: VPNService, FS: FileSyncDaemon>: View {
                 }
                 .width(min: 60, ideal: 80)
             }
+            .contextMenu(forSelectionType: FileSyncSession.ID.self, menu: { _ in },
+                 primaryAction: { selectedSessions in
+                    if let session = selectedSessions.first {
+                        editingSession = fileSync.sessionState.first(where: { $0.id == session })
+                    }
+            })
             .frame(minWidth: 400, minHeight: 200)
             .padding(.bottom, 25)
             .overlay(alignment: .bottom) {

From da2fb7c33559afdb6d51f54d6edc46bee9b13b67 Mon Sep 17 00:00:00 2001
From: Ethan Dickson <ethan@coder.com>
Date: Tue, 25 Mar 2025 12:43:56 +1100
Subject: [PATCH 3/3] set window name to coder file sync

---
 Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift   |  2 +-
 .../Views/FileSync/FileSyncConfig.swift              | 12 ++++++------
 .../Views/FileSync/FileSyncSessionModal.swift        |  5 +----
 Coder-Desktop/Coder-Desktop/Views/LoginForm.swift    |  2 +-
 4 files changed, 9 insertions(+), 12 deletions(-)

diff --git a/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift b/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift
index 334c2f10..a110432d 100644
--- a/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift
+++ b/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift
@@ -23,7 +23,7 @@ struct DesktopApp: App {
                 .environmentObject(appDelegate.state)
         }
         .windowResizability(.contentSize)
-        Window("File Sync", id: Windows.fileSync.rawValue) {
+        Window("Coder File Sync", id: Windows.fileSync.rawValue) {
             FileSyncConfig<CoderVPNService, MutagenDaemon>()
                 .environmentObject(appDelegate.state)
                 .environmentObject(appDelegate.fileSyncDaemon)
diff --git a/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift b/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift
index f16cf4bd..eb3065b8 100644
--- a/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift
+++ b/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift
@@ -30,11 +30,11 @@ struct FileSyncConfig<VPN: VPNService, FS: FileSyncDaemon>: View {
                 .width(min: 60, ideal: 80)
             }
             .contextMenu(forSelectionType: FileSyncSession.ID.self, menu: { _ in },
-                 primaryAction: { selectedSessions in
-                    if let session = selectedSessions.first {
-                        editingSession = fileSync.sessionState.first(where: { $0.id == session })
-                    }
-            })
+                         primaryAction: { selectedSessions in
+                             if let session = selectedSessions.first {
+                                 editingSession = fileSync.sessionState.first(where: { $0.id == session })
+                             }
+                         })
             .frame(minWidth: 400, minHeight: 200)
             .padding(.bottom, 25)
             .overlay(alignment: .bottom) {
@@ -98,7 +98,7 @@ struct FileSyncConfig<VPN: VPNService, FS: FileSyncDaemon>: View {
                 }
             }
         )) {} message: {
-            Text(deleteError?.description ?? "An unknown error occurred. This should never happen.")
+            Text(deleteError?.description ?? "An unknown error occurred.")
         }.task {
             while !Task.isCancelled {
                 await fileSync.refreshSessions()
diff --git a/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncSessionModal.swift b/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncSessionModal.swift
index 18df85c8..c0c7a35b 100644
--- a/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncSessionModal.swift
+++ b/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncSessionModal.swift
@@ -52,9 +52,6 @@ struct FileSyncSessionModal<VPN: VPNService, FS: FileSyncDaemon>: View {
             Divider()
             HStack {
                 Spacer()
-                if loading {
-                    ProgressView()
-                }
                 Button("Cancel", action: { dismiss() }).keyboardShortcut(.cancelAction)
                 Button(existingSession == nil ? "Add" : "Save") { Task { await submit() }}
                     .keyboardShortcut(.defaultAction)
@@ -73,7 +70,7 @@ struct FileSyncSessionModal<VPN: VPNService, FS: FileSyncDaemon>: View {
                 get: { createError != nil },
                 set: { if $0 { createError = nil } }
             )) {} message: {
-                Text(createError?.description ?? "An unknown error occurred. This should never happen.")
+                Text(createError?.description ?? "An unknown error occurred.")
             }
     }
 
diff --git a/Coder-Desktop/Coder-Desktop/Views/LoginForm.swift b/Coder-Desktop/Coder-Desktop/Views/LoginForm.swift
index ee8b98fe..8b3d3a48 100644
--- a/Coder-Desktop/Coder-Desktop/Views/LoginForm.swift
+++ b/Coder-Desktop/Coder-Desktop/Views/LoginForm.swift
@@ -49,7 +49,7 @@ struct LoginForm: View {
                 }
             }
         )) {} message: {
-            Text(loginError?.description ?? "An unknown error occurred. This should never happen.")
+            Text(loginError?.description ?? "An unknown error occurred.")
         }.disabled(loading)
         .frame(width: 550)
         .fixedSize()