mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 02:26:37 +00:00
bb399e6d35
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>
199 lines
7.5 KiB
Swift
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.")
|
|
}
|
|
}
|
|
}
|