From 91d773b09d8094a3d2032aa90f3c4336b6d1e965 Mon Sep 17 00:00:00 2001 From: Ethan Dickson 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() + .environmentObject(appDelegate.state) + .environmentObject(appDelegate.fileSyncDaemon) + .environmentObject(appDelegate.vpn) + } } } @@ -61,9 +67,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { await self.state.handleTokenExpiry() } }, content: { - VPNMenu().frame(width: 256) + VPNMenu().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: 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() + .frame(width: 700) + }.sheet(item: $editingSession) { session in + FileSyncSessionModal(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() + .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: 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) ?? "" + } + } 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: 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: 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: View { +struct VPNMenu: 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: 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().frame(width: 256) + return VPNMenu().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 + let sut: VPNMenu let view: any View init() { vpn = MockVPNService() state = AppState(persistent: false) - sut = VPNMenu() - view = sut.environmentObject(vpn).environmentObject(state) + sut = VPNMenu() + 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 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: 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 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() .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: 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: 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: 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: 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()