Skip to content

feat: add conflict descriptions and file sync context menu #126

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Apr 2, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 21 additions & 16 deletions Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,23 @@ struct FileSyncConfig<VPN: VPNService, FS: FileSyncDaemon>: 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) {
Expand Down Expand Up @@ -142,12 +153,9 @@ struct FileSyncConfig<VPN: VPNService, FS: FileSyncDaemon>: 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")
}
}
Expand Down Expand Up @@ -182,12 +190,9 @@ struct FileSyncConfig<VPN: VPNService, FS: FileSyncDaemon>: 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 {
Expand Down
32 changes: 26 additions & 6 deletions Coder-Desktop/VPNLib/FileSync/FileSyncSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Expand All @@ -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 {
Expand Down Expand Up @@ -272,8 +289,8 @@ public enum FileSyncErrorStatus {
}

public enum FileSyncEndpoint {
case local
case remote
case alpha
case beta
}

public enum FileSyncProblemType {
Expand All @@ -284,13 +301,16 @@ 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 {
case let .generic(error):
error
case let .problem(endpoint, type, path, error):
"\(endpoint) \(type) error at \(path): \(error)"
case let .excludedProblems(endpoint, type, count):
"+ \(count) \(endpoint) \(type) problems"
}
}
}
Expand Down
140 changes: 136 additions & 4 deletions Coder-Desktop/VPNLib/FileSync/MutagenConvert.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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 `<non-existent -> !<non-existent>` 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 ? "<root>" : path
}

func formatEntry(_ entry: Core_Entry?) -> String {
guard let entry else {
return "<non-existent>"
}

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 "<unknown>"
case .phantomDirectory:
return "Phantom Directory"
}
}
Loading