From e1191bae93c2fe614eedaa3b0c0133fb1c104112 Mon Sep 17 00:00:00 2001
From: Ethan Dickson <ethan@coder.com>
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<VPN: VPNService, FS: FileSyncDaemon>: 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 <ethan@coder.com>
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 <ethan@coder.com>
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<VPN: VPNService, FS: FileSyncDaemon>: 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)
     }