Files
scarf/scarf/Scarf iOS/Projects/ProjectsListView.swift
T
Alan Wizemann a73025aba0 feat(ios): 5-tab nav + Projects/Skills feature parity with Mac
Major iOS UI refactor that brings ScarfGo to feature parity with the
Mac app for Projects + Skills, on top of a ScarfCore consolidation
that unifies the view-model + scanner/parser layer between platforms.

Layout (ScarfGoTabRoot):
- Old: Chat / Dashboard / Memory / More (4 tabs).
- New: Dashboard / Projects / Chat / Skills / System (5 tabs, Chat
  centered). Memory + Cron + Settings consolidate under System.

Projects (NEW iOS feature):
- ProjectsListView, ProjectDetailView, ProjectSessionsView_iOS,
  ProjectSiteView.
- Widgets/ subdir: 7 widget views (Chart, List, Progress, Stat,
  Table, Text, Webview) + WidgetHelpers + DashboardWidgetsView.
- Tied to chat via ScarfGoCoordinator.startChatInProject() which
  sets pendingProjectChat + flips selectedTab to .chat.

Skills (NEW iOS surface):
- SkillsView is a 3-sub-tab switcher (Installed / Browse Hub / Updates).
- Installed/: InstalledSkillsListView, SkillDetailView,
  SkillEditorSheet.
- Hub/HubBrowseView for the skills hub catalog.
- Updates/UpdatesView for hermes skills check / update.

ScarfCore consolidation:
- SkillsViewModel and ProjectSessionsViewModel lift from Mac target
  into ScarfCore so iOS and Mac share one type.
- New SkillsScanner walks ~/.hermes/skills/ once for both platforms
  via the supplied transport.
- New SkillFrontmatterParser handles required_config: parsing.
- New HermesSkillsHubParser for the hub catalog format.
- Tests for both new parsers.

Mac touchpoints:
- Features/Skills/Views/SkillsView.swift: .onAppear wraps the now-
  async load() in a Task.
- Old Mac-target SkillsViewModel and ProjectSessionsViewModel
  deleted (replaced by ScarfCore).

Coordinator + chat:
- ScarfGoCoordinator gains pendingProjectChat: String?
  + startChatInProject(path:) helper.
- iOS ChatView consumes pendingProjectChat (mirrors the existing
  pendingResumeSessionID pattern); resolves path → ProjectEntry via
  registry, falls back to a synthesized entry on miss.

Tests:
- M5FeatureVMTests renames 3 IOSSkillsViewModel references to the
  shared SkillsViewModel.
- New SkillFrontmatterParserTests + SkillsHubParserTests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 09:52:16 +02:00

145 lines
5.3 KiB
Swift

import SwiftUI
import ScarfCore
/// Top-level Projects tab. Lists registered Scarf projects from
/// `~/.hermes/scarf/projects.json`. Folder groupings + archive flags
/// from the v2.3 registry schema are honored archived projects are
/// hidden, top-level projects render flat, and any non-empty folder
/// labels become a `Section` per folder.
///
/// Read-only on iOS for v2.5 add / rename / move / archive happens
/// in the Mac app, where the template installer + ConfigEditor live.
/// The empty state copy directs users there.
struct ProjectsListView: View {
let config: IOSServerConfig
private static let sharedContextID: ServerID = ServerID(
uuidString: "00000000-0000-0000-0000-0000000000A2"
)!
@State private var projects: [ProjectEntry] = []
@State private var isLoading: Bool = true
@State private var loadError: String?
private var serverContext: ServerContext {
config.toServerContext(id: Self.sharedContextID)
}
var body: some View {
Group {
if isLoading && projects.isEmpty {
ProgressView("Loading projects…")
} else if let err = loadError, projects.isEmpty {
ContentUnavailableView {
Label("Couldn't load projects", systemImage: "exclamationmark.triangle.fill")
} description: {
Text(err)
}
} else if visibleProjects.isEmpty {
ContentUnavailableView {
Label("No projects yet", systemImage: "square.grid.2x2")
} description: {
Text("Use the Mac app to add and configure projects — they'll appear here automatically.")
}
} else {
projectList
}
}
.navigationTitle("Projects")
.navigationBarTitleDisplayMode(.inline)
.navigationDestination(for: ProjectEntry.self) { project in
ProjectDetailView(project: project, config: config)
}
.refreshable { await load() }
.task { await load() }
}
@ViewBuilder
private var projectList: some View {
let folders = folderLabels
List {
// Top-level (no folder) projects first, then folder
// disclosure sections same shape as Mac
// ProjectsSidebar.swift renders.
let topLevel = visibleProjects.filter { ($0.folder ?? "").isEmpty }
if !topLevel.isEmpty {
Section {
ForEach(topLevel) { project in
projectRow(project)
}
}
}
ForEach(folders, id: \.self) { folder in
Section(folder) {
ForEach(visibleProjects.filter { $0.folder == folder }) { project in
projectRow(project)
}
}
}
}
.scarfGoListDensity()
}
private func projectRow(_ project: ProjectEntry) -> some View {
NavigationLink(value: project) {
HStack(alignment: .center, spacing: 12) {
Image(systemName: "folder.fill")
.font(.title3)
.foregroundStyle(.tint)
.frame(width: 28)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 2) {
Text(project.name)
.font(.body)
.foregroundStyle(.primary)
Text(project.path)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
}
}
.scarfGoCompactListRow()
.accessibilityElement(children: .combine)
.accessibilityLabel("\(project.name), at \(project.path)")
.accessibilityHint("Opens project dashboard, site, and sessions")
}
/// Visible projects = registry minus archived, sorted alphabetically.
/// Mirrors Mac sidebar's default filter.
private var visibleProjects: [ProjectEntry] {
projects
.filter { !$0.archived }
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
}
/// Distinct, sorted folder labels across the visible set. Empty
/// strings are treated as top-level (filtered out here so they
/// don't render as a "" section title).
private var folderLabels: [String] {
let set = Set(visibleProjects.compactMap(\.folder).filter { !$0.isEmpty })
return set.sorted()
}
/// Load the project registry over the active transport. Same
/// pattern as `ProjectPickerSheet.loadProjects` wrap the
/// synchronous `ProjectDashboardService` calls in `Task.detached`
/// so the SFTP read doesn't run on the MainActor.
private func load() async {
isLoading = true
defer { isLoading = false }
let ctx = serverContext
do {
let loaded: [ProjectEntry] = try await Task.detached {
let service = ProjectDashboardService(context: ctx)
return service.loadRegistry().projects
}.value
projects = loaded
loadError = nil
} catch {
loadError = error.localizedDescription
}
}
}