M9 #2+#4: ServerListView root + ServerID-aware onboarding

ScarfGo now boots into a list of configured servers instead of the
single-server Dashboard. Each row renders nickname + user@host:port,
taps to connect, swipes to forget. A "+" toolbar button re-enters
onboarding for a new server. Fresh install → straight to onboarding.

RootModel state machine redesigned around the multi-server world:

- `.loading` → `.serverList` when listAll() returns 1+ servers.
- `.loading` → `.onboarding(forNewServer:)` on fresh install.
- `.serverList` → `.onboarding(newID)` via "+" button.
- `.serverList` → `.connected(id, config, key)` via row tap.
- `.connected(id)` → `.serverList` via soft Disconnect (keeps creds).
- `.connected(id)` → `.serverList|.onboarding` via Forget (wipes id).
- `.onboarding` → `.connected(newID, …)` on completion.

Published `servers: [ServerID: IOSServerConfig]` on the RootModel so
ServerListView renders reactively without re-querying stores on
every re-render. `refreshServers()` is the `.task` hook; `forget()`
wipes a single id + refreshes.

OnboardingViewModel gains an optional `targetServerID` so its final
save lands in `keyStore.save(_:for:)` / `configStore.save(_🆔)`
instead of the singleton shims. Nil falls back to the old singleton
path for any remaining callers (tests, previews).

OnboardingRootView accepts `targetServerID` + a new `onCancel`
closure. The toolbar now shows Cancel so users can back out without
leaving half-written credentials; Cancel hides on the final
.connected step so you can't race-cancel a just-saved server.

ScarfGoTabRoot takes the server's ServerID as the context id so the
CitadelServerTransport pool caches per-server (two active servers →
two connection holders, no SSH channel contention). Splits the v1
onDisconnect into two callbacks:
- onSoftDisconnect: close transport, return to server list, keep creds.
- onForget: wipe this server's creds + return to server list (or
  onboarding if empty).

MoreTab renders both Disconnect and Forget rows in distinct sections
with explicit footers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-24 13:55:31 +02:00
parent aafd9643a4
commit bb399e6d35
5 changed files with 404 additions and 49 deletions
@@ -53,17 +53,25 @@ public final class OnboardingViewModel {
private let configStore: any IOSServerConfigStore
private let tester: any SSHConnectionTester
private let keyGenerator: KeyGenerator
/// ServerID under which to save the key + config on completion.
/// Single-server v1 left this nil and the stores fell back to the
/// singleton APIs. M9 multi-server passes in a fresh ID from the
/// caller (or an existing ID when re-onboarding an existing row),
/// so the save lands in the right slot.
public let targetServerID: ServerID?
public init(
keyStore: any SSHKeyStore,
configStore: any IOSServerConfigStore,
tester: any SSHConnectionTester,
keyGenerator: @escaping KeyGenerator
keyGenerator: @escaping KeyGenerator,
targetServerID: ServerID? = nil
) {
self.keyStore = keyStore
self.configStore = configStore
self.tester = tester
self.keyGenerator = keyGenerator
self.targetServerID = targetServerID
}
// MARK: - Derived
@@ -142,7 +150,11 @@ public final class OnboardingViewModel {
defer { isWorking = false }
do {
try await keyStore.save(bundle)
if let id = targetServerID {
try await keyStore.save(bundle, for: id)
} else {
try await keyStore.save(bundle)
}
} catch {
lastTestError = .other("Couldn't save key to Keychain: \(error.localizedDescription)")
step = .testFailed(reason: lastTestError?.errorDescription ?? "Keychain save failed")
@@ -190,7 +202,11 @@ public final class OnboardingViewModel {
do {
try await tester.testConnection(config: config, key: bundle)
try await configStore.save(config)
if let id = targetServerID {
try await configStore.save(config, id: id)
} else {
try await configStore.save(config)
}
step = .connected
} catch let err as SSHConnectionTestError {
lastTestError = err