From e1191bae93c2fe614eedaa3b0c0133fb1c104112 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 20 Mar 2025 18:29:08 +1100 Subject: [PATCH 1/3] chore: add mutagen session state conversions --- .../Views/FileSync/FileSyncConfig.swift | 10 +- .../VPNLib/FileSync/FileSyncSession.swift | 284 +++++++++++++++++- .../VPNLib/FileSync/MutagenConvert.swift | 59 ++++ .../{Convert.swift => VPNConvert.swift} | 0 4 files changed, 332 insertions(+), 21 deletions(-) create mode 100644 Coder-Desktop/VPNLib/FileSync/MutagenConvert.swift rename Coder-Desktop/VPNLib/{Convert.swift => VPNConvert.swift} (100%) diff --git a/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift b/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift index eb3065b8..dc400b5d 100644 --- a/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift +++ b/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift @@ -20,14 +20,12 @@ struct FileSyncConfig: View { }.width(min: 200, ideal: 240) TableColumn("Workspace", value: \.agentHost) .width(min: 100, ideal: 120) - TableColumn("Remote Path", value: \.betaPath) + TableColumn("Remote Path") { Text($0.betaPath).help($0.betaPath) } .width(min: 100, ideal: 120) - TableColumn("Status") { $0.status.body } + TableColumn("Status") { $0.status.column.help($0.statusAndErrors) } .width(min: 80, ideal: 100) - TableColumn("Size") { item in - Text(item.size) - } - .width(min: 60, ideal: 80) + TableColumn("Size") { Text($0.maxSize.humanSizeBytes).help($0.sizeDescription) } + .width(min: 60, ideal: 80) } .contextMenu(forSelectionType: FileSyncSession.ID.self, menu: { _ in }, primaryAction: { selectedSessions in diff --git a/Coder-Desktop/VPNLib/FileSync/FileSyncSession.swift b/Coder-Desktop/VPNLib/FileSync/FileSyncSession.swift index e251b1a5..af49d18d 100644 --- a/Coder-Desktop/VPNLib/FileSync/FileSyncSession.swift +++ b/Coder-Desktop/VPNLib/FileSync/FileSyncSession.swift @@ -3,19 +3,141 @@ import SwiftUI public struct FileSyncSession: Identifiable { public let id: String public let alphaPath: String + public let name: String + public let agentHost: String public let betaPath: String public let status: FileSyncStatus - public let size: String + + public let maxSize: FileSyncSessionEndpointSize + public let localSize: FileSyncSessionEndpointSize + public let remoteSize: FileSyncSessionEndpointSize + + public let errors: [FileSyncError] + + init(state: Synchronization_State) { + id = state.session.identifier + name = state.session.name + + // If the protocol isn't what we expect for alpha or beta, show unknown + alphaPath = if state.session.alpha.protocol == Url_Protocol.local, !state.session.alpha.path.isEmpty { + state.session.alpha.path + } else { + "Unknown" + } + if state.session.beta.protocol == Url_Protocol.ssh, !state.session.beta.host.isEmpty { + let host = state.session.beta.host + // TOOD: We need to either: + // - make this compatible with custom suffixes + // - always strip the tld + // - always keep the tld + agentHost = host.hasSuffix(".coder") ? String(host.dropLast(6)) : host + } else { + agentHost = "Unknown" + } + betaPath = if !state.session.beta.path.isEmpty { + state.session.beta.path + } else { + "Unknown" + } + + var status: FileSyncStatus = if state.session.paused { + .paused + } else { + convertSessionStatus(status: state.status) + } + if case .error = status {} else { + if state.conflicts.count > 0 { + status = .conflicts + } + } + self.status = status + + localSize = .init( + sizeBytes: state.alphaState.totalFileSize, + fileCount: state.alphaState.files, + dirCount: state.alphaState.directories, + symLinkCount: state.alphaState.symbolicLinks + ) + remoteSize = .init( + sizeBytes: state.betaState.totalFileSize, + fileCount: state.betaState.files, + dirCount: state.betaState.directories, + symLinkCount: state.betaState.symbolicLinks + ) + maxSize = localSize.maxOf(other: remoteSize) + + errors = accumulateErrors(from: state) + } + + public var statusAndErrors: String { + var out = "\(status.type)\n\n\(status.description)" + errors.forEach { out += "\n\t\($0)" } + return out + } + + public var sizeDescription: String { + var out = "" + if localSize != remoteSize { + out += "Maximum:\n\(maxSize.description(linePrefix: " "))\n\n" + } + out += "Local:\n\(localSize.description(linePrefix: " "))\n\n" + out += "Remote:\n\(remoteSize.description(linePrefix: " "))" + return out + } +} + +public struct FileSyncSessionEndpointSize: Equatable { + public let sizeBytes: UInt64 + public let fileCount: UInt64 + public let dirCount: UInt64 + public let symLinkCount: UInt64 + + public init(sizeBytes: UInt64, fileCount: UInt64, dirCount: UInt64, symLinkCount: UInt64) { + self.sizeBytes = sizeBytes + self.fileCount = fileCount + self.dirCount = dirCount + self.symLinkCount = symLinkCount + } + + func maxOf(other: FileSyncSessionEndpointSize) -> FileSyncSessionEndpointSize { + FileSyncSessionEndpointSize( + sizeBytes: max(sizeBytes, other.sizeBytes), + fileCount: max(fileCount, other.fileCount), + dirCount: max(dirCount, other.dirCount), + symLinkCount: max(symLinkCount, other.symLinkCount) + ) + } + + public var humanSizeBytes: String { + humanReadableBytes(sizeBytes) + } + + public func description(linePrefix: String = "") -> String { + var result = "" + result += linePrefix + humanReadableBytes(sizeBytes) + "\n" + let numberFormatter = NumberFormatter() + numberFormatter.numberStyle = .decimal + if let formattedFileCount = numberFormatter.string(from: NSNumber(value: fileCount)) { + result += "\(linePrefix)\(formattedFileCount) file\(fileCount == 1 ? "" : "s")\n" + } + if let formattedDirCount = numberFormatter.string(from: NSNumber(value: dirCount)) { + result += "\(linePrefix)\(formattedDirCount) director\(dirCount == 1 ? "y" : "ies")" + } + if symLinkCount > 0, let formattedSymLinkCount = numberFormatter.string(from: NSNumber(value: symLinkCount)) { + result += "\n\(linePrefix)\(formattedSymLinkCount) symlink\(symLinkCount == 1 ? "" : "s")" + } + return result + } } public enum FileSyncStatus { case unknown - case error(String) + case error(FileSyncErrorStatus) case ok case paused - case needsAttention(String) - case working(String) + case conflicts + case working(FileSyncWorkingStatus) public var color: Color { switch self { @@ -27,32 +149,164 @@ public enum FileSyncStatus { .red case .error: .red - case .needsAttention: + case .conflicts: .orange case .working: - .white + .purple } } - public var description: String { + public var type: String { switch self { case .unknown: "Unknown" - case let .error(msg): - msg + case let .error(status): + status.name case .ok: "Watching" case .paused: "Paused" - case let .needsAttention(msg): - msg - case let .working(msg): - msg + case .conflicts: + "Conflicts" + case let .working(status): + status.name + } + } + + public var description: String { + switch self { + case .unknown: + "Unknown status message." + case let .error(status): + status.description + case .ok: + "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 .working(status): + status.description } } - public var body: some View { - Text(description).foregroundColor(color) + public var column: some View { + Text(type).foregroundColor(color) + } +} + +public enum FileSyncWorkingStatus { + case connectingAlpha + case connectingBeta + case scanning + case reconciling + case stagingAlpha + case stagingBeta + case transitioning + case saving + + var name: String { + switch self { + case .connectingAlpha: + "Connecting (alpha)" + case .connectingBeta: + "Connecting (beta)" + case .scanning: + "Scanning" + case .reconciling: + "Reconciling" + case .stagingAlpha: + "Staging (alpha)" + case .stagingBeta: + "Staging (beta)" + case .transitioning: + "Transitioning" + case .saving: + "Saving" + } + } + + var description: String { + switch self { + case .connectingAlpha: + "The session is attempting to connect to the alpha endpoint." + case .connectingBeta: + "The session is attempting to connect to the beta endpoint." + case .scanning: + "The session is scanning the filesystem on each endpoint." + case .reconciling: + "The session is performing reconciliation." + case .stagingAlpha: + "The session is staging files on the alpha endpoint" + case .stagingBeta: + "The session is staging files on the beta endpoint" + case .transitioning: + "The session is performing transition operations on each endpoint." + case .saving: + "The session is recording synchronization history to disk." + } + } +} + +public enum FileSyncErrorStatus { + case disconnected + case haltedOnRootEmptied + case haltedOnRootDeletion + case haltedOnRootTypeChange + case waitingForRescan + + var name: String { + switch self { + case .disconnected: + "Disconnected" + case .haltedOnRootEmptied: + "Halted on root emptied" + case .haltedOnRootDeletion: + "Halted on root deletion" + case .haltedOnRootTypeChange: + "Halted on root type change" + case .waitingForRescan: + "Waiting for rescan" + } + } + + var description: String { + switch self { + case .disconnected: + "The session is unpaused but not currently connected or connecting to either endpoint." + case .haltedOnRootEmptied: + "The session is halted due to the root emptying safety check." + case .haltedOnRootDeletion: + "The session is halted due to the root deletion safety check." + case .haltedOnRootTypeChange: + "The session is halted due to the root type change safety check." + case .waitingForRescan: + "The session is waiting to retry scanning after an error during the previous scan." + } + } +} + +public enum FileSyncEndpoint { + case local + case remote +} + +public enum FileSyncProblemType { + case scan + case transition +} + +public enum FileSyncError { + case generic(String) + case problem(FileSyncEndpoint, FileSyncProblemType, path: String, error: String) + + var description: String { + switch self { + case let .generic(error): + error + case let .problem(endpoint, type, path, error): + "\(endpoint) \(type) error at \(path): \(error)" + } } } diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenConvert.swift b/Coder-Desktop/VPNLib/FileSync/MutagenConvert.swift new file mode 100644 index 00000000..7afefee1 --- /dev/null +++ b/Coder-Desktop/VPNLib/FileSync/MutagenConvert.swift @@ -0,0 +1,59 @@ +// swiftlint:disable:next cyclomatic_complexity +func convertSessionStatus(status: Synchronization_Status) -> FileSyncStatus { + switch status { + case .disconnected: + .error(.disconnected) + case .haltedOnRootEmptied: + .error(.haltedOnRootEmptied) + case .haltedOnRootDeletion: + .error(.haltedOnRootDeletion) + case .haltedOnRootTypeChange: + .error(.haltedOnRootTypeChange) + case .waitingForRescan: + .error(.waitingForRescan) + case .connectingAlpha: + .working(.connectingAlpha) + case .connectingBeta: + .working(.connectingBeta) + case .scanning: + .working(.scanning) + case .reconciling: + .working(.reconciling) + case .stagingAlpha: + .working(.stagingAlpha) + case .stagingBeta: + .working(.stagingBeta) + case .transitioning: + .working(.transitioning) + case .saving: + .working(.saving) + case .watching: + .ok + case .UNRECOGNIZED: + .unknown + } +} + +func accumulateErrors(from state: Synchronization_State) -> [FileSyncError] { + var errors: [FileSyncError] = [] + if !state.lastError.isEmpty { + errors.append(.generic(state.lastError)) + } + for problem in state.alphaState.scanProblems { + errors.append(.problem(.local, .scan, path: problem.path, error: problem.error)) + } + for problem in state.alphaState.transitionProblems { + errors.append(.problem(.local, .transition, path: problem.path, error: problem.error)) + } + for problem in state.betaState.scanProblems { + errors.append(.problem(.remote, .scan, path: problem.path, error: problem.error)) + } + for problem in state.betaState.transitionProblems { + errors.append(.problem(.remote, .transition, path: problem.path, error: problem.error)) + } + return errors +} + +func humanReadableBytes(_ bytes: UInt64) -> String { + ByteCountFormatter().string(fromByteCount: Int64(bytes)) +} diff --git a/Coder-Desktop/VPNLib/Convert.swift b/Coder-Desktop/VPNLib/VPNConvert.swift similarity index 100% rename from Coder-Desktop/VPNLib/Convert.swift rename to Coder-Desktop/VPNLib/VPNConvert.swift From d42088ed3f0c58432cb4e6c024c864cc99704a94 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 25 Mar 2025 12:34:28 +1100 Subject: [PATCH 2/3] dont strip workspace tld in table --- Coder-Desktop/VPNLib/FileSync/FileSyncSession.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Coder-Desktop/VPNLib/FileSync/FileSyncSession.swift b/Coder-Desktop/VPNLib/FileSync/FileSyncSession.swift index af49d18d..32b7aa5c 100644 --- a/Coder-Desktop/VPNLib/FileSync/FileSyncSession.swift +++ b/Coder-Desktop/VPNLib/FileSync/FileSyncSession.swift @@ -26,12 +26,11 @@ public struct FileSyncSession: Identifiable { "Unknown" } if state.session.beta.protocol == Url_Protocol.ssh, !state.session.beta.host.isEmpty { - let host = state.session.beta.host // TOOD: We need to either: // - make this compatible with custom suffixes // - always strip the tld // - always keep the tld - agentHost = host.hasSuffix(".coder") ? String(host.dropLast(6)) : host + agentHost = state.session.beta.host } else { agentHost = "Unknown" } From 7bfeb9bc3daa559b7542d8f1cb5eb30dd9ba499c Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 25 Mar 2025 12:43:32 +1100 Subject: [PATCH 3/3] remove max size --- .../Views/FileSync/FileSyncConfig.swift | 2 +- .../VPNLib/FileSync/FileSyncSession.swift | 20 +++---------------- 2 files changed, 4 insertions(+), 18 deletions(-) diff --git a/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift b/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift index dc400b5d..dc83c17a 100644 --- a/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift +++ b/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift @@ -24,7 +24,7 @@ struct FileSyncConfig: View { .width(min: 100, ideal: 120) TableColumn("Status") { $0.status.column.help($0.statusAndErrors) } .width(min: 80, ideal: 100) - TableColumn("Size") { Text($0.maxSize.humanSizeBytes).help($0.sizeDescription) } + TableColumn("Size") { Text($0.localSize.humanSizeBytes).help($0.sizeDescription) } .width(min: 60, ideal: 80) } .contextMenu(forSelectionType: FileSyncSession.ID.self, menu: { _ in }, diff --git a/Coder-Desktop/VPNLib/FileSync/FileSyncSession.swift b/Coder-Desktop/VPNLib/FileSync/FileSyncSession.swift index 32b7aa5c..d586908d 100644 --- a/Coder-Desktop/VPNLib/FileSync/FileSyncSession.swift +++ b/Coder-Desktop/VPNLib/FileSync/FileSyncSession.swift @@ -9,7 +9,6 @@ public struct FileSyncSession: Identifiable { public let betaPath: String public let status: FileSyncStatus - public let maxSize: FileSyncSessionEndpointSize public let localSize: FileSyncSessionEndpointSize public let remoteSize: FileSyncSessionEndpointSize @@ -25,14 +24,14 @@ public struct FileSyncSession: Identifiable { } else { "Unknown" } - if state.session.beta.protocol == Url_Protocol.ssh, !state.session.beta.host.isEmpty { + agentHost = if state.session.beta.protocol == Url_Protocol.ssh, !state.session.beta.host.isEmpty { // TOOD: We need to either: // - make this compatible with custom suffixes // - always strip the tld // - always keep the tld - agentHost = state.session.beta.host + state.session.beta.host } else { - agentHost = "Unknown" + "Unknown" } betaPath = if !state.session.beta.path.isEmpty { state.session.beta.path @@ -64,7 +63,6 @@ public struct FileSyncSession: Identifiable { dirCount: state.betaState.directories, symLinkCount: state.betaState.symbolicLinks ) - maxSize = localSize.maxOf(other: remoteSize) errors = accumulateErrors(from: state) } @@ -77,9 +75,6 @@ public struct FileSyncSession: Identifiable { public var sizeDescription: String { var out = "" - if localSize != remoteSize { - out += "Maximum:\n\(maxSize.description(linePrefix: " "))\n\n" - } out += "Local:\n\(localSize.description(linePrefix: " "))\n\n" out += "Remote:\n\(remoteSize.description(linePrefix: " "))" return out @@ -99,15 +94,6 @@ public struct FileSyncSessionEndpointSize: Equatable { self.symLinkCount = symLinkCount } - func maxOf(other: FileSyncSessionEndpointSize) -> FileSyncSessionEndpointSize { - FileSyncSessionEndpointSize( - sizeBytes: max(sizeBytes, other.sizeBytes), - fileCount: max(fileCount, other.fileCount), - dirCount: max(dirCount, other.dirCount), - symLinkCount: max(symLinkCount, other.symLinkCount) - ) - } - public var humanSizeBytes: String { humanReadableBytes(sizeBytes) }