diff --git a/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift b/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift index 6b147ad..345928b 100644 --- a/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift +++ b/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift @@ -29,12 +29,23 @@ struct FileSyncConfig: View { TableColumn("Size") { Text($0.localSize.humanSizeBytes).help($0.sizeDescription) } .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 }) - } - }) + .contextMenu(forSelectionType: FileSyncSession.ID.self, menu: { selections in + // TODO: We only support single selections for now + if let selected = selections.first, + let session = fileSync.sessionState.first(where: { $0.id == selected }) + { + Button("Edit") { editingSession = session } + Button(session.status.isResumable ? "Resume" : "Pause") + { Task { await pauseResume(session: session) } } + Button("Reset") { Task { await reset(session: session) } } + Button("Terminate") { Task { await delete(session: 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) { @@ -142,12 +153,9 @@ struct FileSyncConfig: View { Divider() Button { Task { await pauseResume(session: selectedSession) } } label: { - switch selectedSession.status { - case .paused, .error(.haltedOnRootEmptied), - .error(.haltedOnRootDeletion), - .error(.haltedOnRootTypeChange): + if selectedSession.status.isResumable { Image(systemName: "play").frame(width: 24, height: 24).help("Pause") - default: + } else { Image(systemName: "pause").frame(width: 24, height: 24).help("Resume") } } @@ -182,12 +190,9 @@ struct FileSyncConfig: View { loading = true defer { loading = false } do throws(DaemonError) { - switch session.status { - case .paused, .error(.haltedOnRootEmptied), - .error(.haltedOnRootDeletion), - .error(.haltedOnRootTypeChange): + if session.status.isResumable { try await fileSync.resumeSessions(ids: [session.id]) - default: + } else { try await fileSync.pauseSessions(ids: [session.id]) } } catch { diff --git a/Coder-Desktop/VPNLib/FileSync/FileSyncSession.swift b/Coder-Desktop/VPNLib/FileSync/FileSyncSession.swift index d586908..b0c43f3 100644 --- a/Coder-Desktop/VPNLib/FileSync/FileSyncSession.swift +++ b/Coder-Desktop/VPNLib/FileSync/FileSyncSession.swift @@ -46,7 +46,12 @@ public struct FileSyncSession: Identifiable { } if case .error = status {} else { if state.conflicts.count > 0 { - status = .conflicts + status = .conflicts( + formatConflicts( + conflicts: state.conflicts, + excludedConflicts: state.excludedConflicts + ) + ) } } self.status = status @@ -121,7 +126,7 @@ public enum FileSyncStatus { case error(FileSyncErrorStatus) case ok case paused - case conflicts + case conflicts(String) case working(FileSyncWorkingStatus) public var color: Color { @@ -168,8 +173,8 @@ public enum FileSyncStatus { "The session is watching for filesystem changes." case .paused: "The session is paused." - case .conflicts: - "The session has conflicts that need to be resolved." + case let .conflicts(details): + "The session has conflicts that need to be resolved:\n\n\(details)" case let .working(status): status.description } @@ -178,6 +183,18 @@ public enum FileSyncStatus { public var column: some View { Text(type).foregroundColor(color) } + + public var isResumable: Bool { + switch self { + case .paused, + .error(.haltedOnRootEmptied), + .error(.haltedOnRootDeletion), + .error(.haltedOnRootTypeChange): + true + default: + false + } + } } public enum FileSyncWorkingStatus { @@ -272,8 +289,8 @@ public enum FileSyncErrorStatus { } public enum FileSyncEndpoint { - case local - case remote + case alpha + case beta } public enum FileSyncProblemType { @@ -284,6 +301,7 @@ public enum FileSyncProblemType { public enum FileSyncError { case generic(String) case problem(FileSyncEndpoint, FileSyncProblemType, path: String, error: String) + case excludedProblems(FileSyncEndpoint, FileSyncProblemType, UInt64) var description: String { switch self { @@ -291,6 +309,8 @@ public enum FileSyncError { error case let .problem(endpoint, type, path, error): "\(endpoint) \(type) error at \(path): \(error)" + case let .excludedProblems(endpoint, type, count): + "+ \(count) \(endpoint) \(type) problems" } } } diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenConvert.swift b/Coder-Desktop/VPNLib/FileSync/MutagenConvert.swift index 8a59b23..b422d86 100644 --- a/Coder-Desktop/VPNLib/FileSync/MutagenConvert.swift +++ b/Coder-Desktop/VPNLib/FileSync/MutagenConvert.swift @@ -40,16 +40,28 @@ func accumulateErrors(from state: Synchronization_State) -> [FileSyncError] { errors.append(.generic(state.lastError)) } for problem in state.alphaState.scanProblems { - errors.append(.problem(.local, .scan, path: problem.path, error: problem.error)) + errors.append(.problem(.alpha, .scan, path: problem.path, error: problem.error)) } for problem in state.alphaState.transitionProblems { - errors.append(.problem(.local, .transition, path: problem.path, error: problem.error)) + errors.append(.problem(.alpha, .transition, path: problem.path, error: problem.error)) } for problem in state.betaState.scanProblems { - errors.append(.problem(.remote, .scan, path: problem.path, error: problem.error)) + errors.append(.problem(.beta, .scan, path: problem.path, error: problem.error)) } for problem in state.betaState.transitionProblems { - errors.append(.problem(.remote, .transition, path: problem.path, error: problem.error)) + errors.append(.problem(.beta, .transition, path: problem.path, error: problem.error)) + } + if state.alphaState.excludedScanProblems > 0 { + errors.append(.excludedProblems(.alpha, .scan, state.alphaState.excludedScanProblems)) + } + if state.alphaState.excludedTransitionProblems > 0 { + errors.append(.excludedProblems(.alpha, .transition, state.alphaState.excludedTransitionProblems)) + } + if state.betaState.excludedScanProblems > 0 { + errors.append(.excludedProblems(.beta, .scan, state.betaState.excludedScanProblems)) + } + if state.betaState.excludedTransitionProblems > 0 { + errors.append(.excludedProblems(.beta, .transition, state.betaState.excludedTransitionProblems)) } return errors } @@ -80,3 +92,123 @@ extension Prompting_HostResponse { } } } + +// Translated from `cmd/mutagen/sync/list_monitor_common.go` +func formatConflicts(conflicts: [Core_Conflict], excludedConflicts: UInt64) -> String { + var result = "" + for (i, conflict) in conflicts.enumerated() { + var changesByPath: [String: (alpha: [Core_Change], beta: [Core_Change])] = [:] + + // Group alpha changes by path + for alphaChange in conflict.alphaChanges { + let path = alphaChange.path + if changesByPath[path] == nil { + changesByPath[path] = (alpha: [], beta: []) + } + changesByPath[path]!.alpha.append(alphaChange) + } + + // Group beta changes by path + for betaChange in conflict.betaChanges { + let path = betaChange.path + if changesByPath[path] == nil { + changesByPath[path] = (alpha: [], beta: []) + } + changesByPath[path]!.beta.append(betaChange) + } + + result += formatChanges(changesByPath) + + if i < conflicts.count - 1 || excludedConflicts > 0 { + result += "\n" + } + } + + if excludedConflicts > 0 { + result += "...+\(excludedConflicts) more conflicts...\n" + } + + return result +} + +func formatChanges(_ changesByPath: [String: (alpha: [Core_Change], beta: [Core_Change])]) -> String { + var result = "" + + for (path, changes) in changesByPath { + if changes.alpha.count == 1, changes.beta.count == 1 { + // Simple message for basic file conflicts + if changes.alpha[0].hasNew, + changes.beta[0].hasNew, + changes.alpha[0].new.kind == .file, + changes.beta[0].new.kind == .file + { + result += "File: '\(formatPath(path))'\n" + continue + } + // Friendly message for ` !` conflicts + if !changes.alpha[0].hasOld, + !changes.beta[0].hasOld, + changes.alpha[0].hasNew, + changes.beta[0].hasNew + { + result += """ + An entry, '\(formatPath(path))', was created on both endpoints that does not match. + You can resolve this conflict by deleting one of the entries.\n + """ + continue + } + } + + let formattedPath = formatPath(path) + result += "Path: '\(formattedPath)'\n" + + // TODO: Local & Remote should be replaced with Alpha & Beta, once it's possible to configure which is which + + if !changes.alpha.isEmpty { + result += " Local changes:\n" + for change in changes.alpha { + let old = formatEntry(change.hasOld ? change.old : nil) + let new = formatEntry(change.hasNew ? change.new : nil) + result += " \(old) → \(new)\n" + } + } + + if !changes.beta.isEmpty { + result += " Remote changes:\n" + for change in changes.beta { + let old = formatEntry(change.hasOld ? change.old : nil) + let new = formatEntry(change.hasNew ? change.new : nil) + result += " \(old) → \(new)\n" + } + } + } + + return result +} + +func formatPath(_ path: String) -> String { + path.isEmpty ? "" : path +} + +func formatEntry(_ entry: Core_Entry?) -> String { + guard let entry else { + return "" + } + + switch entry.kind { + case .directory: + return "Directory" + case .file: + return entry.executable ? "Executable File" : "File" + case .symbolicLink: + return "Symbolic Link (\(entry.target))" + case .untracked: + return "Untracked content" + case .problematic: + return "Problematic content (\(entry.problem))" + case .UNRECOGNIZED: + return "" + case .phantomDirectory: + return "Phantom Directory" + } +}