From 1a5dd822cfaddc08a0467feb2be3bc5fa03d0dfe Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Fri, 28 Mar 2025 15:23:49 +1100 Subject: [PATCH 1/2] feat: add conflict descriptions and file sync context menu --- .../Views/FileSync/FileSyncConfig.swift | 37 +++-- .../VPNLib/FileSync/FileSyncSession.swift | 32 +++- .../VPNLib/FileSync/MutagenConvert.swift | 140 +++++++++++++++++- 3 files changed, 183 insertions(+), 26 deletions(-) diff --git a/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift b/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift index 6b147add..345928b6 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 d586908d..b0c43f32 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 8a59b238..197fb49d 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" + } +} From f38aab79df433a53296c7a718627ba35f527a88b Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Fri, 28 Mar 2025 15:28:29 +1100 Subject: [PATCH 2/2] fixup --- Coder-Desktop/VPNLib/FileSync/MutagenConvert.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenConvert.swift b/Coder-Desktop/VPNLib/FileSync/MutagenConvert.swift index 197fb49d..b422d86a 100644 --- a/Coder-Desktop/VPNLib/FileSync/MutagenConvert.swift +++ b/Coder-Desktop/VPNLib/FileSync/MutagenConvert.swift @@ -142,7 +142,7 @@ func formatChanges(_ changesByPath: [String: (alpha: [Core_Change], beta: [Core_ changes.alpha[0].new.kind == .file, changes.beta[0].new.kind == .file { - result += "File: `\(formatPath(path))`\n" + result += "File: '\(formatPath(path))'\n" continue } // Friendly message for ` !` conflicts @@ -152,7 +152,7 @@ func formatChanges(_ changesByPath: [String: (alpha: [Core_Change], beta: [Core_ changes.beta[0].hasNew { result += """ - An entry, `\(formatPath(path))`, was created on both endpoints that does not match. + 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 @@ -160,7 +160,7 @@ func formatChanges(_ changesByPath: [String: (alpha: [Core_Change], beta: [Core_ } let formattedPath = formatPath(path) - result += "Path: \(formattedPath)\n" + result += "Path: '\(formattedPath)'\n" // TODO: Local & Remote should be replaced with Alpha & Beta, once it's possible to configure which is which pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy