From c7bcfd86553480ad77ccafdb7ca796fc776ffb06 Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Mon, 4 May 2026 21:16:29 +0200 Subject: [PATCH] =?UTF-8?q?feat(dashboards):=20v2.7=20widget=20catalog=20?= =?UTF-8?q?=E2=80=94=20file-reading=20widgets,=20sparkline,=20typed=20stat?= =?UTF-8?q?us,=20project-wide=20watch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major project-dashboard release. Five new widget types (markdown_file, log_tail, cron_status, image, status_grid), inline sparkline on stat, typed status enum shared by list + status_grid, structured WidgetErrorCard, and a project-wide .scarf/ directory watch that picks up files cron jobs write next to dashboard.json. - ProjectDashboard: extend DashboardWidget with path/lines/jobId/cells/gridColumns/sparkline; add StatusGridCell + ListItemStatus (lenient parse with synonyms) - HermesFileWatcher: watch each project's .scarf/ dir alongside dashboard.json (local FSEvents + remote SSH mtime poll); updateProjectWatches signature now takes dashboardPaths + scarfDirs - New widget views: CronStatus, Image, LogTail, MarkdownFile, StatusGrid, plus WidgetErrorCard for structured failure messaging; legacy "Unknown" placeholder replaced everywhere - WidgetPathResolver: project-root-anchored path resolution that rejects absolute paths + ".." escapes pre and post canonicalization - Stat widget gains optional inline sparkline (pure SwiftUI Path, no Charts dep); list widget rows route through typed status with semantic icons + ScarfColor tints - iOS list widget + unsupported card adopt typed status + warning-toned error card (parity with Mac error styling); new widget types remain Mac-only - Site mirror: widgets.js renders all five new types (file-reading widgets show annotated catalog placeholders), sparkline SVG, status-grid grid; styles.css adds typed-status palette + error-card + sparkline + grid styles - Catalog validator: tools/widget-schema.json is the single source of truth; build-catalog.py loads it and enforces per-type required fields. 8 new test cases in test_build_catalog.py covering schema load, v2.7 additions, and missing-required rejection - Template-author skill (SKILL.md) gains v2.7 Widget Catalog section + canonical status guidance; CONTRIBUTING.md points authors at widget-schema.json; template-author bundle rebuilt - Localizable.xcstrings picks up auto-extracted strings for the previously-shipped OAuth keepalive feature - Release notes drafted at releases/v2.7.0/RELEASE_NOTES.md Backwards compatible — existing dashboard.json renders byte-identically, status synonyms (ok/up/down/active/etc.) keep working. Co-Authored-By: Claude Opus 4.7 (1M context) --- releases/v2.7.0/RELEASE_NOTES.md | 61 +++++ .../ScarfCore/Models/ProjectDashboard.swift | 133 ++++++++-- .../ViewModels/ProjectsViewModel.swift | 10 + .../ScarfCoreTests/ListItemStatusTests.swift | 48 ++++ .../Widgets/DashboardWidgetsView.swift | 26 +- .../Projects/Widgets/ListWidgetView.swift | 63 +++-- .../Core/Services/HermesFileWatcher.swift | 61 +++-- .../Projects/Views/ProjectsView.swift | 40 +-- .../Views/Widgets/CronStatusWidgetView.swift | 182 ++++++++++++++ .../Views/Widgets/ImageWidgetView.swift | 124 ++++++++++ .../Views/Widgets/ListWidgetView.swift | 74 ++++-- .../Views/Widgets/LogTailWidgetView.swift | 131 ++++++++++ .../Widgets/MarkdownFileWidgetView.swift | 92 +++++++ .../Views/Widgets/StatWidgetView.swift | 32 +++ .../Views/Widgets/StatusGridWidgetView.swift | 78 ++++++ .../Views/Widgets/WidgetErrorCard.swift | 45 ++++ .../Views/Widgets/WidgetHelpers.swift | 40 +++ .../Views/Widgets/WidgetPathResolver.swift | 67 +++++ scarf/scarf/Localizable.xcstrings | 16 ++ site/styles.css | 58 +++++ site/widgets.js | 229 ++++++++++++++++-- templates/CONTRIBUTING.md | 2 +- .../skills/scarf-template-author/SKILL.md | 77 +++++- .../template-author.scarftemplate | Bin 14610 -> 16487 bytes templates/catalog.json | 4 +- tools/build-catalog.py | 30 ++- tools/test_build_catalog.py | 168 +++++++++++++ tools/widget-schema.json | 78 ++++++ 28 files changed, 1846 insertions(+), 123 deletions(-) create mode 100644 releases/v2.7.0/RELEASE_NOTES.md create mode 100644 scarf/Packages/ScarfCore/Tests/ScarfCoreTests/ListItemStatusTests.swift create mode 100644 scarf/scarf/Features/Projects/Views/Widgets/CronStatusWidgetView.swift create mode 100644 scarf/scarf/Features/Projects/Views/Widgets/ImageWidgetView.swift create mode 100644 scarf/scarf/Features/Projects/Views/Widgets/LogTailWidgetView.swift create mode 100644 scarf/scarf/Features/Projects/Views/Widgets/MarkdownFileWidgetView.swift create mode 100644 scarf/scarf/Features/Projects/Views/Widgets/StatusGridWidgetView.swift create mode 100644 scarf/scarf/Features/Projects/Views/Widgets/WidgetErrorCard.swift create mode 100644 scarf/scarf/Features/Projects/Views/Widgets/WidgetPathResolver.swift create mode 100644 tools/widget-schema.json diff --git a/releases/v2.7.0/RELEASE_NOTES.md b/releases/v2.7.0/RELEASE_NOTES.md new file mode 100644 index 0000000..4494a6a --- /dev/null +++ b/releases/v2.7.0/RELEASE_NOTES.md @@ -0,0 +1,61 @@ +## What's in 2.7.0 + +A focused release on **project dashboards** — the most "live" surface for users running cron-driven workflows. This release does three things at once: + +1. **Auto-refresh now covers the entire project, not just `dashboard.json`.** A widget that points at `/.scarf/reports/uptime.md` refreshes the moment the cron job rewrites it. +2. **Five new widget types** make cron-driven monitoring dashboards much more expressive — render markdown reports from disk, tail log files, surface Hermes cron-job state, embed images, and pack many services into a compact status grid. +3. **`stat` widgets gain inline sparklines.** `list` items get a typed status enum with semantic colors. Unknown widget types render as a structured error card (not a generic "Unknown" placeholder). + +**Backwards compatible — no schema bump.** Every existing `dashboard.json` renders byte-identically on v2.7. The catalog manifest format is unchanged. v1, v2, v3 bundles install identically as before. Templates that adopt new widget types still validate against the existing manifest schema — only the catalog validator's vocabulary list was extended. + +### Project-wide auto-refresh + +[`HermesFileWatcher`](../../scarf/scarf/Core/Services/HermesFileWatcher.swift) used to watch each project's `dashboard.json` file specifically. v2.7 promotes that to a watch on the entire `/.scarf/` directory: + +- **Local** — adds a `DispatchSourceFileSystemObject` per project's `.scarf/` dir alongside the existing per-file watch on `dashboard.json`. +- **Remote (SSH)** — folds project `.scarf/` directories into the existing 3-second mtime poll. Closes the explicit "Phase 4 polish item" deferral that landed in v2.3. + +Effect: a `markdown_file` or `log_tail` widget pointing at `/.scarf/reports/foo.md` refreshes automatically when a cron job rewrites the file. **By convention, place files the dashboard reads inside `.scarf/`** so the watch picks them up. Files outside `.scarf/` work too but only refresh when `dashboard.json` itself changes. + +_Limitation:_ in-place appends to an existing file (`>> file.log`) don't tick the watcher — the cron job should write atomically (write-temp + rename), or `touch dashboard.json` after each run to force a refresh. Per-widget data-source watching (the granular alternative) is deferred to a future release; this project-wide pattern covers the common cron-driven workflow without the extra plumbing. + +### Five new widget types + +All five additive — pre-v2.7 Scarf renders unknown widget types as a clearly-labeled error card now (not a crash). They share two conventions: file paths are resolved relative to the project root with a hard `..`-escape rejection at [`WidgetPathResolver`](../../scarf/scarf/Features/Projects/Views/Widgets/WidgetPathResolver.swift), and reads happen in `Task.detached` so dashboards never block the main actor. + +- **`markdown_file`** — renders a markdown file from disk through the same `MarkdownContentView` pipeline used by inline `text` widgets. Pair with cron jobs that write longer-form reports. +- **`log_tail`** — last `lines` of a file (default 20, max 200), monospaced, ANSI codes stripped. Killer for "what did my cron job print last run?". +- **`cron_status`** — last run / next run / state for one Hermes cron job by `jobId`, plus a small inline log tail. Read-only — Run / Pause / Resume controls stay on the Cron tab so the dashboard isn't a place where you accidentally fire a job. +- **`image`** — local file (`path` relative to project root, via `transport.readFile`) or remote `url` (via `AsyncImage`). Optional `height` cap. Useful for matplotlib/Plotly PNGs the cron job generates. +- **`status_grid`** — compact NxM grid of colored cells, one per service / item, with hover labels. Reuses the typed status enum so colors stay consistent with `list` widgets. + +### `stat` widget gains inline sparklines + +`stat` widgets now accept an optional `sparkline: [Number]` field — a tiny inline trend line under the big number. SVG-only render, no Chart.js dependency, dozens per dashboard cost nothing. Old `stat` widgets without the field render exactly as before. + +### Typed status badges (lenient decode) + +`list` items and `status_grid` cells share a typed status enum: `success`, `warning`, `danger`, `info`, `pending`, `done`, `neutral`. Common synonyms map to the canonical case (`ok` / `up` → success, `down` / `error` / `failed` → danger, `active` → info, `complete` → done). Unknown strings render as plain text rather than crashing — the dev's machine alone has dashboards using ad-hoc statuses like `"ok"`, `"up"`, `"info"`, and they all keep working byte-identically. **For new templates, prefer the canonical names** so colors stay predictable across releases. + +### Structured widget error card + +The legacy "Unknown: \" placeholder is replaced with a structured error card surfacing the widget's title, the specific reason (unknown type, missing file, parse error, path escapes project root), and a hint. Used by the dispatcher's default branch and by every v2.7 file-reading widget when its underlying data can't be loaded. + +### Schema mirror — single source of truth + +The widget vocabulary is now defined once at [`tools/widget-schema.json`](../../tools/widget-schema.json) instead of being maintained in three places by hand. The catalog validator ([`tools/build-catalog.py`](../../tools/build-catalog.py)) reads from it and now enforces per-type required fields (e.g. `cron_status` requires `jobId`, `log_tail` requires `path`). Adding a future widget type means editing one JSON file plus implementing a Swift view + a JS renderer; the validator picks up the addition automatically. + +### What's deferred + +Two items from the design plan stayed deferred: + +- **Per-widget data sources + per-widget refresh granularity.** The general "widget points at a typed data source (file / cron / json-path / …)" abstraction is the next-largest win in this area but materially expands the model + JS mirror + validator + authoring skill surface. The project-wide watch covers the common cron-driven workflow without it; revisit when a real-world template wants the granular control. +- **Cross-project health digest sidebar rollup.** Counting attention-needed projects across the registry was scoped for this release but ended up not pulling its weight against the rest of the work; the underlying status enum (B.1) makes a future digest cheap to add. + +### Compatibility + +- macOS 14+ (unchanged). +- Hermes target: still **v2026.4.30 (v0.12.0)**. No new Hermes capability gates added. +- Existing `dashboard.json` files render unchanged. +- Existing `.scarftemplate` bundles install unchanged. Catalog manifest schemaVersion stays at 1/2/3 — no bump. +- The `awizemann/template-author` bundle was rebuilt to ship the updated `SKILL.md` (Widget Catalog v2.7+ section) so Hermes can scaffold dashboards using the new widget types out of the box. diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/ProjectDashboard.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/ProjectDashboard.swift index 507b6ca..4d4c465 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/ProjectDashboard.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/ProjectDashboard.swift @@ -39,6 +39,13 @@ public struct ProjectEntry: Codable, Sendable, Identifiable, Hashable { public var dashboardPath: String { path + "/.scarf/dashboard.json" } + /// Directory holding the project's Scarf-managed sidecar files + /// (dashboard.json, manifest.json, template.lock.json, config.json, + /// plus any cron-job-written reports the dashboard widgets reference). + /// Watched as a unit by `HermesFileWatcher` so any file added / + /// removed / renamed inside refreshes the dashboard automatically. + public var scarfDir: String { path + "/.scarf" } + // MARK: - Codable (custom for backward compat) private enum CodingKeys: String, CodingKey { @@ -152,29 +159,54 @@ public struct DashboardWidget: Codable, Sendable, Identifiable { // List public let items: [ListItem]? - // Webview + // Webview / Image (image reuses `url` for remote, `path` for local) public let url: String? public let height: Double? + // v2.7 — file-reading widgets (markdown_file, log_tail, image-local). + // `path` is resolved relative to the project root (the directory that + // contains `.scarf/`). Renderers must reject `..` segments after + // normalization to prevent escape from the project boundary. + public let path: String? + public let lines: Int? + + // v2.7 — cron_status widget; `jobId` matches HermesCronJob.id. + public let jobId: String? + + // v2.7 — status_grid widget; `cells` carries label + status per square, + // `gridColumns` overrides the auto-fit column count (keep distinct + // from `columns` which is the table-widget header list). + public let cells: [StatusGridCell]? + public let gridColumns: Int? + + // v2.7 — optional sparkline trend on `stat` widgets. + public let sparkline: [Double]? + public init( type: String, title: String, - value: WidgetValue?, - icon: String?, - color: String?, - subtitle: String?, - label: String?, - content: String?, - format: String?, - columns: [String]?, - rows: [[String]]?, - chartType: String?, - xLabel: String?, - yLabel: String?, - series: [ChartSeries]?, - items: [ListItem]?, - url: String?, - height: Double? + value: WidgetValue? = nil, + icon: String? = nil, + color: String? = nil, + subtitle: String? = nil, + label: String? = nil, + content: String? = nil, + format: String? = nil, + columns: [String]? = nil, + rows: [[String]]? = nil, + chartType: String? = nil, + xLabel: String? = nil, + yLabel: String? = nil, + series: [ChartSeries]? = nil, + items: [ListItem]? = nil, + url: String? = nil, + height: Double? = nil, + path: String? = nil, + lines: Int? = nil, + jobId: String? = nil, + cells: [StatusGridCell]? = nil, + gridColumns: Int? = nil, + sparkline: [Double]? = nil ) { self.type = type self.title = title @@ -194,6 +226,29 @@ public struct DashboardWidget: Codable, Sendable, Identifiable { self.items = items self.url = url self.height = height + self.path = path + self.lines = lines + self.jobId = jobId + self.cells = cells + self.gridColumns = gridColumns + self.sparkline = sparkline + } +} + +// MARK: - Status Grid Data (v2.7) + +/// One cell of a `status_grid` widget. Status semantics match `ListItem.status` +/// — parsed via `ListItemStatus(raw:)` so the same vocabulary + synonyms apply. +public struct StatusGridCell: Codable, Sendable, Identifiable, Hashable { + public var id: String { label } + public let label: String + public let status: String? + public let tooltip: String? + + public init(label: String, status: String? = nil, tooltip: String? = nil) { + self.label = label + self.status = status + self.tooltip = tooltip } } @@ -284,3 +339,47 @@ public struct ListItem: Codable, Sendable, Identifiable { self.status = status } } + +/// Typed semantic status for `ListItem` (and `status_grid` cells in v2.7+). +/// +/// Wire format stays a free `String?` on `ListItem` for backwards compatibility — +/// pre-existing dashboards never break. Renderers call `ListItemStatus(raw:)` +/// to map known values + synonyms to a canonical case; unknown values return +/// `nil` and render as plain neutral text. +public enum ListItemStatus: String, Sendable, Hashable, CaseIterable { + case success + case warning + case danger + case info + case pending + case done + case neutral + + /// Lenient parse — accepts canonical names plus common synonyms seen in + /// real-world dashboards (`ok`/`up` → success, `down`/`error`/`failed` → + /// danger, `active` → info). Returns `nil` for unrecognized strings so + /// the renderer can fall back to plain text. + public init?(raw: String?) { + guard let raw = raw?.trimmingCharacters(in: .whitespaces).lowercased(), !raw.isEmpty else { + return nil + } + switch raw { + case "success", "ok", "up", "green", "passing": + self = .success + case "warning", "warn", "yellow", "degraded": + self = .warning + case "danger", "down", "error", "failed", "failure", "red", "critical": + self = .danger + case "info", "active", "blue": + self = .info + case "pending", "queued", "waiting", "scheduled": + self = .pending + case "done", "complete", "completed", "finished": + self = .done + case "neutral", "muted", "gray": + self = .neutral + default: + return nil + } + } +} diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/ProjectsViewModel.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/ProjectsViewModel.swift index 0add43e..048f871 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/ProjectsViewModel.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/ProjectsViewModel.swift @@ -164,6 +164,16 @@ public final class ProjectsViewModel { projects.map(\.dashboardPath) } + /// Per-project `.scarf/` directories — watched alongside `dashboardPaths` + /// so that file-reading widgets (markdown_file, log_tail, image) refresh + /// when their underlying files are added / removed / renamed inside the + /// directory by a cron job. In-place file appends within an existing + /// file are NOT detected here; the cron job should write atomically + /// (write-then-rename) or `touch` dashboard.json after each run. + public var projectScarfDirs: [String] { + projects.map(\.scarfDir) + } + private func loadDashboard(for project: ProjectEntry) { dashboardError = nil if !service.dashboardExists(for: project) { diff --git a/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/ListItemStatusTests.swift b/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/ListItemStatusTests.swift new file mode 100644 index 0000000..162c82f --- /dev/null +++ b/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/ListItemStatusTests.swift @@ -0,0 +1,48 @@ +import Testing +import Foundation +@testable import ScarfCore + +/// Verifies the lenient `ListItemStatus(raw:)` parser. Real dashboards on +/// disk use a mix of canonical names + synonyms (`done`, `info`, `ok`, +/// `pending`, `up` are seen on the dev's machine today) — the parser must +/// fold those onto the canonical case set without throwing or returning nil +/// for the common synonyms. Unknown strings → nil so the renderer can fall +/// back to plain text without losing the original. +@Suite struct ListItemStatusTests { + @Test func canonicalNamesParse() { + for c in ListItemStatus.allCases { + #expect(ListItemStatus(raw: c.rawValue) == c) + } + } + + @Test func synonymsCollapseToCanonical() { + #expect(ListItemStatus(raw: "ok") == .success) + #expect(ListItemStatus(raw: "OK") == .success) // case-insensitive + #expect(ListItemStatus(raw: " up ") == .success) // whitespace trim + #expect(ListItemStatus(raw: "down") == .danger) + #expect(ListItemStatus(raw: "error") == .danger) + #expect(ListItemStatus(raw: "failed") == .danger) + #expect(ListItemStatus(raw: "warn") == .warning) + #expect(ListItemStatus(raw: "degraded") == .warning) + #expect(ListItemStatus(raw: "active") == .info) + #expect(ListItemStatus(raw: "queued") == .pending) + #expect(ListItemStatus(raw: "complete") == .done) + } + + @Test func unknownReturnsNilNotThrows() { + #expect(ListItemStatus(raw: "hologram") == nil) + #expect(ListItemStatus(raw: "") == nil) + #expect(ListItemStatus(raw: nil) == nil) + #expect(ListItemStatus(raw: " ") == nil) + } + + @Test func listItemStillDecodesUnknownStatusString() throws { + // Backwards-compat invariant: `ListItem.status` stays a free String? on + // the wire. Decoding a v2.6 dashboard with a non-canonical status must + // succeed and preserve the original string (renderer falls back). + let json = #"{"text":"foo","status":"weird"}"#.data(using: .utf8)! + let item = try JSONDecoder().decode(ListItem.self, from: json) + #expect(item.status == "weird") + #expect(ListItemStatus(raw: item.status) == nil) + } +} diff --git a/scarf/Scarf iOS/Projects/Widgets/DashboardWidgetsView.swift b/scarf/Scarf iOS/Projects/Widgets/DashboardWidgetsView.swift index 1e12b9a..a7ccf4c 100644 --- a/scarf/Scarf iOS/Projects/Widgets/DashboardWidgetsView.swift +++ b/scarf/Scarf iOS/Projects/Widgets/DashboardWidgetsView.swift @@ -102,17 +102,31 @@ struct WidgetView: View { } private var unsupportedView: some View { - VStack(alignment: .leading, spacing: 4) { - Label(widget.title, systemImage: "questionmark.app.dashed") - .font(.caption) - .foregroundStyle(ScarfColor.foregroundMuted) - Text("Widget type \"\(widget.type)\" isn't supported in this version of Scarf yet.") + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundStyle(ScarfColor.warning) + Text(widget.title.isEmpty ? "Widget error" : widget.title) + .font(.caption) + .foregroundStyle(ScarfColor.foregroundMuted) + } + Text("Unknown widget type: \"\(widget.type)\"") + .font(.callout) + .foregroundStyle(.primary) + .fixedSize(horizontal: false, vertical: true) + Text("This Scarf build doesn't render this widget type. Update Scarf or change the widget type in dashboard.json.") .font(.caption2) .foregroundStyle(.tertiary) + .fixedSize(horizontal: false, vertical: true) } .frame(maxWidth: .infinity, alignment: .leading) .padding(12) - .background(.quaternary.opacity(0.5)) + .background(ScarfColor.warning.opacity(0.08)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .strokeBorder(ScarfColor.warning.opacity(0.3), lineWidth: 1) + ) .clipShape(RoundedRectangle(cornerRadius: 8)) } } diff --git a/scarf/Scarf iOS/Projects/Widgets/ListWidgetView.swift b/scarf/Scarf iOS/Projects/Widgets/ListWidgetView.swift index 2b24a76..60b9bdf 100644 --- a/scarf/Scarf iOS/Projects/Widgets/ListWidgetView.swift +++ b/scarf/Scarf iOS/Projects/Widgets/ListWidgetView.swift @@ -19,15 +19,7 @@ struct ListWidgetView: View { } 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) - } + ListItemRow(item: item) } } } @@ -36,21 +28,52 @@ struct ListWidgetView: View { .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 struct ListItemRow: View { + let item: ListItem + + private var typedStatus: ListItemStatus? { ListItemStatus(raw: item.status) } + + var body: some View { + HStack(spacing: 6) { + Image(systemName: iconName) + .font(.caption2) + .foregroundStyle(tint) + Text(item.text) + .font(.callout) + .strikethrough(typedStatus == .done) + .foregroundStyle(typedStatus == .done ? .secondary : .primary) + if typedStatus == nil, let raw = item.status, !raw.isEmpty { + Text(raw) + .font(.caption2) + .foregroundStyle(.secondary) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(.quaternary.opacity(0.5)) + .clipShape(Capsule()) + } } } - private func statusColor(_ status: String?) -> Color { - switch status { - case "done": return .green - case "active": return .blue - default: return .secondary + private var iconName: String { + switch typedStatus { + case .success, .done: return "checkmark.circle.fill" + case .warning: return "exclamationmark.triangle.fill" + case .danger: return "xmark.octagon.fill" + case .info: return "info.circle.fill" + case .pending: return "circle.dashed" + case .neutral, nil: return "circle" + } + } + + private var tint: Color { + switch typedStatus { + case .success, .done: return ScarfColor.success + case .warning: return ScarfColor.warning + case .danger: return ScarfColor.danger + case .info: return ScarfColor.info + case .pending, .neutral, nil: return .secondary } } } diff --git a/scarf/scarf/Core/Services/HermesFileWatcher.swift b/scarf/scarf/Core/Services/HermesFileWatcher.swift index f8b9dc2..f71909a 100644 --- a/scarf/scarf/Core/Services/HermesFileWatcher.swift +++ b/scarf/scarf/Core/Services/HermesFileWatcher.swift @@ -10,6 +10,10 @@ final class HermesFileWatcher { /// Remote polling task. Non-nil only when `context.isRemote`. Cancelled /// on `stopWatching()`. private var remotePollTask: Task? + /// Project directory paths fed to the SSH poller alongside `watchedCorePaths`. + /// Updated by `updateProjectWatches` so the remote stream restarts whenever + /// the project list changes. + private var remoteProjectPaths: [String] = [] let context: ServerContext private let transport: any ServerTransport @@ -52,17 +56,7 @@ final class HermesFileWatcher { func startWatching() { if context.isRemote { - // FSEvents doesn't reach across SSH. Drive lastChangeDate off - // the transport's AsyncStream, which polls stat mtime on a - // shared ControlMaster channel (~5ms per tick). - let stream = transport.watchPaths(watchedCorePaths) - remotePollTask = Task { [weak self] in - for await _ in stream { - await MainActor.run { [weak self] in - self?.lastChangeDate = Date() - } - } - } + startRemotePoller() return } @@ -79,6 +73,21 @@ final class HermesFileWatcher { // touches `gateway_state.json` which the watcher catches. } + /// (Re)start the SSH polling stream over the union of `watchedCorePaths` + /// and the current `remoteProjectPaths`. Called on initial start and + /// whenever `updateProjectWatches` changes the project set. + private func startRemotePoller() { + remotePollTask?.cancel() + let stream = transport.watchPaths(watchedCorePaths + remoteProjectPaths) + remotePollTask = Task { [weak self] in + for await _ in stream { + await MainActor.run { [weak self] in + self?.lastChangeDate = Date() + } + } + } + } + func stopWatching() { for source in coreSources + projectSources { source.cancel() @@ -91,11 +100,26 @@ final class HermesFileWatcher { remotePollTask = nil } - func updateProjectWatches(_ dashboardPaths: [String]) { - // Remote contexts don't support per-project FSEvents watches today — - // the shared mtime poll covers the core set. Adding per-project - // polling is a Phase 4 polish item. - guard !context.isRemote else { return } + /// Watch each project's `dashboard.json` AND its enclosing `.scarf/` + /// directory. Watching both is what lets file-reading widgets + /// (markdown_file, log_tail, image) refresh when a cron job rewrites + /// a sidecar file: dir-level FSEvents fire on add/remove/rename inside + /// `.scarf/`, file-level FSEvents fire on dashboard.json content + /// changes. In-place writes to an existing sidecar file (e.g., `>>` log + /// append) are NOT detected — by convention the cron job should write + /// atomically (write-then-rename) or `touch dashboard.json` after each + /// run. + func updateProjectWatches(dashboardPaths: [String], scarfDirs: [String]) { + if context.isRemote { + // Restart the SSH poller with the union of core + project dir + // paths. `stat -c %Y` on a directory tracks mtime, which ticks + // on add/remove/rename inside the dir — same coverage as the + // local FSEvents directory watch below. + let union = Array(Set(dashboardPaths + scarfDirs)) + remoteProjectPaths = union.sorted() + startRemotePoller() + return + } for source in projectSources { source.cancel() } @@ -105,6 +129,11 @@ final class HermesFileWatcher { projectSources.append(source) } } + for dir in scarfDirs { + if let source = makeSource(for: dir) { + projectSources.append(source) + } + } } private func makeSource(for path: String) -> DispatchSourceFileSystemObject? { diff --git a/scarf/scarf/Features/Projects/Views/ProjectsView.swift b/scarf/scarf/Features/Projects/Views/ProjectsView.swift index 34f20e0..1ccc68f 100644 --- a/scarf/scarf/Features/Projects/Views/ProjectsView.swift +++ b/scarf/scarf/Features/Projects/Views/ProjectsView.swift @@ -94,7 +94,7 @@ struct ProjectsView: View { let project = viewModel.projects.first(where: { $0.name == name }) { viewModel.selectProject(project) } - fileWatcher.updateProjectWatches(viewModel.dashboardPaths) + fileWatcher.updateProjectWatches(dashboardPaths: viewModel.dashboardPaths, scarfDirs: viewModel.projectScarfDirs) // Cold-launch deep link or Finder double-click: the router may // have a URL staged before this view installed the onChange // observer below. Without this first-appearance check, @@ -107,7 +107,7 @@ struct ProjectsView: View { } .onChange(of: fileWatcher.lastChangeDate) { viewModel.load() - fileWatcher.updateProjectWatches(viewModel.dashboardPaths) + fileWatcher.updateProjectWatches(dashboardPaths: viewModel.dashboardPaths, scarfDirs: viewModel.projectScarfDirs) } .onChange(of: TemplateURLRouter.shared.pendingInstallURL) { _, new in // A URL landed *while the app was already running*. @@ -122,7 +122,7 @@ struct ProjectsView: View { if let project = viewModel.projects.first(where: { $0.name == entry.name }) { viewModel.selectProject(project) } - fileWatcher.updateProjectWatches(viewModel.dashboardPaths) + fileWatcher.updateProjectWatches(dashboardPaths: viewModel.dashboardPaths, scarfDirs: viewModel.projectScarfDirs) } } .sheet(item: $exportSheetProject) { project in @@ -155,7 +155,7 @@ struct ProjectsView: View { coordinator.selectedProjectName = nil } viewModel.load() - fileWatcher.updateProjectWatches(viewModel.dashboardPaths) + fileWatcher.updateProjectWatches(dashboardPaths: viewModel.dashboardPaths, scarfDirs: viewModel.projectScarfDirs) } } .sheet(item: $configEditorProject) { project in @@ -339,7 +339,7 @@ struct ProjectsView: View { .sheet(isPresented: $showingAddSheet) { AddProjectSheet(context: serverContext) { name, path in viewModel.addProject(name: name, path: path) - fileWatcher.updateProjectWatches(viewModel.dashboardPaths) + fileWatcher.updateProjectWatches(dashboardPaths: viewModel.dashboardPaths, scarfDirs: viewModel.projectScarfDirs) } } .sheet(item: $renameTarget) { target in @@ -485,6 +485,9 @@ struct ProjectsView: View { .padding() .frame(maxWidth: .infinity, alignment: .topLeading) } + // v2.7: file-reading widgets (markdown_file, log_tail, image-local) + // resolve their `path` field against this root via WidgetPathResolver. + .environment(\.selectedProjectRoot, viewModel.selectedProject?.path) } private func siteTab(_ widget: DashboardWidget) -> some View { @@ -600,19 +603,22 @@ struct WidgetView: View { ListWidgetView(widget: widget) case "webview": WebviewWidgetView(widget: widget) + case "cron_status": + CronStatusWidgetView(widget: widget) + case "log_tail": + LogTailWidgetView(widget: widget) + case "markdown_file": + MarkdownFileWidgetView(widget: widget) + case "image": + ImageWidgetView(widget: widget) + case "status_grid": + StatusGridWidgetView(widget: widget) default: - VStack { - Image(systemName: "questionmark.square.dashed") - .font(.title2) - .foregroundStyle(.secondary) - Text("Unknown: \(widget.type)") - .font(.caption) - .foregroundStyle(.secondary) - } - .frame(maxWidth: .infinity, minHeight: 60) - .padding(12) - .background(.quaternary.opacity(0.5)) - .clipShape(RoundedRectangle(cornerRadius: 8)) + WidgetErrorCard( + title: widget.title, + reason: "Unknown widget type: \"\(widget.type)\"", + hint: "This Scarf build doesn't render this widget type. Update Scarf or change the widget type in dashboard.json. Known types are listed in tools/widget-schema.json." + ) } } } diff --git a/scarf/scarf/Features/Projects/Views/Widgets/CronStatusWidgetView.swift b/scarf/scarf/Features/Projects/Views/Widgets/CronStatusWidgetView.swift new file mode 100644 index 0000000..b6af410 --- /dev/null +++ b/scarf/scarf/Features/Projects/Views/Widgets/CronStatusWidgetView.swift @@ -0,0 +1,182 @@ +import SwiftUI +import ScarfCore +import ScarfDesign + +/// Surfaces last-run / next-run / state for a single Hermes cron job by id, +/// plus a short tail of its most recent output. Read-only — Run / Pause / +/// Resume controls remain on the main Cron tab to keep this widget non- +/// destructive (the dashboard shouldn't be a place where the user +/// accidentally fires a job). +/// +/// Refreshes whenever `HermesFileWatcher.lastChangeDate` ticks, which +/// covers both `cron/jobs.json` mutations and the project-wide `.scarf/` +/// watch installed in v2.7. Reads happen detached — never on the main +/// actor. +struct CronStatusWidgetView: View { + let widget: DashboardWidget + + @Environment(\.serverContext) private var serverContext + @Environment(HermesFileWatcher.self) private var fileWatcher + + @State private var job: HermesCronJob? + @State private var outputTail: String? + @State private var loadError: String? + @State private var isLoading = false + + private var jobId: String? { widget.jobId } + private var lineCount: Int { max(1, min(40, widget.lines ?? 5)) } + + var body: some View { + Group { + if let jobId, !jobId.isEmpty { + content(jobId: jobId) + } else { + WidgetErrorCard( + title: widget.title, + reason: "Missing required `jobId` field.", + hint: "Set `jobId` to a Hermes cron job's id (visible in the Cron tab)." + ) + } + } + .task(id: fileWatcher.lastChangeDate) { + await reload() + } + } + + @ViewBuilder + private func content(jobId: String) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 6) { + Image(systemName: "clock.arrow.circlepath") + .foregroundStyle(.secondary) + .scarfStyle(.caption) + Text(widget.title) + .scarfStyle(.caption) + .foregroundStyle(.secondary) + Spacer() + if isLoading && job == nil { + ProgressView().controlSize(.mini) + } + } + if let job { + jobRow(job) + if let tail = outputTail, !tail.isEmpty { + tailView(tail) + } + } else if let loadError { + Text(loadError) + .font(.callout) + .foregroundStyle(.secondary) + } else if !isLoading { + Text("No cron job with id `\(jobId)`.") + .font(.callout) + .foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .background(ScarfColor.backgroundSecondary) + .clipShape(RoundedRectangle(cornerRadius: ScarfRadius.lg)) + } + + @ViewBuilder + private func jobRow(_ job: HermesCronJob) -> some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Text(job.name) + .font(.callout.weight(.medium)) + .lineLimit(1) + Spacer() + stateBadge(for: job) + } + HStack(spacing: 12) { + if let last = job.lastRunAt, !last.isEmpty { + Label(last, systemImage: "checkmark.circle") + .font(.caption2) + .foregroundStyle(.secondary) + .labelStyle(.titleAndIcon) + } + if let next = job.nextRunAt, !next.isEmpty { + Label(next, systemImage: "calendar") + .font(.caption2) + .foregroundStyle(.secondary) + .labelStyle(.titleAndIcon) + } + } + if let lastError = job.lastError, !lastError.isEmpty { + HStack(spacing: 4) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(ScarfColor.danger) + Text(lastError) + .lineLimit(2) + } + .font(.caption2) + .foregroundStyle(ScarfColor.danger) + } + } + } + + private func stateBadge(for job: HermesCronJob) -> some View { + let (label, status): (String, ListItemStatus) = { + if !job.enabled { return ("DISABLED", .neutral) } + switch job.state.lowercased() { + case "active", "running": return (job.state.uppercased(), .info) + case "paused": return ("PAUSED", .warning) + case "error", "failed": return (job.state.uppercased(), .danger) + default: return (job.state.uppercased(), .success) + } + }() + return Text(label) + .font(.caption2.weight(.semibold)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(status.tint.opacity(0.15)) + .foregroundStyle(status.tint) + .clipShape(Capsule()) + } + + @ViewBuilder + private func tailView(_ tail: String) -> some View { + let lines = tail.split(separator: "\n", omittingEmptySubsequences: false).suffix(lineCount) + VStack(alignment: .leading, spacing: 1) { + ForEach(Array(lines.enumerated()), id: \.offset) { _, line in + Text(String(line)) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(8) + .background(.quaternary.opacity(0.4)) + .clipShape(RoundedRectangle(cornerRadius: ScarfRadius.sm)) + } + + private func reload() async { + guard let jobId, !jobId.isEmpty else { return } + let context = serverContext + let lines = lineCount + isLoading = true + defer { isLoading = false } + let result: (HermesCronJob?, String?, String?) = await Task.detached { + let fs = HermesFileService(context: context) + let jobs = fs.loadCronJobs() + guard let match = jobs.first(where: { $0.id == jobId }) else { + return (nil, nil, "No cron job with id `\(jobId)`.") + } + let outputRaw = fs.loadCronOutput(jobId: jobId) + let trimmed: String? = { + guard let outputRaw else { return nil } + let stripped = AnsiStripper.strip(outputRaw) + let allLines = stripped.split(separator: "\n", omittingEmptySubsequences: false) + let kept = allLines.suffix(lines) + return kept.joined(separator: "\n") + }() + return (match, trimmed, nil) + }.value + self.job = result.0 + self.outputTail = result.1 + self.loadError = result.2 + } +} diff --git a/scarf/scarf/Features/Projects/Views/Widgets/ImageWidgetView.swift b/scarf/scarf/Features/Projects/Views/Widgets/ImageWidgetView.swift new file mode 100644 index 0000000..bdd7d1b --- /dev/null +++ b/scarf/scarf/Features/Projects/Views/Widgets/ImageWidgetView.swift @@ -0,0 +1,124 @@ +import SwiftUI +import ScarfCore +import ScarfDesign +import AppKit + +/// Renders a local file (`path`, resolved relative to project root) or a +/// remote `url`. `path` wins when both are set. Local files refresh via the +/// project-wide `.scarf/` directory watch (v2.7); remote URLs are loaded +/// once per appearance and cached by the SwiftUI `AsyncImage` machinery. +struct ImageWidgetView: View { + let widget: DashboardWidget + + @Environment(\.serverContext) private var serverContext + @Environment(\.selectedProjectRoot) private var projectRoot + @Environment(HermesFileWatcher.self) private var fileWatcher + + @State private var localImage: NSImage? + @State private var loadError: String? + + private var displayHeight: CGFloat? { + widget.height.map { CGFloat($0) } + } + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + Image(systemName: "photo") + .foregroundStyle(.secondary) + .scarfStyle(.caption) + Text(widget.title) + .scarfStyle(.caption) + .foregroundStyle(.secondary) + } + content + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .background(ScarfColor.backgroundSecondary) + .clipShape(RoundedRectangle(cornerRadius: ScarfRadius.lg)) + } + + @ViewBuilder + private var content: some View { + if let _ = widget.path { + localContent + } else if let url = widget.url, let parsed = URL(string: url) { + remoteContent(url: parsed) + } else { + WidgetErrorCard( + title: "", + reason: "Image widget needs either `path` (local file relative to project root) or `url` (remote)." + ) + } + } + + @ViewBuilder + private var localContent: some View { + switch WidgetPathResolver.resolve(widget.path, projectRoot: projectRoot) { + case .failure(let err): + WidgetErrorCard(title: "", reason: err.userMessage) + case .success(let resolved): + Group { + if let img = localImage { + Image(nsImage: img) + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity, maxHeight: displayHeight) + .clipShape(RoundedRectangle(cornerRadius: ScarfRadius.sm)) + } else if let loadError { + Text(loadError) + .font(.caption) + .foregroundStyle(.secondary) + } else { + ProgressView().controlSize(.small) + } + } + .task(id: "\(resolved)|\(fileWatcher.lastChangeDate.timeIntervalSince1970)") { + await loadLocal(absPath: resolved) + } + } + } + + private func remoteContent(url: URL) -> some View { + AsyncImage(url: url) { phase in + switch phase { + case .empty: + ProgressView().controlSize(.small) + case .success(let img): + img.resizable() + .scaledToFit() + .frame(maxWidth: .infinity, maxHeight: displayHeight) + .clipShape(RoundedRectangle(cornerRadius: ScarfRadius.sm)) + case .failure(let err): + Text("Could not load image: \(err.localizedDescription)") + .font(.caption) + .foregroundStyle(.secondary) + @unknown default: + EmptyView() + } + } + } + + private func loadLocal(absPath: String) async { + let context = serverContext + let outcome: WidgetIOResult = await Task.detached { + let transport = context.makeTransport() + do { + let data = try transport.readFile(absPath) + if let img = NSImage(data: data) { return .success(img) } + return .failure("File is not a recognized image format.") + } catch { + return .failure("Could not read file: \(error.localizedDescription)") + } + }.value + switch outcome { + case .success(let img): + self.localImage = img + self.loadError = nil + case .failure(let err): + self.localImage = nil + self.loadError = err.message + } + } +} diff --git a/scarf/scarf/Features/Projects/Views/Widgets/ListWidgetView.swift b/scarf/scarf/Features/Projects/Views/Widgets/ListWidgetView.swift index eefb721..88fd2bb 100644 --- a/scarf/scarf/Features/Projects/Views/Widgets/ListWidgetView.swift +++ b/scarf/scarf/Features/Projects/Views/Widgets/ListWidgetView.swift @@ -19,15 +19,7 @@ struct ListWidgetView: View { } 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) - } + ListItemRow(item: item) } } } @@ -36,21 +28,59 @@ struct ListWidgetView: View { .background(ScarfColor.backgroundSecondary) .clipShape(RoundedRectangle(cornerRadius: ScarfRadius.lg)) } +} - 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" - } - } +/// One row of a list widget. Maps `item.status` through `ListItemStatus(raw:)` +/// to a typed badge (icon + color). Unknown strings render as plain text with +/// the original string preserved as a trailing badge so nothing's hidden. +struct ListItemRow: View { + let item: ListItem - private func statusColor(_ status: String?) -> Color { - switch status { - case "done": return .green - case "active": return .blue - default: return .secondary + private var typedStatus: ListItemStatus? { ListItemStatus(raw: item.status) } + + var body: some View { + HStack(spacing: 6) { + Image(systemName: typedStatus?.iconName ?? "circle") + .font(.caption2) + .foregroundStyle(typedStatus?.tint ?? .secondary) + Text(item.text) + .font(.callout) + .strikethrough(typedStatus == .done) + .foregroundStyle(typedStatus == .done ? .secondary : .primary) + if typedStatus == nil, let raw = item.status, !raw.isEmpty { + Text(raw) + .font(.caption2) + .foregroundStyle(.secondary) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(.quaternary.opacity(0.5)) + .clipShape(Capsule()) + } + } + } +} + +extension ListItemStatus { + var iconName: String { + switch self { + case .success: return "checkmark.circle.fill" + case .warning: return "exclamationmark.triangle.fill" + case .danger: return "xmark.octagon.fill" + case .info: return "info.circle.fill" + case .pending: return "circle.dashed" + case .done: return "checkmark.circle.fill" + case .neutral: return "circle" + } + } + + var tint: Color { + switch self { + case .success, .done: return ScarfColor.success + case .warning: return ScarfColor.warning + case .danger: return ScarfColor.danger + case .info: return ScarfColor.info + case .pending: return .secondary + case .neutral: return .secondary } } } diff --git a/scarf/scarf/Features/Projects/Views/Widgets/LogTailWidgetView.swift b/scarf/scarf/Features/Projects/Views/Widgets/LogTailWidgetView.swift new file mode 100644 index 0000000..40346a5 --- /dev/null +++ b/scarf/scarf/Features/Projects/Views/Widgets/LogTailWidgetView.swift @@ -0,0 +1,131 @@ +import SwiftUI +import ScarfCore +import ScarfDesign + +/// Tails the last N lines of a file under the project root, monospaced. +/// Best paired with cron jobs that write atomically (write-temp + rename) +/// — the project-wide `.scarf/` directory watch (v2.7) refreshes the +/// widget when a new file lands. In-place appends to an existing file +/// won't tick `lastChangeDate`; the cron job should `touch dashboard.json` +/// after each run if it appends in place. +struct LogTailWidgetView: View { + let widget: DashboardWidget + + @Environment(\.serverContext) private var serverContext + @Environment(\.selectedProjectRoot) private var projectRoot + @Environment(HermesFileWatcher.self) private var fileWatcher + + @State private var loadedTail: String? + @State private var loadError: WidgetPathResolver.ResolveError? + @State private var ioError: String? + @State private var isLoading = false + + private var lineCount: Int { max(1, min(200, widget.lines ?? 20)) } + + var body: some View { + Group { + switch WidgetPathResolver.resolve(widget.path, projectRoot: projectRoot) { + case .failure(let err): + WidgetErrorCard( + title: widget.title, + reason: err.userMessage, + hint: "Set `path` to a file relative to the project root, e.g. `reports/uptime.log`." + ) + case .success(let resolved): + content(for: resolved) + .task(id: refreshKey(resolved)) { + await reload(absPath: resolved) + } + } + } + } + + private func refreshKey(_ resolved: String) -> String { + // Force a reload whenever either the widget config or any project + // file changes (the latter via fileWatcher.lastChangeDate). + "\(resolved)|\(lineCount)|\(fileWatcher.lastChangeDate.timeIntervalSince1970)" + } + + @ViewBuilder + private func content(for absPath: String) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + Image(systemName: "doc.text.below.ecg") + .foregroundStyle(.secondary) + .scarfStyle(.caption) + Text(widget.title) + .scarfStyle(.caption) + .foregroundStyle(.secondary) + Spacer() + Text("last \(lineCount)") + .font(.caption2) + .foregroundStyle(.tertiary) + if isLoading { + ProgressView().controlSize(.mini) + } + } + if let ioError { + Text(ioError) + .font(.caption) + .foregroundStyle(.secondary) + } else if let loadedTail { + tailBody(loadedTail) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .background(ScarfColor.backgroundSecondary) + .clipShape(RoundedRectangle(cornerRadius: ScarfRadius.lg)) + } + + @ViewBuilder + private func tailBody(_ tail: String) -> some View { + let lines = tail.split(separator: "\n", omittingEmptySubsequences: false) + if lines.isEmpty { + Text("(empty)").font(.caption2).foregroundStyle(.secondary) + } else { + VStack(alignment: .leading, spacing: 1) { + ForEach(Array(lines.enumerated()), id: \.offset) { _, line in + Text(String(line)) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.primary) + .lineLimit(1) + .truncationMode(.tail) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(8) + .background(.quaternary.opacity(0.4)) + .clipShape(RoundedRectangle(cornerRadius: ScarfRadius.sm)) + } + } + + private func reload(absPath: String) async { + let context = serverContext + let n = lineCount + isLoading = true + defer { isLoading = false } + let outcome: WidgetIOResult = await Task.detached { + let transport = context.makeTransport() + do { + let data = try transport.readFile(absPath) + guard let text = String(data: data, encoding: .utf8) else { + return .failure("File is not UTF-8 — log_tail expects text.") + } + let stripped = AnsiStripper.strip(text) + let parts = stripped.split(separator: "\n", omittingEmptySubsequences: false) + return .success(parts.suffix(n).joined(separator: "\n")) + } catch { + return .failure("Could not read file: \(error.localizedDescription)") + } + }.value + switch outcome { + case .success(let s): + self.loadedTail = s + self.ioError = nil + case .failure(let err): + self.loadedTail = nil + self.ioError = err.message + } + } +} diff --git a/scarf/scarf/Features/Projects/Views/Widgets/MarkdownFileWidgetView.swift b/scarf/scarf/Features/Projects/Views/Widgets/MarkdownFileWidgetView.swift new file mode 100644 index 0000000..2489314 --- /dev/null +++ b/scarf/scarf/Features/Projects/Views/Widgets/MarkdownFileWidgetView.swift @@ -0,0 +1,92 @@ +import SwiftUI +import ScarfCore +import ScarfDesign + +/// Renders a markdown file from the project root through the same +/// `MarkdownContentView` pipeline used by the inline `text` widget. Picks +/// up edits automatically via the project-wide `.scarf/` directory watch +/// (v2.7). +struct MarkdownFileWidgetView: View { + let widget: DashboardWidget + + @Environment(\.serverContext) private var serverContext + @Environment(\.selectedProjectRoot) private var projectRoot + @Environment(HermesFileWatcher.self) private var fileWatcher + + @State private var loadedContent: String? + @State private var ioError: String? + @State private var isLoading = false + + var body: some View { + Group { + switch WidgetPathResolver.resolve(widget.path, projectRoot: projectRoot) { + case .failure(let err): + WidgetErrorCard( + title: widget.title, + reason: err.userMessage, + hint: "Set `path` to a markdown file relative to the project root, e.g. `reports/weekly.md`." + ) + case .success(let resolved): + content(for: resolved) + .task(id: "\(resolved)|\(fileWatcher.lastChangeDate.timeIntervalSince1970)") { + await reload(absPath: resolved) + } + } + } + } + + @ViewBuilder + private func content(for absPath: String) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + Image(systemName: "doc.text") + .foregroundStyle(.secondary) + .scarfStyle(.caption) + Text(widget.title) + .scarfStyle(.caption) + .foregroundStyle(.secondary) + Spacer() + if isLoading { + ProgressView().controlSize(.mini) + } + } + if let ioError { + Text(ioError) + .font(.caption) + .foregroundStyle(.secondary) + } else if let loadedContent { + MarkdownContentView(content: loadedContent) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .background(ScarfColor.backgroundSecondary) + .clipShape(RoundedRectangle(cornerRadius: ScarfRadius.lg)) + } + + private func reload(absPath: String) async { + let context = serverContext + isLoading = true + defer { isLoading = false } + let outcome: WidgetIOResult = await Task.detached { + let transport = context.makeTransport() + do { + let data = try transport.readFile(absPath) + guard let text = String(data: data, encoding: .utf8) else { + return .failure("File is not UTF-8 — markdown_file expects text.") + } + return .success(text) + } catch { + return .failure("Could not read file: \(error.localizedDescription)") + } + }.value + switch outcome { + case .success(let s): + self.loadedContent = s + self.ioError = nil + case .failure(let err): + self.loadedContent = nil + self.ioError = err.message + } + } +} diff --git a/scarf/scarf/Features/Projects/Views/Widgets/StatWidgetView.swift b/scarf/scarf/Features/Projects/Views/Widgets/StatWidgetView.swift index 0e6696f..052ab2f 100644 --- a/scarf/scarf/Features/Projects/Views/Widgets/StatWidgetView.swift +++ b/scarf/scarf/Features/Projects/Views/Widgets/StatWidgetView.swift @@ -2,6 +2,33 @@ import SwiftUI import ScarfCore import ScarfDesign +/// Tiny inline trend line drawn under a `stat` widget's value. Pure SwiftUI +/// `Path`, no Swift Charts dependency — stays light enough to render +/// dozens per dashboard without measurable cost. +struct SparklineView: View { + let values: [Double] + let tint: Color + + var body: some View { + GeometryReader { geo in + let minV = values.min() ?? 0 + let maxV = values.max() ?? 1 + let span = max(0.0001, maxV - minV) + let stepX = values.count > 1 ? geo.size.width / CGFloat(values.count - 1) : 0 + Path { path in + for (i, v) in values.enumerated() { + let x = CGFloat(i) * stepX + let normalized = (v - minV) / span + let y = geo.size.height - CGFloat(normalized) * geo.size.height + if i == 0 { path.move(to: CGPoint(x: x, y: y)) } + else { path.addLine(to: CGPoint(x: x, y: y)) } + } + } + .stroke(tint.opacity(0.85), lineWidth: 1.2) + } + } +} + struct StatWidgetView: View { let widget: DashboardWidget @@ -30,6 +57,11 @@ struct StatWidgetView: View { .font(.caption2) .foregroundStyle(widgetColor) } + if let sparkline = widget.sparkline, sparkline.count >= 2 { + SparklineView(values: sparkline, tint: widgetColor) + .frame(height: 18) + .padding(.top, 2) + } } .frame(maxWidth: .infinity, alignment: .leading) .padding(12) diff --git a/scarf/scarf/Features/Projects/Views/Widgets/StatusGridWidgetView.swift b/scarf/scarf/Features/Projects/Views/Widgets/StatusGridWidgetView.swift new file mode 100644 index 0000000..20c66e2 --- /dev/null +++ b/scarf/scarf/Features/Projects/Views/Widgets/StatusGridWidgetView.swift @@ -0,0 +1,78 @@ +import SwiftUI +import ScarfCore +import ScarfDesign + +/// Compact NxM grid of colored cells, one per service / item. Denser than +/// `list` for monitoring dashboards with 12+ entities. Uses the same +/// `ListItemStatus` vocabulary as the list widget so colors stay consistent. +struct StatusGridWidgetView: View { + let widget: DashboardWidget + + private var cells: [StatusGridCell] { widget.cells ?? [] } + + /// Auto-fit columns when not specified: aim for ~6 cells per row, capped + /// at 12, floored at 4. Ensures both 8-cell and 36-cell grids look ok. + private var columnCount: Int { + if let n = widget.gridColumns, n > 0 { return min(20, n) } + let count = cells.count + if count <= 4 { return max(1, count) } + if count <= 12 { return 6 } + if count <= 24 { return 8 } + return 12 + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 6) { + Image(systemName: "square.grid.3x3.fill") + .foregroundStyle(.secondary) + .scarfStyle(.caption) + Text(widget.title) + .scarfStyle(.caption) + .foregroundStyle(.secondary) + Spacer() + Text("\(cells.count)") + .font(.caption2) + .foregroundStyle(.tertiary) + } + if cells.isEmpty { + Text("No cells.") + .font(.caption) + .foregroundStyle(.secondary) + } else { + LazyVGrid( + columns: Array(repeating: GridItem(.flexible(), spacing: 4), count: columnCount), + spacing: 4 + ) { + ForEach(cells) { cell in + StatusGridCellView(cell: cell) + } + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .background(ScarfColor.backgroundSecondary) + .clipShape(RoundedRectangle(cornerRadius: ScarfRadius.lg)) + } +} + +private struct StatusGridCellView: View { + let cell: StatusGridCell + + private var typedStatus: ListItemStatus { ListItemStatus(raw: cell.status) ?? .neutral } + + var body: some View { + VStack(spacing: 2) { + RoundedRectangle(cornerRadius: 3) + .fill(typedStatus.tint.opacity(0.85)) + .frame(height: 18) + Text(cell.label) + .font(.system(size: 9, weight: .medium)) + .lineLimit(1) + .truncationMode(.tail) + .foregroundStyle(.secondary) + } + .help(cell.tooltip ?? cell.label + (cell.status.map { " — \($0)" } ?? "")) + } +} diff --git a/scarf/scarf/Features/Projects/Views/Widgets/WidgetErrorCard.swift b/scarf/scarf/Features/Projects/Views/Widgets/WidgetErrorCard.swift new file mode 100644 index 0000000..0717826 --- /dev/null +++ b/scarf/scarf/Features/Projects/Views/Widgets/WidgetErrorCard.swift @@ -0,0 +1,45 @@ +import SwiftUI +import ScarfDesign + +/// Replacement for the legacy "Unknown widget" placeholder. Surfaces the +/// widget's own title plus a structured reason so dashboard authors can see +/// at a glance what's wrong (unknown type, missing file, parse error, …). +/// +/// Used by the `WidgetView` dispatcher's default branch and (in v2.7+) by +/// file-reading widgets that can't load their underlying data. +struct WidgetErrorCard: View { + let title: String + let reason: String + var hint: String? = nil + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(ScarfColor.warning) + .font(.caption) + Text(title.isEmpty ? "Widget error" : title) + .scarfStyle(.caption) + .foregroundStyle(.secondary) + } + Text(reason) + .font(.callout) + .foregroundStyle(.primary) + .fixedSize(horizontal: false, vertical: true) + if let hint, !hint.isEmpty { + Text(hint) + .font(.caption2) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .background(ScarfColor.warning.opacity(0.08)) + .overlay( + RoundedRectangle(cornerRadius: ScarfRadius.lg) + .strokeBorder(ScarfColor.warning.opacity(0.3), lineWidth: 1) + ) + .clipShape(RoundedRectangle(cornerRadius: ScarfRadius.lg)) + } +} diff --git a/scarf/scarf/Features/Projects/Views/Widgets/WidgetHelpers.swift b/scarf/scarf/Features/Projects/Views/Widgets/WidgetHelpers.swift index 57e16cd..ded2256 100644 --- a/scarf/scarf/Features/Projects/Views/Widgets/WidgetHelpers.swift +++ b/scarf/scarf/Features/Projects/Views/Widgets/WidgetHelpers.swift @@ -1,4 +1,44 @@ import SwiftUI +import Foundation + +/// Strips CSI ANSI escape sequences (`ESC [ ... letter`) so log output +/// pasted into the dashboard renders cleanly. Single regex, fast enough +/// for the small windows the log_tail / cron_status widgets work with. +/// Lightweight result type for file-reading widgets — failure is just a +/// human-readable string the widget surfaces in its error card. `Result<_, String>` +/// won't compile because `String` doesn't conform to `Error`; this alias +/// uses a typed wrapper so the rest of the call sites stay readable. +typealias WidgetIOResult = Result + +struct WidgetIOError: Error, Sendable { + let message: String + nonisolated init(_ m: String) { self.message = m } +} + +extension Result where Failure == WidgetIOError { + /// Convenience constructor — `.failure("…")` instead of + /// `.failure(WidgetIOError("…"))`. Marked nonisolated so detached + /// tasks can call it from outside the main actor. + nonisolated static func failure(_ message: String) -> Self { + .failure(WidgetIOError(message)) + } +} + +enum AnsiStripper { + /// Single-call regex strip. Compiles per call — log windows are small, + /// the cost is negligible, and skipping a `static let` cache means + /// callers from `Task.detached` don't fight the Swift 6 actor checker. + nonisolated static func strip(_ s: String) -> String { + // ESC = \u{1B}; CSI = ESC [ ; final byte is in 0x40..0x7E. + guard let pattern = try? NSRegularExpression( + pattern: "\u{1B}\\[[0-?]*[ -/]*[@-~]", options: [] + ) else { return s } + let range = NSRange(s.startIndex..., in: s) + return pattern.stringByReplacingMatches( + in: s, options: [], range: range, withTemplate: "" + ) + } +} func parseColor(_ name: String?) -> Color { switch name?.lowercased() { diff --git a/scarf/scarf/Features/Projects/Views/Widgets/WidgetPathResolver.swift b/scarf/scarf/Features/Projects/Views/Widgets/WidgetPathResolver.swift new file mode 100644 index 0000000..e98060b --- /dev/null +++ b/scarf/scarf/Features/Projects/Views/Widgets/WidgetPathResolver.swift @@ -0,0 +1,67 @@ +import SwiftUI + +/// Project root the dashboard widgets resolve relative `path` fields against. +/// Set by `ProjectsView` from the currently-selected project; nil when no +/// project is active. v2.7+ file-reading widgets (markdown_file, log_tail, +/// image-local) read this via the environment. +private struct SelectedProjectRootKey: EnvironmentKey { + static let defaultValue: String? = nil +} + +extension EnvironmentValues { + var selectedProjectRoot: String? { + get { self[SelectedProjectRootKey.self] } + set { self[SelectedProjectRootKey.self] = newValue } + } +} + +/// Resolves a widget's `path` field against the project root. Rejects paths +/// that escape the project boundary via `..` segments after normalization, +/// rejects absolute paths, and rejects empty / nil inputs. The returned +/// path is suitable to hand to `transport.readFile`. +/// +/// Returns nil + the reason if the path is invalid; widgets surface that +/// reason via `WidgetErrorCard`. +enum WidgetPathResolver { + enum ResolveError: Error, Equatable { + case noProject + case missingPath + case absolutePath + case escapesProject + } + + static func resolve(_ relativePath: String?, projectRoot: String?) -> Result { + guard let projectRoot, !projectRoot.isEmpty else { return .failure(.noProject) } + guard let relativePath, !relativePath.isEmpty else { return .failure(.missingPath) } + if relativePath.hasPrefix("/") { return .failure(.absolutePath) } + // Strip a single leading "./" — common in template-authored paths. + let trimmed = relativePath.hasPrefix("./") ? String(relativePath.dropFirst(2)) : relativePath + // Walk the segments and reject any "..": the project root is the + // trust boundary, anything reaching outside it is rejected. We do + // this BEFORE join+standardize so symlink games can't smuggle a + // ".." through path canonicalization. + let segments = trimmed.split(separator: "/", omittingEmptySubsequences: true) + for s in segments where s == ".." { return .failure(.escapesProject) } + let joined = (projectRoot as NSString).appendingPathComponent(trimmed) + let standardized = (joined as NSString).standardizingPath + // Belt and suspenders: ensure the standardized path is still + // beneath projectRoot. Standardizing resolves "./" and may follow + // symlinks; this catch checks the final string prefix. + let rootStd = (projectRoot as NSString).standardizingPath + if !standardized.hasPrefix(rootStd) { + return .failure(.escapesProject) + } + return .success(standardized) + } +} + +extension WidgetPathResolver.ResolveError { + var userMessage: String { + switch self { + case .noProject: return "No project selected." + case .missingPath: return "Missing required `path` field." + case .absolutePath: return "Path must be relative to the project root, not absolute." + case .escapesProject: return "Path escapes the project root (`..` segments are not allowed)." + } + } +} diff --git a/scarf/scarf/Localizable.xcstrings b/scarf/scarf/Localizable.xcstrings index 7afbbf7..b4f4f68 100644 --- a/scarf/scarf/Localizable.xcstrings +++ b/scarf/scarf/Localizable.xcstrings @@ -3087,6 +3087,10 @@ } } }, + "Auto-refresh OAuth tokens daily" : { + "comment" : "A toggle that enables or disables automatic refresh of OAuth tokens.", + "isCommentAutoGenerated" : true + }, "Aux Models" : { "localizations" : { "de" : { @@ -11217,6 +11221,10 @@ "comment" : "A description of a file that is not part of the template's installation.", "isCommentAutoGenerated" : true }, + "Keep tokens fresh" : { + "comment" : "Title of a section that lets you enable a cron job that refreshes OAuth tokens.", + "isCommentAutoGenerated" : true + }, "Keep typing to send as a message, or press Esc." : { "localizations" : { "de" : { @@ -17369,6 +17377,10 @@ "comment" : "A message that appears when the app is refreshing", "isCommentAutoGenerated" : true }, + "Registers a `%@` cron job that runs at 4am daily. Booting a Hermes session is what triggers token refresh — without this, refresh tokens silently expire if you go ~30 days without using Scarf." : { + "comment" : "A paragraph describing the purpose of the auto-refresh toggle.", + "isCommentAutoGenerated" : true + }, "Reload" : { "localizations" : { "de" : { @@ -25378,6 +25390,10 @@ "comment" : "A label for the user's name.", "isCommentAutoGenerated" : true }, + "Your Nous subscription was last refreshed %lld days ago. Enable the toggle above to prevent the refresh token from expiring." : { + "comment" : "A warning that the refresh token is stale.", + "isCommentAutoGenerated" : true + }, "Your tools will now route through your subscription." : { "comment" : "A description of the success state of the", "isCommentAutoGenerated" : true diff --git a/site/styles.css b/site/styles.css index ad9a87a..a555df6 100644 --- a/site/styles.css +++ b/site/styles.css @@ -411,6 +411,64 @@ h1, h2, h3 { line-height: 1.25; } } .widget-list-status[data-status="up"] { background: rgba(42,168,118,0.18); color: var(--accent-dark); } .widget-list-status[data-status="down"] { background: rgba(217,83,79,0.18); color: var(--red); } +/* v2.7 typed semantic statuses (mirrors ScarfDesign palette + Swift ListItemStatus enum). */ +.widget-list-status.status-success { background: rgba(42,168,118,0.18); color: var(--accent-dark); } +.widget-list-status.status-warning { background: rgba(240,173,78,0.20); color: var(--orange); } +.widget-list-status.status-danger { background: rgba(217,83,79,0.18); color: var(--red); } +.widget-list-status.status-info { background: rgba(91,143,225,0.20); color: var(--blue); } +.widget-list-status.status-pending { background: rgba(0,0,0,0.06); color: var(--fg-muted); } +.widget-list-status.status-done { background: rgba(42,168,118,0.14); color: var(--accent-dark); } +.widget-list-status.status-neutral { background: rgba(0,0,0,0.06); color: var(--fg-muted); } +.widget-list-status.status-unknown { background: rgba(0,0,0,0.06); color: var(--fg-muted); font-style: italic; } +.widget-list-item-done .widget-list-text { text-decoration: line-through; color: var(--fg-muted); } + +/* v2.7 structured widget error card (mirrors Swift WidgetErrorCard). */ +.widget-error { + background: rgba(240,173,78,0.08); + border: 1px solid rgba(240,173,78,0.30); +} +.widget-error-head { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; } +.widget-error-icon { color: var(--orange); font-size: 14px; } +.widget-error-reason { font-size: 14px; color: var(--fg); } +.widget-error-hint { margin-top: 4px; font-size: 11px; color: var(--fg-muted); } + +/* v2.7 cron_status / log_tail / markdown_file (catalog preview placeholders). */ +.widget-cron-status, .widget-log-tail, .widget-markdown-file { padding: 12px; } +.widget-cron-head { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; } +.widget-cron-icon { font-size: 14px; color: var(--fg-muted); } +.widget-cron-meta { font-size: 13px; color: var(--fg); font-family: monospace; } +.widget-cron-hint { margin-top: 6px; font-size: 11px; color: var(--fg-muted); font-style: italic; } + +/* v2.7 image widget. */ +.widget-image { padding: 12px; } +.widget-image-img { display: block; max-width: 100%; height: auto; margin-top: 8px; border-radius: 4px; } + +/* v2.7 status_grid widget — compact NxM grid with semantic-status swatches. */ +.widget-status-grid { padding: 12px; } +.widget-status-grid-grid { + display: grid; + grid-template-columns: repeat(var(--cols, 6), 1fr); + gap: 4px; + margin-top: 6px; +} +.widget-status-grid-cell { display: flex; flex-direction: column; gap: 2px; align-items: center; } +.widget-status-grid-swatch { width: 100%; height: 18px; border-radius: 3px; } +.widget-status-grid-label { font-size: 9px; color: var(--fg-muted); text-align: center; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.widget-status-grid-swatch.status-success { background: rgba(42,168,118,0.85); } +.widget-status-grid-swatch.status-warning { background: rgba(240,173,78,0.85); } +.widget-status-grid-swatch.status-danger { background: rgba(217,83,79,0.85); } +.widget-status-grid-swatch.status-info { background: rgba(91,143,225,0.85); } +.widget-status-grid-swatch.status-pending { background: rgba(120,120,120,0.40); } +.widget-status-grid-swatch.status-done { background: rgba(42,168,118,0.55); } +.widget-status-grid-swatch.status-neutral { background: rgba(120,120,120,0.30); } + +/* v2.7 sparkline under stat value. Inherits color from widget-stat data-color. */ +.widget-stat-sparkline { display: block; margin-top: 4px; width: 100%; } +.widget-stat[data-color="accent"] .widget-stat-sparkline, +.widget-stat[data-color="green"] .widget-stat-sparkline { color: var(--accent-dark); } +.widget-stat[data-color="red"] .widget-stat-sparkline { color: var(--red); } +.widget-stat[data-color="blue"] .widget-stat-sparkline { color: var(--blue); } +.widget-stat[data-color="orange"] .widget-stat-sparkline { color: var(--orange); } /* chart */ .widget-chart-svg { width: 100%; height: auto; margin-top: 8px; } diff --git a/site/widgets.js b/site/widgets.js index fc72e7e..a13edf8 100644 --- a/site/widgets.js +++ b/site/widgets.js @@ -6,16 +6,13 @@ // shows a live preview of exactly what the user's project dashboard // will look like after install. // -// Widget types mirrored from the Swift dispatcher: -// stat — big number + label + icon + color -// progress — label + 0..1 bar -// text — markdown (tiny subset renderer) -// table — plain HTML table -// list — bulleted list with optional status badge -// chart — SVG line/bar by series -// webview — sandboxed