mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
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>
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
import Foundation
|
||||
|
||||
/// Pure-Swift parsers for `hermes skills` CLI output. Extracted from
|
||||
/// the Mac `SkillsViewModel` in v2.5 so iOS can share the same parse
|
||||
/// logic — both targets call `transport.runProcess(executable: hermes…)`
|
||||
/// and feed the captured stdout/stderr through these parsers.
|
||||
///
|
||||
/// Marked `Sendable` so they can run inside `Task.detached` blocks
|
||||
/// without isolation gymnastics. All members are `nonisolated`.
|
||||
public enum HermesSkillsHubParser: Sendable {
|
||||
|
||||
/// Parse `hermes skills browse|search` output.
|
||||
///
|
||||
/// Hermes emits a Rich box-drawn table with vertical bars as column
|
||||
/// separators:
|
||||
///
|
||||
/// │ # │ Name │ Description │ Source │ Trust │
|
||||
/// ├──────┼────────────────┼────────────────────────┼──────────────┼────────────┤
|
||||
/// │ 1 │ 1password │ Set up and use 1Pass… │ official │ ★ official │
|
||||
///
|
||||
/// Description cells can wrap across multiple rows — the
|
||||
/// continuation rows have an empty `#` column. We join consecutive
|
||||
/// rows with the same skill by checking whether the first column
|
||||
/// (after `│`) is whitespace-only.
|
||||
public static func parseHubList(_ output: String) -> [HermesHubSkill] {
|
||||
var results: [HermesHubSkill] = []
|
||||
for raw in output.components(separatedBy: "\n") {
|
||||
let line = raw
|
||||
// Skip everything that isn't a data row. Data rows start
|
||||
// with `│` and contain multiple `│` separators. Border
|
||||
// rows (`┏`, `┡`, `├`, `└`, etc.) are drawn with `━` or
|
||||
// `─` and should be skipped.
|
||||
guard line.contains("│") else { continue }
|
||||
let cells = line
|
||||
.split(separator: "│", omittingEmptySubsequences: false)
|
||||
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||
// Expect at least: leading empty, #, Name, Description,
|
||||
// Source, Trust, trailing empty
|
||||
guard cells.count >= 6 else { continue }
|
||||
|
||||
let numCell = cells[1]
|
||||
let nameCell = cells[2]
|
||||
let descCell = cells[3]
|
||||
let sourceCell = cells[4]
|
||||
// Trust column (index 5) is informational only — we ignore
|
||||
// it in the UI.
|
||||
|
||||
// Continuation row: `#` column is empty. Merge its
|
||||
// description into the last-added entry if present.
|
||||
if numCell.isEmpty {
|
||||
guard !results.isEmpty else { continue }
|
||||
let last = results.removeLast()
|
||||
let merged = [last.description, descCell]
|
||||
.filter { !$0.isEmpty }
|
||||
.joined(separator: " ")
|
||||
results.append(HermesHubSkill(
|
||||
identifier: last.identifier,
|
||||
name: last.name,
|
||||
description: merged,
|
||||
source: last.source
|
||||
))
|
||||
continue
|
||||
}
|
||||
// Header row — first data-looking row whose number cell
|
||||
// isn't a digit.
|
||||
if Int(numCell) == nil { continue }
|
||||
// Empty name cell shouldn't happen but guard anyway.
|
||||
guard !nameCell.isEmpty else { continue }
|
||||
|
||||
// Identifier: `hermes skills browse` shows the short name
|
||||
// in the Name column. For install we need the full
|
||||
// identifier like `<source>/<name>`. The CLI accepts just
|
||||
// the name for official hub, so we use that as the install
|
||||
// target.
|
||||
let source = sourceCell
|
||||
.replacingOccurrences(of: "★", with: "")
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
results.append(HermesHubSkill(
|
||||
identifier: nameCell,
|
||||
name: nameCell,
|
||||
description: descCell,
|
||||
source: source
|
||||
))
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
/// Parse `hermes skills check` output for available updates. Format
|
||||
/// is undocumented; we look for `→` (U+2192) or `->` arrow markers
|
||||
/// between version strings.
|
||||
public static func parseUpdateList(_ output: String) -> [HermesSkillUpdate] {
|
||||
var results: [HermesSkillUpdate] = []
|
||||
for raw in output.components(separatedBy: "\n") {
|
||||
let line = raw.trimmingCharacters(in: .whitespaces)
|
||||
guard line.contains("→") || line.contains("->") else { continue }
|
||||
let marker = line.contains("→") ? "→" : "->"
|
||||
let parts = line.components(separatedBy: marker)
|
||||
guard parts.count == 2 else { continue }
|
||||
let left = parts[0].trimmingCharacters(in: .whitespaces)
|
||||
let available = parts[1].trimmingCharacters(in: .whitespaces)
|
||||
let leftTokens = left
|
||||
.split(separator: " ", omittingEmptySubsequences: true)
|
||||
.map(String.init)
|
||||
guard leftTokens.count >= 2 else { continue }
|
||||
let identifier = leftTokens[0]
|
||||
let current = leftTokens[leftTokens.count - 1]
|
||||
results.append(HermesSkillUpdate(
|
||||
identifier: identifier,
|
||||
currentVersion: current,
|
||||
availableVersion: available
|
||||
))
|
||||
}
|
||||
return results
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public model types
|
||||
|
||||
/// A single search/browse result from a skill registry. Mirrors the
|
||||
/// shape `SkillsViewModel` had on Mac before the v2.5 ScarfCore promotion.
|
||||
public struct HermesHubSkill: Identifiable, Sendable, Equatable {
|
||||
public var id: String { identifier }
|
||||
public let identifier: String // e.g. "openai/skills/skill-creator"
|
||||
public let name: String
|
||||
public let description: String
|
||||
public let source: String // "official" | "skills-sh" | etc.
|
||||
|
||||
public init(
|
||||
identifier: String,
|
||||
name: String,
|
||||
description: String,
|
||||
source: String
|
||||
) {
|
||||
self.identifier = identifier
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.source = source
|
||||
}
|
||||
}
|
||||
|
||||
/// A local skill that has an upstream version available.
|
||||
public struct HermesSkillUpdate: Identifiable, Sendable, Equatable {
|
||||
public var id: String { identifier }
|
||||
public let identifier: String
|
||||
public let currentVersion: String
|
||||
public let availableVersion: String
|
||||
|
||||
public init(
|
||||
identifier: String,
|
||||
currentVersion: String,
|
||||
availableVersion: String
|
||||
) {
|
||||
self.identifier = identifier
|
||||
self.currentVersion = currentVersion
|
||||
self.availableVersion = availableVersion
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import Foundation
|
||||
|
||||
/// Pure-Swift YAML-frontmatter parser for skill manifests' `required_config:`
|
||||
/// list. Extracted from `HermesFileService.parseSkillRequiredConfig` in
|
||||
/// v2.5 so iOS can flag missing config keys without depending on the
|
||||
/// Mac target.
|
||||
///
|
||||
/// Intentionally not a full YAML parser — Hermes skill manifests use a
|
||||
/// very narrow subset of YAML for this list. We look for a top-level
|
||||
/// `required_config:` key followed by `- key` entries with at least one
|
||||
/// space of indent. Lines outside that section are ignored.
|
||||
public enum SkillFrontmatterParser: Sendable {
|
||||
|
||||
/// Parse the `required_config:` list from a skill.yaml's text. Empty
|
||||
/// result on any kind of malformation — callers treat it as "no
|
||||
/// required config, proceed".
|
||||
public static func parseRequiredConfig(_ content: String) -> [String] {
|
||||
var result: [String] = []
|
||||
var inRequiredConfig = false
|
||||
for line in content.components(separatedBy: "\n") {
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
if trimmed.isEmpty || trimmed.hasPrefix("#") { continue }
|
||||
let indent = line.prefix(while: { $0 == " " }).count
|
||||
if trimmed == "required_config:" || trimmed.hasPrefix("required_config:") {
|
||||
inRequiredConfig = true
|
||||
continue
|
||||
}
|
||||
if inRequiredConfig {
|
||||
if indent < 2 && !trimmed.isEmpty {
|
||||
break
|
||||
}
|
||||
if trimmed.hasPrefix("- ") {
|
||||
result.append(String(trimmed.dropFirst(2)))
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// Walks `~/.hermes/skills/<category>/<name>/` and returns a populated
|
||||
/// list of `HermesSkillCategory`. Body ported from
|
||||
/// `HermesFileService.loadSkills` in v2.5 so iOS and Mac share the same
|
||||
/// scan logic — only difference vs the Mac function is that this one
|
||||
/// reads through the supplied transport rather than holding its own.
|
||||
///
|
||||
/// Synchronous + transport-backed: callers running on the MainActor
|
||||
/// should wrap in `Task.detached` (the iOS pattern) since SFTP `stat` /
|
||||
/// `listDirectory` calls block.
|
||||
public enum SkillsScanner: Sendable {
|
||||
private static let logger = Logger(subsystem: "com.scarf", category: "SkillsScanner")
|
||||
|
||||
public static func scan(context: ServerContext, transport: any ServerTransport) -> [HermesSkillCategory] {
|
||||
let dir = context.paths.skillsDir
|
||||
// Fresh install: skills/ may not exist yet — return [] without
|
||||
// logging an error.
|
||||
guard transport.fileExists(dir) else { return [] }
|
||||
guard let categories = try? transport.listDirectory(dir) else { return [] }
|
||||
|
||||
return categories
|
||||
.filter { !$0.hasPrefix(".") }
|
||||
.sorted()
|
||||
.compactMap { categoryName -> HermesSkillCategory? in
|
||||
let categoryPath = dir + "/" + categoryName
|
||||
guard transport.stat(categoryPath)?.isDirectory == true else { return nil }
|
||||
guard let skillNames = try? transport.listDirectory(categoryPath) else { return nil }
|
||||
|
||||
let skills = skillNames
|
||||
.filter { !$0.hasPrefix(".") }
|
||||
.sorted()
|
||||
.compactMap { skillName -> HermesSkill? in
|
||||
let skillPath = categoryPath + "/" + skillName
|
||||
guard transport.stat(skillPath)?.isDirectory == true else { return nil }
|
||||
let files = ((try? transport.listDirectory(skillPath)) ?? [])
|
||||
.filter { !$0.hasPrefix(".") }
|
||||
.sorted()
|
||||
let requiredConfig = readRequiredConfig(
|
||||
yamlPath: skillPath + "/skill.yaml",
|
||||
transport: transport
|
||||
)
|
||||
return HermesSkill(
|
||||
id: categoryName + "/" + skillName,
|
||||
name: skillName,
|
||||
category: categoryName,
|
||||
path: skillPath,
|
||||
files: files,
|
||||
requiredConfig: requiredConfig
|
||||
)
|
||||
}
|
||||
|
||||
guard !skills.isEmpty else { return nil }
|
||||
return HermesSkillCategory(id: categoryName, name: categoryName, skills: skills)
|
||||
}
|
||||
}
|
||||
|
||||
private static func readRequiredConfig(yamlPath: String, transport: any ServerTransport) -> [String] {
|
||||
guard let data = try? transport.readFile(yamlPath),
|
||||
let content = String(data: data, encoding: .utf8)
|
||||
else { return [] }
|
||||
return SkillFrontmatterParser.parseRequiredConfig(content)
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// iOS read-only Skills view-state. Scans `~/.hermes/skills/` for
|
||||
/// category directories, then each category for skill directories,
|
||||
/// then each skill directory for its file list. Mirrors what the
|
||||
/// Mac app's `HermesFileService.loadSkills` does but scoped to what
|
||||
/// the transport's `listDirectory` primitive can surface (no deep
|
||||
/// YAML frontmatter parsing — users who want to inspect a skill's
|
||||
/// full definition still do that on the Mac).
|
||||
///
|
||||
/// M5 is read-only by design. Installing new skills would need a
|
||||
/// git-clone over SSH plus schema validation; that's a separate
|
||||
/// feature in a later phase.
|
||||
@Observable
|
||||
@MainActor
|
||||
public final class IOSSkillsViewModel {
|
||||
public let context: ServerContext
|
||||
|
||||
public private(set) var categories: [HermesSkillCategory] = []
|
||||
public private(set) var isLoading: Bool = true
|
||||
public private(set) var lastError: String?
|
||||
|
||||
public init(context: ServerContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
public func load() async {
|
||||
isLoading = true
|
||||
lastError = nil
|
||||
let ctx = context
|
||||
let skillsRoot = ctx.paths.skillsDir
|
||||
|
||||
let loaded: Result<[HermesSkillCategory], Error> = await Task.detached {
|
||||
let transport = ctx.makeTransport()
|
||||
guard transport.fileExists(skillsRoot) else {
|
||||
// Fresh install → no skills/ dir yet.
|
||||
return .success([])
|
||||
}
|
||||
do {
|
||||
let categoryNames = try transport.listDirectory(skillsRoot)
|
||||
.filter { !$0.hasPrefix(".") }
|
||||
.sorted()
|
||||
|
||||
var categories: [HermesSkillCategory] = []
|
||||
for categoryName in categoryNames {
|
||||
let categoryPath = skillsRoot + "/" + categoryName
|
||||
// Only include directories.
|
||||
guard transport.stat(categoryPath)?.isDirectory == true else { continue }
|
||||
|
||||
var skills: [HermesSkill] = []
|
||||
let skillNames: [String]
|
||||
do {
|
||||
skillNames = try transport.listDirectory(categoryPath)
|
||||
.filter { !$0.hasPrefix(".") }
|
||||
.sorted()
|
||||
} catch {
|
||||
// Skip categories we can't read (permissions etc.)
|
||||
// rather than failing the whole load.
|
||||
continue
|
||||
}
|
||||
|
||||
for skillName in skillNames {
|
||||
let skillPath = categoryPath + "/" + skillName
|
||||
guard transport.stat(skillPath)?.isDirectory == true else { continue }
|
||||
let files: [String] = (try? transport.listDirectory(skillPath)) ?? []
|
||||
skills.append(HermesSkill(
|
||||
id: categoryName + "/" + skillName,
|
||||
name: skillName,
|
||||
category: categoryName,
|
||||
path: skillPath,
|
||||
files: files.filter { !$0.hasPrefix(".") }.sorted(),
|
||||
requiredConfig: [] // Skills frontmatter parsing deferred.
|
||||
))
|
||||
}
|
||||
|
||||
if !skills.isEmpty {
|
||||
categories.append(HermesSkillCategory(
|
||||
id: categoryName,
|
||||
name: categoryName,
|
||||
skills: skills
|
||||
))
|
||||
}
|
||||
}
|
||||
return .success(categories)
|
||||
} catch {
|
||||
return .failure(error)
|
||||
}
|
||||
}.value
|
||||
|
||||
switch loaded {
|
||||
case .success(let cats):
|
||||
categories = cats
|
||||
case .failure(let err):
|
||||
categories = []
|
||||
lastError = "Couldn't list skills: \(err.localizedDescription)"
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
+20
-18
@@ -1,23 +1,26 @@
|
||||
import Foundation
|
||||
import os
|
||||
import ScarfCore
|
||||
|
||||
/// Drives the per-project Sessions tab introduced in v2.3. Pulls the
|
||||
/// global session list from `HermesDataService`, filters by the
|
||||
/// attribution sidecar, and exposes a minimal surface for the view:
|
||||
/// the filtered sessions array, loading state, and a refresh entry
|
||||
/// point that the view can call on appearance + on file-watcher
|
||||
/// change.
|
||||
/// Drives the per-project Sessions tab introduced in v2.3 and reused
|
||||
/// by the iOS Project Detail view in v2.5. Pulls the global session
|
||||
/// list from `HermesDataService`, filters by the attribution sidecar,
|
||||
/// and exposes a minimal surface for the view: the filtered sessions
|
||||
/// array, loading state, and a refresh entry point that the view can
|
||||
/// call on appearance + on file-watcher change.
|
||||
///
|
||||
/// Promoted from the Mac target into ScarfCore in v2.5 so iOS and Mac
|
||||
/// share the exact same filtering + attribution semantics — there's
|
||||
/// nothing AppKit-specific here, just transport-backed I/O.
|
||||
@Observable
|
||||
@MainActor
|
||||
final class ProjectSessionsViewModel {
|
||||
public final class ProjectSessionsViewModel {
|
||||
private static let logger = Logger(subsystem: "com.scarf", category: "ProjectSessionsViewModel")
|
||||
|
||||
private let dataService: HermesDataService
|
||||
private let attribution: SessionAttributionService
|
||||
private let project: ProjectEntry
|
||||
|
||||
init(context: ServerContext, project: ProjectEntry) {
|
||||
public init(context: ServerContext, project: ProjectEntry) {
|
||||
self.dataService = HermesDataService(context: context)
|
||||
self.attribution = SessionAttributionService(context: context)
|
||||
self.project = project
|
||||
@@ -25,23 +28,23 @@ final class ProjectSessionsViewModel {
|
||||
|
||||
/// Sessions attributed to the owning project, in the order
|
||||
/// `HermesDataService.fetchSessions` returns them (newest first).
|
||||
var sessions: [HermesSession] = []
|
||||
public var sessions: [HermesSession] = []
|
||||
|
||||
/// True from `load()` start to its completion. The view renders
|
||||
/// a ProgressView during the first fetch; afterwards, re-fetches
|
||||
/// triggered by file-watcher changes happen silently.
|
||||
var isLoading: Bool = false
|
||||
public var isLoading: Bool = false
|
||||
|
||||
/// Short diagnostic string for an empty list — nil when sessions
|
||||
/// are loaded and populated, otherwise explains the empty state
|
||||
/// (no sessions ever created in this project, vs. no sessions
|
||||
/// matched the project's attribution map).
|
||||
var emptyStateHint: String?
|
||||
public var emptyStateHint: String?
|
||||
|
||||
/// Refresh the session list. Safe to call repeatedly; the data
|
||||
/// service reconnects to state.db on demand and the attribution
|
||||
/// service reads the sidecar afresh each call.
|
||||
func load() async {
|
||||
public func load() async {
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
|
||||
@@ -87,11 +90,10 @@ final class ProjectSessionsViewModel {
|
||||
}
|
||||
|
||||
/// Release the underlying DB handle. Safe to call repeatedly; the
|
||||
/// service re-opens on the next `load()`. Mirrors the pattern in
|
||||
/// ActivityViewModel.swift:80 — view calls this on `.onDisappear`
|
||||
/// so file descriptors and the SQLite cache don't dangle once
|
||||
/// the tab isn't visible.
|
||||
func close() async {
|
||||
/// service re-opens on the next `load()`. View calls this on
|
||||
/// `.onDisappear` so file descriptors and the SQLite cache don't
|
||||
/// dangle once the tab isn't visible.
|
||||
public func close() async {
|
||||
await dataService.close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// Unified Skills viewmodel. Promoted from the Mac target into ScarfCore
|
||||
/// in v2.5 so iOS and Mac share the exact same Installed / Hub / Updates
|
||||
/// state machine. Replaces the old Mac `SkillsViewModel` and the
|
||||
/// minimal iOS `IOSSkillsViewModel`.
|
||||
///
|
||||
/// Transport-backed throughout: skill scanning goes through
|
||||
/// `SkillsScanner.scan(context:transport:)`, file I/O goes through
|
||||
/// `transport.readFile / writeFile`, and CLI invocations go through
|
||||
/// `transport.runProcess(executable:args:stdin:timeout:)`. iOS gets the
|
||||
/// same hub features as Mac without a target-specific code path.
|
||||
@Observable
|
||||
public final class SkillsViewModel {
|
||||
private let logger = Logger(subsystem: "com.scarf", category: "SkillsViewModel")
|
||||
public let context: ServerContext
|
||||
private let transport: any ServerTransport
|
||||
|
||||
public init(context: ServerContext = .local) {
|
||||
self.context = context
|
||||
self.transport = context.makeTransport()
|
||||
}
|
||||
|
||||
// MARK: - Installed skills
|
||||
|
||||
public var categories: [HermesSkillCategory] = []
|
||||
public var selectedSkill: HermesSkill?
|
||||
public var skillContent = ""
|
||||
public var selectedFileName: String?
|
||||
public var searchText = ""
|
||||
public var missingConfig: [String] = []
|
||||
public var isEditing = false
|
||||
public var editText = ""
|
||||
/// True while the installed-skills scan is in flight. Renders a
|
||||
/// progress indicator on iOS; Mac historically didn't surface this
|
||||
/// from VM state but adding it doesn't break the existing UI.
|
||||
public var isLoading: Bool = false
|
||||
/// Diagnostic for a failed scan. Nil on success or when the dir
|
||||
/// is simply missing (fresh install).
|
||||
public var lastError: String?
|
||||
|
||||
// MARK: - Hub integration
|
||||
|
||||
public var hubQuery = ""
|
||||
public var hubResults: [HermesHubSkill] = []
|
||||
public var updates: [HermesSkillUpdate] = []
|
||||
public var isHubLoading = false
|
||||
public var hubMessage: String?
|
||||
public var hubSource: String = "all"
|
||||
|
||||
public let hubSources = ["all", "official", "skills-sh", "well-known", "github", "clawhub", "lobehub"]
|
||||
|
||||
public var filteredCategories: [HermesSkillCategory] {
|
||||
guard !searchText.isEmpty else { return categories }
|
||||
return categories.compactMap { category in
|
||||
let filtered = category.skills.filter {
|
||||
$0.name.localizedCaseInsensitiveContains(searchText) ||
|
||||
$0.category.localizedCaseInsensitiveContains(searchText)
|
||||
}
|
||||
guard !filtered.isEmpty else { return nil }
|
||||
return HermesSkillCategory(id: category.id, name: category.name, skills: filtered)
|
||||
}
|
||||
}
|
||||
|
||||
public var totalSkillCount: Int {
|
||||
categories.reduce(0) { $0 + $1.skills.count }
|
||||
}
|
||||
|
||||
/// Awaitable scan. iOS's `.task { await vm.load() }` and the
|
||||
/// ScarfCore unit tests use this directly; Mac call sites wrap in
|
||||
/// `Task { await ... }` from `onAppear`.
|
||||
@MainActor
|
||||
public func load() async {
|
||||
isLoading = true
|
||||
lastError = nil
|
||||
let ctx = context
|
||||
let xport = transport
|
||||
let cats: [HermesSkillCategory] = await Task.detached {
|
||||
SkillsScanner.scan(context: ctx, transport: xport)
|
||||
}.value
|
||||
categories = cats
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
public func selectSkill(_ skill: HermesSkill) {
|
||||
selectedSkill = skill
|
||||
let mainFile = skill.files.first(where: { $0.hasSuffix(".md") }) ?? skill.files.first
|
||||
if let file = mainFile {
|
||||
selectedFileName = file
|
||||
skillContent = loadSkillContent(path: skill.path + "/" + file)
|
||||
} else {
|
||||
selectedFileName = nil
|
||||
skillContent = ""
|
||||
}
|
||||
missingConfig = computeMissingConfig(for: skill)
|
||||
}
|
||||
|
||||
private func computeMissingConfig(for skill: HermesSkill) -> [String] {
|
||||
guard !skill.requiredConfig.isEmpty else { return [] }
|
||||
guard let yaml = context.readText(context.paths.configYAML) else {
|
||||
return skill.requiredConfig
|
||||
}
|
||||
return skill.requiredConfig.filter { key in
|
||||
!yaml.contains(key)
|
||||
}
|
||||
}
|
||||
|
||||
public func selectFile(_ file: String) {
|
||||
guard let skill = selectedSkill else { return }
|
||||
selectedFileName = file
|
||||
skillContent = loadSkillContent(path: skill.path + "/" + file)
|
||||
}
|
||||
|
||||
public var isMarkdownFile: Bool {
|
||||
selectedFileName?.hasSuffix(".md") == true
|
||||
}
|
||||
|
||||
private var currentFilePath: String? {
|
||||
guard let skill = selectedSkill, let file = selectedFileName else { return nil }
|
||||
return skill.path + "/" + file
|
||||
}
|
||||
|
||||
public func startEditing() {
|
||||
editText = skillContent
|
||||
isEditing = true
|
||||
}
|
||||
|
||||
public func saveEdit() {
|
||||
guard let path = currentFilePath else { return }
|
||||
saveSkillContent(path: path, content: editText)
|
||||
skillContent = editText
|
||||
isEditing = false
|
||||
}
|
||||
|
||||
public func cancelEditing() {
|
||||
isEditing = false
|
||||
}
|
||||
|
||||
// MARK: - Hub browse / search / install / update
|
||||
|
||||
public func browseHub() {
|
||||
isHubLoading = true
|
||||
let bin = context.paths.hermesBinary
|
||||
let xport = transport
|
||||
let source = hubSource
|
||||
Task.detached { [weak self] in
|
||||
var args = ["skills", "browse", "--size", "40"]
|
||||
if source != "all" { args += ["--source", source] }
|
||||
let result = Self.runHermes(executable: bin, args: args, transport: xport, timeout: 30)
|
||||
let parsed = HermesSkillsHubParser.parseHubList(result.output)
|
||||
await self?.finishBrowse(results: parsed, exitCode: result.exitCode, isSearch: false)
|
||||
}
|
||||
}
|
||||
|
||||
public func searchHub() {
|
||||
guard !hubQuery.isEmpty else {
|
||||
browseHub()
|
||||
return
|
||||
}
|
||||
isHubLoading = true
|
||||
let bin = context.paths.hermesBinary
|
||||
let xport = transport
|
||||
let source = hubSource
|
||||
let query = hubQuery
|
||||
Task.detached { [weak self] in
|
||||
var args = ["skills", "search", query, "--limit", "40"]
|
||||
if source != "all" { args += ["--source", source] }
|
||||
let result = Self.runHermes(executable: bin, args: args, transport: xport, timeout: 30)
|
||||
let parsed = HermesSkillsHubParser.parseHubList(result.output)
|
||||
await self?.finishBrowse(results: parsed, exitCode: result.exitCode, isSearch: true)
|
||||
}
|
||||
}
|
||||
|
||||
public func installHubSkill(_ skill: HermesHubSkill) {
|
||||
isHubLoading = true
|
||||
hubMessage = "Installing \(skill.identifier)…"
|
||||
let bin = context.paths.hermesBinary
|
||||
let xport = transport
|
||||
let identifier = skill.identifier
|
||||
Task.detached { [weak self] in
|
||||
// --yes skips confirmation since we're running non-interactively.
|
||||
let result = Self.runHermes(
|
||||
executable: bin,
|
||||
args: ["skills", "install", identifier, "--yes"],
|
||||
transport: xport,
|
||||
timeout: 120
|
||||
)
|
||||
await self?.finishInstall(identifier: identifier, exitCode: result.exitCode)
|
||||
}
|
||||
}
|
||||
|
||||
public func uninstallHubSkill(_ identifier: String) {
|
||||
let bin = context.paths.hermesBinary
|
||||
let xport = transport
|
||||
Task.detached { [weak self] in
|
||||
let result = Self.runHermes(
|
||||
executable: bin,
|
||||
args: ["skills", "uninstall", identifier, "--yes"],
|
||||
transport: xport,
|
||||
timeout: 60
|
||||
)
|
||||
await self?.finishUninstall(exitCode: result.exitCode)
|
||||
}
|
||||
}
|
||||
|
||||
public func checkForUpdates() {
|
||||
isHubLoading = true
|
||||
let bin = context.paths.hermesBinary
|
||||
let xport = transport
|
||||
Task.detached { [weak self] in
|
||||
let result = Self.runHermes(
|
||||
executable: bin,
|
||||
args: ["skills", "check"],
|
||||
transport: xport,
|
||||
timeout: 60
|
||||
)
|
||||
let parsed = HermesSkillsHubParser.parseUpdateList(result.output)
|
||||
await self?.finishCheckForUpdates(updates: parsed)
|
||||
}
|
||||
}
|
||||
|
||||
public func updateAll() {
|
||||
let bin = context.paths.hermesBinary
|
||||
let xport = transport
|
||||
Task.detached { [weak self] in
|
||||
let result = Self.runHermes(
|
||||
executable: bin,
|
||||
args: ["skills", "update", "--yes"],
|
||||
transport: xport,
|
||||
timeout: 300
|
||||
)
|
||||
await self?.finishUpdateAll(exitCode: result.exitCode)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Hub action finishers
|
||||
//
|
||||
// Each detached task above bounces through exactly one of these
|
||||
// MainActor-isolated finishers. Keeping the post-CLI sequencing
|
||||
// (load + sleep + clear status) here means the detached closure
|
||||
// crosses the `self?` weak boundary only once — required for clean
|
||||
// builds under Swift 6 strict concurrency, and clearer to reason
|
||||
// about than the prior interleaved `MainActor.run` chains.
|
||||
|
||||
@MainActor
|
||||
private func finishBrowse(results: [HermesHubSkill], exitCode: Int32, isSearch: Bool) async {
|
||||
isHubLoading = false
|
||||
hubResults = results
|
||||
if results.isEmpty {
|
||||
hubMessage = isSearch
|
||||
? "No matches"
|
||||
: (exitCode == 0 ? "No results" : "Browse failed")
|
||||
} else {
|
||||
hubMessage = nil
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func finishInstall(identifier: String, exitCode: Int32) async {
|
||||
isHubLoading = false
|
||||
hubMessage = exitCode == 0 ? "Installed \(identifier)" : "Install failed"
|
||||
await load()
|
||||
try? await Task.sleep(nanoseconds: 3_000_000_000)
|
||||
hubMessage = nil
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func finishUninstall(exitCode: Int32) async {
|
||||
hubMessage = exitCode == 0 ? "Uninstalled" : "Uninstall failed"
|
||||
await load()
|
||||
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||
hubMessage = nil
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func finishCheckForUpdates(updates: [HermesSkillUpdate]) async {
|
||||
isHubLoading = false
|
||||
self.updates = updates
|
||||
hubMessage = updates.isEmpty ? "No updates available" : "\(updates.count) update(s)"
|
||||
try? await Task.sleep(nanoseconds: 3_000_000_000)
|
||||
hubMessage = nil
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func finishUpdateAll(exitCode: Int32) async {
|
||||
hubMessage = exitCode == 0 ? "Updated" : "Update failed"
|
||||
await load()
|
||||
checkForUpdates()
|
||||
try? await Task.sleep(nanoseconds: 3_000_000_000)
|
||||
hubMessage = nil
|
||||
}
|
||||
|
||||
// MARK: - Transport helpers
|
||||
|
||||
/// Combined stdout+stderr CLI runner. Mirrors the legacy
|
||||
/// `HermesFileService.runHermesCLI` shape so callers grepping
|
||||
/// through `output` keep working.
|
||||
nonisolated private static func runHermes(
|
||||
executable: String,
|
||||
args: [String],
|
||||
transport: any ServerTransport,
|
||||
timeout: TimeInterval
|
||||
) -> (exitCode: Int32, output: String) {
|
||||
do {
|
||||
let result = try transport.runProcess(
|
||||
executable: executable,
|
||||
args: args,
|
||||
stdin: nil,
|
||||
timeout: timeout
|
||||
)
|
||||
return (result.exitCode, result.stdoutString + result.stderrString)
|
||||
} catch let error as TransportError {
|
||||
return (-1, error.diagnosticStderr.isEmpty
|
||||
? (error.errorDescription ?? "transport error")
|
||||
: error.diagnosticStderr)
|
||||
} catch {
|
||||
return (-1, error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadSkillContent(path: String) -> String {
|
||||
guard isValidSkillPath(path) else { return "" }
|
||||
guard let data = try? transport.readFile(path),
|
||||
let s = String(data: data, encoding: .utf8)
|
||||
else { return "" }
|
||||
return s
|
||||
}
|
||||
|
||||
private func saveSkillContent(path: String, content: String) {
|
||||
guard isValidSkillPath(path) else { return }
|
||||
guard let data = content.data(using: .utf8) else { return }
|
||||
do {
|
||||
try transport.writeFile(path, data: data)
|
||||
} catch {
|
||||
logger.error("saveSkillContent(\(path, privacy: .public)) failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
private func isValidSkillPath(_ path: String) -> Bool {
|
||||
guard !path.contains(".."), path.hasPrefix(context.paths.skillsDir) else {
|
||||
logger.warning("Rejected skill path outside skills dir: \(path, privacy: .public)")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -257,7 +257,7 @@ import Foundation
|
||||
try FileManager.default.removeItem(
|
||||
at: home.appendingPathComponent("skills")
|
||||
)
|
||||
let vm = IOSSkillsViewModel(context: ctx)
|
||||
let vm = SkillsViewModel(context: ctx)
|
||||
await vm.load()
|
||||
#expect(vm.categories.isEmpty)
|
||||
#expect(vm.lastError == nil)
|
||||
@@ -284,7 +284,7 @@ import Foundation
|
||||
// Dotfile should be filtered
|
||||
try "".write(to: pJournal.appendingPathComponent(".DS_Store"), atomically: true, encoding: .utf8)
|
||||
|
||||
let vm = IOSSkillsViewModel(context: ctx)
|
||||
let vm = SkillsViewModel(context: ctx)
|
||||
await vm.load()
|
||||
#expect(vm.categories.count == 2)
|
||||
#expect(vm.categories[0].name == "dev")
|
||||
@@ -305,7 +305,7 @@ import Foundation
|
||||
at: home.appendingPathComponent("skills/empty-cat"),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
let vm = IOSSkillsViewModel(context: ctx)
|
||||
let vm = SkillsViewModel(context: ctx)
|
||||
await vm.load()
|
||||
#expect(vm.categories.isEmpty)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import Testing
|
||||
@testable import ScarfCore
|
||||
|
||||
/// Coverage for `SkillFrontmatterParser` — narrow YAML reader for the
|
||||
/// `required_config:` list in a skill's `skill.yaml`. The parser was
|
||||
/// extracted from the Mac `HermesFileService` in v2.5 so iOS can flag
|
||||
/// missing config keys with the same semantics.
|
||||
@Suite("SkillFrontmatterParser")
|
||||
struct SkillFrontmatterParserTests {
|
||||
|
||||
@Test func parsesSimpleRequiredConfigList() {
|
||||
let yaml = """
|
||||
name: example
|
||||
required_config:
|
||||
- api_key
|
||||
- api_secret
|
||||
version: 1.0.0
|
||||
"""
|
||||
let keys = SkillFrontmatterParser.parseRequiredConfig(yaml)
|
||||
#expect(keys == ["api_key", "api_secret"])
|
||||
}
|
||||
|
||||
@Test func returnsEmptyWhenSectionMissing() {
|
||||
let yaml = """
|
||||
name: example
|
||||
version: 1.0.0
|
||||
"""
|
||||
#expect(SkillFrontmatterParser.parseRequiredConfig(yaml).isEmpty)
|
||||
}
|
||||
|
||||
@Test func skipsCommentsAndEmptyLines() {
|
||||
let yaml = """
|
||||
# top comment
|
||||
required_config:
|
||||
# in-section comment
|
||||
- first
|
||||
|
||||
- second
|
||||
"""
|
||||
let keys = SkillFrontmatterParser.parseRequiredConfig(yaml)
|
||||
#expect(keys == ["first", "second"])
|
||||
}
|
||||
|
||||
@Test func breaksOnNextTopLevelKey() {
|
||||
let yaml = """
|
||||
required_config:
|
||||
- one
|
||||
- two
|
||||
next_key: hello
|
||||
- three
|
||||
"""
|
||||
let keys = SkillFrontmatterParser.parseRequiredConfig(yaml)
|
||||
// `next_key:` is at indent 0, terminating the list — `three`
|
||||
// is no longer in scope and shouldn't be picked up.
|
||||
#expect(keys == ["one", "two"])
|
||||
}
|
||||
|
||||
@Test func handlesEmptyInput() {
|
||||
#expect(SkillFrontmatterParser.parseRequiredConfig("").isEmpty)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import Testing
|
||||
@testable import ScarfCore
|
||||
|
||||
/// Coverage for `HermesSkillsHubParser` — the Rich-table parser that
|
||||
/// translates `hermes skills browse|search|check` stdout into typed
|
||||
/// `HermesHubSkill` / `HermesSkillUpdate` arrays. The parser is shared
|
||||
/// by Mac + iOS in v2.5; this suite locks in the canonical fixtures
|
||||
/// so regressions on either platform fail here first.
|
||||
@Suite("HermesSkillsHubParser")
|
||||
struct SkillsHubParserTests {
|
||||
|
||||
// MARK: - parseHubList
|
||||
|
||||
@Test func parsesSingleRowFromBrowseOutput() {
|
||||
let output = """
|
||||
┏━━━━━━┳━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━┓
|
||||
┃ # ┃ Name ┃ Description ┃ Source ┃ Trust ┃
|
||||
┡━━━━━━╇━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━┩
|
||||
│ 1 │ 1password │ Set up and use 1Password integration. │ official │ ★ official │
|
||||
└──────┴────────────────┴────────────────────────────────────────────────────────┴──────────────┴────────────┘
|
||||
"""
|
||||
let result = HermesSkillsHubParser.parseHubList(output)
|
||||
#expect(result.count == 1)
|
||||
#expect(result[0].identifier == "1password")
|
||||
#expect(result[0].name == "1password")
|
||||
#expect(result[0].description == "Set up and use 1Password integration.")
|
||||
#expect(result[0].source == "official")
|
||||
}
|
||||
|
||||
@Test func mergesContinuationRowsIntoDescription() {
|
||||
// Continuation rows have an empty `#` cell — the parser should
|
||||
// append their description to the previous skill rather than
|
||||
// emit a blank entry.
|
||||
let output = """
|
||||
│ 1 │ skill-creator │ Create new skills, modify and improve existing skills, │ official │ ★ official │
|
||||
│ │ │ and measure skill performance. │ │ │
|
||||
"""
|
||||
let result = HermesSkillsHubParser.parseHubList(output)
|
||||
#expect(result.count == 1)
|
||||
#expect(result[0].identifier == "skill-creator")
|
||||
#expect(result[0].description.contains("Create new skills"))
|
||||
#expect(result[0].description.contains("measure skill performance"))
|
||||
}
|
||||
|
||||
@Test func skipsHeaderAndBorderRows() {
|
||||
let output = """
|
||||
┏━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━━━━━┓
|
||||
┃ # ┃ Name ┃ Description ┃ Source ┃ Trust ┃
|
||||
┡━━━━━━╇━━━━━━━━╇━━━━━━━━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━━━━━┩
|
||||
│ 1 │ alpha │ alpha skill │ official │ ★ official │
|
||||
│ 2 │ beta │ beta skill │ skills-sh │ │
|
||||
└──────┴────────┴───────────────┴───────────┴────────────┘
|
||||
"""
|
||||
let result = HermesSkillsHubParser.parseHubList(output)
|
||||
#expect(result.count == 2)
|
||||
#expect(result[0].name == "alpha")
|
||||
#expect(result[1].name == "beta")
|
||||
}
|
||||
|
||||
@Test func stripsStarFromSourceCell() {
|
||||
// The Trust column shows `★ official` for trusted sources;
|
||||
// the Source column itself doesn't, but if the layout shifts
|
||||
// and we end up with the star in our captured cell we should
|
||||
// strip it.
|
||||
let output = """
|
||||
│ 1 │ widget │ a widget │ ★ official │ official │
|
||||
"""
|
||||
let result = HermesSkillsHubParser.parseHubList(output)
|
||||
#expect(result.count == 1)
|
||||
#expect(result[0].source == "official")
|
||||
}
|
||||
|
||||
@Test func returnsEmptyOnNoTable() {
|
||||
let result = HermesSkillsHubParser.parseHubList("Just plain text\n no table here")
|
||||
#expect(result.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - parseUpdateList
|
||||
|
||||
@Test func parsesArrowVersionMarker() {
|
||||
let output = """
|
||||
Checking for updates…
|
||||
skill-creator 1.0.0 → 1.1.0
|
||||
another-skill 2.3.4 → 2.3.5
|
||||
"""
|
||||
let result = HermesSkillsHubParser.parseUpdateList(output)
|
||||
#expect(result.count == 2)
|
||||
#expect(result[0].identifier == "skill-creator")
|
||||
#expect(result[0].currentVersion == "1.0.0")
|
||||
#expect(result[0].availableVersion == "1.1.0")
|
||||
#expect(result[1].identifier == "another-skill")
|
||||
#expect(result[1].currentVersion == "2.3.4")
|
||||
#expect(result[1].availableVersion == "2.3.5")
|
||||
}
|
||||
|
||||
@Test func parsesAsciiArrowMarker() {
|
||||
// Some terminals or older Hermes versions emit `->` instead of
|
||||
// the unicode `→`. Both should parse identically.
|
||||
let output = "skill-creator 1.0.0 -> 1.1.0"
|
||||
let result = HermesSkillsHubParser.parseUpdateList(output)
|
||||
#expect(result.count == 1)
|
||||
#expect(result[0].identifier == "skill-creator")
|
||||
#expect(result[0].availableVersion == "1.1.0")
|
||||
}
|
||||
|
||||
@Test func updateListIgnoresLinesWithoutArrow() {
|
||||
let output = """
|
||||
Checking for updates…
|
||||
Skill named foo is up to date.
|
||||
skill-creator 1.0.0 → 1.1.0
|
||||
"""
|
||||
let result = HermesSkillsHubParser.parseUpdateList(output)
|
||||
#expect(result.count == 1)
|
||||
#expect(result[0].identifier == "skill-creator")
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,11 @@ import ScarfCore
|
||||
/// `AppCoordinator` pattern: an `@Observable` carrier injected via
|
||||
/// `.environment(_:)` that any view in the tab tree can reach.
|
||||
///
|
||||
/// Single responsibility in M9 scope: route "user tapped a recent
|
||||
/// session in Dashboard" → "open the Chat tab with a resume request."
|
||||
/// Future uses (project-scoped chat handoff, notification deep-link
|
||||
/// → specific session) compose naturally on the same primitive.
|
||||
/// v2.5 expands the surface to include project handoff: tapping
|
||||
/// "New Chat" inside a Project Detail view sets `pendingProjectChat`
|
||||
/// and routes to the Chat tab, where ChatController consumes it and
|
||||
/// dispatches `resetAndStartInProject(_:)` (same wiring the in-Chat
|
||||
/// project picker sheet already uses).
|
||||
@Observable
|
||||
@MainActor
|
||||
final class ScarfGoCoordinator {
|
||||
@@ -23,8 +24,15 @@ final class ScarfGoCoordinator {
|
||||
/// ChatController after it honours the request.
|
||||
var pendingResumeSessionID: String?
|
||||
|
||||
/// If non-nil, the Chat tab should start an in-project session at
|
||||
/// this absolute remote path on next appear instead of a quick
|
||||
/// chat. Consumed (cleared) by ChatController after it kicks off
|
||||
/// `resetAndStartInProject(_:)`. Mirrors Mac's
|
||||
/// `AppCoordinator.pendingProjectChat`.
|
||||
var pendingProjectChat: String?
|
||||
|
||||
enum Tab: Hashable {
|
||||
case chat, dashboard, memory, more
|
||||
case dashboard, projects, chat, skills, system
|
||||
}
|
||||
|
||||
/// Convenience: route to Chat and queue a resume. Dashboard rows
|
||||
@@ -35,6 +43,16 @@ final class ScarfGoCoordinator {
|
||||
pendingResumeSessionID = id
|
||||
selectedTab = .chat
|
||||
}
|
||||
|
||||
/// Convenience: route to Chat and queue a project-scoped session
|
||||
/// start at `path`. Project Detail's "New Chat" toolbar button
|
||||
/// calls this. Clearing `pendingProjectChat` is the consumer's
|
||||
/// responsibility (ChatController) once `resetAndStartInProject`
|
||||
/// has been dispatched.
|
||||
func startChatInProject(path: String) {
|
||||
pendingProjectChat = path
|
||||
selectedTab = .chat
|
||||
}
|
||||
}
|
||||
|
||||
/// Environment key so subviews can pull the coordinator without
|
||||
|
||||
@@ -2,22 +2,26 @@ 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:
|
||||
/// ScarfGo's primary navigation surface. v2.5 expands the original
|
||||
/// 4-tab layout (Chat | Dashboard | Memory | More) to 5 primary tabs
|
||||
/// with Chat in the mathematical center:
|
||||
///
|
||||
/// > "We should have the actions for the user in a permanent footer?
|
||||
/// > I don't see any navigation."
|
||||
/// Dashboard | Projects | Chat | Skills | System
|
||||
///
|
||||
/// 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.
|
||||
/// "Chat in the middle" is the v2.5 product ask — chat is the action
|
||||
/// users come back for, so it's the most thumb-reachable slot on a
|
||||
/// phone-sized device. We stay on Apple's native `TabView` instead of
|
||||
/// drawing a custom raised center button: 5 tabs is exactly the iPhone
|
||||
/// system maximum (no auto-collapse to "More"), and `.sidebarAdaptable`
|
||||
/// continues to give us a real sidebar on iPad / macCatalyst for free.
|
||||
/// Memory drops out of primary slots and lives inside the renamed
|
||||
/// "System" tab (was "More"). Skills graduates from a System sub-row
|
||||
/// into its own primary tab to match v2.5's full Mac parity for skills
|
||||
/// (Installed / Browse Hub / Updates).
|
||||
///
|
||||
/// 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.
|
||||
/// Each tab wraps its feature view in its own `NavigationStack` so push
|
||||
/// navigation (Cron editor, Memory detail, Project detail, etc.) stays
|
||||
/// scoped to the tab instead of bleeding across.
|
||||
struct ScarfGoTabRoot: View {
|
||||
let serverID: ServerID
|
||||
let config: IOSServerConfig
|
||||
@@ -26,8 +30,9 @@ struct ScarfGoTabRoot: View {
|
||||
let onForget: @MainActor () async -> Void
|
||||
|
||||
/// One coordinator per server-connected session. Cross-tab
|
||||
/// signalling (Dashboard row → Chat tab resume, eventually
|
||||
/// notification deep-link → Chat) flows through here.
|
||||
/// signalling (Dashboard row → Chat tab resume, Project Detail
|
||||
/// → in-project chat handoff, notification deep-link → Chat) flows
|
||||
/// through here.
|
||||
@State private var coordinator = ScarfGoCoordinator()
|
||||
|
||||
var body: some View {
|
||||
@@ -39,18 +44,7 @@ struct ScarfGoTabRoot: View {
|
||||
// SSH channel contention.
|
||||
let ctx = config.toServerContext(id: serverID)
|
||||
TabView(selection: $coordinator.selectedTab) {
|
||||
// 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")
|
||||
}
|
||||
.tag(ScarfGoCoordinator.Tab.chat)
|
||||
|
||||
// 2 — Dashboard: stats + recent sessions (no surfaces list
|
||||
// anymore — those live in More).
|
||||
// 1 — Dashboard: stats + recent sessions.
|
||||
NavigationStack {
|
||||
DashboardView(config: config, key: key)
|
||||
}
|
||||
@@ -58,33 +52,59 @@ struct ScarfGoTabRoot: View {
|
||||
Label("Dashboard", systemImage: "gauge.with.needle")
|
||||
}
|
||||
.tag(ScarfGoCoordinator.Tab.dashboard)
|
||||
.accessibilityLabel("Dashboard tab")
|
||||
|
||||
// 3 — Memory: MEMORY.md + USER.md + SOUL.md.
|
||||
// 2 — Projects: registered projects → per-project dashboard,
|
||||
// site, and sessions. Read-only registry on iOS — add /
|
||||
// rename / archive happens in the Mac app.
|
||||
NavigationStack {
|
||||
MemoryListView(config: config)
|
||||
ProjectsListView(config: config)
|
||||
}
|
||||
.tabItem {
|
||||
Label("Memory", systemImage: "brain.head.profile")
|
||||
Label("Projects", systemImage: "square.grid.2x2")
|
||||
}
|
||||
.tag(ScarfGoCoordinator.Tab.memory)
|
||||
.tag(ScarfGoCoordinator.Tab.projects)
|
||||
.accessibilityLabel("Projects tab")
|
||||
|
||||
// 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.
|
||||
// 3 — Chat: the reason the app is on your phone. Centered
|
||||
// among the 5 tabs for thumb reach + visual prominence.
|
||||
NavigationStack {
|
||||
MoreTab(
|
||||
ChatView(config: config, key: key)
|
||||
}
|
||||
.tabItem {
|
||||
Label("Chat", systemImage: "bubble.left.and.bubble.right.fill")
|
||||
}
|
||||
.tag(ScarfGoCoordinator.Tab.chat)
|
||||
.accessibilityLabel("Chat tab")
|
||||
|
||||
// 4 — Skills: Installed | Browse Hub | Updates, mirroring
|
||||
// the Mac app's 3-tab skills surface.
|
||||
NavigationStack {
|
||||
SkillsView(config: config)
|
||||
}
|
||||
.tabItem {
|
||||
Label("Skills", systemImage: "lightbulb")
|
||||
}
|
||||
.tag(ScarfGoCoordinator.Tab.skills)
|
||||
.accessibilityLabel("Skills tab")
|
||||
|
||||
// 5 — System: server identity, Memory, Cron, Settings, plus
|
||||
// the destructive disconnect / forget actions. Renamed from
|
||||
// "More" to match the user-facing v2.5 vocabulary; the
|
||||
// .sidebarAdaptable system fallback label happens not to
|
||||
// matter here because we never overflow.
|
||||
NavigationStack {
|
||||
SystemTab(
|
||||
config: config,
|
||||
onSoftDisconnect: onSoftDisconnect,
|
||||
onForget: onForget
|
||||
)
|
||||
}
|
||||
.tabItem {
|
||||
Label("More", systemImage: "ellipsis.circle")
|
||||
Label("System", systemImage: "gearshape.fill")
|
||||
}
|
||||
.tag(ScarfGoCoordinator.Tab.more)
|
||||
.tag(ScarfGoCoordinator.Tab.system)
|
||||
.accessibilityLabel("System tab")
|
||||
}
|
||||
// Pulls the sidebar-on-iPad affordance into the same code path
|
||||
// as the bottom-bar-on-iPhone one. No-op on iPhone today.
|
||||
@@ -101,14 +121,15 @@ struct ScarfGoTabRoot: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Server identity + Memory + Cron + Settings + destructive actions.
|
||||
/// "System" reads as configuration / server-meta; the reorganization
|
||||
/// in v2.5 promotes Skills out of here into its own primary tab and
|
||||
/// pulls Memory in from a primary tab into a NavigationLink row.
|
||||
///
|
||||
/// 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 {
|
||||
private struct SystemTab: View {
|
||||
let config: IOSServerConfig
|
||||
let onSoftDisconnect: @MainActor () async -> Void
|
||||
let onForget: @MainActor () async -> Void
|
||||
@@ -131,15 +152,15 @@ private struct MoreTab: View {
|
||||
|
||||
Section("Features") {
|
||||
NavigationLink {
|
||||
CronListView(config: config)
|
||||
MemoryListView(config: config)
|
||||
} label: {
|
||||
Label("Cron jobs", systemImage: "clock.arrow.circlepath")
|
||||
Label("Memory", systemImage: "brain.head.profile")
|
||||
}
|
||||
.scarfGoCompactListRow()
|
||||
NavigationLink {
|
||||
SkillsListView(config: config)
|
||||
CronListView(config: config)
|
||||
} label: {
|
||||
Label("Skills", systemImage: "sparkles")
|
||||
Label("Cron jobs", systemImage: "clock.arrow.circlepath")
|
||||
}
|
||||
.scarfGoCompactListRow()
|
||||
NavigationLink {
|
||||
@@ -194,7 +215,7 @@ private struct MoreTab: View {
|
||||
}
|
||||
}
|
||||
.scarfGoListDensity()
|
||||
.navigationTitle("More")
|
||||
.navigationTitle("System")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.confirmationDialog(
|
||||
"Forget this server?",
|
||||
|
||||
@@ -75,29 +75,37 @@ struct ChatView: View {
|
||||
)
|
||||
}
|
||||
.task {
|
||||
// Dashboard row taps set `pendingResumeSessionID` on the
|
||||
// coordinator before switching to the Chat tab. Honor
|
||||
// that if present, else open a fresh session. Clearing
|
||||
// the coordinator value is the consumer's responsibility
|
||||
// (us) — otherwise a later plain tap on the Chat tab
|
||||
// would accidentally re-resume the old session.
|
||||
// Dashboard row taps set `pendingResumeSessionID`, Project
|
||||
// Detail's "New Chat" sets `pendingProjectChat`. Both fire
|
||||
// a tab switch to .chat alongside the value set; we
|
||||
// consume + clear here on first appear. Resume wins over
|
||||
// project-chat if both somehow get set in a single hop —
|
||||
// but in practice the coordinator never sets both at once.
|
||||
if let sessionID = coordinator?.pendingResumeSessionID {
|
||||
coordinator?.pendingResumeSessionID = nil
|
||||
await controller.startResuming(sessionID: sessionID)
|
||||
} else if let projectPath = coordinator?.pendingProjectChat {
|
||||
coordinator?.pendingProjectChat = nil
|
||||
await consumePendingProjectChat(projectPath)
|
||||
} else {
|
||||
await controller.start()
|
||||
}
|
||||
}
|
||||
// Also react to a coordinator change that happens while Chat
|
||||
// is already mounted (e.g., user is in Chat, switches to
|
||||
// Dashboard, taps a session row — coordinator flips the tab
|
||||
// AND sets pendingResumeSessionID. The `.task` above only
|
||||
// fires on first appear; this is the mid-session hook.)
|
||||
// React to coordinator changes that happen while Chat is
|
||||
// already mounted (e.g., user is in Chat, taps Projects, opens
|
||||
// a project detail, taps "New Chat" — coordinator flips the
|
||||
// tab AND sets pendingProjectChat. The `.task` above only
|
||||
// fires on first appear; these are the mid-session hooks.)
|
||||
.onChange(of: coordinator?.pendingResumeSessionID) { _, new in
|
||||
guard let sessionID = new else { return }
|
||||
coordinator?.pendingResumeSessionID = nil
|
||||
Task { await controller.startResuming(sessionID: sessionID) }
|
||||
}
|
||||
.onChange(of: coordinator?.pendingProjectChat) { _, new in
|
||||
guard let projectPath = new else { return }
|
||||
coordinator?.pendingProjectChat = nil
|
||||
Task { await consumePendingProjectChat(projectPath) }
|
||||
}
|
||||
// Deliberately NOT tearing down the ACP session on .onDisappear.
|
||||
// `TabView` unmounts tab content when the user switches tabs
|
||||
// (disappear fires), but `@State var controller` keeps the
|
||||
@@ -140,6 +148,27 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a project absolute path to a `ProjectEntry` via the
|
||||
/// transport-backed registry, then dispatch `resetAndStartInProject`.
|
||||
/// If the path isn't registered (race with a Mac-app removal, or
|
||||
/// SFTP read failure), fall back to a synthesized entry whose name
|
||||
/// is the path's last component — chat still starts and the user
|
||||
/// sees a usable project chip.
|
||||
private func consumePendingProjectChat(_ path: String) async {
|
||||
let ctx = config.toServerContext(id: Self.sharedContextID)
|
||||
let entry: ProjectEntry = await Task.detached {
|
||||
let registry = ProjectDashboardService(context: ctx).loadRegistry()
|
||||
if let match = registry.projects.first(where: { $0.path == path }) {
|
||||
return match
|
||||
}
|
||||
return ProjectEntry(
|
||||
name: (path as NSString).lastPathComponent.isEmpty ? path : (path as NSString).lastPathComponent,
|
||||
path: path
|
||||
)
|
||||
}.value
|
||||
await controller.resetAndStartInProject(entry)
|
||||
}
|
||||
|
||||
// MARK: - Subviews
|
||||
|
||||
@ViewBuilder
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
/// Per-project detail view, presented when a row in `ProjectsListView`
|
||||
/// is tapped. Mirrors the Mac three-tab layout (Dashboard | Site |
|
||||
/// Sessions) using a segmented `Picker`. The Site segment is gated on
|
||||
/// the dashboard containing a `webview` widget — empty dashboards or
|
||||
/// dashboards without a site URL hide the segment to match Mac's
|
||||
/// `visibleTabs` logic in `ProjectsView.swift`.
|
||||
///
|
||||
/// "New Chat" toolbar button calls `ScarfGoCoordinator.startChatInProject`
|
||||
/// which sets `pendingProjectChat` and routes to the Chat tab.
|
||||
/// `ChatController` consumes `pendingProjectChat` on next appear and
|
||||
/// dispatches `resetAndStartInProject(_:)` — same wiring the existing
|
||||
/// in-Chat picker sheet uses.
|
||||
struct ProjectDetailView: View {
|
||||
let project: ProjectEntry
|
||||
let config: IOSServerConfig
|
||||
|
||||
@Environment(\.scarfGoCoordinator) private var coordinator
|
||||
|
||||
private static let sharedContextID: ServerID = ServerID(
|
||||
uuidString: "00000000-0000-0000-0000-0000000000A2"
|
||||
)!
|
||||
|
||||
@State private var dashboard: ProjectDashboard?
|
||||
@State private var dashboardError: String?
|
||||
@State private var isLoading: Bool = true
|
||||
@State private var selectedTab: DetailTab = .dashboard
|
||||
/// Last-seen mtime on `<project>/.scarf/dashboard.json`. The
|
||||
/// foreground poll task compares this against a fresh stat to
|
||||
/// decide whether to re-parse — cheap when the file is unchanged,
|
||||
/// and the poll only runs while the view is visible.
|
||||
@State private var lastDashboardMtime: Date?
|
||||
|
||||
enum DetailTab: Hashable {
|
||||
case dashboard, site, sessions
|
||||
}
|
||||
|
||||
private var serverContext: ServerContext {
|
||||
config.toServerContext(id: Self.sharedContextID)
|
||||
}
|
||||
|
||||
/// First webview widget across all sections, if any. Nil → Site
|
||||
/// segment hidden. Mirrors Mac `siteWidget`.
|
||||
private var siteWidget: DashboardWidget? {
|
||||
dashboard?
|
||||
.sections
|
||||
.flatMap(\.widgets)
|
||||
.first { $0.type == "webview" }
|
||||
}
|
||||
|
||||
private var visibleTabs: [DetailTab] {
|
||||
var tabs: [DetailTab] = [.dashboard]
|
||||
if siteWidget != nil { tabs.append(.site) }
|
||||
tabs.append(.sessions)
|
||||
return tabs
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
tabPicker
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 6)
|
||||
Divider()
|
||||
tabContent
|
||||
}
|
||||
.navigationTitle(project.name)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
coordinator?.startChatInProject(path: project.path)
|
||||
} label: {
|
||||
Label("New Chat", systemImage: "message.badge.filled.fill")
|
||||
}
|
||||
.accessibilityLabel("Start new chat in \(project.name)")
|
||||
.accessibilityHint("Opens the Chat tab and begins a session scoped to this project")
|
||||
}
|
||||
}
|
||||
.task(id: project.id) { await loadDashboard() }
|
||||
.task(id: project.id) { await pollDashboardMtime() }
|
||||
.refreshable { await loadDashboard() }
|
||||
.onChange(of: visibleTabs) { _, newTabs in
|
||||
// If the user was on Site and a refresh removed the
|
||||
// webview widget, fall back to Dashboard so the segmented
|
||||
// picker doesn't end up out-of-sync with its segments.
|
||||
if !newTabs.contains(selectedTab) {
|
||||
selectedTab = .dashboard
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tab picker
|
||||
|
||||
@ViewBuilder
|
||||
private var tabPicker: some View {
|
||||
Picker("Section", selection: $selectedTab) {
|
||||
ForEach(visibleTabs, id: \.self) { tab in
|
||||
Text(label(for: tab)).tag(tab)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
|
||||
private func label(for tab: DetailTab) -> String {
|
||||
switch tab {
|
||||
case .dashboard: return "Dashboard"
|
||||
case .site: return "Site"
|
||||
case .sessions: return "Sessions"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tab content
|
||||
|
||||
@ViewBuilder
|
||||
private var tabContent: some View {
|
||||
switch selectedTab {
|
||||
case .dashboard:
|
||||
dashboardTab
|
||||
case .site:
|
||||
if let widget = siteWidget {
|
||||
ProjectSiteView(widget: widget)
|
||||
} else {
|
||||
emptyDashboard
|
||||
}
|
||||
case .sessions:
|
||||
ProjectSessionsView_iOS(project: project)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var dashboardTab: some View {
|
||||
if isLoading && dashboard == nil {
|
||||
ProgressView("Loading dashboard…")
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if let dash = dashboard {
|
||||
DashboardWidgetsView(dashboard: dash)
|
||||
} else {
|
||||
emptyDashboard
|
||||
}
|
||||
}
|
||||
|
||||
private var emptyDashboard: some View {
|
||||
ContentUnavailableView {
|
||||
Label("No Dashboard", systemImage: "rectangle.dashed")
|
||||
} description: {
|
||||
Text(dashboardError ?? "This project doesn't have a dashboard at \(project.dashboardPath) yet.")
|
||||
.font(.caption)
|
||||
} actions: {
|
||||
Button("Try Again") {
|
||||
Task { await loadDashboard() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Loading
|
||||
|
||||
/// Load the project's dashboard via `ProjectDashboardService` on a
|
||||
/// background task — same `Task.detached` pattern the registry
|
||||
/// loader uses to keep the SFTP read off MainActor.
|
||||
private func loadDashboard() async {
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
let ctx = serverContext
|
||||
let proj = project
|
||||
let result: (ProjectDashboard?, String?, Date?) = await Task.detached {
|
||||
let service = ProjectDashboardService(context: ctx)
|
||||
if !service.dashboardExists(for: proj) {
|
||||
return (nil, "No dashboard found at \(proj.dashboardPath)", nil)
|
||||
}
|
||||
let mtime = service.dashboardModificationDate(for: proj)
|
||||
if let loaded = service.loadDashboard(for: proj) {
|
||||
return (loaded, nil, mtime)
|
||||
}
|
||||
return (nil, "Failed to parse dashboard JSON", mtime)
|
||||
}.value
|
||||
dashboard = result.0
|
||||
dashboardError = result.1
|
||||
lastDashboardMtime = result.2
|
||||
}
|
||||
|
||||
/// Poll the dashboard file's mtime every 4 seconds while the view
|
||||
/// is foregrounded; reload on any change. iOS doesn't have an
|
||||
/// inotify-style watcher over SFTP, but a per-view poll is cheap
|
||||
/// (one stat call per tick) and stops the moment the user
|
||||
/// navigates away — the `.task` modifier cancels the loop on view
|
||||
/// disappear automatically.
|
||||
private func pollDashboardMtime() async {
|
||||
let ctx = serverContext
|
||||
let proj = project
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: 4_000_000_000)
|
||||
if Task.isCancelled { break }
|
||||
let fresh: Date? = await Task.detached {
|
||||
ProjectDashboardService(context: ctx)
|
||||
.dashboardModificationDate(for: proj)
|
||||
}.value
|
||||
// First tick after a missing-dashboard error: nil → nil is
|
||||
// a no-op; nil → Date triggers a reload (file just appeared).
|
||||
// Date → newer Date triggers a reload. Same Date is a no-op.
|
||||
switch (lastDashboardMtime, fresh) {
|
||||
case (nil, nil), (_, nil):
|
||||
continue
|
||||
case (nil, _):
|
||||
await loadDashboard()
|
||||
case (let prev?, let now?) where now > prev:
|
||||
await loadDashboard()
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
/// iOS twin of the Mac per-project Sessions tab. Reuses the
|
||||
/// ScarfCore-side `ProjectSessionsViewModel` (promoted from the Mac
|
||||
/// target in v2.5) so attribution + filtering semantics stay
|
||||
/// identical. The "New Chat" button routes into the Chat tab via
|
||||
/// `ScarfGoCoordinator.startChatInProject(path:)`; row taps route via
|
||||
/// `coordinator.resumeSession(_:)`, the same primitive
|
||||
/// `DashboardView` already uses.
|
||||
struct ProjectSessionsView_iOS: View {
|
||||
let project: ProjectEntry
|
||||
|
||||
@Environment(\.scarfGoCoordinator) private var coordinator
|
||||
@Environment(\.serverContext) private var serverContext
|
||||
|
||||
@State private var viewModel: ProjectSessionsViewModel?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
Divider()
|
||||
content
|
||||
}
|
||||
.task(id: project.id) {
|
||||
// Rebuild the VM when the project changes so stale state
|
||||
// from a previously-selected project doesn't bleed
|
||||
// through.
|
||||
viewModel = ProjectSessionsViewModel(
|
||||
context: serverContext,
|
||||
project: project
|
||||
)
|
||||
await viewModel?.load()
|
||||
}
|
||||
.onDisappear {
|
||||
// Release the SQLite handle so it doesn't dangle once
|
||||
// the user leaves this tab. `load()` will re-open next
|
||||
// time. Mirrors ActivityView's disappear cleanup.
|
||||
Task { await viewModel?.close() }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Sessions in this project")
|
||||
.font(.subheadline)
|
||||
.fontWeight(.semibold)
|
||||
Text("Chats you start here are attributed automatically.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
Spacer()
|
||||
Button {
|
||||
coordinator?.startChatInProject(path: project.path)
|
||||
} label: {
|
||||
Label("New Chat", systemImage: "message.badge.filled.fill")
|
||||
.labelStyle(.iconOnly)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
.accessibilityLabel("Start new chat in \(project.name)")
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
// MARK: - Content
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
if let vm = viewModel {
|
||||
if vm.isLoading && vm.sessions.isEmpty {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding()
|
||||
} else if vm.sessions.isEmpty {
|
||||
emptyState(hint: vm.emptyStateHint)
|
||||
} else {
|
||||
sessionList(vm.sessions)
|
||||
}
|
||||
} else {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
private func emptyState(hint: String?) -> some View {
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: "bubble.left.and.bubble.right")
|
||||
.font(.system(size: 36))
|
||||
.foregroundStyle(.tertiary)
|
||||
Text(hint ?? "No sessions yet.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.horizontal, 32)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding(.vertical, 40)
|
||||
}
|
||||
|
||||
private func sessionList(_ sessions: [HermesSession]) -> some View {
|
||||
List {
|
||||
ForEach(sessions) { session in
|
||||
Button {
|
||||
coordinator?.resumeSession(session.id)
|
||||
} label: {
|
||||
ProjectSessionRow_iOS(session: session)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.scarfGoCompactListRow()
|
||||
}
|
||||
}
|
||||
.scarfGoListDensity()
|
||||
}
|
||||
}
|
||||
|
||||
/// Single row in the per-project Sessions list. Mirrors the Mac
|
||||
/// `ProjectSessionRow` content but uses iOS-friendly text sizing.
|
||||
private struct ProjectSessionRow_iOS: View {
|
||||
let session: HermesSession
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: iconForSource(session.source))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 22)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(displayTitle)
|
||||
.font(.callout)
|
||||
.lineLimit(1)
|
||||
HStack(spacing: 6) {
|
||||
Text(session.id.prefix(12))
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(.tertiary)
|
||||
if let started = formattedStart {
|
||||
Text("·")
|
||||
.foregroundStyle(.tertiary)
|
||||
Text(started)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 12)
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text("\(session.messageCount)")
|
||||
.font(.caption.monospaced())
|
||||
Text("msgs")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
private var displayTitle: String {
|
||||
if let t = session.title, !t.isEmpty { return t }
|
||||
return "Untitled session"
|
||||
}
|
||||
|
||||
private var formattedStart: String? {
|
||||
guard let date = session.startedAt else { return nil }
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .short
|
||||
formatter.timeStyle = .short
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
private func iconForSource(_ source: String) -> String {
|
||||
switch source.lowercased() {
|
||||
case "cli", "acp": return "terminal"
|
||||
case "telegram": return "paperplane"
|
||||
case "discord": return "bubble.left.and.bubble.right"
|
||||
default: return "message"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
/// Full-canvas webview wrapper for the Site sub-tab. Reuses the
|
||||
/// `WebviewWidgetView` representable in its `fullCanvas: true` mode so
|
||||
/// rendering, error handling, and the non-persistent data store all
|
||||
/// stay in one place.
|
||||
struct ProjectSiteView: View {
|
||||
let widget: DashboardWidget
|
||||
|
||||
var body: some View {
|
||||
WebviewWidgetView(widget: widget, fullCanvas: true)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
import Charts
|
||||
|
||||
// Flattened data point for Charts to avoid complex nested generic inference
|
||||
private struct PlottablePoint: Identifiable {
|
||||
let id = UUID()
|
||||
let seriesName: String
|
||||
let x: String
|
||||
let y: Double
|
||||
let color: Color
|
||||
}
|
||||
|
||||
struct ChartWidgetView: View {
|
||||
let widget: DashboardWidget
|
||||
|
||||
private var points: [PlottablePoint] {
|
||||
guard let series = widget.series else { return [] }
|
||||
return series.flatMap { s in
|
||||
let color = parseColor(s.color)
|
||||
return s.data.map { d in
|
||||
PlottablePoint(seriesName: s.name, x: d.x, y: d.y, color: color)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(widget.title)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
chartContent
|
||||
.frame(height: 150)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(12)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var chartContent: some View {
|
||||
switch widget.chartType {
|
||||
case "pie":
|
||||
pieChart
|
||||
case "bar":
|
||||
barChart
|
||||
default:
|
||||
lineChart
|
||||
}
|
||||
}
|
||||
|
||||
private var lineChart: some View {
|
||||
Chart(points) { point in
|
||||
LineMark(
|
||||
x: .value("X", point.x),
|
||||
y: .value("Y", point.y)
|
||||
)
|
||||
.foregroundStyle(point.color)
|
||||
.symbol(by: .value("Series", point.seriesName))
|
||||
}
|
||||
}
|
||||
|
||||
private var barChart: some View {
|
||||
Chart(points) { point in
|
||||
BarMark(
|
||||
x: .value("X", point.x),
|
||||
y: .value("Y", point.y)
|
||||
)
|
||||
.foregroundStyle(point.color)
|
||||
}
|
||||
}
|
||||
|
||||
private var pieChart: some View {
|
||||
Chart(points) { point in
|
||||
SectorMark(
|
||||
angle: .value(point.x, point.y),
|
||||
innerRadius: .ratio(0.5)
|
||||
)
|
||||
.foregroundStyle(point.color)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
/// iOS dashboard layout. ScrollView of sections; each section is a
|
||||
/// `LazyVGrid` whose column count is clamped to the device's
|
||||
/// `horizontalSizeClass`. iPhone (compact) → 1 column. iPad / split-
|
||||
/// view (regular) → 2 columns max, even when the dashboard JSON asks
|
||||
/// for 3 (3-column on a 13" iPad portrait still cramps individual
|
||||
/// widgets).
|
||||
///
|
||||
/// Webview widgets in card mode render inline like any other widget.
|
||||
/// The full-canvas Site tab is rendered separately by `ProjectSiteView`
|
||||
/// and excluded from this grid by `ProjectDetailView` before passing
|
||||
/// the dashboard down — so we don't filter here.
|
||||
struct DashboardWidgetsView: View {
|
||||
let dashboard: ProjectDashboard
|
||||
|
||||
@Environment(\.horizontalSizeClass) private var hSizeClass
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
if let description = dashboard.description, !description.isEmpty {
|
||||
Text(description)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
ForEach(dashboard.sections) { section in
|
||||
sectionView(section)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func sectionView(_ section: DashboardSection) -> some View {
|
||||
// Filter out webview widgets — those are rendered full-screen
|
||||
// in the Site tab instead. Matches Mac DashboardSectionView.
|
||||
let displayWidgets = section.widgets.filter { $0.type != "webview" }
|
||||
if !displayWidgets.isEmpty {
|
||||
let cols = columnCount(for: section)
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if !section.title.isEmpty {
|
||||
Text(section.title)
|
||||
.font(.headline)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
LazyVGrid(
|
||||
columns: Array(repeating: GridItem(.flexible(), spacing: 10), count: cols),
|
||||
spacing: 10
|
||||
) {
|
||||
ForEach(displayWidgets) { widget in
|
||||
WidgetView(widget: widget)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cap the requested column count by available width. Compact
|
||||
/// (iPhone) is always 1; regular (iPad / large split-view) caps at
|
||||
/// 2 to avoid a 3-up layout that crowds chart + table widgets.
|
||||
private func columnCount(for section: DashboardSection) -> Int {
|
||||
switch hSizeClass {
|
||||
case .compact: return 1
|
||||
case .regular: return min(section.columnCount, 2)
|
||||
default: return 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Widget-type dispatcher. Mirrors Mac's `WidgetView` switch in
|
||||
/// `scarf/Features/Projects/Views/ProjectsView.swift`. Unknown types
|
||||
/// fall through to a small placeholder so a manifest from a future
|
||||
/// schema version doesn't crash the UI.
|
||||
struct WidgetView: View {
|
||||
let widget: DashboardWidget
|
||||
|
||||
var body: some View {
|
||||
switch widget.type {
|
||||
case "stat":
|
||||
StatWidgetView(widget: widget)
|
||||
case "progress":
|
||||
ProgressWidgetView(widget: widget)
|
||||
case "text":
|
||||
TextWidgetView(widget: widget)
|
||||
case "table":
|
||||
TableWidgetView(widget: widget)
|
||||
case "chart":
|
||||
ChartWidgetView(widget: widget)
|
||||
case "list":
|
||||
ListWidgetView(widget: widget)
|
||||
case "webview":
|
||||
WebviewWidgetView(widget: widget)
|
||||
default:
|
||||
unsupportedView
|
||||
}
|
||||
}
|
||||
|
||||
private var unsupportedView: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Label(widget.title, systemImage: "questionmark.app.dashed")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Widget type \"\(widget.type)\" isn't supported in this version of Scarf yet.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(12)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
struct ListWidgetView: View {
|
||||
let widget: DashboardWidget
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 4) {
|
||||
if let icon = widget.icon {
|
||||
Image(systemName: icon)
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.caption)
|
||||
}
|
||||
Text(widget.title)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let items = widget.items {
|
||||
ForEach(items) { item in
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: statusIcon(item.status))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(statusColor(item.status))
|
||||
Text(item.text)
|
||||
.font(.callout)
|
||||
.strikethrough(item.status == "done")
|
||||
.foregroundStyle(item.status == "done" ? .secondary : .primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(12)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
private func statusIcon(_ status: String?) -> String {
|
||||
switch status {
|
||||
case "done": return "checkmark.circle.fill"
|
||||
case "active": return "circle.inset.filled"
|
||||
case "pending": return "circle"
|
||||
default: return "circle"
|
||||
}
|
||||
}
|
||||
|
||||
private func statusColor(_ status: String?) -> Color {
|
||||
switch status {
|
||||
case "done": return .green
|
||||
case "active": return .blue
|
||||
default: return .secondary
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
struct ProgressWidgetView: View {
|
||||
let widget: DashboardWidget
|
||||
|
||||
private var progressValue: Double {
|
||||
switch widget.value {
|
||||
case .number(let n): return n
|
||||
default: return 0
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(widget.title)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
ProgressView(value: progressValue) {
|
||||
if let label = widget.label {
|
||||
Text(label)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.tint(parseColor(widget.color))
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(12)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
struct StatWidgetView: View {
|
||||
let widget: DashboardWidget
|
||||
|
||||
private var widgetColor: Color {
|
||||
parseColor(widget.color)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 4) {
|
||||
if let icon = widget.icon {
|
||||
Image(systemName: icon)
|
||||
.foregroundStyle(widgetColor)
|
||||
.font(.caption)
|
||||
}
|
||||
Text(widget.title)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let value = widget.value {
|
||||
Text(value.displayString)
|
||||
.font(.system(.title2, design: .monospaced, weight: .semibold))
|
||||
}
|
||||
if let subtitle = widget.subtitle {
|
||||
Text(subtitle)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(widgetColor)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(12)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
struct TableWidgetView: View {
|
||||
let widget: DashboardWidget
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(widget.title)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
if let columns = widget.columns, let rows = widget.rows {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 4) {
|
||||
GridRow {
|
||||
ForEach(columns, id: \.self) { col in
|
||||
Text(col)
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
ForEach(Array(rows.enumerated()), id: \.offset) { _, row in
|
||||
GridRow {
|
||||
ForEach(Array(row.enumerated()), id: \.offset) { _, cell in
|
||||
Text(cell)
|
||||
.font(.callout)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(12)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
struct TextWidgetView: View {
|
||||
let widget: DashboardWidget
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(widget.title)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
if let content = widget.content {
|
||||
if widget.format == "markdown" {
|
||||
// SwiftUI's built-in inline markdown via AttributedString.
|
||||
// Doesn't support block elements (lists, tables) the way
|
||||
// Mac's MarkdownContentView does, but covers the common
|
||||
// dashboard cases (bold, italic, links, inline code).
|
||||
Text(attributed(content))
|
||||
.font(.callout)
|
||||
} else {
|
||||
Text(content)
|
||||
.font(.callout)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(12)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
private func attributed(_ markdown: String) -> AttributedString {
|
||||
(try? AttributedString(markdown: markdown)) ?? AttributedString(markdown)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
import WebKit
|
||||
|
||||
/// iOS twin of Mac's `WebviewWidgetView`. Same two modes (inline card
|
||||
/// + full-canvas Site tab); the only platform-specific bit is the
|
||||
/// `UIViewRepresentable` wrapper around `WKWebView` (Mac uses
|
||||
/// `NSViewRepresentable`).
|
||||
struct WebviewWidgetView: View {
|
||||
let widget: DashboardWidget
|
||||
var fullCanvas: Bool = false
|
||||
|
||||
private var webURL: URL? {
|
||||
guard let urlString = widget.url else { return nil }
|
||||
return URL(string: urlString)
|
||||
}
|
||||
|
||||
private var viewHeight: CGFloat {
|
||||
CGFloat(widget.height ?? 400)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if fullCanvas {
|
||||
fullCanvasView
|
||||
} else {
|
||||
cardView
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Full Canvas (Site tab)
|
||||
|
||||
private var fullCanvasView: some View {
|
||||
VStack(spacing: 0) {
|
||||
if let url = webURL {
|
||||
WebViewRepresentable(url: url)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
} else {
|
||||
ContentUnavailableView {
|
||||
Label("Invalid URL", systemImage: "globe")
|
||||
} description: {
|
||||
Text(widget.url ?? "No URL provided")
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
// MARK: - Card (inline widget)
|
||||
|
||||
private var cardView: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
if let icon = widget.icon {
|
||||
Image(systemName: icon)
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.caption)
|
||||
}
|
||||
Text(widget.title)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
if let urlString = widget.url {
|
||||
Text(urlString)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
}
|
||||
if let url = webURL {
|
||||
WebViewRepresentable(url: url)
|
||||
.frame(height: viewHeight)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
} else {
|
||||
ContentUnavailableView {
|
||||
Label("Invalid URL", systemImage: "globe")
|
||||
} description: {
|
||||
Text(widget.url ?? "No URL provided")
|
||||
}
|
||||
.frame(height: viewHeight)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(12)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - WKWebView Wrapper
|
||||
|
||||
private struct WebViewRepresentable: UIViewRepresentable {
|
||||
let url: URL
|
||||
|
||||
func makeUIView(context: Context) -> WKWebView {
|
||||
let config = WKWebViewConfiguration()
|
||||
config.websiteDataStore = .nonPersistent()
|
||||
let webView = WKWebView(frame: .zero, configuration: config)
|
||||
webView.navigationDelegate = context.coordinator
|
||||
webView.load(URLRequest(url: url))
|
||||
return webView
|
||||
}
|
||||
|
||||
func updateUIView(_ webView: WKWebView, context: Context) {
|
||||
if webView.url != url {
|
||||
webView.load(URLRequest(url: url))
|
||||
}
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator()
|
||||
}
|
||||
|
||||
class Coordinator: NSObject, WKNavigationDelegate {
|
||||
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
||||
print("[Scarf] WebView navigation failed: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
||||
print("[Scarf] WebView failed to load: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Map a server-supplied color name to a SwiftUI `Color`. iOS twin of
|
||||
/// the Mac helper at `scarf/Features/Projects/Views/Widgets/WidgetHelpers.swift`.
|
||||
/// Unknown names default to `.blue` to keep dashboards visually
|
||||
/// consistent across platforms.
|
||||
func parseColor(_ name: String?) -> Color {
|
||||
switch name?.lowercased() {
|
||||
case "red": return .red
|
||||
case "orange": return .orange
|
||||
case "yellow": return .yellow
|
||||
case "green": return .green
|
||||
case "blue": return .blue
|
||||
case "purple": return .purple
|
||||
case "pink": return .pink
|
||||
case "teal", "cyan": return .teal
|
||||
case "indigo": return .indigo
|
||||
case "mint": return .mint
|
||||
case "brown": return .brown
|
||||
case "gray", "grey": return .gray
|
||||
default: return .blue
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
/// Browse / search the Hermes skills hub. Source picker is a `Menu`
|
||||
/// (more compact than Mac's segmented Picker on a phone-width screen).
|
||||
/// Search submits on Return; empty query falls through to a "browse"
|
||||
/// listing (top results across the chosen source).
|
||||
struct HubBrowseView: View {
|
||||
@Bindable var vm: SkillsViewModel
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
toolbar
|
||||
Divider()
|
||||
content
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var toolbar: some View {
|
||||
VStack(spacing: 8) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundStyle(.secondary)
|
||||
TextField("Search skills…", text: $vm.hubQuery)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.submitLabel(.search)
|
||||
.onSubmit { vm.searchHub() }
|
||||
Menu {
|
||||
Picker("Source", selection: $vm.hubSource) {
|
||||
ForEach(vm.hubSources, id: \.self) { src in
|
||||
Text(src).tag(src)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Text(vm.hubSource)
|
||||
.font(.callout)
|
||||
Image(systemName: "chevron.down")
|
||||
.font(.caption2)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
HStack(spacing: 8) {
|
||||
Button {
|
||||
vm.searchHub()
|
||||
} label: {
|
||||
Label("Search", systemImage: "magnifyingglass")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
.disabled(vm.isHubLoading)
|
||||
Button {
|
||||
vm.browseHub()
|
||||
} label: {
|
||||
Label("Browse", systemImage: "books.vertical")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.disabled(vm.isHubLoading)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
if vm.hubResults.isEmpty {
|
||||
ContentUnavailableView {
|
||||
Label("Browse the Hub", systemImage: "books.vertical")
|
||||
} description: {
|
||||
Text("Search for a skill or tap Browse to see top results across registries (skills.sh, official, etc.).")
|
||||
.font(.caption)
|
||||
} actions: {
|
||||
Button {
|
||||
vm.browseHub()
|
||||
} label: {
|
||||
Label("Browse top skills", systemImage: "books.vertical")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(vm.isHubLoading)
|
||||
}
|
||||
} else {
|
||||
List {
|
||||
ForEach(vm.hubResults) { hubSkill in
|
||||
HubSkillRow(skill: hubSkill, isInstalling: vm.isHubLoading) {
|
||||
vm.installHubSkill(hubSkill)
|
||||
}
|
||||
.scarfGoCompactListRow()
|
||||
}
|
||||
}
|
||||
.scarfGoListDensity()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct HubSkillRow: View {
|
||||
let skill: HermesHubSkill
|
||||
let isInstalling: Bool
|
||||
let onInstall: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Image(systemName: "books.vertical")
|
||||
.foregroundStyle(.tint)
|
||||
.font(.title3)
|
||||
.frame(width: 28)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 6) {
|
||||
Text(skill.name)
|
||||
.font(.callout.monospaced())
|
||||
.fontWeight(.medium)
|
||||
if !skill.source.isEmpty {
|
||||
Text(skill.source)
|
||||
.font(.caption2)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 1)
|
||||
.background(.tint.opacity(0.15))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
if !skill.description.isEmpty {
|
||||
Text(skill.description)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(3)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
Button {
|
||||
onInstall()
|
||||
} label: {
|
||||
Image(systemName: "arrow.down.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.tint)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isInstalling)
|
||||
.accessibilityLabel("Install \(skill.name)")
|
||||
.accessibilityHint(skill.description)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.accessibilityElement(children: .contain)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
/// Installed skills sub-tab. Category-grouped list; tapping a row
|
||||
/// pushes `SkillDetailView` for that skill. Filtering uses the VM's
|
||||
/// `filteredCategories` derivation so the search field works against
|
||||
/// the same model the Mac uses.
|
||||
struct InstalledSkillsListView: View {
|
||||
@Bindable var vm: SkillsViewModel
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if vm.isLoading && vm.categories.isEmpty {
|
||||
ProgressView("Scanning skills…")
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if vm.categories.isEmpty {
|
||||
ContentUnavailableView {
|
||||
Label("No skills installed", systemImage: "lightbulb")
|
||||
} description: {
|
||||
Text("Browse the Hub tab to install one, or run `hermes skills install <name>` on the remote.")
|
||||
.font(.caption)
|
||||
}
|
||||
} else {
|
||||
listContent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var listContent: some View {
|
||||
List {
|
||||
ForEach(vm.filteredCategories) { category in
|
||||
Section(category.name) {
|
||||
ForEach(category.skills) { skill in
|
||||
NavigationLink {
|
||||
SkillDetailView(skill: skill, vm: vm)
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(skill.name)
|
||||
.font(.body)
|
||||
Text("\(skill.files.count) file\(skill.files.count == 1 ? "" : "s")")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.scarfGoCompactListRow()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.scarfGoListDensity()
|
||||
.searchable(text: $vm.searchText, placement: .navigationBarDrawer(displayMode: .always))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
/// Installed skill detail. Shows location + required-config warning
|
||||
/// banner + file picker + content viewer. Edit and Uninstall buttons
|
||||
/// live in the toolbar.
|
||||
struct SkillDetailView: View {
|
||||
let skill: HermesSkill
|
||||
@Bindable var vm: SkillsViewModel
|
||||
|
||||
@State private var showEditor: Bool = false
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section("Location") {
|
||||
LabeledContent("Category", value: skill.category)
|
||||
Text(skill.path)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
|
||||
if !vm.missingConfig.isEmpty {
|
||||
Section {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Required config not set")
|
||||
.font(.callout)
|
||||
.fontWeight(.semibold)
|
||||
Text("Add these keys to ~/.hermes/config.yaml:")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
ForEach(vm.missingConfig, id: \.self) { key in
|
||||
Text("• \(key)")
|
||||
.font(.caption.monospaced())
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
}
|
||||
|
||||
if !skill.files.isEmpty {
|
||||
Section("Files") {
|
||||
ForEach(skill.files, id: \.self) { file in
|
||||
Button {
|
||||
vm.selectFile(file)
|
||||
} label: {
|
||||
HStack {
|
||||
Text(file)
|
||||
.font(.callout.monospaced())
|
||||
Spacer()
|
||||
if vm.selectedFileName == file {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundStyle(.tint)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.scarfGoCompactListRow()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if vm.selectedFileName != nil {
|
||||
Section("Content") {
|
||||
if vm.skillContent.isEmpty {
|
||||
Text("(empty file)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
} else if vm.isMarkdownFile {
|
||||
Text(markdown(vm.skillContent))
|
||||
.font(.callout)
|
||||
.textSelection(.enabled)
|
||||
} else {
|
||||
Text(vm.skillContent)
|
||||
.font(.footnote.monospaced())
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.scarfGoListDensity()
|
||||
.navigationTitle(skill.name)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task {
|
||||
// Selecting the skill (re)loads its main file content +
|
||||
// missingConfig diagnostics. Idempotent on re-appears.
|
||||
vm.selectSkill(skill)
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .topBarTrailing) {
|
||||
if vm.selectedFileName != nil {
|
||||
Button {
|
||||
vm.startEditing()
|
||||
showEditor = true
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
}
|
||||
Menu {
|
||||
Button(role: .destructive) {
|
||||
vm.uninstallHubSkill(skill.id)
|
||||
} label: {
|
||||
Label("Uninstall", systemImage: "trash")
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showEditor) {
|
||||
SkillEditorSheet(vm: vm, fileName: vm.selectedFileName ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
private func markdown(_ raw: String) -> AttributedString {
|
||||
let opts = AttributedString.MarkdownParsingOptions(
|
||||
interpretedSyntax: .inlineOnlyPreservingWhitespace
|
||||
)
|
||||
return (try? AttributedString(markdown: raw, options: opts)) ?? AttributedString(raw)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
/// Sheet-presented TextEditor for the currently-selected skill file.
|
||||
/// Save commits via `vm.saveEdit()` (which calls `transport.writeFile`);
|
||||
/// Cancel discards. Validation lives entirely in the VM
|
||||
/// (`isValidSkillPath` guard) so the sheet is purely UI.
|
||||
struct SkillEditorSheet: View {
|
||||
@Bindable var vm: SkillsViewModel
|
||||
let fileName: String
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
TextEditor(text: $vm.editText)
|
||||
.font(.footnote.monospaced())
|
||||
.padding(8)
|
||||
.navigationTitle(fileName)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("Cancel") {
|
||||
vm.cancelEditing()
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Save") {
|
||||
vm.saveEdit()
|
||||
dismiss()
|
||||
}
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.large])
|
||||
}
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
/// iOS Skills browser. Read-only list grouped by category. Tapping
|
||||
/// a skill shows its files + on-disk path — enough for a user to
|
||||
/// verify what's installed without opening Terminal.
|
||||
struct SkillsListView: View {
|
||||
let config: IOSServerConfig
|
||||
|
||||
@State private var vm: IOSSkillsViewModel
|
||||
|
||||
private static let sharedContextID: ServerID = ServerID(
|
||||
uuidString: "00000000-0000-0000-0000-0000000000A1"
|
||||
)!
|
||||
|
||||
init(config: IOSServerConfig) {
|
||||
self.config = config
|
||||
let ctx = config.toServerContext(id: Self.sharedContextID)
|
||||
_vm = State(initialValue: IOSSkillsViewModel(context: ctx))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
if let err = vm.lastError {
|
||||
Section {
|
||||
Label(err, systemImage: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
|
||||
if vm.categories.isEmpty, !vm.isLoading {
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("No skills installed")
|
||||
.font(.headline)
|
||||
Text("Skills live under `~/.hermes/skills/<category>/<name>/` on the remote. Install them from the Mac app or by cloning directly.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
} else {
|
||||
ForEach(vm.categories) { category in
|
||||
Section(category.name) {
|
||||
ForEach(category.skills) { skill in
|
||||
NavigationLink {
|
||||
SkillDetailView(skill: skill)
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(skill.name)
|
||||
.font(.body)
|
||||
Text("\(skill.files.count) file\(skill.files.count == 1 ? "" : "s")")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.scarfGoCompactListRow()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.scarfGoListDensity()
|
||||
.navigationTitle("Skills")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.overlay {
|
||||
if vm.isLoading && vm.categories.isEmpty {
|
||||
ProgressView("Scanning skills…")
|
||||
.padding()
|
||||
.background(.regularMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
}
|
||||
.refreshable { await vm.load() }
|
||||
.task { await vm.load() }
|
||||
}
|
||||
}
|
||||
|
||||
private struct SkillDetailView: View {
|
||||
let skill: HermesSkill
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section("Location") {
|
||||
LabeledContent("Category", value: skill.category)
|
||||
Text(skill.path)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
|
||||
if !skill.files.isEmpty {
|
||||
Section("Files") {
|
||||
ForEach(skill.files, id: \.self) { file in
|
||||
Text(file)
|
||||
.font(.caption.monospaced())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(skill.name)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
/// iOS Skills tab — 3-tab segmented surface mirroring the Mac
|
||||
/// `SkillsView`. Owns one `SkillsViewModel` (ScarfCore-side, unified
|
||||
/// in v2.5) shared across the three sub-tabs so installed-list state +
|
||||
/// hub query/results + update results all live in one place.
|
||||
///
|
||||
/// Sub-tabs:
|
||||
/// - **Installed**: category-grouped list. Tap a skill to view its
|
||||
/// files, edit content, or uninstall.
|
||||
/// - **Browse Hub**: search + source picker. Tap to install. Calls
|
||||
/// remote `hermes skills search/browse` over SSH.
|
||||
/// - **Updates**: check + update-all buttons. Calls remote
|
||||
/// `hermes skills check / update --yes`.
|
||||
struct SkillsView: View {
|
||||
let config: IOSServerConfig
|
||||
|
||||
@State private var vm: SkillsViewModel
|
||||
@State private var currentTab: Tab = .installed
|
||||
|
||||
private static let sharedContextID: ServerID = ServerID(
|
||||
uuidString: "00000000-0000-0000-0000-0000000000A1"
|
||||
)!
|
||||
|
||||
enum Tab: String, CaseIterable, Identifiable {
|
||||
case installed = "Installed"
|
||||
case hub = "Browse Hub"
|
||||
case updates = "Updates"
|
||||
var id: String { rawValue }
|
||||
var displayName: String { rawValue }
|
||||
}
|
||||
|
||||
init(config: IOSServerConfig) {
|
||||
self.config = config
|
||||
let ctx = config.toServerContext(id: Self.sharedContextID)
|
||||
_vm = State(initialValue: SkillsViewModel(context: ctx))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
tabPicker
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 6)
|
||||
statusBanner
|
||||
Divider()
|
||||
content
|
||||
}
|
||||
.navigationTitle(titleString)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task { await vm.load() }
|
||||
.refreshable { await vm.load() }
|
||||
}
|
||||
|
||||
private var titleString: String {
|
||||
vm.totalSkillCount > 0 ? "Skills (\(vm.totalSkillCount))" : "Skills"
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var tabPicker: some View {
|
||||
Picker("Section", selection: $currentTab) {
|
||||
ForEach(Tab.allCases) { tab in
|
||||
Text(tab.displayName).tag(tab)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var statusBanner: some View {
|
||||
if let msg = vm.hubMessage {
|
||||
HStack(spacing: 6) {
|
||||
if vm.isHubLoading {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
}
|
||||
Text(msg)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color.secondary.opacity(0.08))
|
||||
} else if vm.isHubLoading {
|
||||
HStack(spacing: 6) {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
Text("Working…")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color.secondary.opacity(0.08))
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
switch currentTab {
|
||||
case .installed:
|
||||
InstalledSkillsListView(vm: vm)
|
||||
case .hub:
|
||||
HubBrowseView(vm: vm)
|
||||
case .updates:
|
||||
UpdatesView(vm: vm)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
/// Updates sub-tab. Mirrors Mac: Check button populates `vm.updates`;
|
||||
/// Update All button is enabled only when there's at least one
|
||||
/// available update. Both calls run remote `hermes skills` over SSH;
|
||||
/// the parse logic is shared with Mac via `HermesSkillsHubParser`.
|
||||
struct UpdatesView: View {
|
||||
@Bindable var vm: SkillsViewModel
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
toolbar
|
||||
Divider()
|
||||
content
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var toolbar: some View {
|
||||
HStack(spacing: 8) {
|
||||
Button {
|
||||
vm.checkForUpdates()
|
||||
} label: {
|
||||
Label("Check for Updates", systemImage: "arrow.triangle.2.circlepath")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
.disabled(vm.isHubLoading)
|
||||
|
||||
if !vm.updates.isEmpty {
|
||||
Button {
|
||||
vm.updateAll()
|
||||
} label: {
|
||||
Label("Update All", systemImage: "arrow.down.app")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.disabled(vm.isHubLoading)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
if vm.updates.isEmpty {
|
||||
ContentUnavailableView {
|
||||
Label("No updates", systemImage: "checkmark.circle.fill")
|
||||
} description: {
|
||||
Text("Tap Check for Updates to query each installed skill against its source registry.")
|
||||
.font(.caption)
|
||||
}
|
||||
} else {
|
||||
List {
|
||||
ForEach(vm.updates) { update in
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "arrow.triangle.2.circlepath")
|
||||
.foregroundStyle(.orange)
|
||||
.frame(width: 24)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(update.identifier)
|
||||
.font(.callout.monospaced())
|
||||
HStack(spacing: 6) {
|
||||
Text(update.currentVersion)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
Image(systemName: "arrow.right")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
Text(update.availableVersion)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.scarfGoCompactListRow()
|
||||
}
|
||||
}
|
||||
.scarfGoListDensity()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,331 +0,0 @@
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
import os
|
||||
|
||||
/// A single search/browse result from a skill registry.
|
||||
struct HermesHubSkill: Identifiable, Sendable, Equatable {
|
||||
var id: String { identifier }
|
||||
let identifier: String // e.g. "openai/skills/skill-creator"
|
||||
let name: String
|
||||
let description: String
|
||||
let source: String // "official" | "skills-sh" | etc.
|
||||
}
|
||||
|
||||
/// A local skill that has an upstream version available.
|
||||
struct HermesSkillUpdate: Identifiable, Sendable, Equatable {
|
||||
var id: String { identifier }
|
||||
let identifier: String
|
||||
let currentVersion: String
|
||||
let availableVersion: String
|
||||
}
|
||||
|
||||
@Observable
|
||||
final class SkillsViewModel {
|
||||
private let logger = Logger(subsystem: "com.scarf", category: "SkillsViewModel")
|
||||
let context: ServerContext
|
||||
private let fileService: HermesFileService
|
||||
|
||||
init(context: ServerContext = .local) {
|
||||
self.context = context
|
||||
self.fileService = HermesFileService(context: context)
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Installed skills (existing behavior)
|
||||
var categories: [HermesSkillCategory] = []
|
||||
var selectedSkill: HermesSkill?
|
||||
var skillContent = ""
|
||||
var selectedFileName: String?
|
||||
var searchText = ""
|
||||
var missingConfig: [String] = []
|
||||
var isEditing = false
|
||||
var editText = ""
|
||||
private var currentConfig = HermesConfig.empty
|
||||
|
||||
// MARK: - Hub integration (new)
|
||||
var hubQuery = ""
|
||||
var hubResults: [HermesHubSkill] = []
|
||||
var updates: [HermesSkillUpdate] = []
|
||||
var isHubLoading = false
|
||||
var hubMessage: String?
|
||||
var hubSource: String = "all"
|
||||
|
||||
let hubSources = ["all", "official", "skills-sh", "well-known", "github", "clawhub", "lobehub"]
|
||||
|
||||
var filteredCategories: [HermesSkillCategory] {
|
||||
guard !searchText.isEmpty else { return categories }
|
||||
return categories.compactMap { category in
|
||||
let filtered = category.skills.filter {
|
||||
$0.name.localizedCaseInsensitiveContains(searchText) ||
|
||||
$0.category.localizedCaseInsensitiveContains(searchText)
|
||||
}
|
||||
guard !filtered.isEmpty else { return nil }
|
||||
return HermesSkillCategory(id: category.id, name: category.name, skills: filtered)
|
||||
}
|
||||
}
|
||||
|
||||
var totalSkillCount: Int {
|
||||
categories.reduce(0) { $0 + $1.skills.count }
|
||||
}
|
||||
|
||||
func load() {
|
||||
let svc = fileService
|
||||
// loadSkills walks ~/.hermes/skills/* — many transport ops on remote.
|
||||
Task.detached { [weak self] in
|
||||
let cats = svc.loadSkills()
|
||||
let cfg = svc.loadConfig()
|
||||
await MainActor.run { [weak self] in
|
||||
self?.categories = cats
|
||||
self?.currentConfig = cfg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func selectSkill(_ skill: HermesSkill) {
|
||||
selectedSkill = skill
|
||||
let mainFile = skill.files.first(where: { $0.hasSuffix(".md") }) ?? skill.files.first
|
||||
if let file = mainFile {
|
||||
selectedFileName = file
|
||||
skillContent = fileService.loadSkillContent(path: skill.path + "/" + file)
|
||||
} else {
|
||||
selectedFileName = nil
|
||||
skillContent = ""
|
||||
}
|
||||
missingConfig = computeMissingConfig(for: skill)
|
||||
}
|
||||
|
||||
private func computeMissingConfig(for skill: HermesSkill) -> [String] {
|
||||
guard !skill.requiredConfig.isEmpty else { return [] }
|
||||
guard let yaml = context.readText(context.paths.configYAML) else {
|
||||
return skill.requiredConfig
|
||||
}
|
||||
return skill.requiredConfig.filter { key in
|
||||
!yaml.contains(key)
|
||||
}
|
||||
}
|
||||
|
||||
func selectFile(_ file: String) {
|
||||
guard let skill = selectedSkill else { return }
|
||||
selectedFileName = file
|
||||
skillContent = fileService.loadSkillContent(path: skill.path + "/" + file)
|
||||
}
|
||||
|
||||
var isMarkdownFile: Bool {
|
||||
selectedFileName?.hasSuffix(".md") == true
|
||||
}
|
||||
|
||||
private var currentFilePath: String? {
|
||||
guard let skill = selectedSkill, let file = selectedFileName else { return nil }
|
||||
return skill.path + "/" + file
|
||||
}
|
||||
|
||||
func startEditing() {
|
||||
editText = skillContent
|
||||
isEditing = true
|
||||
}
|
||||
|
||||
func saveEdit() {
|
||||
guard let path = currentFilePath else { return }
|
||||
fileService.saveSkillContent(path: path, content: editText)
|
||||
skillContent = editText
|
||||
isEditing = false
|
||||
}
|
||||
|
||||
func cancelEditing() {
|
||||
isEditing = false
|
||||
}
|
||||
|
||||
// MARK: - Hub browse/search/install/update
|
||||
|
||||
func browseHub() {
|
||||
isHubLoading = true
|
||||
Task.detached { [fileService, hubSource] in
|
||||
var args = ["skills", "browse", "--size", "40"]
|
||||
if hubSource != "all" { args += ["--source", hubSource] }
|
||||
let result = fileService.runHermesCLI(args: args, timeout: 30)
|
||||
let parsed = Self.parseHubList(result.output)
|
||||
await MainActor.run {
|
||||
self.isHubLoading = false
|
||||
self.hubResults = parsed
|
||||
if parsed.isEmpty {
|
||||
self.hubMessage = result.exitCode == 0 ? "No results" : "Browse failed"
|
||||
} else {
|
||||
self.hubMessage = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func searchHub() {
|
||||
guard !hubQuery.isEmpty else {
|
||||
browseHub()
|
||||
return
|
||||
}
|
||||
isHubLoading = true
|
||||
Task.detached { [fileService, hubSource, hubQuery] in
|
||||
var args = ["skills", "search", hubQuery, "--limit", "40"]
|
||||
if hubSource != "all" { args += ["--source", hubSource] }
|
||||
let result = fileService.runHermesCLI(args: args, timeout: 30)
|
||||
let parsed = Self.parseHubList(result.output)
|
||||
await MainActor.run {
|
||||
self.isHubLoading = false
|
||||
self.hubResults = parsed
|
||||
if parsed.isEmpty {
|
||||
self.hubMessage = "No matches"
|
||||
} else {
|
||||
self.hubMessage = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func installHubSkill(_ skill: HermesHubSkill) {
|
||||
isHubLoading = true
|
||||
hubMessage = "Installing \(skill.identifier)…"
|
||||
Task.detached { [fileService] in
|
||||
// --yes skips confirmation since we're running non-interactively.
|
||||
let result = fileService.runHermesCLI(args: ["skills", "install", skill.identifier, "--yes"], timeout: 120)
|
||||
await MainActor.run {
|
||||
self.isHubLoading = false
|
||||
self.hubMessage = result.exitCode == 0 ? "Installed \(skill.identifier)" : "Install failed"
|
||||
self.load()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.hubMessage = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func uninstallHubSkill(_ identifier: String) {
|
||||
Task.detached { [fileService] in
|
||||
let result = fileService.runHermesCLI(args: ["skills", "uninstall", identifier, "--yes"], timeout: 60)
|
||||
await MainActor.run {
|
||||
self.hubMessage = result.exitCode == 0 ? "Uninstalled" : "Uninstall failed"
|
||||
self.load()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||
self?.hubMessage = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func checkForUpdates() {
|
||||
isHubLoading = true
|
||||
Task.detached { [fileService] in
|
||||
let result = fileService.runHermesCLI(args: ["skills", "check"], timeout: 60)
|
||||
let parsed = Self.parseUpdateList(result.output)
|
||||
await MainActor.run {
|
||||
self.isHubLoading = false
|
||||
self.updates = parsed
|
||||
self.hubMessage = parsed.isEmpty ? "No updates available" : "\(parsed.count) update(s)"
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.hubMessage = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func updateAll() {
|
||||
Task.detached { [fileService] in
|
||||
let result = fileService.runHermesCLI(args: ["skills", "update", "--yes"], timeout: 300)
|
||||
await MainActor.run {
|
||||
self.hubMessage = result.exitCode == 0 ? "Updated" : "Update failed"
|
||||
self.load()
|
||||
self.checkForUpdates()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.hubMessage = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Parsers (best-effort, tolerant of format changes)
|
||||
// `nonisolated` so callers in `Task.detached` can run them off the main actor.
|
||||
|
||||
/// Parse `hermes skills browse|search` output.
|
||||
///
|
||||
/// Hermes emits a Rich box-drawn table with vertical bars as column separators:
|
||||
///
|
||||
/// │ # │ Name │ Description │ Source │ Trust │
|
||||
/// ├──────┼────────────────┼────────────────────────┼──────────────┼────────────┤
|
||||
/// │ 1 │ 1password │ Set up and use 1Pass… │ official │ ★ official │
|
||||
///
|
||||
/// Description cells can wrap across multiple rows — the continuation rows have
|
||||
/// an empty `#` column. We join consecutive rows with the same skill by checking
|
||||
/// if the first column (after `│`) is whitespace-only.
|
||||
nonisolated private static func parseHubList(_ output: String) -> [HermesHubSkill] {
|
||||
var results: [HermesHubSkill] = []
|
||||
for raw in output.components(separatedBy: "\n") {
|
||||
let line = raw
|
||||
// Skip everything that isn't a data row. Data rows start with `│` and
|
||||
// contain multiple `│` separators. Border rows (`┏`, `┡`, `├`, `└`, etc.)
|
||||
// are drawn with `━` or `─` and should be skipped.
|
||||
guard line.contains("│") else { continue }
|
||||
let cells = line.split(separator: "│", omittingEmptySubsequences: false).map { $0.trimmingCharacters(in: .whitespaces) }
|
||||
// Expect at least: leading empty, #, Name, Description, Source, Trust, trailing empty
|
||||
guard cells.count >= 6 else { continue }
|
||||
|
||||
let numCell = cells[1]
|
||||
let nameCell = cells[2]
|
||||
let descCell = cells[3]
|
||||
let sourceCell = cells[4]
|
||||
// Trust column (index 5) is informational only — we ignore it in the UI.
|
||||
|
||||
// Continuation row: `#` column is empty. Merge its description into the
|
||||
// last-added entry if present.
|
||||
if numCell.isEmpty {
|
||||
guard !results.isEmpty else { continue }
|
||||
let last = results.removeLast()
|
||||
let merged = [last.description, descCell].filter { !$0.isEmpty }.joined(separator: " ")
|
||||
results.append(HermesHubSkill(
|
||||
identifier: last.identifier,
|
||||
name: last.name,
|
||||
description: merged,
|
||||
source: last.source
|
||||
))
|
||||
continue
|
||||
}
|
||||
// Header row — first data-looking row whose number cell isn't a digit.
|
||||
if Int(numCell) == nil { continue }
|
||||
// Empty name cell shouldn't happen but guard anyway.
|
||||
guard !nameCell.isEmpty else { continue }
|
||||
|
||||
// Identifier: `hermes skills browse` shows the short name in the Name
|
||||
// column. For install we need the full identifier like
|
||||
// `<source>/<name>`. The CLI accepts just the name for official hub,
|
||||
// so we use that as the install target.
|
||||
let source = sourceCell
|
||||
.replacingOccurrences(of: "★", with: "")
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
results.append(HermesHubSkill(
|
||||
identifier: nameCell, // hermes skills install accepts the name for official/hub-indexed skills
|
||||
name: nameCell,
|
||||
description: descCell,
|
||||
source: source
|
||||
))
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
/// Parse `hermes skills check` output for available updates. Format is
|
||||
/// undocumented; we look for `→` (U+2192) or `->` arrow markers between
|
||||
/// version strings.
|
||||
nonisolated private static func parseUpdateList(_ output: String) -> [HermesSkillUpdate] {
|
||||
var results: [HermesSkillUpdate] = []
|
||||
for raw in output.components(separatedBy: "\n") {
|
||||
let line = raw.trimmingCharacters(in: .whitespaces)
|
||||
guard line.contains("→") || line.contains("->") else { continue }
|
||||
let marker = line.contains("→") ? "→" : "->"
|
||||
let parts = line.components(separatedBy: marker)
|
||||
guard parts.count == 2 else { continue }
|
||||
let left = parts[0].trimmingCharacters(in: .whitespaces)
|
||||
let available = parts[1].trimmingCharacters(in: .whitespaces)
|
||||
let leftTokens = left.split(separator: " ", omittingEmptySubsequences: true).map(String.init)
|
||||
guard leftTokens.count >= 2 else { continue }
|
||||
let identifier = leftTokens[0]
|
||||
let current = leftTokens[leftTokens.count - 1]
|
||||
results.append(HermesSkillUpdate(identifier: identifier, currentVersion: current, availableVersion: available))
|
||||
}
|
||||
return results
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,13 @@ struct SkillsView: View {
|
||||
}
|
||||
}
|
||||
.navigationTitle("Skills (\(viewModel.totalSkillCount))")
|
||||
.onAppear { viewModel.load() }
|
||||
// SkillsViewModel.load() is async after the v2.5 ScarfCore
|
||||
// promotion. Wrap in a Task here so the existing onAppear
|
||||
// contract (fire-and-forget) keeps working without making
|
||||
// the Mac UI care about the new isolation.
|
||||
.onAppear {
|
||||
Task { await viewModel.load() }
|
||||
}
|
||||
}
|
||||
|
||||
private var modePicker: some View {
|
||||
|
||||
Reference in New Issue
Block a user