diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Parsing/HermesSkillsHubParser.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Parsing/HermesSkillsHubParser.swift new file mode 100644 index 0000000..26e05d4 --- /dev/null +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Parsing/HermesSkillsHubParser.swift @@ -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 `/`. 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 + } +} diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Parsing/SkillFrontmatterParser.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Parsing/SkillFrontmatterParser.swift new file mode 100644 index 0000000..b58c734 --- /dev/null +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Parsing/SkillFrontmatterParser.swift @@ -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 + } +} diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/SkillsScanner.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/SkillsScanner.swift new file mode 100644 index 0000000..b527265 --- /dev/null +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/SkillsScanner.swift @@ -0,0 +1,65 @@ +import Foundation +import os + +/// Walks `~/.hermes/skills///` 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) + } +} diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/IOSSkillsViewModel.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/IOSSkillsViewModel.swift deleted file mode 100644 index e2eda01..0000000 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/IOSSkillsViewModel.swift +++ /dev/null @@ -1,100 +0,0 @@ -import Foundation -import Observation - -/// iOS read-only Skills view-state. Scans `~/.hermes/skills/` for -/// category directories, then each category for skill directories, -/// then each skill directory for its file list. Mirrors what the -/// Mac app's `HermesFileService.loadSkills` does but scoped to what -/// the transport's `listDirectory` primitive can surface (no deep -/// YAML frontmatter parsing — users who want to inspect a skill's -/// full definition still do that on the Mac). -/// -/// M5 is read-only by design. Installing new skills would need a -/// git-clone over SSH plus schema validation; that's a separate -/// feature in a later phase. -@Observable -@MainActor -public final class IOSSkillsViewModel { - public let context: ServerContext - - public private(set) var categories: [HermesSkillCategory] = [] - public private(set) var isLoading: Bool = true - public private(set) var lastError: String? - - public init(context: ServerContext) { - self.context = context - } - - public func load() async { - isLoading = true - lastError = nil - let ctx = context - let skillsRoot = ctx.paths.skillsDir - - let loaded: Result<[HermesSkillCategory], Error> = await Task.detached { - let transport = ctx.makeTransport() - guard transport.fileExists(skillsRoot) else { - // Fresh install → no skills/ dir yet. - return .success([]) - } - do { - let categoryNames = try transport.listDirectory(skillsRoot) - .filter { !$0.hasPrefix(".") } - .sorted() - - var categories: [HermesSkillCategory] = [] - for categoryName in categoryNames { - let categoryPath = skillsRoot + "/" + categoryName - // Only include directories. - guard transport.stat(categoryPath)?.isDirectory == true else { continue } - - var skills: [HermesSkill] = [] - let skillNames: [String] - do { - skillNames = try transport.listDirectory(categoryPath) - .filter { !$0.hasPrefix(".") } - .sorted() - } catch { - // Skip categories we can't read (permissions etc.) - // rather than failing the whole load. - continue - } - - for skillName in skillNames { - let skillPath = categoryPath + "/" + skillName - guard transport.stat(skillPath)?.isDirectory == true else { continue } - let files: [String] = (try? transport.listDirectory(skillPath)) ?? [] - skills.append(HermesSkill( - id: categoryName + "/" + skillName, - name: skillName, - category: categoryName, - path: skillPath, - files: files.filter { !$0.hasPrefix(".") }.sorted(), - requiredConfig: [] // Skills frontmatter parsing deferred. - )) - } - - if !skills.isEmpty { - categories.append(HermesSkillCategory( - id: categoryName, - name: categoryName, - skills: skills - )) - } - } - return .success(categories) - } catch { - return .failure(error) - } - }.value - - switch loaded { - case .success(let cats): - categories = cats - case .failure(let err): - categories = [] - lastError = "Couldn't list skills: \(err.localizedDescription)" - } - isLoading = false - } -} diff --git a/scarf/scarf/Features/Projects/ViewModels/ProjectSessionsViewModel.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/ProjectSessionsViewModel.swift similarity index 76% rename from scarf/scarf/Features/Projects/ViewModels/ProjectSessionsViewModel.swift rename to scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/ProjectSessionsViewModel.swift index ce092dd..6f78896 100644 --- a/scarf/scarf/Features/Projects/ViewModels/ProjectSessionsViewModel.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/ProjectSessionsViewModel.swift @@ -1,23 +1,26 @@ import Foundation import os -import ScarfCore -/// Drives the per-project Sessions tab introduced in v2.3. Pulls the -/// global session list from `HermesDataService`, filters by the -/// attribution sidecar, and exposes a minimal surface for the view: -/// the filtered sessions array, loading state, and a refresh entry -/// point that the view can call on appearance + on file-watcher -/// change. +/// Drives the per-project Sessions tab introduced in v2.3 and reused +/// by the iOS Project Detail view in v2.5. Pulls the global session +/// list from `HermesDataService`, filters by the attribution sidecar, +/// and exposes a minimal surface for the view: the filtered sessions +/// array, loading state, and a refresh entry point that the view can +/// call on appearance + on file-watcher change. +/// +/// Promoted from the Mac target into ScarfCore in v2.5 so iOS and Mac +/// share the exact same filtering + attribution semantics — there's +/// nothing AppKit-specific here, just transport-backed I/O. @Observable @MainActor -final class ProjectSessionsViewModel { +public final class ProjectSessionsViewModel { private static let logger = Logger(subsystem: "com.scarf", category: "ProjectSessionsViewModel") private let dataService: HermesDataService private let attribution: SessionAttributionService private let project: ProjectEntry - init(context: ServerContext, project: ProjectEntry) { + public init(context: ServerContext, project: ProjectEntry) { self.dataService = HermesDataService(context: context) self.attribution = SessionAttributionService(context: context) self.project = project @@ -25,23 +28,23 @@ final class ProjectSessionsViewModel { /// Sessions attributed to the owning project, in the order /// `HermesDataService.fetchSessions` returns them (newest first). - var sessions: [HermesSession] = [] + public var sessions: [HermesSession] = [] /// True from `load()` start to its completion. The view renders /// a ProgressView during the first fetch; afterwards, re-fetches /// triggered by file-watcher changes happen silently. - var isLoading: Bool = false + public var isLoading: Bool = false /// Short diagnostic string for an empty list — nil when sessions /// are loaded and populated, otherwise explains the empty state /// (no sessions ever created in this project, vs. no sessions /// matched the project's attribution map). - var emptyStateHint: String? + public var emptyStateHint: String? /// Refresh the session list. Safe to call repeatedly; the data /// service reconnects to state.db on demand and the attribution /// service reads the sidecar afresh each call. - func load() async { + public func load() async { isLoading = true defer { isLoading = false } @@ -87,11 +90,10 @@ final class ProjectSessionsViewModel { } /// Release the underlying DB handle. Safe to call repeatedly; the - /// service re-opens on the next `load()`. Mirrors the pattern in - /// ActivityViewModel.swift:80 — view calls this on `.onDisappear` - /// so file descriptors and the SQLite cache don't dangle once - /// the tab isn't visible. - func close() async { + /// service re-opens on the next `load()`. View calls this on + /// `.onDisappear` so file descriptors and the SQLite cache don't + /// dangle once the tab isn't visible. + public func close() async { await dataService.close() } } diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/SkillsViewModel.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/SkillsViewModel.swift new file mode 100644 index 0000000..dbe8820 --- /dev/null +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/SkillsViewModel.swift @@ -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 + } +} diff --git a/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/M5FeatureVMTests.swift b/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/M5FeatureVMTests.swift index e8eb2c7..5c84cdb 100644 --- a/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/M5FeatureVMTests.swift +++ b/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/M5FeatureVMTests.swift @@ -257,7 +257,7 @@ import Foundation try FileManager.default.removeItem( at: home.appendingPathComponent("skills") ) - let vm = IOSSkillsViewModel(context: ctx) + let vm = SkillsViewModel(context: ctx) await vm.load() #expect(vm.categories.isEmpty) #expect(vm.lastError == nil) @@ -284,7 +284,7 @@ import Foundation // Dotfile should be filtered try "".write(to: pJournal.appendingPathComponent(".DS_Store"), atomically: true, encoding: .utf8) - let vm = IOSSkillsViewModel(context: ctx) + let vm = SkillsViewModel(context: ctx) await vm.load() #expect(vm.categories.count == 2) #expect(vm.categories[0].name == "dev") @@ -305,7 +305,7 @@ import Foundation at: home.appendingPathComponent("skills/empty-cat"), withIntermediateDirectories: true ) - let vm = IOSSkillsViewModel(context: ctx) + let vm = SkillsViewModel(context: ctx) await vm.load() #expect(vm.categories.isEmpty) } diff --git a/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/SkillFrontmatterParserTests.swift b/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/SkillFrontmatterParserTests.swift new file mode 100644 index 0000000..07fbdfe --- /dev/null +++ b/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/SkillFrontmatterParserTests.swift @@ -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) + } +} diff --git a/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/SkillsHubParserTests.swift b/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/SkillsHubParserTests.swift new file mode 100644 index 0000000..10b0d9c --- /dev/null +++ b/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/SkillsHubParserTests.swift @@ -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") + } +} diff --git a/scarf/Scarf iOS/App/ScarfGoCoordinator.swift b/scarf/Scarf iOS/App/ScarfGoCoordinator.swift index 3c7cbe4..cb06184 100644 --- a/scarf/Scarf iOS/App/ScarfGoCoordinator.swift +++ b/scarf/Scarf iOS/App/ScarfGoCoordinator.swift @@ -5,10 +5,11 @@ import ScarfCore /// `AppCoordinator` pattern: an `@Observable` carrier injected via /// `.environment(_:)` that any view in the tab tree can reach. /// -/// Single responsibility in M9 scope: route "user tapped a recent -/// session in Dashboard" → "open the Chat tab with a resume request." -/// Future uses (project-scoped chat handoff, notification deep-link -/// → specific session) compose naturally on the same primitive. +/// v2.5 expands the surface to include project handoff: tapping +/// "New Chat" inside a Project Detail view sets `pendingProjectChat` +/// and routes to the Chat tab, where ChatController consumes it and +/// dispatches `resetAndStartInProject(_:)` (same wiring the in-Chat +/// project picker sheet already uses). @Observable @MainActor final class ScarfGoCoordinator { @@ -23,8 +24,15 @@ final class ScarfGoCoordinator { /// ChatController after it honours the request. var pendingResumeSessionID: String? + /// If non-nil, the Chat tab should start an in-project session at + /// this absolute remote path on next appear instead of a quick + /// chat. Consumed (cleared) by ChatController after it kicks off + /// `resetAndStartInProject(_:)`. Mirrors Mac's + /// `AppCoordinator.pendingProjectChat`. + var pendingProjectChat: String? + enum Tab: Hashable { - case chat, dashboard, memory, more + case dashboard, projects, chat, skills, system } /// Convenience: route to Chat and queue a resume. Dashboard rows @@ -35,6 +43,16 @@ final class ScarfGoCoordinator { pendingResumeSessionID = id selectedTab = .chat } + + /// Convenience: route to Chat and queue a project-scoped session + /// start at `path`. Project Detail's "New Chat" toolbar button + /// calls this. Clearing `pendingProjectChat` is the consumer's + /// responsibility (ChatController) once `resetAndStartInProject` + /// has been dispatched. + func startChatInProject(path: String) { + pendingProjectChat = path + selectedTab = .chat + } } /// Environment key so subviews can pull the coordinator without diff --git a/scarf/Scarf iOS/App/ScarfGoTabRoot.swift b/scarf/Scarf iOS/App/ScarfGoTabRoot.swift index 4e7dc8f..4ee9de6 100644 --- a/scarf/Scarf iOS/App/ScarfGoTabRoot.swift +++ b/scarf/Scarf iOS/App/ScarfGoTabRoot.swift @@ -2,22 +2,26 @@ import SwiftUI import ScarfCore import ScarfIOS -/// ScarfGo's primary navigation surface. Replaces the pre-M8 -/// "Dashboard is the hub" pattern where Chat/Memory/Cron/Skills/ -/// Settings lived as NavigationLink rows three-quarters of the way -/// down a scrolling List — pass-1 user-visible complaint: +/// ScarfGo's primary navigation surface. v2.5 expands the original +/// 4-tab layout (Chat | Dashboard | Memory | More) to 5 primary tabs +/// with Chat in the mathematical center: /// -/// > "We should have the actions for the user in a permanent footer? -/// > I don't see any navigation." +/// Dashboard | Projects | Chat | Skills | System /// -/// 4 primary tabs + a "More" bucket for the read-heavy / seldom-used -/// features. Uses iOS 18's `.sidebarAdaptable` tab style so the same -/// tree degrades to a bottom tab bar on iPhone and gets a native -/// sidebar on iPadOS / macCatalyst if we ever add those targets. +/// "Chat in the middle" is the v2.5 product ask — chat is the action +/// users come back for, so it's the most thumb-reachable slot on a +/// phone-sized device. We stay on Apple's native `TabView` instead of +/// drawing a custom raised center button: 5 tabs is exactly the iPhone +/// system maximum (no auto-collapse to "More"), and `.sidebarAdaptable` +/// continues to give us a real sidebar on iPad / macCatalyst for free. +/// Memory drops out of primary slots and lives inside the renamed +/// "System" tab (was "More"). Skills graduates from a System sub-row +/// into its own primary tab to match v2.5's full Mac parity for skills +/// (Installed / Browse Hub / Updates). /// -/// Each tab wraps its feature view in its own `NavigationStack` so -/// push navigation (Cron editor, Memory detail, etc.) stays scoped -/// to the tab instead of bleeding across. +/// Each tab wraps its feature view in its own `NavigationStack` so push +/// navigation (Cron editor, Memory detail, Project detail, etc.) stays +/// scoped to the tab instead of bleeding across. struct ScarfGoTabRoot: View { let serverID: ServerID let config: IOSServerConfig @@ -26,8 +30,9 @@ struct ScarfGoTabRoot: View { let onForget: @MainActor () async -> Void /// One coordinator per server-connected session. Cross-tab - /// signalling (Dashboard row → Chat tab resume, eventually - /// notification deep-link → Chat) flows through here. + /// signalling (Dashboard row → Chat tab resume, Project Detail + /// → in-project chat handoff, notification deep-link → Chat) flows + /// through here. @State private var coordinator = ScarfGoCoordinator() var body: some View { @@ -39,18 +44,7 @@ struct ScarfGoTabRoot: View { // SSH channel contention. let ctx = config.toServerContext(id: serverID) TabView(selection: $coordinator.selectedTab) { - // 1 — Chat: the reason the app is on your phone. Primary - // tab; opens straight into the chat surface. - NavigationStack { - ChatView(config: config, key: key) - } - .tabItem { - Label("Chat", systemImage: "bubble.left.and.bubble.right.fill") - } - .tag(ScarfGoCoordinator.Tab.chat) - - // 2 — Dashboard: stats + recent sessions (no surfaces list - // anymore — those live in More). + // 1 — Dashboard: stats + recent sessions. NavigationStack { DashboardView(config: config, key: key) } @@ -58,33 +52,59 @@ struct ScarfGoTabRoot: View { Label("Dashboard", systemImage: "gauge.with.needle") } .tag(ScarfGoCoordinator.Tab.dashboard) + .accessibilityLabel("Dashboard tab") - // 3 — Memory: MEMORY.md + USER.md + SOUL.md. + // 2 — Projects: registered projects → per-project dashboard, + // site, and sessions. Read-only registry on iOS — add / + // rename / archive happens in the Mac app. NavigationStack { - MemoryListView(config: config) + ProjectsListView(config: config) } .tabItem { - Label("Memory", systemImage: "brain.head.profile") + Label("Projects", systemImage: "square.grid.2x2") } - .tag(ScarfGoCoordinator.Tab.memory) + .tag(ScarfGoCoordinator.Tab.projects) + .accessibilityLabel("Projects tab") - // 4 — More: Cron, Skills, Settings, plus the destructive - // "Forget this server" action. Named "More" because on - // iOS 18 with .sidebarAdaptable the system collapses - // leftover tabs into a disclosure group with that exact - // label automatically; choosing the same word keeps our - // More tab visually consistent with the system default. + // 3 — Chat: the reason the app is on your phone. Centered + // among the 5 tabs for thumb reach + visual prominence. NavigationStack { - MoreTab( + ChatView(config: config, key: key) + } + .tabItem { + Label("Chat", systemImage: "bubble.left.and.bubble.right.fill") + } + .tag(ScarfGoCoordinator.Tab.chat) + .accessibilityLabel("Chat tab") + + // 4 — Skills: Installed | Browse Hub | Updates, mirroring + // the Mac app's 3-tab skills surface. + NavigationStack { + SkillsView(config: config) + } + .tabItem { + Label("Skills", systemImage: "lightbulb") + } + .tag(ScarfGoCoordinator.Tab.skills) + .accessibilityLabel("Skills tab") + + // 5 — System: server identity, Memory, Cron, Settings, plus + // the destructive disconnect / forget actions. Renamed from + // "More" to match the user-facing v2.5 vocabulary; the + // .sidebarAdaptable system fallback label happens not to + // matter here because we never overflow. + NavigationStack { + SystemTab( config: config, onSoftDisconnect: onSoftDisconnect, onForget: onForget ) } .tabItem { - Label("More", systemImage: "ellipsis.circle") + Label("System", systemImage: "gearshape.fill") } - .tag(ScarfGoCoordinator.Tab.more) + .tag(ScarfGoCoordinator.Tab.system) + .accessibilityLabel("System tab") } // Pulls the sidebar-on-iPad affordance into the same code path // as the bottom-bar-on-iPhone one. No-op on iPhone today. @@ -101,14 +121,15 @@ struct ScarfGoTabRoot: View { } } -/// Groups the features that don't deserve a primary tab on a phone: -/// Cron (infrequent edits), Skills (read-only), Settings (read-only -/// until M9 scoped editor), plus the destructive server-forget action. +/// Server identity + Memory + Cron + Settings + destructive actions. +/// "System" reads as configuration / server-meta; the reorganization +/// in v2.5 promotes Skills out of here into its own primary tab and +/// pulls Memory in from a primary tab into a NavigationLink row. /// /// Kept private to this file because we don't expect it to be reused /// elsewhere — if a feature graduates to a primary tab, that's a /// deliberate design decision. -private struct MoreTab: View { +private struct SystemTab: View { let config: IOSServerConfig let onSoftDisconnect: @MainActor () async -> Void let onForget: @MainActor () async -> Void @@ -131,15 +152,15 @@ private struct MoreTab: View { Section("Features") { NavigationLink { - CronListView(config: config) + MemoryListView(config: config) } label: { - Label("Cron jobs", systemImage: "clock.arrow.circlepath") + Label("Memory", systemImage: "brain.head.profile") } .scarfGoCompactListRow() NavigationLink { - SkillsListView(config: config) + CronListView(config: config) } label: { - Label("Skills", systemImage: "sparkles") + Label("Cron jobs", systemImage: "clock.arrow.circlepath") } .scarfGoCompactListRow() NavigationLink { @@ -194,7 +215,7 @@ private struct MoreTab: View { } } .scarfGoListDensity() - .navigationTitle("More") + .navigationTitle("System") .navigationBarTitleDisplayMode(.inline) .confirmationDialog( "Forget this server?", diff --git a/scarf/Scarf iOS/Chat/ChatView.swift b/scarf/Scarf iOS/Chat/ChatView.swift index c76e14b..4746c73 100644 --- a/scarf/Scarf iOS/Chat/ChatView.swift +++ b/scarf/Scarf iOS/Chat/ChatView.swift @@ -75,29 +75,37 @@ struct ChatView: View { ) } .task { - // Dashboard row taps set `pendingResumeSessionID` on the - // coordinator before switching to the Chat tab. Honor - // that if present, else open a fresh session. Clearing - // the coordinator value is the consumer's responsibility - // (us) — otherwise a later plain tap on the Chat tab - // would accidentally re-resume the old session. + // Dashboard row taps set `pendingResumeSessionID`, Project + // Detail's "New Chat" sets `pendingProjectChat`. Both fire + // a tab switch to .chat alongside the value set; we + // consume + clear here on first appear. Resume wins over + // project-chat if both somehow get set in a single hop — + // but in practice the coordinator never sets both at once. if let sessionID = coordinator?.pendingResumeSessionID { coordinator?.pendingResumeSessionID = nil await controller.startResuming(sessionID: sessionID) + } else if let projectPath = coordinator?.pendingProjectChat { + coordinator?.pendingProjectChat = nil + await consumePendingProjectChat(projectPath) } else { await controller.start() } } - // Also react to a coordinator change that happens while Chat - // is already mounted (e.g., user is in Chat, switches to - // Dashboard, taps a session row — coordinator flips the tab - // AND sets pendingResumeSessionID. The `.task` above only - // fires on first appear; this is the mid-session hook.) + // React to coordinator changes that happen while Chat is + // already mounted (e.g., user is in Chat, taps Projects, opens + // a project detail, taps "New Chat" — coordinator flips the + // tab AND sets pendingProjectChat. The `.task` above only + // fires on first appear; these are the mid-session hooks.) .onChange(of: coordinator?.pendingResumeSessionID) { _, new in guard let sessionID = new else { return } coordinator?.pendingResumeSessionID = nil Task { await controller.startResuming(sessionID: sessionID) } } + .onChange(of: coordinator?.pendingProjectChat) { _, new in + guard let projectPath = new else { return } + coordinator?.pendingProjectChat = nil + Task { await consumePendingProjectChat(projectPath) } + } // Deliberately NOT tearing down the ACP session on .onDisappear. // `TabView` unmounts tab content when the user switches tabs // (disappear fires), but `@State var controller` keeps the @@ -140,6 +148,27 @@ struct ChatView: View { } } + /// Resolve a project absolute path to a `ProjectEntry` via the + /// transport-backed registry, then dispatch `resetAndStartInProject`. + /// If the path isn't registered (race with a Mac-app removal, or + /// SFTP read failure), fall back to a synthesized entry whose name + /// is the path's last component — chat still starts and the user + /// sees a usable project chip. + private func consumePendingProjectChat(_ path: String) async { + let ctx = config.toServerContext(id: Self.sharedContextID) + let entry: ProjectEntry = await Task.detached { + let registry = ProjectDashboardService(context: ctx).loadRegistry() + if let match = registry.projects.first(where: { $0.path == path }) { + return match + } + return ProjectEntry( + name: (path as NSString).lastPathComponent.isEmpty ? path : (path as NSString).lastPathComponent, + path: path + ) + }.value + await controller.resetAndStartInProject(entry) + } + // MARK: - Subviews @ViewBuilder diff --git a/scarf/Scarf iOS/Projects/ProjectDetailView.swift b/scarf/Scarf iOS/Projects/ProjectDetailView.swift new file mode 100644 index 0000000..6c23089 --- /dev/null +++ b/scarf/Scarf iOS/Projects/ProjectDetailView.swift @@ -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 `/.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 + } + } + } +} diff --git a/scarf/Scarf iOS/Projects/ProjectSessionsView_iOS.swift b/scarf/Scarf iOS/Projects/ProjectSessionsView_iOS.swift new file mode 100644 index 0000000..140d82d --- /dev/null +++ b/scarf/Scarf iOS/Projects/ProjectSessionsView_iOS.swift @@ -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" + } + } +} diff --git a/scarf/Scarf iOS/Projects/ProjectSiteView.swift b/scarf/Scarf iOS/Projects/ProjectSiteView.swift new file mode 100644 index 0000000..dab33bd --- /dev/null +++ b/scarf/Scarf iOS/Projects/ProjectSiteView.swift @@ -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) + } +} diff --git a/scarf/Scarf iOS/Projects/ProjectsListView.swift b/scarf/Scarf iOS/Projects/ProjectsListView.swift new file mode 100644 index 0000000..1018db4 --- /dev/null +++ b/scarf/Scarf iOS/Projects/ProjectsListView.swift @@ -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 + } + } +} diff --git a/scarf/Scarf iOS/Projects/Widgets/ChartWidgetView.swift b/scarf/Scarf iOS/Projects/Widgets/ChartWidgetView.swift new file mode 100644 index 0000000..525c0da --- /dev/null +++ b/scarf/Scarf iOS/Projects/Widgets/ChartWidgetView.swift @@ -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) + } + } +} diff --git a/scarf/Scarf iOS/Projects/Widgets/DashboardWidgetsView.swift b/scarf/Scarf iOS/Projects/Widgets/DashboardWidgetsView.swift new file mode 100644 index 0000000..18e821e --- /dev/null +++ b/scarf/Scarf iOS/Projects/Widgets/DashboardWidgetsView.swift @@ -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)) + } +} diff --git a/scarf/Scarf iOS/Projects/Widgets/ListWidgetView.swift b/scarf/Scarf iOS/Projects/Widgets/ListWidgetView.swift new file mode 100644 index 0000000..a8c82d5 --- /dev/null +++ b/scarf/Scarf iOS/Projects/Widgets/ListWidgetView.swift @@ -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 + } + } +} diff --git a/scarf/Scarf iOS/Projects/Widgets/ProgressWidgetView.swift b/scarf/Scarf iOS/Projects/Widgets/ProgressWidgetView.swift new file mode 100644 index 0000000..e89c392 --- /dev/null +++ b/scarf/Scarf iOS/Projects/Widgets/ProgressWidgetView.swift @@ -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)) + } +} diff --git a/scarf/Scarf iOS/Projects/Widgets/StatWidgetView.swift b/scarf/Scarf iOS/Projects/Widgets/StatWidgetView.swift new file mode 100644 index 0000000..7ebf5ee --- /dev/null +++ b/scarf/Scarf iOS/Projects/Widgets/StatWidgetView.swift @@ -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)) + } +} diff --git a/scarf/Scarf iOS/Projects/Widgets/TableWidgetView.swift b/scarf/Scarf iOS/Projects/Widgets/TableWidgetView.swift new file mode 100644 index 0000000..caaa3b2 --- /dev/null +++ b/scarf/Scarf iOS/Projects/Widgets/TableWidgetView.swift @@ -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)) + } +} diff --git a/scarf/Scarf iOS/Projects/Widgets/TextWidgetView.swift b/scarf/Scarf iOS/Projects/Widgets/TextWidgetView.swift new file mode 100644 index 0000000..ebbe2ff --- /dev/null +++ b/scarf/Scarf iOS/Projects/Widgets/TextWidgetView.swift @@ -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) + } +} diff --git a/scarf/Scarf iOS/Projects/Widgets/WebviewWidgetView.swift b/scarf/Scarf iOS/Projects/Widgets/WebviewWidgetView.swift new file mode 100644 index 0000000..3c7fa6e --- /dev/null +++ b/scarf/Scarf iOS/Projects/Widgets/WebviewWidgetView.swift @@ -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)") + } + } +} diff --git a/scarf/Scarf iOS/Projects/Widgets/WidgetHelpers.swift b/scarf/Scarf iOS/Projects/Widgets/WidgetHelpers.swift new file mode 100644 index 0000000..6326b62 --- /dev/null +++ b/scarf/Scarf iOS/Projects/Widgets/WidgetHelpers.swift @@ -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 + } +} diff --git a/scarf/Scarf iOS/Skills/Hub/HubBrowseView.swift b/scarf/Scarf iOS/Skills/Hub/HubBrowseView.swift new file mode 100644 index 0000000..55ed1ba --- /dev/null +++ b/scarf/Scarf iOS/Skills/Hub/HubBrowseView.swift @@ -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) + } +} diff --git a/scarf/Scarf iOS/Skills/Installed/InstalledSkillsListView.swift b/scarf/Scarf iOS/Skills/Installed/InstalledSkillsListView.swift new file mode 100644 index 0000000..c4402fe --- /dev/null +++ b/scarf/Scarf iOS/Skills/Installed/InstalledSkillsListView.swift @@ -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 ` 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)) + } +} diff --git a/scarf/Scarf iOS/Skills/Installed/SkillDetailView.swift b/scarf/Scarf iOS/Skills/Installed/SkillDetailView.swift new file mode 100644 index 0000000..3702978 --- /dev/null +++ b/scarf/Scarf iOS/Skills/Installed/SkillDetailView.swift @@ -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) + } +} diff --git a/scarf/Scarf iOS/Skills/Installed/SkillEditorSheet.swift b/scarf/Scarf iOS/Skills/Installed/SkillEditorSheet.swift new file mode 100644 index 0000000..7c8087f --- /dev/null +++ b/scarf/Scarf iOS/Skills/Installed/SkillEditorSheet.swift @@ -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]) + } +} diff --git a/scarf/Scarf iOS/Skills/SkillsListView.swift b/scarf/Scarf iOS/Skills/SkillsListView.swift deleted file mode 100644 index 650ed96..0000000 --- a/scarf/Scarf iOS/Skills/SkillsListView.swift +++ /dev/null @@ -1,104 +0,0 @@ -import SwiftUI -import ScarfCore - -/// iOS Skills browser. Read-only list grouped by category. Tapping -/// a skill shows its files + on-disk path — enough for a user to -/// verify what's installed without opening Terminal. -struct SkillsListView: View { - let config: IOSServerConfig - - @State private var vm: IOSSkillsViewModel - - private static let sharedContextID: ServerID = ServerID( - uuidString: "00000000-0000-0000-0000-0000000000A1" - )! - - init(config: IOSServerConfig) { - self.config = config - let ctx = config.toServerContext(id: Self.sharedContextID) - _vm = State(initialValue: IOSSkillsViewModel(context: ctx)) - } - - var body: some View { - List { - if let err = vm.lastError { - Section { - Label(err, systemImage: "exclamationmark.triangle.fill") - .foregroundStyle(.orange) - } - } - - if vm.categories.isEmpty, !vm.isLoading { - Section { - VStack(alignment: .leading, spacing: 6) { - Text("No skills installed") - .font(.headline) - Text("Skills live under `~/.hermes/skills///` on the remote. Install them from the Mac app or by cloning directly.") - .font(.caption) - .foregroundStyle(.secondary) - } - .padding(.vertical, 4) - } - } else { - ForEach(vm.categories) { category in - Section(category.name) { - ForEach(category.skills) { skill in - NavigationLink { - SkillDetailView(skill: skill) - } label: { - VStack(alignment: .leading, spacing: 2) { - Text(skill.name) - .font(.body) - Text("\(skill.files.count) file\(skill.files.count == 1 ? "" : "s")") - .font(.caption) - .foregroundStyle(.secondary) - } - } - .scarfGoCompactListRow() - } - } - } - } - } - .scarfGoListDensity() - .navigationTitle("Skills") - .navigationBarTitleDisplayMode(.inline) - .overlay { - if vm.isLoading && vm.categories.isEmpty { - ProgressView("Scanning skills…") - .padding() - .background(.regularMaterial) - .clipShape(RoundedRectangle(cornerRadius: 10)) - } - } - .refreshable { await vm.load() } - .task { await vm.load() } - } -} - -private struct SkillDetailView: View { - let skill: HermesSkill - - var body: some View { - List { - Section("Location") { - LabeledContent("Category", value: skill.category) - Text(skill.path) - .font(.caption.monospaced()) - .foregroundStyle(.secondary) - .textSelection(.enabled) - } - - if !skill.files.isEmpty { - Section("Files") { - ForEach(skill.files, id: \.self) { file in - Text(file) - .font(.caption.monospaced()) - } - } - } - } - .navigationTitle(skill.name) - .navigationBarTitleDisplayMode(.inline) - } -} diff --git a/scarf/Scarf iOS/Skills/SkillsView.swift b/scarf/Scarf iOS/Skills/SkillsView.swift new file mode 100644 index 0000000..1692c00 --- /dev/null +++ b/scarf/Scarf iOS/Skills/SkillsView.swift @@ -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) + } + } +} diff --git a/scarf/Scarf iOS/Skills/Updates/UpdatesView.swift b/scarf/Scarf iOS/Skills/Updates/UpdatesView.swift new file mode 100644 index 0000000..2996e00 --- /dev/null +++ b/scarf/Scarf iOS/Skills/Updates/UpdatesView.swift @@ -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() + } + } +} diff --git a/scarf/scarf/Features/Skills/ViewModels/SkillsViewModel.swift b/scarf/scarf/Features/Skills/ViewModels/SkillsViewModel.swift deleted file mode 100644 index a929577..0000000 --- a/scarf/scarf/Features/Skills/ViewModels/SkillsViewModel.swift +++ /dev/null @@ -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 - // `/`. 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 - } -} diff --git a/scarf/scarf/Features/Skills/Views/SkillsView.swift b/scarf/scarf/Features/Skills/Views/SkillsView.swift index b29e065..2279694 100644 --- a/scarf/scarf/Features/Skills/Views/SkillsView.swift +++ b/scarf/scarf/Features/Skills/Views/SkillsView.swift @@ -36,7 +36,13 @@ struct SkillsView: View { } } .navigationTitle("Skills (\(viewModel.totalSkillCount))") - .onAppear { viewModel.load() } + // SkillsViewModel.load() is async after the v2.5 ScarfCore + // promotion. Wrap in a Task here so the existing onAppear + // contract (fire-and-forget) keeps working without making + // the Mac UI care about the new isolation. + .onAppear { + Task { await viewModel.load() } + } } private var modePicker: some View {