diff --git a/Coder-Desktop/Coder-Desktop/Preview Content/PreviewFileSync.swift b/Coder-Desktop/Coder-Desktop/Preview Content/PreviewFileSync.swift index 608b368..4559716 100644 --- a/Coder-Desktop/Coder-Desktop/Preview Content/PreviewFileSync.swift +++ b/Coder-Desktop/Coder-Desktop/Preview Content/PreviewFileSync.swift @@ -27,4 +27,6 @@ final class PreviewFileSync: FileSyncDaemon { func pauseSessions(ids _: [String]) async throws(VPNLib.DaemonError) {} func resumeSessions(ids _: [String]) async throws(VPNLib.DaemonError) {} + + func resetSessions(ids _: [String]) async throws(VPNLib.DaemonError) {} } diff --git a/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift b/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift index ff4fbe1..6b147ad 100644 --- a/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift +++ b/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift @@ -10,7 +10,7 @@ struct FileSyncConfig: View { @State private var editingSession: FileSyncSession? @State private var loading: Bool = false - @State private var deleteError: DaemonError? + @State private var actionError: DaemonError? @State private var isVisible: Bool = false @State private var dontRetry: Bool = false @@ -50,14 +50,14 @@ struct FileSyncConfig: View { FileSyncSessionModal(existingSession: session) .frame(width: 700) }.alert("Error", isPresented: Binding( - get: { deleteError != nil }, + get: { actionError != nil }, set: { isPresented in if !isPresented { - deleteError = nil + actionError = nil } } )) {} message: { - Text(deleteError?.description ?? "An unknown error occurred.") + Text(actionError?.description ?? "An unknown error occurred.") }.alert("Error", isPresented: Binding( // We only show the alert if the file config window is open // Users will see the alert symbol on the menu bar to prompt them to @@ -89,7 +89,7 @@ struct FileSyncConfig: View { Text(""" File sync daemon failed. The daemon log file at\n\(fileSync.logFile.path)\nhas been opened. """).onAppear { - // Open the log file in the default editor + // Opens the log file in Console NSWorkspace.shared.open(fileSync.logFile) } }.task { @@ -120,58 +120,90 @@ struct FileSyncConfig: View { addingNewSession = true } label: { Image(systemName: "plus") - .frame(width: 24, height: 24) + .frame(width: 24, height: 24).help("Create") }.disabled(vpn.menuState.agents.isEmpty) - Divider() - Button { - Task { - loading = true - defer { loading = false } - do throws(DaemonError) { - // TODO: Support selecting & deleting multiple sessions at once - try await fileSync.deleteSessions(ids: [selection!]) - if fileSync.sessionState.isEmpty { - // Last session was deleted, stop the daemon - await fileSync.stop() - } - } catch { - deleteError = error + sessionControls + } + .buttonStyle(.borderless) + } + .background(.primary.opacity(0.04)) + .fixedSize(horizontal: false, vertical: true) + } + + var sessionControls: some View { + Group { + if let selection { + if let selectedSession = fileSync.sessionState.first(where: { $0.id == selection }) { + Divider() + Button { Task { await delete(session: selectedSession) } } + label: { + Image(systemName: "minus").frame(width: 24, height: 24).help("Terminate") } - 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 { - Task { - // TODO: Support pausing & resuming multiple sessions at once - loading = true - defer { loading = false } - switch selectedSession.status { - case .paused: - try await fileSync.resumeSessions(ids: [selectedSession.id]) - default: - try await fileSync.pauseSessions(ids: [selectedSession.id]) - } - } - } label: { + Divider() + Button { Task { await pauseResume(session: selectedSession) } } + label: { switch selectedSession.status { - case .paused: - Image(systemName: "play").frame(width: 24, height: 24) + case .paused, .error(.haltedOnRootEmptied), + .error(.haltedOnRootDeletion), + .error(.haltedOnRootTypeChange): + Image(systemName: "play").frame(width: 24, height: 24).help("Pause") default: - Image(systemName: "pause").frame(width: 24, height: 24) + Image(systemName: "pause").frame(width: 24, height: 24).help("Resume") } } - } + Divider() + Button { Task { await reset(session: selectedSession) } } + label: { + Image(systemName: "arrow.clockwise").frame(width: 24, height: 24).help("Reset") + } } } - .buttonStyle(.borderless) } - .background(.primary.opacity(0.04)) - .fixedSize(horizontal: false, vertical: true) + } + + // TODO: Support selecting & deleting multiple sessions at once + func delete(session _: FileSyncSession) async { + loading = true + defer { loading = false } + do throws(DaemonError) { + try await fileSync.deleteSessions(ids: [selection!]) + if fileSync.sessionState.isEmpty { + // Last session was deleted, stop the daemon + await fileSync.stop() + } + } catch { + actionError = error + } + selection = nil + } + + // TODO: Support pausing & resuming multiple sessions at once + func pauseResume(session: FileSyncSession) async { + loading = true + defer { loading = false } + do throws(DaemonError) { + switch session.status { + case .paused, .error(.haltedOnRootEmptied), + .error(.haltedOnRootDeletion), + .error(.haltedOnRootTypeChange): + try await fileSync.resumeSessions(ids: [session.id]) + default: + try await fileSync.pauseSessions(ids: [session.id]) + } + } catch { + actionError = error + } + } + + // TODO: Support restarting multiple sessions at once + func reset(session: FileSyncSession) async { + loading = true + defer { loading = false } + do throws(DaemonError) { + try await fileSync.resetSessions(ids: [session.id]) + } catch { + actionError = error + } } } diff --git a/Coder-Desktop/Coder-DesktopTests/Util.swift b/Coder-Desktop/Coder-DesktopTests/Util.swift index bfae516..4301cbc 100644 --- a/Coder-Desktop/Coder-DesktopTests/Util.swift +++ b/Coder-Desktop/Coder-DesktopTests/Util.swift @@ -52,6 +52,8 @@ class MockFileSyncDaemon: FileSyncDaemon { func pauseSessions(ids _: [String]) async throws(VPNLib.DaemonError) {} func resumeSessions(ids _: [String]) async throws(VPNLib.DaemonError) {} + + func resetSessions(ids _: [String]) async throws(VPNLib.DaemonError) {} } extension Inspection: @unchecked Sendable, @retroactive InspectionEmissary {} diff --git a/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift b/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift index 1bac93c..9e10f2a 100644 --- a/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift +++ b/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift @@ -18,6 +18,7 @@ public protocol FileSyncDaemon: ObservableObject { func deleteSessions(ids: [String]) async throws(DaemonError) func pauseSessions(ids: [String]) async throws(DaemonError) func resumeSessions(ids: [String]) async throws(DaemonError) + func resetSessions(ids: [String]) async throws(DaemonError) } @MainActor diff --git a/Coder-Desktop/VPNLib/FileSync/FileSyncManagement.swift b/Coder-Desktop/VPNLib/FileSync/FileSyncManagement.swift index c826fa7..d1d3f6c 100644 --- a/Coder-Desktop/VPNLib/FileSync/FileSyncManagement.swift +++ b/Coder-Desktop/VPNLib/FileSync/FileSyncManagement.swift @@ -100,9 +100,8 @@ public extension MutagenDaemon { } func resumeSessions(ids: [String]) async throws(DaemonError) { - // Resuming sessions does not require prompting, according to the - // Mutagen CLI - let (stream, promptID) = try await host(allowPrompts: false) + // Resuming sessions does use prompting, as it may start a new SSH connection + let (stream, promptID) = try await host(allowPrompts: true) defer { stream.cancel() } guard case .running = state else { return } do { @@ -117,4 +116,22 @@ public extension MutagenDaemon { } await refreshSessions() } + + func resetSessions(ids: [String]) async throws(DaemonError) { + // Resetting a session involves pausing & resuming, so it does use prompting + let (stream, promptID) = try await host(allowPrompts: true) + defer { stream.cancel() } + guard case .running = state else { return } + do { + _ = try await client!.sync.reset(Synchronization_ResetRequest.with { req in + req.prompter = promptID + req.selection = .with { selection in + selection.specifications = ids + } + }, callOptions: .init(timeLimit: .timeout(sessionMgmtReqTimeout))) + } catch { + throw .grpcFailure(error) + } + await refreshSessions() + } }