Skip to content

Commit 185a894

Browse files
chore: add mutagen session state conversion (#117)
Relates to #63. This allows a Mutagen `Synchronization/State` message to be displayed as a single row in the table. Just like on the Windows app, extra details are shown on hover. Though all columns can be expanded by dragging the column separators, full paths will also be shown on hover. <img width="905" alt="Screenshot 2025-03-20 at 6 20 10 pm" src="https://github.com/user-attachments/assets/c3a51296-54e3-48c7-b3f6-9c915ba1b1a0" /> <img width="903" alt="Screenshot 2025-03-20 at 6 21 48 pm" src="https://github.com/user-attachments/assets/2cf9cd9f-6349-42f2-abcd-b354126aafda" /> <img width="906" alt="Screenshot 2025-03-20 at 6 21 40 pm" src="https://github.com/user-attachments/assets/ab6a50f5-9018-4712-be8c-9f6ad3ce44d0" /> <img width="906" alt="Screenshot 2025-03-20 at 6 20 18 pm" src="https://github.com/user-attachments/assets/7d5bb734-4a40-4897-a7ac-86513501b1ce" /> <img width="904" alt="image" src="https://github.com/user-attachments/assets/fdb1f2f8-7b70-4a78-9fb9-ac84df4ca5af" />
1 parent f0cf155 commit 185a894

File tree

4 files changed

+317
-21
lines changed

4 files changed

+317
-21
lines changed

Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift

+4-6
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,12 @@ struct FileSyncConfig<VPN: VPNService, FS: FileSyncDaemon>: View {
2020
}.width(min: 200, ideal: 240)
2121
TableColumn("Workspace", value: \.agentHost)
2222
.width(min: 100, ideal: 120)
23-
TableColumn("Remote Path", value: \.betaPath)
23+
TableColumn("Remote Path") { Text($0.betaPath).help($0.betaPath) }
2424
.width(min: 100, ideal: 120)
25-
TableColumn("Status") { $0.status.body }
25+
TableColumn("Status") { $0.status.column.help($0.statusAndErrors) }
2626
.width(min: 80, ideal: 100)
27-
TableColumn("Size") { item in
28-
Text(item.size)
29-
}
30-
.width(min: 60, ideal: 80)
27+
TableColumn("Size") { Text($0.localSize.humanSizeBytes).help($0.sizeDescription) }
28+
.width(min: 60, ideal: 80)
3129
}
3230
.contextMenu(forSelectionType: FileSyncSession.ID.self, menu: { _ in },
3331
primaryAction: { selectedSessions in

Coder-Desktop/VPNLib/FileSync/FileSyncSession.swift

+254-15
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,126 @@ import SwiftUI
33
public struct FileSyncSession: Identifiable {
44
public let id: String
55
public let alphaPath: String
6+
public let name: String
7+
68
public let agentHost: String
79
public let betaPath: String
810
public let status: FileSyncStatus
9-
public let size: String
11+
12+
public let localSize: FileSyncSessionEndpointSize
13+
public let remoteSize: FileSyncSessionEndpointSize
14+
15+
public let errors: [FileSyncError]
16+
17+
init(state: Synchronization_State) {
18+
id = state.session.identifier
19+
name = state.session.name
20+
21+
// If the protocol isn't what we expect for alpha or beta, show unknown
22+
alphaPath = if state.session.alpha.protocol == Url_Protocol.local, !state.session.alpha.path.isEmpty {
23+
state.session.alpha.path
24+
} else {
25+
"Unknown"
26+
}
27+
agentHost = if state.session.beta.protocol == Url_Protocol.ssh, !state.session.beta.host.isEmpty {
28+
// TOOD: We need to either:
29+
// - make this compatible with custom suffixes
30+
// - always strip the tld
31+
// - always keep the tld
32+
state.session.beta.host
33+
} else {
34+
"Unknown"
35+
}
36+
betaPath = if !state.session.beta.path.isEmpty {
37+
state.session.beta.path
38+
} else {
39+
"Unknown"
40+
}
41+
42+
var status: FileSyncStatus = if state.session.paused {
43+
.paused
44+
} else {
45+
convertSessionStatus(status: state.status)
46+
}
47+
if case .error = status {} else {
48+
if state.conflicts.count > 0 {
49+
status = .conflicts
50+
}
51+
}
52+
self.status = status
53+
54+
localSize = .init(
55+
sizeBytes: state.alphaState.totalFileSize,
56+
fileCount: state.alphaState.files,
57+
dirCount: state.alphaState.directories,
58+
symLinkCount: state.alphaState.symbolicLinks
59+
)
60+
remoteSize = .init(
61+
sizeBytes: state.betaState.totalFileSize,
62+
fileCount: state.betaState.files,
63+
dirCount: state.betaState.directories,
64+
symLinkCount: state.betaState.symbolicLinks
65+
)
66+
67+
errors = accumulateErrors(from: state)
68+
}
69+
70+
public var statusAndErrors: String {
71+
var out = "\(status.type)\n\n\(status.description)"
72+
errors.forEach { out += "\n\t\($0)" }
73+
return out
74+
}
75+
76+
public var sizeDescription: String {
77+
var out = ""
78+
out += "Local:\n\(localSize.description(linePrefix: " "))\n\n"
79+
out += "Remote:\n\(remoteSize.description(linePrefix: " "))"
80+
return out
81+
}
82+
}
83+
84+
public struct FileSyncSessionEndpointSize: Equatable {
85+
public let sizeBytes: UInt64
86+
public let fileCount: UInt64
87+
public let dirCount: UInt64
88+
public let symLinkCount: UInt64
89+
90+
public init(sizeBytes: UInt64, fileCount: UInt64, dirCount: UInt64, symLinkCount: UInt64) {
91+
self.sizeBytes = sizeBytes
92+
self.fileCount = fileCount
93+
self.dirCount = dirCount
94+
self.symLinkCount = symLinkCount
95+
}
96+
97+
public var humanSizeBytes: String {
98+
humanReadableBytes(sizeBytes)
99+
}
100+
101+
public func description(linePrefix: String = "") -> String {
102+
var result = ""
103+
result += linePrefix + humanReadableBytes(sizeBytes) + "\n"
104+
let numberFormatter = NumberFormatter()
105+
numberFormatter.numberStyle = .decimal
106+
if let formattedFileCount = numberFormatter.string(from: NSNumber(value: fileCount)) {
107+
result += "\(linePrefix)\(formattedFileCount) file\(fileCount == 1 ? "" : "s")\n"
108+
}
109+
if let formattedDirCount = numberFormatter.string(from: NSNumber(value: dirCount)) {
110+
result += "\(linePrefix)\(formattedDirCount) director\(dirCount == 1 ? "y" : "ies")"
111+
}
112+
if symLinkCount > 0, let formattedSymLinkCount = numberFormatter.string(from: NSNumber(value: symLinkCount)) {
113+
result += "\n\(linePrefix)\(formattedSymLinkCount) symlink\(symLinkCount == 1 ? "" : "s")"
114+
}
115+
return result
116+
}
10117
}
11118

12119
public enum FileSyncStatus {
13120
case unknown
14-
case error(String)
121+
case error(FileSyncErrorStatus)
15122
case ok
16123
case paused
17-
case needsAttention(String)
18-
case working(String)
124+
case conflicts
125+
case working(FileSyncWorkingStatus)
19126

20127
public var color: Color {
21128
switch self {
@@ -27,32 +134,164 @@ public enum FileSyncStatus {
27134
.red
28135
case .error:
29136
.red
30-
case .needsAttention:
137+
case .conflicts:
31138
.orange
32139
case .working:
33-
.white
140+
.purple
34141
}
35142
}
36143

37-
public var description: String {
144+
public var type: String {
38145
switch self {
39146
case .unknown:
40147
"Unknown"
41-
case let .error(msg):
42-
msg
148+
case let .error(status):
149+
status.name
43150
case .ok:
44151
"Watching"
45152
case .paused:
46153
"Paused"
47-
case let .needsAttention(msg):
48-
msg
49-
case let .working(msg):
50-
msg
154+
case .conflicts:
155+
"Conflicts"
156+
case let .working(status):
157+
status.name
158+
}
159+
}
160+
161+
public var description: String {
162+
switch self {
163+
case .unknown:
164+
"Unknown status message."
165+
case let .error(status):
166+
status.description
167+
case .ok:
168+
"The session is watching for filesystem changes."
169+
case .paused:
170+
"The session is paused."
171+
case .conflicts:
172+
"The session has conflicts that need to be resolved."
173+
case let .working(status):
174+
status.description
175+
}
176+
}
177+
178+
public var column: some View {
179+
Text(type).foregroundColor(color)
180+
}
181+
}
182+
183+
public enum FileSyncWorkingStatus {
184+
case connectingAlpha
185+
case connectingBeta
186+
case scanning
187+
case reconciling
188+
case stagingAlpha
189+
case stagingBeta
190+
case transitioning
191+
case saving
192+
193+
var name: String {
194+
switch self {
195+
case .connectingAlpha:
196+
"Connecting (alpha)"
197+
case .connectingBeta:
198+
"Connecting (beta)"
199+
case .scanning:
200+
"Scanning"
201+
case .reconciling:
202+
"Reconciling"
203+
case .stagingAlpha:
204+
"Staging (alpha)"
205+
case .stagingBeta:
206+
"Staging (beta)"
207+
case .transitioning:
208+
"Transitioning"
209+
case .saving:
210+
"Saving"
211+
}
212+
}
213+
214+
var description: String {
215+
switch self {
216+
case .connectingAlpha:
217+
"The session is attempting to connect to the alpha endpoint."
218+
case .connectingBeta:
219+
"The session is attempting to connect to the beta endpoint."
220+
case .scanning:
221+
"The session is scanning the filesystem on each endpoint."
222+
case .reconciling:
223+
"The session is performing reconciliation."
224+
case .stagingAlpha:
225+
"The session is staging files on the alpha endpoint"
226+
case .stagingBeta:
227+
"The session is staging files on the beta endpoint"
228+
case .transitioning:
229+
"The session is performing transition operations on each endpoint."
230+
case .saving:
231+
"The session is recording synchronization history to disk."
51232
}
52233
}
234+
}
235+
236+
public enum FileSyncErrorStatus {
237+
case disconnected
238+
case haltedOnRootEmptied
239+
case haltedOnRootDeletion
240+
case haltedOnRootTypeChange
241+
case waitingForRescan
242+
243+
var name: String {
244+
switch self {
245+
case .disconnected:
246+
"Disconnected"
247+
case .haltedOnRootEmptied:
248+
"Halted on root emptied"
249+
case .haltedOnRootDeletion:
250+
"Halted on root deletion"
251+
case .haltedOnRootTypeChange:
252+
"Halted on root type change"
253+
case .waitingForRescan:
254+
"Waiting for rescan"
255+
}
256+
}
257+
258+
var description: String {
259+
switch self {
260+
case .disconnected:
261+
"The session is unpaused but not currently connected or connecting to either endpoint."
262+
case .haltedOnRootEmptied:
263+
"The session is halted due to the root emptying safety check."
264+
case .haltedOnRootDeletion:
265+
"The session is halted due to the root deletion safety check."
266+
case .haltedOnRootTypeChange:
267+
"The session is halted due to the root type change safety check."
268+
case .waitingForRescan:
269+
"The session is waiting to retry scanning after an error during the previous scan."
270+
}
271+
}
272+
}
53273

54-
public var body: some View {
55-
Text(description).foregroundColor(color)
274+
public enum FileSyncEndpoint {
275+
case local
276+
case remote
277+
}
278+
279+
public enum FileSyncProblemType {
280+
case scan
281+
case transition
282+
}
283+
284+
public enum FileSyncError {
285+
case generic(String)
286+
case problem(FileSyncEndpoint, FileSyncProblemType, path: String, error: String)
287+
288+
var description: String {
289+
switch self {
290+
case let .generic(error):
291+
error
292+
case let .problem(endpoint, type, path, error):
293+
"\(endpoint) \(type) error at \(path): \(error)"
294+
}
56295
}
57296
}
58297

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// swiftlint:disable:next cyclomatic_complexity
2+
func convertSessionStatus(status: Synchronization_Status) -> FileSyncStatus {
3+
switch status {
4+
case .disconnected:
5+
.error(.disconnected)
6+
case .haltedOnRootEmptied:
7+
.error(.haltedOnRootEmptied)
8+
case .haltedOnRootDeletion:
9+
.error(.haltedOnRootDeletion)
10+
case .haltedOnRootTypeChange:
11+
.error(.haltedOnRootTypeChange)
12+
case .waitingForRescan:
13+
.error(.waitingForRescan)
14+
case .connectingAlpha:
15+
.working(.connectingAlpha)
16+
case .connectingBeta:
17+
.working(.connectingBeta)
18+
case .scanning:
19+
.working(.scanning)
20+
case .reconciling:
21+
.working(.reconciling)
22+
case .stagingAlpha:
23+
.working(.stagingAlpha)
24+
case .stagingBeta:
25+
.working(.stagingBeta)
26+
case .transitioning:
27+
.working(.transitioning)
28+
case .saving:
29+
.working(.saving)
30+
case .watching:
31+
.ok
32+
case .UNRECOGNIZED:
33+
.unknown
34+
}
35+
}
36+
37+
func accumulateErrors(from state: Synchronization_State) -> [FileSyncError] {
38+
var errors: [FileSyncError] = []
39+
if !state.lastError.isEmpty {
40+
errors.append(.generic(state.lastError))
41+
}
42+
for problem in state.alphaState.scanProblems {
43+
errors.append(.problem(.local, .scan, path: problem.path, error: problem.error))
44+
}
45+
for problem in state.alphaState.transitionProblems {
46+
errors.append(.problem(.local, .transition, path: problem.path, error: problem.error))
47+
}
48+
for problem in state.betaState.scanProblems {
49+
errors.append(.problem(.remote, .scan, path: problem.path, error: problem.error))
50+
}
51+
for problem in state.betaState.transitionProblems {
52+
errors.append(.problem(.remote, .transition, path: problem.path, error: problem.error))
53+
}
54+
return errors
55+
}
56+
57+
func humanReadableBytes(_ bytes: UInt64) -> String {
58+
ByteCountFormatter().string(fromByteCount: Int64(bytes))
59+
}
File renamed without changes.

0 commit comments

Comments
 (0)