diff --git a/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift b/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift index eb3065b..dc83c17 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.localSize.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 e251b1a..d586908 100644 --- a/Coder-Desktop/VPNLib/FileSync/FileSyncSession.swift +++ b/Coder-Desktop/VPNLib/FileSync/FileSyncSession.swift @@ -3,19 +3,126 @@ 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 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" + } + 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 + state.session.beta.host + } else { + "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 + ) + + 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 = "" + 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 + } + + 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 +134,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 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 var body: some View { - Text(description).foregroundColor(color) +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 0000000..7afefee --- /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