mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
00ca7229df
Adds the ability to manage multiple Hermes installations — local and remote over SSH — from the same Scarf app, each in its own window. Architecture: - ServerContext value type carries per-server identity + paths through every VM and service. ContentView routes serverContext into each feature view's init; all 22 routed views thread it through to their @State VMs. - ServerTransport protocol with LocalTransport (FileManager/Process/ FSEvents) and SSHTransport (system ssh + scp + ControlMaster). Services were ported from direct Foundation I/O to transport-routed helpers so the same code runs against local or remote. - WindowGroup(for: ServerID.self) gives each window its own AppCoordinator + HermesFileWatcher + ChatViewModel. File menu has Open Server commands with keyboard shortcuts (⌘1..⌘9). MenuBarExtra fans out per-server with start/stop/restart controls. - ServerRegistry persists connections to ~/Library/Application Support/scarf/servers.json. Add Server sheet probes the remote with ssh -v to capture the full handshake on failure. - Connection-status pill in remote-window toolbars with silent reconnect (3s retry on first failure, escalate to red after 2 consecutive), known-hosts-mismatch + ssh-add hint cards with copy buttons. Concurrency / UX hardening (the parts learned the hard way during dogfooding — captured in the feedback memory): - ServerContext exposes context.readText / readData / writeText / fileExists / runHermes / openInLocalEditor as the canonical I/O surface. Every VM uses these; never raw FileManager / Process() / NSWorkspace.open with a Hermes path. - SSHTransport.remotePathArg rewrites ~/foo to "$HOME/foo" so paths expand correctly inside the sh -c command we build (POSIX shells don't expand ~ inside any quotes). - Heavy VM load() methods detach to a background task and commit results back via MainActor.run, so synchronous ssh round-trips don't beach-ball the UI. Applied to Dashboard, Memory, Settings, MCPServers, Cron, Plugins, Personalities, QuickCommands, Skills, Gateway, Health, CredentialPools. - LoadingOverlay modifier shows a spinner over empty/stale section content during background reloads. - enrichedShellEnv (zsh -l -i probe, up to 8s) is now warmed at app launch off-main so first MainActor caller doesn't block. - Drop the file watcher's 5s heartbeat — FSEvents covers real changes and the heartbeat was triggering wasted reloads across every subscribing view. Chat polish: - ChatViewModel.hermesBinaryExists is a stored bool probed once at init, not a sync transport call evaluated on every body re-render. - MessageGroupView identifies assistant bubbles by array offset rather than message.id, so the streaming → finalized id transition no longer destroys + recreates the bubble. - Static scroll anchor in RichChatMessageList prevents two onChange handlers from racing on isWorking flips. Branch state: feature complete, in active dogfooding. Plan + per-phase status live at ~/.claude/plans/we-developed-an-application-harmonic-stroustrup.md; the four hard-won transport/concurrency rules are saved in the ServerContext-pattern feedback memory for future sessions. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
82 lines
2.4 KiB
Swift
82 lines
2.4 KiB
Swift
import Foundation
|
|
|
|
@Observable
|
|
final class ProjectsViewModel {
|
|
let context: ServerContext
|
|
private let service: ProjectDashboardService
|
|
|
|
init(context: ServerContext = .local) {
|
|
self.context = context
|
|
self.service = ProjectDashboardService(context: context)
|
|
}
|
|
|
|
|
|
var projects: [ProjectEntry] = []
|
|
var selectedProject: ProjectEntry?
|
|
var dashboard: ProjectDashboard?
|
|
var dashboardError: String?
|
|
var isLoading = false
|
|
|
|
func load() {
|
|
let registry = service.loadRegistry()
|
|
projects = registry.projects
|
|
if let selected = selectedProject, !projects.contains(where: { $0.name == selected.name }) {
|
|
selectedProject = nil
|
|
dashboard = nil
|
|
}
|
|
if let selected = selectedProject {
|
|
loadDashboard(for: selected)
|
|
}
|
|
}
|
|
|
|
func selectProject(_ project: ProjectEntry) {
|
|
selectedProject = project
|
|
loadDashboard(for: project)
|
|
}
|
|
|
|
func addProject(name: String, path: String) {
|
|
var registry = service.loadRegistry()
|
|
guard !registry.projects.contains(where: { $0.name == name }) else { return }
|
|
let entry = ProjectEntry(name: name, path: path)
|
|
registry.projects.append(entry)
|
|
service.saveRegistry(registry)
|
|
projects = registry.projects
|
|
selectProject(entry)
|
|
}
|
|
|
|
func removeProject(_ project: ProjectEntry) {
|
|
var registry = service.loadRegistry()
|
|
registry.projects.removeAll { $0.name == project.name }
|
|
service.saveRegistry(registry)
|
|
projects = registry.projects
|
|
if selectedProject?.name == project.name {
|
|
selectedProject = nil
|
|
dashboard = nil
|
|
}
|
|
}
|
|
|
|
func refreshDashboard() {
|
|
guard let project = selectedProject else { return }
|
|
loadDashboard(for: project)
|
|
}
|
|
|
|
var dashboardPaths: [String] {
|
|
projects.map(\.dashboardPath)
|
|
}
|
|
|
|
private func loadDashboard(for project: ProjectEntry) {
|
|
dashboardError = nil
|
|
if !service.dashboardExists(for: project) {
|
|
dashboard = nil
|
|
dashboardError = "No dashboard found at \(project.dashboardPath)"
|
|
return
|
|
}
|
|
if let loaded = service.loadDashboard(for: project) {
|
|
dashboard = loaded
|
|
} else {
|
|
dashboard = nil
|
|
dashboardError = "Failed to parse dashboard JSON"
|
|
}
|
|
}
|
|
}
|