mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
Merge branch 'claude/pedantic-mcnulty-bac7cc' (iOS UI refactor)
Brings the major iOS UI refactor into scarf-mobile-development on top
of the v0.11 work that landed after the merge base (commit 6808adf).
Reconciled in this merge:
- iOS Chat/ChatView.swift — auto-merged. Their project-chat handoff
(lines 75-148: pendingProjectChat consumer + consumePendingProjectChat
helper) sits cleanly alongside my v0.11 chat additions at lines 350+
(slash command chip + browser sheet), 500+ (/steer toast), 700+
(per-turn stopwatch + git branch chip).
- Mac Features/Skills/Views/SkillsView.swift — manual resolution.
Took their async-wrap of viewModel.load() (the new ScarfCore
SkillsViewModel.load is async) AND kept my v0.11 modifiers
(designMdNpxStatus probe + recomputeSnapshotDiff + .onChange + .task)
+ helpers (recomputeSnapshotDiff, whatsNewPill).
- M5FeatureVMTests.swift — auto-merged. Their 3-line rename of
IOSSkillsViewModel → SkillsViewModel is in a different region from
my Phase 1.10 slash-command tests.
- iOS Skills/SkillsListView.swift — resolved as DELETE (their
refactor replaces it with Skills/Installed/SkillDetailView and
Skills/SkillsView). My v0.11 features there (Spotify info row,
design-md banner, frontmatter chips, What's New pill) get re-ported
to the new files in follow-up commits.
- ScarfCore IOSSkillsViewModel.swift — resolved as DELETE (replaced
by the shared SkillsViewModel in ScarfCore). My parseFrontmatter
function relocates to SkillFrontmatterParser via Phase C.
- ProjectSlashCommandsViewModel.swift — git's location-conflict
heuristic moved my Mac VM into ScarfCore (because the parent dir
was renamed). Manually relocated back to scarf/scarf/Features/Projects/ViewModels/
where it belongs (the file imports ScarfCore as a dependency, can't
live inside it).
Wholesale-accepted (no overlap with v0.11):
- ScarfCore: SkillsScanner, SkillFrontmatterParser, HermesSkillsHubParser,
SkillsViewModel, ProjectSessionsViewModel + new tests.
- iOS Projects/ feature (NEW): ProjectsListView, ProjectDetailView,
ProjectSessionsView_iOS, ProjectSiteView, Widgets/ subdir.
- iOS Skills/ refactor (NEW): SkillsView (3-sub-tab switcher),
Hub/HubBrowseView, Installed/{InstalledSkillsListView, SkillDetailView,
SkillEditorSheet}, Updates/UpdatesView.
- ScarfGoCoordinator: pendingProjectChat, startChatInProject(path:).
- ScarfGoTabRoot: 5-tab nav (Dashboard / Projects / Chat / Skills /
System) replacing the old Chat / Dashboard / Memory / More.
Verified: ScarfCore + Mac + iOS schemes all build clean on first try
post-merge. Phase C/D/E follow-up commits will:
1. Extend SkillsScanner so HermesSkill.allowedTools / relatedSkills /
dependencies populate (currently nil because the new scanner only
parses skill.yaml's required_config).
2. Port my v0.11 iOS Skills features into the new SkillDetailView /
SkillsView (Spotify info row, design-md npx banner, frontmatter
chips, What's New pill).
3. Clean up Mac dead code (HermesFileService.parseSkillFrontmatter,
parseSkillRequiredConfig — superseded by SkillsScanner /
SkillFrontmatterParser).
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,140 +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)) ?? []
|
|
||||||
// v2.5: parse SKILL.md frontmatter for the
|
|
||||||
// Hermes v2026.4.23 fields (allowed_tools,
|
|
||||||
// related_skills, dependencies). Falls back
|
|
||||||
// to nil-everything on absent/malformed
|
|
||||||
// frontmatter — old skills behave as before.
|
|
||||||
let frontmatter = Self.parseFrontmatter(
|
|
||||||
skillMdPath: skillPath + "/SKILL.md",
|
|
||||||
transport: transport
|
|
||||||
)
|
|
||||||
skills.append(HermesSkill(
|
|
||||||
id: categoryName + "/" + skillName,
|
|
||||||
name: skillName,
|
|
||||||
category: categoryName,
|
|
||||||
path: skillPath,
|
|
||||||
files: files.filter { !$0.hasPrefix(".") }.sorted(),
|
|
||||||
requiredConfig: [], // skill.yaml parsing still deferred for iOS
|
|
||||||
allowedTools: frontmatter.allowedTools,
|
|
||||||
relatedSkills: frontmatter.relatedSkills,
|
|
||||||
dependencies: frontmatter.dependencies
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read `<skill>/SKILL.md`'s YAML frontmatter and pull the v2.5
|
|
||||||
/// fields (allowed_tools, related_skills, dependencies). Returns
|
|
||||||
/// nil-filled tuple on missing file, missing frontmatter, or empty
|
|
||||||
/// fields. Mirrors Mac's `HermesFileService.parseSkillFrontmatter`.
|
|
||||||
nonisolated static func parseFrontmatter(
|
|
||||||
skillMdPath: String,
|
|
||||||
transport: any ServerTransport
|
|
||||||
) -> (allowedTools: [String]?, relatedSkills: [String]?, dependencies: [String]?) {
|
|
||||||
guard transport.fileExists(skillMdPath),
|
|
||||||
let data = try? transport.readFile(skillMdPath),
|
|
||||||
let raw = String(data: data, encoding: .utf8)
|
|
||||||
else { return (nil, nil, nil) }
|
|
||||||
let lines = raw.components(separatedBy: "\n")
|
|
||||||
guard lines.first == "---",
|
|
||||||
let endIdx = lines.dropFirst().firstIndex(of: "---")
|
|
||||||
else { return (nil, nil, nil) }
|
|
||||||
let frontmatter = lines[1..<endIdx].joined(separator: "\n")
|
|
||||||
let parsed = HermesYAML.parseNestedYAML(frontmatter)
|
|
||||||
let allowed = parsed.lists["allowed_tools"]
|
|
||||||
let related = parsed.lists["related_skills"]
|
|
||||||
let deps = parsed.lists["dependencies"]
|
|
||||||
return (
|
|
||||||
allowedTools: (allowed?.isEmpty ?? true) ? nil : allowed,
|
|
||||||
relatedSkills: (related?.isEmpty ?? true) ? nil : related,
|
|
||||||
dependencies: (deps?.isEmpty ?? true) ? nil : deps
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+20
-18
@@ -1,23 +1,26 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import os
|
import os
|
||||||
import ScarfCore
|
|
||||||
|
|
||||||
/// Drives the per-project Sessions tab introduced in v2.3. Pulls the
|
/// Drives the per-project Sessions tab introduced in v2.3 and reused
|
||||||
/// global session list from `HermesDataService`, filters by the
|
/// by the iOS Project Detail view in v2.5. Pulls the global session
|
||||||
/// attribution sidecar, and exposes a minimal surface for the view:
|
/// list from `HermesDataService`, filters by the attribution sidecar,
|
||||||
/// the filtered sessions array, loading state, and a refresh entry
|
/// and exposes a minimal surface for the view: the filtered sessions
|
||||||
/// point that the view can call on appearance + on file-watcher
|
/// array, loading state, and a refresh entry point that the view can
|
||||||
/// change.
|
/// 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
|
@Observable
|
||||||
@MainActor
|
@MainActor
|
||||||
final class ProjectSessionsViewModel {
|
public final class ProjectSessionsViewModel {
|
||||||
private static let logger = Logger(subsystem: "com.scarf", category: "ProjectSessionsViewModel")
|
private static let logger = Logger(subsystem: "com.scarf", category: "ProjectSessionsViewModel")
|
||||||
|
|
||||||
private let dataService: HermesDataService
|
private let dataService: HermesDataService
|
||||||
private let attribution: SessionAttributionService
|
private let attribution: SessionAttributionService
|
||||||
private let project: ProjectEntry
|
private let project: ProjectEntry
|
||||||
|
|
||||||
init(context: ServerContext, project: ProjectEntry) {
|
public init(context: ServerContext, project: ProjectEntry) {
|
||||||
self.dataService = HermesDataService(context: context)
|
self.dataService = HermesDataService(context: context)
|
||||||
self.attribution = SessionAttributionService(context: context)
|
self.attribution = SessionAttributionService(context: context)
|
||||||
self.project = project
|
self.project = project
|
||||||
@@ -25,23 +28,23 @@ final class ProjectSessionsViewModel {
|
|||||||
|
|
||||||
/// Sessions attributed to the owning project, in the order
|
/// Sessions attributed to the owning project, in the order
|
||||||
/// `HermesDataService.fetchSessions` returns them (newest first).
|
/// `HermesDataService.fetchSessions` returns them (newest first).
|
||||||
var sessions: [HermesSession] = []
|
public var sessions: [HermesSession] = []
|
||||||
|
|
||||||
/// True from `load()` start to its completion. The view renders
|
/// True from `load()` start to its completion. The view renders
|
||||||
/// a ProgressView during the first fetch; afterwards, re-fetches
|
/// a ProgressView during the first fetch; afterwards, re-fetches
|
||||||
/// triggered by file-watcher changes happen silently.
|
/// 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
|
/// Short diagnostic string for an empty list — nil when sessions
|
||||||
/// are loaded and populated, otherwise explains the empty state
|
/// are loaded and populated, otherwise explains the empty state
|
||||||
/// (no sessions ever created in this project, vs. no sessions
|
/// (no sessions ever created in this project, vs. no sessions
|
||||||
/// matched the project's attribution map).
|
/// matched the project's attribution map).
|
||||||
var emptyStateHint: String?
|
public var emptyStateHint: String?
|
||||||
|
|
||||||
/// Refresh the session list. Safe to call repeatedly; the data
|
/// Refresh the session list. Safe to call repeatedly; the data
|
||||||
/// service reconnects to state.db on demand and the attribution
|
/// service reconnects to state.db on demand and the attribution
|
||||||
/// service reads the sidecar afresh each call.
|
/// service reads the sidecar afresh each call.
|
||||||
func load() async {
|
public func load() async {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
defer { isLoading = false }
|
defer { isLoading = false }
|
||||||
|
|
||||||
@@ -87,11 +90,10 @@ final class ProjectSessionsViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Release the underlying DB handle. Safe to call repeatedly; the
|
/// Release the underlying DB handle. Safe to call repeatedly; the
|
||||||
/// service re-opens on the next `load()`. Mirrors the pattern in
|
/// service re-opens on the next `load()`. View calls this on
|
||||||
/// ActivityViewModel.swift:80 — view calls this on `.onDisappear`
|
/// `.onDisappear` so file descriptors and the SQLite cache don't
|
||||||
/// so file descriptors and the SQLite cache don't dangle once
|
/// dangle once the tab isn't visible.
|
||||||
/// the tab isn't visible.
|
public func close() async {
|
||||||
func close() async {
|
|
||||||
await dataService.close()
|
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(
|
try FileManager.default.removeItem(
|
||||||
at: home.appendingPathComponent("skills")
|
at: home.appendingPathComponent("skills")
|
||||||
)
|
)
|
||||||
let vm = IOSSkillsViewModel(context: ctx)
|
let vm = SkillsViewModel(context: ctx)
|
||||||
await vm.load()
|
await vm.load()
|
||||||
#expect(vm.categories.isEmpty)
|
#expect(vm.categories.isEmpty)
|
||||||
#expect(vm.lastError == nil)
|
#expect(vm.lastError == nil)
|
||||||
@@ -284,7 +284,7 @@ import Foundation
|
|||||||
// Dotfile should be filtered
|
// Dotfile should be filtered
|
||||||
try "".write(to: pJournal.appendingPathComponent(".DS_Store"), atomically: true, encoding: .utf8)
|
try "".write(to: pJournal.appendingPathComponent(".DS_Store"), atomically: true, encoding: .utf8)
|
||||||
|
|
||||||
let vm = IOSSkillsViewModel(context: ctx)
|
let vm = SkillsViewModel(context: ctx)
|
||||||
await vm.load()
|
await vm.load()
|
||||||
#expect(vm.categories.count == 2)
|
#expect(vm.categories.count == 2)
|
||||||
#expect(vm.categories[0].name == "dev")
|
#expect(vm.categories[0].name == "dev")
|
||||||
@@ -305,7 +305,7 @@ import Foundation
|
|||||||
at: home.appendingPathComponent("skills/empty-cat"),
|
at: home.appendingPathComponent("skills/empty-cat"),
|
||||||
withIntermediateDirectories: true
|
withIntermediateDirectories: true
|
||||||
)
|
)
|
||||||
let vm = IOSSkillsViewModel(context: ctx)
|
let vm = SkillsViewModel(context: ctx)
|
||||||
await vm.load()
|
await vm.load()
|
||||||
#expect(vm.categories.isEmpty)
|
#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
|
/// `AppCoordinator` pattern: an `@Observable` carrier injected via
|
||||||
/// `.environment(_:)` that any view in the tab tree can reach.
|
/// `.environment(_:)` that any view in the tab tree can reach.
|
||||||
///
|
///
|
||||||
/// Single responsibility in M9 scope: route "user tapped a recent
|
/// v2.5 expands the surface to include project handoff: tapping
|
||||||
/// session in Dashboard" → "open the Chat tab with a resume request."
|
/// "New Chat" inside a Project Detail view sets `pendingProjectChat`
|
||||||
/// Future uses (project-scoped chat handoff, notification deep-link
|
/// and routes to the Chat tab, where ChatController consumes it and
|
||||||
/// → specific session) compose naturally on the same primitive.
|
/// dispatches `resetAndStartInProject(_:)` (same wiring the in-Chat
|
||||||
|
/// project picker sheet already uses).
|
||||||
@Observable
|
@Observable
|
||||||
@MainActor
|
@MainActor
|
||||||
final class ScarfGoCoordinator {
|
final class ScarfGoCoordinator {
|
||||||
@@ -23,8 +24,15 @@ final class ScarfGoCoordinator {
|
|||||||
/// ChatController after it honours the request.
|
/// ChatController after it honours the request.
|
||||||
var pendingResumeSessionID: String?
|
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 {
|
enum Tab: Hashable {
|
||||||
case chat, dashboard, memory, more
|
case dashboard, projects, chat, skills, system
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convenience: route to Chat and queue a resume. Dashboard rows
|
/// Convenience: route to Chat and queue a resume. Dashboard rows
|
||||||
@@ -35,6 +43,16 @@ final class ScarfGoCoordinator {
|
|||||||
pendingResumeSessionID = id
|
pendingResumeSessionID = id
|
||||||
selectedTab = .chat
|
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
|
/// Environment key so subviews can pull the coordinator without
|
||||||
|
|||||||
@@ -2,22 +2,26 @@ import SwiftUI
|
|||||||
import ScarfCore
|
import ScarfCore
|
||||||
import ScarfIOS
|
import ScarfIOS
|
||||||
|
|
||||||
/// ScarfGo's primary navigation surface. Replaces the pre-M8
|
/// ScarfGo's primary navigation surface. v2.5 expands the original
|
||||||
/// "Dashboard is the hub" pattern where Chat/Memory/Cron/Skills/
|
/// 4-tab layout (Chat | Dashboard | Memory | More) to 5 primary tabs
|
||||||
/// Settings lived as NavigationLink rows three-quarters of the way
|
/// with Chat in the mathematical center:
|
||||||
/// down a scrolling List — pass-1 user-visible complaint:
|
|
||||||
///
|
///
|
||||||
/// > "We should have the actions for the user in a permanent footer?
|
/// Dashboard | Projects | Chat | Skills | System
|
||||||
/// > I don't see any navigation."
|
|
||||||
///
|
///
|
||||||
/// 4 primary tabs + a "More" bucket for the read-heavy / seldom-used
|
/// "Chat in the middle" is the v2.5 product ask — chat is the action
|
||||||
/// features. Uses iOS 18's `.sidebarAdaptable` tab style so the same
|
/// users come back for, so it's the most thumb-reachable slot on a
|
||||||
/// tree degrades to a bottom tab bar on iPhone and gets a native
|
/// phone-sized device. We stay on Apple's native `TabView` instead of
|
||||||
/// sidebar on iPadOS / macCatalyst if we ever add those targets.
|
/// 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
|
/// Each tab wraps its feature view in its own `NavigationStack` so push
|
||||||
/// push navigation (Cron editor, Memory detail, etc.) stays scoped
|
/// navigation (Cron editor, Memory detail, Project detail, etc.) stays
|
||||||
/// to the tab instead of bleeding across.
|
/// scoped to the tab instead of bleeding across.
|
||||||
struct ScarfGoTabRoot: View {
|
struct ScarfGoTabRoot: View {
|
||||||
let serverID: ServerID
|
let serverID: ServerID
|
||||||
let config: IOSServerConfig
|
let config: IOSServerConfig
|
||||||
@@ -26,8 +30,9 @@ struct ScarfGoTabRoot: View {
|
|||||||
let onForget: @MainActor () async -> Void
|
let onForget: @MainActor () async -> Void
|
||||||
|
|
||||||
/// One coordinator per server-connected session. Cross-tab
|
/// One coordinator per server-connected session. Cross-tab
|
||||||
/// signalling (Dashboard row → Chat tab resume, eventually
|
/// signalling (Dashboard row → Chat tab resume, Project Detail
|
||||||
/// notification deep-link → Chat) flows through here.
|
/// → in-project chat handoff, notification deep-link → Chat) flows
|
||||||
|
/// through here.
|
||||||
@State private var coordinator = ScarfGoCoordinator()
|
@State private var coordinator = ScarfGoCoordinator()
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -39,18 +44,7 @@ struct ScarfGoTabRoot: View {
|
|||||||
// SSH channel contention.
|
// SSH channel contention.
|
||||||
let ctx = config.toServerContext(id: serverID)
|
let ctx = config.toServerContext(id: serverID)
|
||||||
TabView(selection: $coordinator.selectedTab) {
|
TabView(selection: $coordinator.selectedTab) {
|
||||||
// 1 — Chat: the reason the app is on your phone. Primary
|
// 1 — Dashboard: stats + recent sessions.
|
||||||
// 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).
|
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
DashboardView(config: config, key: key)
|
DashboardView(config: config, key: key)
|
||||||
}
|
}
|
||||||
@@ -58,33 +52,59 @@ struct ScarfGoTabRoot: View {
|
|||||||
Label("Dashboard", systemImage: "gauge.with.needle")
|
Label("Dashboard", systemImage: "gauge.with.needle")
|
||||||
}
|
}
|
||||||
.tag(ScarfGoCoordinator.Tab.dashboard)
|
.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 {
|
NavigationStack {
|
||||||
MemoryListView(config: config)
|
ProjectsListView(config: config)
|
||||||
}
|
}
|
||||||
.tabItem {
|
.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
|
// 3 — Chat: the reason the app is on your phone. Centered
|
||||||
// "Forget this server" action. Named "More" because on
|
// among the 5 tabs for thumb reach + visual prominence.
|
||||||
// iOS 18 with .sidebarAdaptable the system collapses
|
|
||||||
// leftover tabs into a disclosure group with that exact
|
|
||||||
// label automatically; choosing the same word keeps our
|
|
||||||
// More tab visually consistent with the system default.
|
|
||||||
NavigationStack {
|
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,
|
config: config,
|
||||||
onSoftDisconnect: onSoftDisconnect,
|
onSoftDisconnect: onSoftDisconnect,
|
||||||
onForget: onForget
|
onForget: onForget
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.tabItem {
|
.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
|
// Pulls the sidebar-on-iPad affordance into the same code path
|
||||||
// as the bottom-bar-on-iPhone one. No-op on iPhone today.
|
// 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:
|
/// Server identity + Memory + Cron + Settings + destructive actions.
|
||||||
/// Cron (infrequent edits), Skills (read-only), Settings (read-only
|
/// "System" reads as configuration / server-meta; the reorganization
|
||||||
/// until M9 scoped editor), plus the destructive server-forget action.
|
/// 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
|
/// 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
|
/// elsewhere — if a feature graduates to a primary tab, that's a
|
||||||
/// deliberate design decision.
|
/// deliberate design decision.
|
||||||
private struct MoreTab: View {
|
private struct SystemTab: View {
|
||||||
let config: IOSServerConfig
|
let config: IOSServerConfig
|
||||||
let onSoftDisconnect: @MainActor () async -> Void
|
let onSoftDisconnect: @MainActor () async -> Void
|
||||||
let onForget: @MainActor () async -> Void
|
let onForget: @MainActor () async -> Void
|
||||||
@@ -131,15 +152,15 @@ private struct MoreTab: View {
|
|||||||
|
|
||||||
Section("Features") {
|
Section("Features") {
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
CronListView(config: config)
|
MemoryListView(config: config)
|
||||||
} label: {
|
} label: {
|
||||||
Label("Cron jobs", systemImage: "clock.arrow.circlepath")
|
Label("Memory", systemImage: "brain.head.profile")
|
||||||
}
|
}
|
||||||
.scarfGoCompactListRow()
|
.scarfGoCompactListRow()
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
SkillsListView(config: config)
|
CronListView(config: config)
|
||||||
} label: {
|
} label: {
|
||||||
Label("Skills", systemImage: "sparkles")
|
Label("Cron jobs", systemImage: "clock.arrow.circlepath")
|
||||||
}
|
}
|
||||||
.scarfGoCompactListRow()
|
.scarfGoCompactListRow()
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
@@ -194,7 +215,7 @@ private struct MoreTab: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.scarfGoListDensity()
|
.scarfGoListDensity()
|
||||||
.navigationTitle("More")
|
.navigationTitle("System")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.confirmationDialog(
|
.confirmationDialog(
|
||||||
"Forget this server?",
|
"Forget this server?",
|
||||||
|
|||||||
@@ -79,29 +79,37 @@ struct ChatView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
.task {
|
.task {
|
||||||
// Dashboard row taps set `pendingResumeSessionID` on the
|
// Dashboard row taps set `pendingResumeSessionID`, Project
|
||||||
// coordinator before switching to the Chat tab. Honor
|
// Detail's "New Chat" sets `pendingProjectChat`. Both fire
|
||||||
// that if present, else open a fresh session. Clearing
|
// a tab switch to .chat alongside the value set; we
|
||||||
// the coordinator value is the consumer's responsibility
|
// consume + clear here on first appear. Resume wins over
|
||||||
// (us) — otherwise a later plain tap on the Chat tab
|
// project-chat if both somehow get set in a single hop —
|
||||||
// would accidentally re-resume the old session.
|
// but in practice the coordinator never sets both at once.
|
||||||
if let sessionID = coordinator?.pendingResumeSessionID {
|
if let sessionID = coordinator?.pendingResumeSessionID {
|
||||||
coordinator?.pendingResumeSessionID = nil
|
coordinator?.pendingResumeSessionID = nil
|
||||||
await controller.startResuming(sessionID: sessionID)
|
await controller.startResuming(sessionID: sessionID)
|
||||||
|
} else if let projectPath = coordinator?.pendingProjectChat {
|
||||||
|
coordinator?.pendingProjectChat = nil
|
||||||
|
await consumePendingProjectChat(projectPath)
|
||||||
} else {
|
} else {
|
||||||
await controller.start()
|
await controller.start()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Also react to a coordinator change that happens while Chat
|
// React to coordinator changes that happen while Chat is
|
||||||
// is already mounted (e.g., user is in Chat, switches to
|
// already mounted (e.g., user is in Chat, taps Projects, opens
|
||||||
// Dashboard, taps a session row — coordinator flips the tab
|
// a project detail, taps "New Chat" — coordinator flips the
|
||||||
// AND sets pendingResumeSessionID. The `.task` above only
|
// tab AND sets pendingProjectChat. The `.task` above only
|
||||||
// fires on first appear; this is the mid-session hook.)
|
// fires on first appear; these are the mid-session hooks.)
|
||||||
.onChange(of: coordinator?.pendingResumeSessionID) { _, new in
|
.onChange(of: coordinator?.pendingResumeSessionID) { _, new in
|
||||||
guard let sessionID = new else { return }
|
guard let sessionID = new else { return }
|
||||||
coordinator?.pendingResumeSessionID = nil
|
coordinator?.pendingResumeSessionID = nil
|
||||||
Task { await controller.startResuming(sessionID: sessionID) }
|
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.
|
// Deliberately NOT tearing down the ACP session on .onDisappear.
|
||||||
// `TabView` unmounts tab content when the user switches tabs
|
// `TabView` unmounts tab content when the user switches tabs
|
||||||
// (disappear fires), but `@State var controller` keeps the
|
// (disappear fires), but `@State var controller` keeps the
|
||||||
@@ -144,6 +152,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
|
// MARK: - Subviews
|
||||||
|
|
||||||
@ViewBuilder
|
@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,284 +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
|
|
||||||
@State private var snapshotDiff: SkillSnapshotDiff?
|
|
||||||
|
|
||||||
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 diff = snapshotDiff,
|
|
||||||
diff.hasChanges,
|
|
||||||
!diff.previousSnapshotEmpty {
|
|
||||||
Section {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
Image(systemName: "sparkles")
|
|
||||||
.foregroundStyle(.tint)
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
|
||||||
Text(diff.label)
|
|
||||||
.font(.callout)
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
Button("Seen") {
|
|
||||||
SkillSnapshotService(serverID: Self.sharedContextID)
|
|
||||||
.markSeen(vm.categories.flatMap(\.skills))
|
|
||||||
snapshotDiff = nil
|
|
||||||
}
|
|
||||||
.controlSize(.small)
|
|
||||||
.buttonStyle(.bordered)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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()
|
|
||||||
recomputeSnapshotDiff()
|
|
||||||
}
|
|
||||||
.task {
|
|
||||||
await vm.load()
|
|
||||||
recomputeSnapshotDiff()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// v2.5 "What's New" diff against the last-seen snapshot for this
|
|
||||||
/// server. First-time users get a silent prime — the pill only
|
|
||||||
/// renders on subsequent loads when something actually changed.
|
|
||||||
private func recomputeSnapshotDiff() {
|
|
||||||
let allSkills = vm.categories.flatMap(\.skills)
|
|
||||||
let svc = SkillSnapshotService(serverID: Self.sharedContextID)
|
|
||||||
let diff = svc.diff(against: allSkills)
|
|
||||||
if diff.previousSnapshotEmpty {
|
|
||||||
svc.markSeen(allSkills)
|
|
||||||
snapshotDiff = nil
|
|
||||||
} else {
|
|
||||||
snapshotDiff = diff
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct SkillDetailView: View {
|
|
||||||
let skill: HermesSkill
|
|
||||||
|
|
||||||
@Environment(\.serverContext) private var serverContext
|
|
||||||
@State private var npxStatus: SkillPrereqService.Status?
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
List {
|
|
||||||
Section("Location") {
|
|
||||||
LabeledContent("Category", value: skill.category)
|
|
||||||
Text(skill.path)
|
|
||||||
.font(.caption.monospaced())
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.textSelection(.enabled)
|
|
||||||
}
|
|
||||||
|
|
||||||
// v2.5 design-md prereq surface — the skill needs `npx`
|
|
||||||
// (Node.js 18+) on the host. iOS read-only banner: same
|
|
||||||
// wording as the Mac one, no install button (the user is
|
|
||||||
// already going to need a shell to fix this).
|
|
||||||
if skill.name.lowercased() == "design-md",
|
|
||||||
case .missing(let hint) = npxStatus {
|
|
||||||
Section("Prerequisite missing") {
|
|
||||||
Label {
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
Text("`npx` not found on the Hermes host.")
|
|
||||||
.font(.callout.weight(.medium))
|
|
||||||
Text(hint)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
}
|
|
||||||
} icon: {
|
|
||||||
Image(systemName: "exclamationmark.triangle.fill")
|
|
||||||
.foregroundStyle(.orange)
|
|
||||||
}
|
|
||||||
.padding(.vertical, 4)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if skill.name.lowercased() == "spotify" {
|
|
||||||
Section("Authentication") {
|
|
||||||
Label {
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
Text("Spotify needs OAuth")
|
|
||||||
.font(.callout.weight(.medium))
|
|
||||||
Text("Run `hermes auth spotify` from the Scarf macOS app or a shell — it opens your browser to complete the OAuth flow. Once authorised, this skill picks up the credentials from `~/.hermes/auth.json` automatically.")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
}
|
|
||||||
} icon: {
|
|
||||||
Image(systemName: "music.note")
|
|
||||||
.foregroundStyle(.green)
|
|
||||||
}
|
|
||||||
.padding(.vertical, 4)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let tools = skill.allowedTools, !tools.isEmpty {
|
|
||||||
Section("Allowed tools") {
|
|
||||||
chipRow(tools)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let related = skill.relatedSkills, !related.isEmpty {
|
|
||||||
Section("Related skills") {
|
|
||||||
chipRow(related)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let deps = skill.dependencies, !deps.isEmpty {
|
|
||||||
Section("Dependencies") {
|
|
||||||
chipRow(deps)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !skill.files.isEmpty {
|
|
||||||
Section("Files") {
|
|
||||||
ForEach(skill.files, id: \.self) { file in
|
|
||||||
Text(file)
|
|
||||||
.font(.caption.monospaced())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.navigationTitle(skill.name)
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
.task(id: skill.id) {
|
|
||||||
// Only probe when this skill needs it. design-md is the
|
|
||||||
// only skill in v2.5 with a host-side prereq surface; the
|
|
||||||
// probe runs once per appear and isn't cached across
|
|
||||||
// navigation events (cheap — single SSH `which` call).
|
|
||||||
guard skill.name.lowercased() == "design-md" else {
|
|
||||||
npxStatus = nil
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let svc = SkillPrereqService(context: serverContext)
|
|
||||||
npxStatus = await svc.probe(binary: "npx")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Render a list of strings as wrapping pill chips. Used for
|
|
||||||
/// allowed_tools / related_skills / dependencies sections (v2.5
|
|
||||||
/// SKILL.md frontmatter).
|
|
||||||
@ViewBuilder
|
|
||||||
private func chipRow(_ items: [String]) -> some View {
|
|
||||||
FlowLayout(spacing: 6) {
|
|
||||||
ForEach(items, id: \.self) { item in
|
|
||||||
Text(item)
|
|
||||||
.font(.caption.monospaced())
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.padding(.horizontal, 8)
|
|
||||||
.padding(.vertical, 3)
|
|
||||||
.background(.secondary.opacity(0.12), in: Capsule())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.vertical, 4)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Minimal flow-layout for chip rows (wraps onto multiple lines when
|
|
||||||
/// content overflows the available width). Built-in `Layout` API,
|
|
||||||
/// no third-party dep. Used by the Skills detail view for the v2.5
|
|
||||||
/// allowed_tools / related_skills / dependencies sections.
|
|
||||||
private struct FlowLayout: Layout {
|
|
||||||
var spacing: CGFloat = 4
|
|
||||||
|
|
||||||
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
|
||||||
guard let maxWidth = proposal.width else { return .zero }
|
|
||||||
var rowWidth: CGFloat = 0
|
|
||||||
var totalHeight: CGFloat = 0
|
|
||||||
var rowHeight: CGFloat = 0
|
|
||||||
for subview in subviews {
|
|
||||||
let size = subview.sizeThatFits(.unspecified)
|
|
||||||
if rowWidth + size.width > maxWidth, rowWidth > 0 {
|
|
||||||
totalHeight += rowHeight + spacing
|
|
||||||
rowWidth = 0
|
|
||||||
rowHeight = 0
|
|
||||||
}
|
|
||||||
rowWidth += size.width + spacing
|
|
||||||
rowHeight = max(rowHeight, size.height)
|
|
||||||
}
|
|
||||||
totalHeight += rowHeight
|
|
||||||
return CGSize(width: maxWidth, height: totalHeight)
|
|
||||||
}
|
|
||||||
|
|
||||||
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
|
||||||
var x = bounds.minX
|
|
||||||
var y = bounds.minY
|
|
||||||
var rowHeight: CGFloat = 0
|
|
||||||
for subview in subviews {
|
|
||||||
let size = subview.sizeThatFits(.unspecified)
|
|
||||||
if x + size.width > bounds.maxX, x > bounds.minX {
|
|
||||||
x = bounds.minX
|
|
||||||
y += rowHeight + spacing
|
|
||||||
rowHeight = 0
|
|
||||||
}
|
|
||||||
subview.place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size))
|
|
||||||
x += size.width + spacing
|
|
||||||
rowHeight = max(rowHeight, size.height)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -56,7 +56,13 @@ struct SkillsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Skills (\(viewModel.totalSkillCount))")
|
.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() }
|
||||||
|
}
|
||||||
// v2.5: re-probe `npx` whenever the selected skill changes;
|
// v2.5: re-probe `npx` whenever the selected skill changes;
|
||||||
// only the design-md skill cares about the result, but binding
|
// only the design-md skill cares about the result, but binding
|
||||||
// to the selection makes the probe automatic across switches.
|
// to the selection makes the probe automatic across switches.
|
||||||
|
|||||||
Reference in New Issue
Block a user