Skip to content

Commit 6210775

Browse files
feat: add progress messages when creating sync sessions (#139)
This loading might take a minute on a poor connection, and there's currently no feedback indicating what's going on, so we can display the prompt messages in the meantime. i.e. setting up a workspace with a fair bit of latency: https://github.com/user-attachments/assets/4321fbf7-8be6-4d4b-aead-0581c609d668 This PR also contains a small refactor for the `Agent` `primaryHost`, removing all the subsequent nil checks as we know it exists on creation.
1 parent 5f067b6 commit 6210775

File tree

11 files changed

+73
-33
lines changed

11 files changed

+73
-33
lines changed

Coder-Desktop/Coder-Desktop/Preview Content/PreviewFileSync.swift

+6-1
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,12 @@ final class PreviewFileSync: FileSyncDaemon {
2020
state = .stopped
2121
}
2222

23-
func createSession(arg _: CreateSyncSessionRequest) async throws(DaemonError) {}
23+
func createSession(
24+
arg _: CreateSyncSessionRequest,
25+
promptCallback _: (
26+
@MainActor (String) -> Void
27+
)?
28+
) async throws(DaemonError) {}
2429

2530
func deleteSessions(ids _: [String]) async throws(VPNLib.DaemonError) {}
2631

Coder-Desktop/Coder-Desktop/Preview Content/PreviewVPN.swift

+10-10
Original file line numberDiff line numberDiff line change
@@ -6,25 +6,25 @@ final class PreviewVPN: Coder_Desktop.VPNService {
66
@Published var state: Coder_Desktop.VPNServiceState = .connected
77
@Published var menuState: VPNMenuState = .init(agents: [
88
UUID(): Agent(id: UUID(), name: "dev", status: .error, hosts: ["asdf.coder"], wsName: "dogfood2",
9-
wsID: UUID()),
9+
wsID: UUID(), primaryHost: "asdf.coder"),
1010
UUID(): Agent(id: UUID(), name: "dev", status: .okay, hosts: ["asdf.coder"],
11-
wsName: "testing-a-very-long-name", wsID: UUID()),
11+
wsName: "testing-a-very-long-name", wsID: UUID(), primaryHost: "asdf.coder"),
1212
UUID(): Agent(id: UUID(), name: "dev", status: .warn, hosts: ["asdf.coder"], wsName: "opensrc",
13-
wsID: UUID()),
13+
wsID: UUID(), primaryHost: "asdf.coder"),
1414
UUID(): Agent(id: UUID(), name: "dev", status: .off, hosts: ["asdf.coder"], wsName: "gvisor",
15-
wsID: UUID()),
15+
wsID: UUID(), primaryHost: "asdf.coder"),
1616
UUID(): Agent(id: UUID(), name: "dev", status: .off, hosts: ["asdf.coder"], wsName: "example",
17-
wsID: UUID()),
17+
wsID: UUID(), primaryHost: "asdf.coder"),
1818
UUID(): Agent(id: UUID(), name: "dev", status: .error, hosts: ["asdf.coder"], wsName: "dogfood2",
19-
wsID: UUID()),
19+
wsID: UUID(), primaryHost: "asdf.coder"),
2020
UUID(): Agent(id: UUID(), name: "dev", status: .okay, hosts: ["asdf.coder"],
21-
wsName: "testing-a-very-long-name", wsID: UUID()),
21+
wsName: "testing-a-very-long-name", wsID: UUID(), primaryHost: "asdf.coder"),
2222
UUID(): Agent(id: UUID(), name: "dev", status: .warn, hosts: ["asdf.coder"], wsName: "opensrc",
23-
wsID: UUID()),
23+
wsID: UUID(), primaryHost: "asdf.coder"),
2424
UUID(): Agent(id: UUID(), name: "dev", status: .off, hosts: ["asdf.coder"], wsName: "gvisor",
25-
wsID: UUID()),
25+
wsID: UUID(), primaryHost: "asdf.coder"),
2626
UUID(): Agent(id: UUID(), name: "dev", status: .off, hosts: ["asdf.coder"], wsName: "example",
27-
wsID: UUID()),
27+
wsID: UUID(), primaryHost: "asdf.coder"),
2828
], workspaces: [:])
2929
let shouldFail: Bool
3030
let longError = "This is a long error to test the UI with long error messages"

Coder-Desktop/Coder-Desktop/VPN/MenuState.swift

+9-8
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,7 @@ struct Agent: Identifiable, Equatable, Comparable, Hashable {
1818
return lhs.wsName.localizedCompare(rhs.wsName) == .orderedAscending
1919
}
2020

21-
// Hosts arrive sorted by length, the shortest looks best in the UI.
22-
var primaryHost: String? { hosts.first }
21+
let primaryHost: String
2322
}
2423

2524
enum AgentStatus: Int, Equatable, Comparable {
@@ -69,6 +68,9 @@ struct VPNMenuState {
6968
invalidAgents.append(agent)
7069
return
7170
}
71+
// Remove trailing dot if present
72+
let nonEmptyHosts = agent.fqdn.map { $0.hasSuffix(".") ? String($0.dropLast()) : $0 }
73+
7274
// An existing agent with the same name, belonging to the same workspace
7375
// is from a previous workspace build, and should be removed.
7476
agents.filter { $0.value.name == agent.name && $0.value.wsID == wsID }
@@ -81,10 +83,11 @@ struct VPNMenuState {
8183
name: agent.name,
8284
// If last handshake was not within last five minutes, the agent is unhealthy
8385
status: agent.lastHandshake.date > Date.now.addingTimeInterval(-300) ? .okay : .warn,
84-
// Remove trailing dot if present
85-
hosts: agent.fqdn.map { $0.hasSuffix(".") ? String($0.dropLast()) : $0 },
86+
hosts: nonEmptyHosts,
8687
wsName: workspace.name,
87-
wsID: wsID
88+
wsID: wsID,
89+
// Hosts arrive sorted by length, the shortest looks best in the UI.
90+
primaryHost: nonEmptyHosts.first!
8891
)
8992
}
9093

@@ -135,9 +138,7 @@ struct VPNMenuState {
135138
return items.sorted()
136139
}
137140

138-
var onlineAgents: [Agent] {
139-
agents.map(\.value).filter { $0.primaryHost != nil }
140-
}
141+
var onlineAgents: [Agent] { agents.map(\.value) }
141142

142143
mutating func clear() {
143144
agents.removeAll()

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

+12-2
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ struct FileSyncSessionModal<VPN: VPNService, FS: FileSyncDaemon>: View {
1515
@State private var createError: DaemonError?
1616
@State private var pickingRemote: Bool = false
1717

18+
@State private var lastPromptMessage: String?
19+
1820
var body: some View {
1921
let agents = vpn.menuState.onlineAgents
2022
VStack(spacing: 0) {
@@ -40,7 +42,7 @@ struct FileSyncSessionModal<VPN: VPNService, FS: FileSyncDaemon>: View {
4042
Section {
4143
Picker("Workspace", selection: $remoteHostname) {
4244
ForEach(agents, id: \.id) { agent in
43-
Text(agent.primaryHost!).tag(agent.primaryHost!)
45+
Text(agent.primaryHost).tag(agent.primaryHost)
4446
}
4547
// HACK: Silence error logs for no-selection.
4648
Divider().tag(nil as String?)
@@ -62,6 +64,12 @@ struct FileSyncSessionModal<VPN: VPNService, FS: FileSyncDaemon>: View {
6264
Divider()
6365
HStack {
6466
Spacer()
67+
if let msg = lastPromptMessage {
68+
Text(msg).foregroundStyle(.secondary)
69+
}
70+
if loading {
71+
ProgressView().controlSize(.small)
72+
}
6573
Button("Cancel", action: { dismiss() }).keyboardShortcut(.cancelAction)
6674
Button(existingSession == nil ? "Add" : "Save") { Task { await submit() }}
6775
.keyboardShortcut(.defaultAction)
@@ -103,8 +111,10 @@ struct FileSyncSessionModal<VPN: VPNService, FS: FileSyncDaemon>: View {
103111
arg: .init(
104112
alpha: .init(path: localPath, protocolKind: .local),
105113
beta: .init(path: remotePath, protocolKind: .ssh(host: remoteHostname))
106-
)
114+
),
115+
promptCallback: { lastPromptMessage = $0 }
107116
)
117+
lastPromptMessage = nil
108118
} catch {
109119
createError = error
110120
return

Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift

+4-5
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ struct MenuItemView: View {
6666

6767
private var itemName: AttributedString {
6868
let name = switch item {
69-
case let .agent(agent): agent.primaryHost ?? "\(item.wsName).\(state.hostnameSuffix)"
69+
case let .agent(agent): agent.primaryHost
7070
case .offlineWorkspace: "\(item.wsName).\(state.hostnameSuffix)"
7171
}
7272

@@ -103,10 +103,10 @@ struct MenuItemView: View {
103103
}
104104
Spacer()
105105
}.buttonStyle(.plain)
106-
if case let .agent(agent) = item, let copyableDNS = agent.primaryHost {
106+
if case let .agent(agent) = item {
107107
Button {
108108
NSPasteboard.general.clearContents()
109-
NSPasteboard.general.setString(copyableDNS, forType: .string)
109+
NSPasteboard.general.setString(agent.primaryHost, forType: .string)
110110
} label: {
111111
Image(systemName: "doc.on.doc")
112112
.symbolVariant(.fill)
@@ -143,7 +143,6 @@ struct MenuItemView: View {
143143
// If this menu item is an agent, and the user is logged in
144144
if case let .agent(agent) = item,
145145
let client = state.client,
146-
let host = agent.primaryHost,
147146
let baseAccessURL = state.baseAccessURL,
148147
// Like the CLI, we'll re-use the existing session token to populate the URL
149148
let sessionToken = state.sessionToken
@@ -166,7 +165,7 @@ struct MenuItemView: View {
166165
.flatMap(\.self)
167166
.first(where: { $0.id == agent.id })
168167
{
169-
apps = agentToApps(logger, wsAgent, host, baseAccessURL, sessionToken)
168+
apps = agentToApps(logger, wsAgent, agent.primaryHost, baseAccessURL, sessionToken)
170169
} else {
171170
logger.error("Could not find agent '\(agent.id)' in workspace '\(item.wsName)' resources")
172171
}

Coder-Desktop/Coder-DesktopTests/AgentsTests.swift

+2-1
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ struct AgentsTests {
2727
status: status,
2828
hosts: ["a\($0).coder"],
2929
wsName: "ws\($0)",
30-
wsID: UUID()
30+
wsID: UUID(),
31+
primaryHost: "a\($0).coder"
3132
)
3233
return (agent.id, agent)
3334
})

Coder-Desktop/Coder-DesktopTests/FileSyncDaemonTests.swift

+9-1
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ class FileSyncDaemonTests {
6161
#expect(statesEqual(daemon.state, .stopped))
6262
#expect(daemon.sessionState.count == 0)
6363

64+
var promptMessages: [String] = []
6465
try await daemon.createSession(
6566
arg: .init(
6667
alpha: .init(
@@ -71,9 +72,16 @@ class FileSyncDaemonTests {
7172
path: mutagenBetaDirectory.path(),
7273
protocolKind: .local
7374
)
74-
)
75+
),
76+
promptCallback: {
77+
promptMessages.append($0)
78+
}
7579
)
7680

81+
// There should be at least one prompt message
82+
// Usually "Creating session..."
83+
#expect(promptMessages.count > 0)
84+
7785
// Daemon should have started itself
7886
#expect(statesEqual(daemon.state, .running))
7987
#expect(daemon.sessionState.count == 1)

Coder-Desktop/Coder-DesktopTests/Util.swift

+6-1
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ class MockVPNService: VPNService, ObservableObject {
3131
class MockFileSyncDaemon: FileSyncDaemon {
3232
var logFile: URL = .init(filePath: "~/log.txt")
3333

34+
var lastPromptMessage: String?
35+
3436
var sessionState: [VPNLib.FileSyncSession] = []
3537

3638
func refreshSessions() async {}
@@ -47,7 +49,10 @@ class MockFileSyncDaemon: FileSyncDaemon {
4749
[]
4850
}
4951

50-
func createSession(arg _: CreateSyncSessionRequest) async throws(DaemonError) {}
52+
func createSession(
53+
arg _: CreateSyncSessionRequest,
54+
promptCallback _: (@MainActor (String) -> Void)?
55+
) async throws(DaemonError) {}
5156

5257
func pauseSessions(ids _: [String]) async throws(VPNLib.DaemonError) {}
5358

Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift

+4-1
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@ public protocol FileSyncDaemon: ObservableObject {
1414
func tryStart() async
1515
func stop() async
1616
func refreshSessions() async
17-
func createSession(arg: CreateSyncSessionRequest) async throws(DaemonError)
17+
func createSession(
18+
arg: CreateSyncSessionRequest,
19+
promptCallback: (@MainActor (String) -> Void)?
20+
) async throws(DaemonError)
1821
func deleteSessions(ids: [String]) async throws(DaemonError)
1922
func pauseSessions(ids: [String]) async throws(DaemonError)
2023
func resumeSessions(ids: [String]) async throws(DaemonError)

Coder-Desktop/VPNLib/FileSync/FileSyncManagement.swift

+5-2
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@ public extension MutagenDaemon {
1717
sessionState = sessions.sessionStates.map { FileSyncSession(state: $0) }
1818
}
1919

20-
func createSession(arg: CreateSyncSessionRequest) async throws(DaemonError) {
20+
func createSession(
21+
arg: CreateSyncSessionRequest,
22+
promptCallback: (@MainActor (String) -> Void)? = nil
23+
) async throws(DaemonError) {
2124
if case .stopped = state {
2225
do throws(DaemonError) {
2326
try await start()
@@ -26,7 +29,7 @@ public extension MutagenDaemon {
2629
throw error
2730
}
2831
}
29-
let (stream, promptID) = try await host()
32+
let (stream, promptID) = try await host(promptCallback: promptCallback)
3033
defer { stream.cancel() }
3134
let req = Synchronization_CreateRequest.with { req in
3235
req.prompter = promptID

Coder-Desktop/VPNLib/FileSync/FileSyncPrompting.swift

+6-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ import GRPC
33
extension MutagenDaemon {
44
typealias PromptStream = GRPCAsyncBidirectionalStreamingCall<Prompting_HostRequest, Prompting_HostResponse>
55

6-
func host(allowPrompts: Bool = true) async throws(DaemonError) -> (PromptStream, identifier: String) {
6+
func host(
7+
allowPrompts: Bool = true,
8+
promptCallback: (@MainActor (String) -> Void)? = nil
9+
) async throws(DaemonError) -> (PromptStream, identifier: String) {
710
let stream = client!.prompt.makeHostCall()
811

912
do {
@@ -39,6 +42,8 @@ extension MutagenDaemon {
3942
}
4043
// Any other messages that require a non-empty response will
4144
// cause the create op to fail, showing an error. This is ok for now.
45+
} else {
46+
Task { @MainActor in promptCallback?(msg.message) }
4247
}
4348
try await stream.requestStream.send(reply)
4449
}

0 commit comments

Comments
 (0)