Skip to content

chore: add mutagen session state conversion #117

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 3 commits into from
Mar 28, 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
10 changes: 4 additions & 6 deletions Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.localSize.humanSizeBytes).help($0.sizeDescription) }
.width(min: 60, ideal: 80)
}
.contextMenu(forSelectionType: FileSyncSession.ID.self, menu: { _ in },
primaryAction: { selectedSessions in
Expand Down
269 changes: 254 additions & 15 deletions Coder-Desktop/VPNLib/FileSync/FileSyncSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)"
}
}
}

Expand Down
59 changes: 59 additions & 0 deletions Coder-Desktop/VPNLib/FileSync/MutagenConvert.swift
Original file line number Diff line number Diff line change
@@ -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))
}
Loading