Files
scarf/scarf/Scarf iOS/App/ScarfGoTabRoot.swift
T
Alan Wizemann bb399e6d35 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>
2026-04-24 13:55:31 +02:00

199 lines
7.5 KiB
Swift

import SwiftUI
import ScarfCore
import ScarfIOS
/// ScarfGo's primary navigation surface. Replaces the pre-M8
/// "Dashboard is the hub" pattern where Chat/Memory/Cron/Skills/
/// Settings lived as NavigationLink rows three-quarters of the way
/// down a scrolling List pass-1 user-visible complaint:
///
/// > "We should have the actions for the user in a permanent footer?
/// > I don't see any navigation."
///
/// 4 primary tabs + a "More" bucket for the read-heavy / seldom-used
/// features. Uses iOS 18's `.sidebarAdaptable` tab style so the same
/// tree degrades to a bottom tab bar on iPhone and gets a native
/// sidebar on iPadOS / macCatalyst if we ever add those targets.
///
/// Each tab wraps its feature view in its own `NavigationStack` so
/// push navigation (Cron editor, Memory detail, etc.) stays scoped
/// to the tab instead of bleeding across.
struct ScarfGoTabRoot: View {
let serverID: ServerID
let config: IOSServerConfig
let key: SSHKeyBundle
let onSoftDisconnect: @MainActor () async -> Void
let onForget: @MainActor () async -> Void
var body: some View {
// The transport factory is keyed by ServerID, so the correct
// Keychain slot + config is picked automatically. Reuses the
// server's own id as the context id so the CitadelServerTransport
// pool caches per-server (instead of the singleton we had
// pre-M9). Two active servers two connection holders, no
// SSH channel contention.
let ctx = config.toServerContext(id: serverID)
TabView {
// 1 Chat: the reason the app is on your phone. Primary
// tab; opens straight into the chat surface.
NavigationStack {
ChatView(config: config, key: key)
}
.tabItem {
Label("Chat", systemImage: "bubble.left.and.bubble.right.fill")
}
// 2 Dashboard: stats + recent sessions (no surfaces list
// anymore those live in More).
NavigationStack {
DashboardView(config: config, key: key)
}
.tabItem {
Label("Dashboard", systemImage: "gauge.with.needle")
}
// 3 Memory: MEMORY.md + USER.md + SOUL.md.
NavigationStack {
MemoryListView(config: config)
}
.tabItem {
Label("Memory", systemImage: "brain.head.profile")
}
// 4 More: Cron, Skills, Settings, plus the destructive
// "Forget this server" action. Named "More" because on
// iOS 18 with .sidebarAdaptable the system collapses
// leftover tabs into a disclosure group with that exact
// label automatically; choosing the same word keeps our
// More tab visually consistent with the system default.
NavigationStack {
MoreTab(
config: config,
onSoftDisconnect: onSoftDisconnect,
onForget: onForget
)
}
.tabItem {
Label("More", systemImage: "ellipsis.circle")
}
}
// Pulls the sidebar-on-iPad affordance into the same code path
// as the bottom-bar-on-iPhone one. No-op on iPhone today.
.tabViewStyle(.sidebarAdaptable)
.environment(\.serverContext, ctx)
}
}
/// Groups the features that don't deserve a primary tab on a phone:
/// Cron (infrequent edits), Skills (read-only), Settings (read-only
/// until M9 scoped editor), plus the destructive server-forget action.
///
/// Kept private to this file because we don't expect it to be reused
/// elsewhere if a feature graduates to a primary tab, that's a
/// deliberate design decision.
private struct MoreTab: View {
let config: IOSServerConfig
let onSoftDisconnect: @MainActor () async -> Void
let onForget: @MainActor () async -> Void
@State private var showForgetConfirmation = false
@State private var isForgetting = false
@State private var isDisconnecting = false
var body: some View {
List {
Section("Server") {
LabeledContent("Host", value: config.host)
if let user = config.user {
LabeledContent("User", value: user)
}
if let port = config.port {
LabeledContent("Port", value: String(port))
}
}
Section("Features") {
NavigationLink {
CronListView(config: config)
} label: {
Label("Cron jobs", systemImage: "clock.arrow.circlepath")
}
.scarfGoCompactListRow()
NavigationLink {
SkillsListView(config: config)
} label: {
Label("Skills", systemImage: "sparkles")
}
.scarfGoCompactListRow()
NavigationLink {
SettingsView(config: config)
} label: {
Label("Settings", systemImage: "gearshape.fill")
}
.scarfGoCompactListRow()
}
Section {
Button {
Task {
isDisconnecting = true
await onSoftDisconnect()
}
} label: {
HStack {
Spacer()
if isDisconnecting {
ProgressView()
} else {
Text("Disconnect")
}
Spacer()
}
}
.disabled(isDisconnecting || isForgetting)
} footer: {
Text("Closes the live connection. Your key and host details stay on this device; tapping the server from the list reconnects with no re-onboarding.")
.font(.caption)
}
Section {
Button(role: .destructive) {
showForgetConfirmation = true
} label: {
HStack {
Spacer()
if isForgetting {
ProgressView()
} else {
Text("Forget this server")
}
Spacer()
}
}
.disabled(isForgetting || isDisconnecting)
} footer: {
Text("Removes this server's SSH key and host info from the device. You'll need to add the public key back to `~/.ssh/authorized_keys` to reconnect.")
.font(.caption)
}
}
.scarfGoListDensity()
.navigationTitle("More")
.navigationBarTitleDisplayMode(.inline)
.confirmationDialog(
"Forget this server?",
isPresented: $showForgetConfirmation,
titleVisibility: .visible
) {
Button("Forget \(config.displayName)", role: .destructive) {
Task {
isForgetting = true
await onForget()
}
}
Button("Cancel", role: .cancel) {}
} message: {
Text("Your SSH key and host settings for \(config.displayName) will be removed. Other servers stay configured. This cannot be undone.")
}
}
}