mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 17:31:56 -10:00
feat(ui-enhancement-pack): v0 — progress + markdown + files browser + edit mode
UI Enhancement Pack v0 — operator gets a substantially better UI for
browsing + viewing + editing workspace canon files. Four items ship as
one ambitious vertical because they share infrastructure (allowlist +
path-safety daemon-side; markdown rendering UI-side). NO new schema,
NO migrations, NO new event types.
Item 1A — Acceptance tab enhancement (Slice Story View v0 extension):
- Checkbox pills (rounded with status icon: ✓ done / ◯ active /
⚠ blocked) instead of raw [ ]/[x] syntax.
- 4-filter chip row (All / Active / Done / Blocked; default All).
- Click-to-expand row detail panel with prominent file:line citation.
- v0's PROGRESS.md parser still drives the row list; this slice
enhances the rendering layer only.
Item 1B — Top-level Progress browse view:
- New /progress route + Progress sidebar Link (CheckSquare icon)
alongside the existing Slices entry.
- Daemon: new domain/progress/progress-indexer.ts walks
OPENRIG_PROGRESS_SCAN_ROOTS (env-configured, comma-separated
name:path pairs), parses PROGRESS.md files with checkbox-hierarchy
awareness ([ ]/[x]/[~] checkboxes + ## / ### / #### headings +
YAML frontmatter strip + 2-space-indent depth + skip-dirs for
node_modules/.git/.worktrees/dist), and returns aggregate counts
per file + workspace-wide.
- New routes/progress.ts: GET /api/progress/tree returns
{ files, aggregate, scannedRoots } or 503 with structured
setup hint when no scan roots are configured.
- New ProgressWorkspace.tsx with two-pane layout: tree pane (per-
file collapsible sections with checkbox pills + status icons) +
inline detail panel on row click. Filter row (status: all/active/
done/blocked) + free-text search across rows. Read-only at v0
(edit is item 4 territory).
Item 2 — Markdown viewer expansion:
- New components/markdown/MarkdownViewer.tsx + SyntaxHighlight.tsx —
written from scratch (~500 lines total, no new dependencies). Per
the audit: no markdown / highlighting libs are installed in the UI
package; the inline parser keeps the bundle weight unchanged.
- Handles: YAML frontmatter (rendered as metadata header above body),
headings (# / ## / ### / ####), bullet + ordered lists with
indentation depth, fenced code blocks with per-language syntax
highlighting, inline code with backticks, links + images with
relative-src resolution against assetBasePath, tables (| col |
syntax with header + body rows + zebra striping), bold (**) and
italic (*) inline emphasis.
- Per-language tokenizer covers ts/tsx/js/jsx, py/python, bash/sh,
sql, yaml/yml, json. Token classes: keyword (violet), string
(emerald), comment (stone-500 italic), number (amber), tag (sky),
punctuation (stone-400). Unknown languages render as plain text
in the dark code panel.
- Item 2 mermaid carve-out activated: NO mermaid library bundled at
v0. Mermaid code blocks render as a labeled placeholder with a
disabled '[render mermaid] (v0+1)' button. NAMED v0+1 trigger:
"operator dogfood reports the click-to-render flow is friction"
— recorded in commit + handoff per PRD § Item 2.
- DocsTab.tsx refactored to consume MarkdownViewer for .md files;
non-.md files keep the v0 plain-`<pre>` fallback. Slice Story
View v0's docs UX is preserved + extended.
Item 3 — File browser (allowlisted, daemon-enforced path-safety):
- Driver pivot from PRD's suggested ConfigStore key family
(files.allowlist.<name>): the existing ConfigStore uses a strict
VALID_KEYS allowlist that doesn't admit dynamic key families
cleanly. Switched to env-var approach:
OPENRIG_FILES_ALLOWLIST=name1:/abs/path,name2:/abs/path. Same
delimited-pair shape as OPENRIG_PROGRESS_SCAN_ROOTS for
consistency. Fail-closed default: empty/unset → routes return
503 with structured setup hint.
- New domain/files/path-safety.ts: shared resolver with three
guarantees — '..' segment rejection (literal path-component, not
substring; foo..bar.md is allowed), absolute-path-as-relative
rejection, and symlink-realpath check ensuring no resolved file
escapes the canonical root via path.sep boundary (prevents the
/foo/bar matching /foo/bar-other false-positive).
- New routes/files.ts: GET /roots, GET /list, GET /read,
GET /asset, POST /write. Path-safety errors map to structured
4xx codes (root_unknown / path_invalid / path_escape /
not_a_file / not_a_directory / stat_failed) so the UI can
render specific error messages.
- /asset infers Content-Type from extension (image/png /
image/jpeg / image/svg+xml / video/mp4 / etc.) for embedded
image rendering in MarkdownViewer.
- New FilesWorkspace.tsx + useFiles.ts hooks: two-pane layout
(allowlist root selector + breadcrumbs + directory tree on left;
file content panel on right). Auto-selects first root; resets
path + selection on root change. Up-directory affordance.
Content-type-aware rendering: .md → MarkdownViewer; text-like →
SyntaxHighlight; image → inline `<img>` via /api/files/asset;
download-only types (.zip, .pdf, .mp4) → download link;
unknown → text fallback.
Item 4 — Optional edit mode (atomic write + JSONL audit):
- Per PRD's recommended landing posture: lightweight `<textarea>`
edit (no CodeMirror dependency) + daemon write route +
audit JSONL. The atomic-write contract is the load-bearing
primitive; the UI shell is intentionally minimalist.
- New domain/files/file-write-service.ts: writeAtomic({...}):
resolves path-safety → re-stats target → re-hashes via SHA-256 →
rejects with WriteConflictError carrying current{Mtime,
ContentHash} on mismatch (409) → writes to temp file in same
directory → fsync → atomic rename → re-stats for new mtime/
contentHash → appends one JSONL row to ~/.openrig/file-edit-
audit.jsonl with ts, actor, root, path, prev/new mtime + hash,
byteCountDelta. Audit-write failure surfaces as a separate
error code (write succeeded; audit append did not — operator
can re-derive from filesystem state). PRD's no-clobber-on-
concurrent-edit invariant pinned by tests.
- POST /api/files/write integrated into routes/files.ts; conflict
path returns 409 with the daemon's current state for the UI
to surface a Refresh affordance.
- FilesWorkspace edit mode: header-bar Edit toggle (default off);
toggling on flips the right pane to a `<textarea>` editor with
Save / Cancel / status indicator (draft (unsaved) / no changes /
saved). Save submits POST /write with last-known mtime +
contentHash; 409 conflict surfaces inline with current
daemon-side mtime; Cancel discards the draft and re-reads.
- Driver carve-out NOT invoked: edit mode ships as the PRD's
recommended in-scope item. NAMED v0+1 trigger left available
for future scope-down if dogfood reveals friction (per
PRD § Item 4 Driver carve-out).
AppShell + routes wiring:
- New header Links: Progress (CheckSquare icon) + Files (Folder
icon) alongside Slices (Layers). Surface-title resolver
recognizes /progress + /files paths.
- routes.tsx: register progressRoute (/progress) + filesRoute
(/files) on the existing root tree.
PRD reinterpretations recorded:
- Item 3 allowlist source: env-var (OPENRIG_FILES_ALLOWLIST) instead
of ConfigStore key family — driver pivot to fit existing
ConfigStore's strict VALID_KEYS allowlist.
- Item 1B scan roots: env-var (OPENRIG_PROGRESS_SCAN_ROOTS) for
consistency with the same delimited-pair shape.
- Item 2 mermaid carve-out activated per PRD: NO mermaid bundled;
placeholder + disabled button + named v0+1 trigger.
Verification:
- daemon: 2796/2796 (was 2750 baseline at 6097f4e + 46 new across
3 new test files: 18 files-path-safety + 15 files-routes + 12
progress-indexer + 1 from setup). Path-safety tests pin '..'
segment rejection vs filename-substring, absolute-path
rejection, symlink-escape rejection, env-var fallback chain
(empty-string treats as missing for legacy fallback). Files-
routes tests pin 503 graceful, list+read+asset round-trip,
atomic-write success + 409 conflict path, audit JSONL append.
Progress indexer tests pin checkbox state parsing (active/done/
blocked), heading depth derivation, indent depth derivation,
frontmatter strip, skip-dirs, max-depth, aggregate counts.
- ui: 478/478 (was 458 baseline + 20 new across 2 test files:
13 markdown-viewer + 7 acceptance-tab-enhancement). Markdown
tests pin frontmatter rendering, headings/lists/code/inline-
code/links/images/tables/bold/italic, mermaid carve-out
placeholder. Acceptance tests pin pill rendering, filter row,
filtering behavior, expand/collapse, v0-baseline preservation.
- cli: untouched by this slice; baseline 800.
- tsc clean for daemon + cli + ui.
Boundaries preserved:
- Worktree-only on ui-enhancement-pack-v0; main untouched at 6097f4e.
- NO new schema, NO migrations, NO new event types.
- Slice Story View v0's StoryTab / DecisionsTab / TestsVerificationTab
/ TopologyTab — untouched. Acceptance + Docs tabs extended only.
- v1 in-flight (separate worktree at 7bb892e): non-overlap on
load-bearing files; AcceptanceTab will need a clean merge when
v1 lands (v1 adds CurrentStepPanel ABOVE the checkbox list; this
slice extends the checkbox list rendering — surgical merge).
- PL-019 surfaces (RigNode/Explorer/graph-projection/edge animation)
— untouched.
- PL-004 Phase A/B/C/D — read-only consumers; no modifications.
- PL-005 Mission Control — untouched.
- ConfigStore (cli/src/config-store.ts) — untouched per the
env-var pivot.
- All migrations on main — untouched.
- AppShell entries other than the new Progress + Files Links —
untouched.
Operator setup hint for QA dogfood (orch routes to QA):
export OPENRIG_FILES_ALLOWLIST="workspace:/Users/wrandom/code/substrate/shared-docs/openrig-work,hub:/Users/wrandom/code/projects/openrig-hub"
export OPENRIG_PROGRESS_SCAN_ROOTS="workspace:/Users/wrandom/code/substrate/shared-docs/openrig-work"
rig daemon restart
Then:
- Header → Slices/Progress/Files (3 sidebar Links).
- /progress: tree of PROGRESS.md across the workspace with
filter + search + click-to-expand detail.
- /files: allowlist roots + directory tree + content panel
(markdown rendered via MarkdownViewer; code via
SyntaxHighlight; images inline; download-only fallback for
zip/pdf/video).
- /files edit mode: toggle Edit → save STEERING.md inline →
audit row appears in ~/.openrig/file-edit-audit.jsonl →
concurrent edit from a separate terminal triggers 409
Conflict with Refresh affordance.
Hard-gate (operator dogfood per PRD audit row 12) is QA-owned
per feedback_qa_owns_dogfood_fix_loop.md. Worktree retained for
guard inspection.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
1 parent
6097f4e633
commit
1a12230dce
22 files changed
+3535
-30
No files matched your search
@@ -0,0 +1,227 @@
|
||||
// UI Enhancement Pack v0 — atomic file write + JSONL audit.
|
||||
//
|
||||
// Item 4: operator-actionable write surface for STEERING.md /
|
||||
// PROGRESS.md / spec YAML / any allowlisted file. Honors the dashboard
|
||||
// precedent's mtime + content-hash invariants (cited as retired-but-
|
||||
// instructive from `services/dashboard/bin/server.py:1269-1289`;
|
||||
// reimplemented fresh in TypeScript per current OpenRig conventions).
|
||||
//
|
||||
// Atomic-write semantics:
|
||||
// 1. Resolve target path under allowlist root (path-safety from
|
||||
// file-allowlist.ts).
|
||||
// 2. Re-stat file: if mtime != expectedMtime OR contentHash !=
|
||||
// expectedContentHash, reject with WriteConflictError carrying
|
||||
// the current mtime + contentHash for the UI to surface.
|
||||
// 3. Write content to a temp file in the same directory.
|
||||
// 4. fsync the temp file so the write is durable before the rename.
|
||||
// 5. Atomic rename over the target path. On POSIX this is a single
|
||||
// inode swap, so other readers either see the old file or the
|
||||
// new file — never a partial write.
|
||||
// 6. Compute new mtime + contentHash from the rename'd target.
|
||||
// 7. Append one JSONL row to the audit file.
|
||||
//
|
||||
// The audit file is `~/.openrig/file-edit-audit.jsonl` by default
|
||||
// (operator-overridable via env). Append-only; never rotates at v0
|
||||
// per PRD § Item 4 (rotation is a future concern).
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { resolveAllowedPath, type AllowlistRoot } from "./path-safety.js";
|
||||
|
||||
export interface FileWriteRequest {
|
||||
rootName: string;
|
||||
/** Relative path under the allowlist root; same shape as item 3 routes. */
|
||||
path: string;
|
||||
content: string;
|
||||
/** Caller-known mtime (ISO string) at the time of last read. */
|
||||
expectedMtime: string;
|
||||
/** Caller-known SHA-256 content hash (hex) at the time of last read. */
|
||||
expectedContentHash: string;
|
||||
/** Operator session that initiated the write (for the audit row). */
|
||||
actor: string;
|
||||
}
|
||||
|
||||
export interface FileWriteResult {
|
||||
/** Resolved canonical absolute path that was written. */
|
||||
absolutePath: string;
|
||||
/** New mtime after the write (ISO string). */
|
||||
newMtime: string;
|
||||
/** New SHA-256 content hash (hex) after the write. */
|
||||
newContentHash: string;
|
||||
/** Byte-count delta (new - prev). */
|
||||
byteCountDelta: number;
|
||||
}
|
||||
|
||||
export class WriteConflictError extends Error {
|
||||
constructor(
|
||||
public readonly currentMtime: string,
|
||||
public readonly currentContentHash: string,
|
||||
public readonly details: Record<string, unknown>,
|
||||
) {
|
||||
super("file changed externally; refresh required before writing");
|
||||
this.name = "WriteConflictError";
|
||||
}
|
||||
}
|
||||
|
||||
export class FileWriteError extends Error {
|
||||
constructor(
|
||||
public readonly code: "stat_failed" | "tmp_write_failed" | "rename_failed" | "audit_write_failed",
|
||||
message: string,
|
||||
public readonly details?: Record<string, unknown>,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "FileWriteError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface FileWriteServiceOpts {
|
||||
/** Allowlist roots resolved from env at startup (re-resolved per request is fine; cheap). */
|
||||
allowlist: AllowlistRoot[];
|
||||
/** Override default audit file location for tests. */
|
||||
auditFilePath?: string;
|
||||
/** Override Date.now() for tests (returns ISO timestamp). */
|
||||
now?: () => Date;
|
||||
}
|
||||
|
||||
const DEFAULT_AUDIT_FILE = path.join(
|
||||
process.env.HOME ?? process.env.USERPROFILE ?? "/tmp",
|
||||
".openrig",
|
||||
"file-edit-audit.jsonl",
|
||||
);
|
||||
|
||||
export class FileWriteService {
|
||||
private readonly allowlist: AllowlistRoot[];
|
||||
private readonly auditFilePath: string;
|
||||
private readonly now: () => Date;
|
||||
|
||||
constructor(opts: FileWriteServiceOpts) {
|
||||
this.allowlist = opts.allowlist;
|
||||
this.auditFilePath = opts.auditFilePath ?? DEFAULT_AUDIT_FILE;
|
||||
this.now = opts.now ?? (() => new Date());
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically write content to an allowlisted file. Throws
|
||||
* WriteConflictError on mtime/contentHash mismatch; throws
|
||||
* FileWriteError on stat / temp-write / rename / audit failures.
|
||||
* Returns FileWriteResult with the new mtime + contentHash on
|
||||
* success.
|
||||
*/
|
||||
writeAtomic(req: FileWriteRequest): FileWriteResult {
|
||||
const target = resolveAllowedPath(this.allowlist, req.rootName, req.path);
|
||||
|
||||
// Pre-write checks: re-stat + re-hash for conflict detection.
|
||||
let prevStat: fs.Stats;
|
||||
let prevContent: Buffer;
|
||||
try {
|
||||
prevStat = fs.statSync(target);
|
||||
prevContent = fs.readFileSync(target);
|
||||
} catch (err) {
|
||||
throw new FileWriteError(
|
||||
"stat_failed",
|
||||
`failed to read '${target}' for conflict detection: ${err instanceof Error ? err.message : String(err)}`,
|
||||
{ target },
|
||||
);
|
||||
}
|
||||
const prevMtime = prevStat.mtime.toISOString();
|
||||
const prevContentHash = sha256Hex(prevContent);
|
||||
if (prevMtime !== req.expectedMtime || prevContentHash !== req.expectedContentHash) {
|
||||
throw new WriteConflictError(prevMtime, prevContentHash, {
|
||||
target,
|
||||
expectedMtime: req.expectedMtime,
|
||||
expectedContentHash: req.expectedContentHash,
|
||||
});
|
||||
}
|
||||
|
||||
// Write to temp file in the SAME directory (so atomic rename
|
||||
// works across the same filesystem). Suffix with PID + random
|
||||
// so concurrent writes don't collide.
|
||||
const tmpName = `.openrig-write-${process.pid}-${Math.random().toString(36).slice(2, 10)}-${path.basename(target)}`;
|
||||
const tmpPath = path.join(path.dirname(target), tmpName);
|
||||
let tmpFd: number | null = null;
|
||||
try {
|
||||
tmpFd = fs.openSync(tmpPath, "w");
|
||||
fs.writeFileSync(tmpFd, req.content);
|
||||
fs.fsyncSync(tmpFd);
|
||||
} catch (err) {
|
||||
// Clean up temp file if it was partially created.
|
||||
try { if (tmpFd !== null) fs.closeSync(tmpFd); } catch { /* ignore */ }
|
||||
try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
|
||||
throw new FileWriteError(
|
||||
"tmp_write_failed",
|
||||
`failed to write+fsync temp file at '${tmpPath}': ${err instanceof Error ? err.message : String(err)}`,
|
||||
{ target, tmpPath },
|
||||
);
|
||||
} finally {
|
||||
try { if (tmpFd !== null) fs.closeSync(tmpFd); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
try {
|
||||
fs.renameSync(tmpPath, target);
|
||||
} catch (err) {
|
||||
// Best-effort cleanup of the orphan temp file. We don't try to
|
||||
// restore the previous content because we never touched the
|
||||
// target (rename failed atomically).
|
||||
try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
|
||||
throw new FileWriteError(
|
||||
"rename_failed",
|
||||
`failed to atomically rename '${tmpPath}' over '${target}': ${err instanceof Error ? err.message : String(err)}`,
|
||||
{ target, tmpPath },
|
||||
);
|
||||
}
|
||||
|
||||
// Re-stat to get the new mtime + content hash.
|
||||
const newStat = fs.statSync(target);
|
||||
const newContent = fs.readFileSync(target);
|
||||
const newMtime = newStat.mtime.toISOString();
|
||||
const newContentHash = sha256Hex(newContent);
|
||||
const byteCountDelta = newContent.byteLength - prevContent.byteLength;
|
||||
|
||||
// Append the audit JSONL row. Failure here is logged but does
|
||||
// NOT undo the write — the user's edit landed; we don't want
|
||||
// audit-system flakes to revert canon edits.
|
||||
const auditRow = {
|
||||
ts: this.now().toISOString(),
|
||||
actor: req.actor,
|
||||
root: req.rootName,
|
||||
path: req.path,
|
||||
absolutePath: target,
|
||||
prevMtime,
|
||||
newMtime,
|
||||
prevContentHash,
|
||||
newContentHash,
|
||||
byteCountDelta,
|
||||
};
|
||||
try {
|
||||
this.appendAuditRow(auditRow);
|
||||
} catch (err) {
|
||||
throw new FileWriteError(
|
||||
"audit_write_failed",
|
||||
`write succeeded but audit append failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
{ target, auditFilePath: this.auditFilePath, ...auditRow },
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
absolutePath: target,
|
||||
newMtime,
|
||||
newContentHash,
|
||||
byteCountDelta,
|
||||
};
|
||||
}
|
||||
|
||||
private appendAuditRow(row: Record<string, unknown>): void {
|
||||
fs.mkdirSync(path.dirname(this.auditFilePath), { recursive: true });
|
||||
fs.appendFileSync(this.auditFilePath, `${JSON.stringify(row)}\n`);
|
||||
}
|
||||
|
||||
/** Test/debug helper: returns the configured audit file path. */
|
||||
getAuditFilePath(): string {
|
||||
return this.auditFilePath;
|
||||
}
|
||||
}
|
||||
|
||||
export function sha256Hex(content: Buffer | string): string {
|
||||
return createHash("sha256").update(content).digest("hex");
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
// UI Enhancement Pack v0 — file allowlist + path-safety helpers.
|
||||
//
|
||||
// Daemon-enforced fail-closed path-safety for the operator-allowlisted
|
||||
// file browser surface (item 3 + item 4). Reused by all four
|
||||
// /api/files/* routes.
|
||||
//
|
||||
// Allowlist source (driver pivot from PRD's suggested ConfigStore key
|
||||
// family — see handoff): OPENRIG_FILES_ALLOWLIST env var, comma-
|
||||
// separated `<name>:<absolute-path>` pairs. Operator sets via shell
|
||||
// (or via `~/.openrig` env file). Empty / unset → no allowlist roots
|
||||
// → routes return empty roots list with a structured "configure
|
||||
// OPENRIG_FILES_ALLOWLIST" hint. Default after fresh install: nothing
|
||||
// (the safe default per PRD § Item 3).
|
||||
//
|
||||
// Path-safety contract:
|
||||
// - Each request resolves <relativePath> against <rootName>'s
|
||||
// canonical absolute path.
|
||||
// - Any escape attempt (.., absolute-path passed as relativePath,
|
||||
// symlink whose realpath escapes the root) is rejected with a
|
||||
// structured error.
|
||||
// - Symlinks INSIDE the allowlisted tree resolve normally; symlinks
|
||||
// pointing OUTSIDE the tree are treated as escape attempts.
|
||||
// - Reads/lists never return content from outside the resolved root.
|
||||
//
|
||||
// MVP single-host context: no per-rig overrides, no remote allowlist
|
||||
// management, no audit at allowlist-resolution time (allowlist
|
||||
// resolution is read-only). Audit applies to writes only (item 4).
|
||||
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
export interface AllowlistRoot {
|
||||
/** Operator-supplied display name (e.g., "workspace"). */
|
||||
name: string;
|
||||
/** Canonical absolute path on disk. */
|
||||
canonicalPath: string;
|
||||
}
|
||||
|
||||
export class FilePathSafetyError extends Error {
|
||||
constructor(
|
||||
public readonly code: "root_unknown" | "path_escape" | "path_invalid" | "stat_failed" | "not_a_file" | "not_a_directory",
|
||||
message: string,
|
||||
public readonly details?: Record<string, unknown>,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "FilePathSafetyError";
|
||||
}
|
||||
}
|
||||
|
||||
const ENV_VAR = "OPENRIG_FILES_ALLOWLIST";
|
||||
const LEGACY_ENV_VAR = "RIGGED_FILES_ALLOWLIST";
|
||||
|
||||
/**
|
||||
* Reads the allowlist env var and returns the parsed root list. Each
|
||||
* pair is `<name>:<absolute-path>`; whitespace around delimiters is
|
||||
* trimmed. Invalid pairs (no colon, empty name, non-absolute path)
|
||||
* are silently skipped (logged at construction by the caller, not
|
||||
* here — keep this pure for tests). Duplicate names: last entry wins
|
||||
* (operator presumably means to override).
|
||||
*/
|
||||
export function readAllowlistFromEnv(env: NodeJS.ProcessEnv = process.env): AllowlistRoot[] {
|
||||
// Use || (not ??) so an empty-string OPENRIG_FILES_ALLOWLIST falls
|
||||
// back to the legacy var. ?? only handles null/undefined.
|
||||
const raw = (env[ENV_VAR] || env[LEGACY_ENV_VAR] || "").toString();
|
||||
if (!raw.trim()) return [];
|
||||
const out = new Map<string, string>();
|
||||
for (const pair of raw.split(",")) {
|
||||
const trimmed = pair.trim();
|
||||
if (!trimmed) continue;
|
||||
const colon = trimmed.indexOf(":");
|
||||
if (colon === -1) continue;
|
||||
const name = trimmed.slice(0, colon).trim();
|
||||
const rawPath = trimmed.slice(colon + 1).trim();
|
||||
if (!name || !rawPath) continue;
|
||||
if (!path.isAbsolute(rawPath)) continue;
|
||||
// Resolve to canonical absolute path. If the path doesn't exist
|
||||
// on disk yet, fall back to path.resolve so the operator can
|
||||
// pre-configure roots. realpath would throw on missing paths.
|
||||
let canonical: string;
|
||||
try {
|
||||
canonical = fs.realpathSync(rawPath);
|
||||
} catch {
|
||||
canonical = path.resolve(rawPath);
|
||||
}
|
||||
out.set(name, canonical);
|
||||
}
|
||||
return Array.from(out.entries()).map(([name, canonicalPath]) => ({ name, canonicalPath }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a relative path against an allowlist root. Throws
|
||||
* FilePathSafetyError on any unsafe condition. Returns the
|
||||
* canonical absolute path on success. Caller is expected to stat
|
||||
* the result to distinguish file/directory.
|
||||
*
|
||||
* Path-safety algorithm:
|
||||
* 1. Validate the root exists in the allowlist (reject "root_unknown").
|
||||
* 2. Reject relative paths that look absolute or contain `..`
|
||||
* segments (reject "path_invalid" / "path_escape").
|
||||
* 3. Resolve the relative path against the canonical root.
|
||||
* 4. Resolve symlinks: if the realpath does not start with the
|
||||
* canonical root + path separator, reject "path_escape".
|
||||
* 5. The base case `path = ""` resolves to the root itself (allowed
|
||||
* so callers can list the root directory).
|
||||
*/
|
||||
export function resolveAllowedPath(
|
||||
allowlist: AllowlistRoot[],
|
||||
rootName: string,
|
||||
relativePath: string,
|
||||
): string {
|
||||
const root = allowlist.find((r) => r.name === rootName);
|
||||
if (!root) {
|
||||
throw new FilePathSafetyError(
|
||||
"root_unknown",
|
||||
`allowlist root '${rootName}' is not configured. Allowlist configured roots: ${allowlist.map((r) => r.name).join(", ") || "(none)"}.`,
|
||||
{ rootName, configuredRoots: allowlist.map((r) => r.name) },
|
||||
);
|
||||
}
|
||||
// Reject ../ escape attempts BEFORE filesystem resolution. Even
|
||||
// though step 4 catches realpath escapes, this rejects the
|
||||
// expressed-intent-to-escape with a clearer error code.
|
||||
if (relativePath.includes("..")) {
|
||||
// Distinguish a literal ".." segment from a filename like "foo..bar"
|
||||
// — split on path.sep and `/` and inspect each segment.
|
||||
const segments = relativePath.split(/[\\/]/).filter((s) => s.length > 0);
|
||||
for (const seg of segments) {
|
||||
if (seg === "..") {
|
||||
throw new FilePathSafetyError(
|
||||
"path_escape",
|
||||
`relative path '${relativePath}' contains a '..' segment; reject.`,
|
||||
{ rootName, relativePath },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (path.isAbsolute(relativePath)) {
|
||||
throw new FilePathSafetyError(
|
||||
"path_invalid",
|
||||
`relative path '${relativePath}' must not be absolute (reject).`,
|
||||
{ rootName, relativePath },
|
||||
);
|
||||
}
|
||||
const candidate = path.resolve(root.canonicalPath, relativePath);
|
||||
let realpath: string;
|
||||
try {
|
||||
realpath = fs.realpathSync(candidate);
|
||||
} catch {
|
||||
// If the candidate doesn't exist on disk yet, fall back to the
|
||||
// unresolved candidate. Subsequent stat will surface the absence
|
||||
// with a more specific error code.
|
||||
realpath = candidate;
|
||||
}
|
||||
// Containment check with path.sep boundary to avoid the
|
||||
// /foo/bar matching /foo/bar-other false-positive.
|
||||
const rootWithSep = root.canonicalPath.endsWith(path.sep)
|
||||
? root.canonicalPath
|
||||
: `${root.canonicalPath}${path.sep}`;
|
||||
if (realpath !== root.canonicalPath && !realpath.startsWith(rootWithSep)) {
|
||||
throw new FilePathSafetyError(
|
||||
"path_escape",
|
||||
`resolved path '${realpath}' falls outside allowlist root '${rootName}' (${root.canonicalPath}).`,
|
||||
{ rootName, relativePath, resolved: realpath, rootCanonical: root.canonicalPath },
|
||||
);
|
||||
}
|
||||
return realpath;
|
||||
}
|
||||
|
||||
/** Convenience: resolve + assert the result is an existing regular file. */
|
||||
export function resolveAllowedFile(
|
||||
allowlist: AllowlistRoot[],
|
||||
rootName: string,
|
||||
relativePath: string,
|
||||
): string {
|
||||
const resolved = resolveAllowedPath(allowlist, rootName, relativePath);
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.statSync(resolved);
|
||||
} catch (err) {
|
||||
throw new FilePathSafetyError(
|
||||
"stat_failed",
|
||||
`failed to stat '${resolved}': ${err instanceof Error ? err.message : String(err)}`,
|
||||
{ rootName, relativePath, resolved },
|
||||
);
|
||||
}
|
||||
if (!stat.isFile()) {
|
||||
throw new FilePathSafetyError(
|
||||
"not_a_file",
|
||||
`resolved path '${resolved}' is not a regular file.`,
|
||||
{ rootName, relativePath, resolved },
|
||||
);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/** Convenience: resolve + assert the result is an existing directory. */
|
||||
export function resolveAllowedDirectory(
|
||||
allowlist: AllowlistRoot[],
|
||||
rootName: string,
|
||||
relativePath: string,
|
||||
): string {
|
||||
const resolved = resolveAllowedPath(allowlist, rootName, relativePath);
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.statSync(resolved);
|
||||
} catch (err) {
|
||||
throw new FilePathSafetyError(
|
||||
"stat_failed",
|
||||
`failed to stat '${resolved}': ${err instanceof Error ? err.message : String(err)}`,
|
||||
{ rootName, relativePath, resolved },
|
||||
);
|
||||
}
|
||||
if (!stat.isDirectory()) {
|
||||
throw new FilePathSafetyError(
|
||||
"not_a_directory",
|
||||
`resolved path '${resolved}' is not a directory.`,
|
||||
{ rootName, relativePath, resolved },
|
||||
);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
// UI Enhancement Pack v0 — workspace PROGRESS.md indexer.
|
||||
//
|
||||
// Walks operator-allowlisted scan roots, finds PROGRESS.md files,
|
||||
// parses each into a checkbox-hierarchy tree, and emits a normalized
|
||||
// payload consumed by:
|
||||
// - GET /api/progress/tree (the new top-level Progress browse view)
|
||||
// - the new ProgressTree React component
|
||||
//
|
||||
// Scan strategy (item 1B):
|
||||
// - Operator configures progress-scan roots via env var
|
||||
// OPENRIG_PROGRESS_SCAN_ROOTS=root1:/abs/path,root2:/abs/path
|
||||
// (same delimited-pair shape as OPENRIG_FILES_ALLOWLIST).
|
||||
// - For each configured root, recursively find PROGRESS.md files
|
||||
// up to a small max depth (default 6) — enough to catch
|
||||
// mission/lane/slice nesting without descending into node_modules
|
||||
// or other deep trees.
|
||||
// - Each PROGRESS.md becomes one mission/lane/slice node; rows
|
||||
// parsed from `[ ]` / `[x]` / `[~]` checkbox lines + Markdown
|
||||
// headings (## / ###) for hierarchy.
|
||||
//
|
||||
// MVP single-host: in-memory walk per request; no caching at v0
|
||||
// (file count is bounded by operator's allowlist scope; sub-second
|
||||
// for the typical workspace).
|
||||
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
export type CheckboxStatus = "active" | "done" | "blocked" | "unknown";
|
||||
|
||||
export interface ProgressRow {
|
||||
/** 1-based source line number. */
|
||||
line: number;
|
||||
/** Indent depth (0 = top-level; nested by 2-space indent / Markdown heading depth). */
|
||||
depth: number;
|
||||
/** Parsed status from the `[x]` / `[ ]` / `[~]` syntax. */
|
||||
status: CheckboxStatus;
|
||||
/** The text after the checkbox (or the heading text when this row is a heading). */
|
||||
text: string;
|
||||
/** "checkbox" for `[ ]` lines; "heading" for `##` / `###`. */
|
||||
kind: "checkbox" | "heading";
|
||||
}
|
||||
|
||||
export interface ProgressFileNode {
|
||||
/** Operator-supplied scan-root display name. */
|
||||
rootName: string;
|
||||
/** Path relative to the scan root (e.g., "missions/foo/PROGRESS.md"). */
|
||||
relPath: string;
|
||||
/** Canonical absolute path on disk (handy for the UI's "open in editor" affordance). */
|
||||
absolutePath: string;
|
||||
mtime: string;
|
||||
rows: ProgressRow[];
|
||||
/** Top-level title (from frontmatter or first H1). */
|
||||
title: string | null;
|
||||
/** Aggregate counts derived from rows (kind === "checkbox"). */
|
||||
counts: { total: number; done: number; blocked: number; active: number };
|
||||
}
|
||||
|
||||
export interface ProgressScanRoot {
|
||||
name: string;
|
||||
canonicalPath: string;
|
||||
}
|
||||
|
||||
export interface ProgressTreeResult {
|
||||
files: ProgressFileNode[];
|
||||
/** Aggregate over all files. */
|
||||
aggregate: { totalFiles: number; totalRows: number; totalDone: number; totalBlocked: number; totalActive: number };
|
||||
/** Roots that were scanned (for the UI to render "scanned N roots"). */
|
||||
scannedRoots: ProgressScanRoot[];
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_DEPTH = 6;
|
||||
const PROGRESS_FILENAME = "PROGRESS.md";
|
||||
const SKIP_DIRS = new Set(["node_modules", ".git", ".worktrees", "dist", "build", ".turbo", ".next"]);
|
||||
|
||||
const ENV_VAR = "OPENRIG_PROGRESS_SCAN_ROOTS";
|
||||
const LEGACY_ENV_VAR = "RIGGED_PROGRESS_SCAN_ROOTS";
|
||||
|
||||
export function readProgressRootsFromEnv(env: NodeJS.ProcessEnv = process.env): ProgressScanRoot[] {
|
||||
const raw = env[ENV_VAR] ?? env[LEGACY_ENV_VAR] ?? "";
|
||||
if (!raw.trim()) return [];
|
||||
const out = new Map<string, string>();
|
||||
for (const pair of raw.split(",")) {
|
||||
const trimmed = pair.trim();
|
||||
if (!trimmed) continue;
|
||||
const colon = trimmed.indexOf(":");
|
||||
if (colon === -1) continue;
|
||||
const name = trimmed.slice(0, colon).trim();
|
||||
const rawPath = trimmed.slice(colon + 1).trim();
|
||||
if (!name || !rawPath || !path.isAbsolute(rawPath)) continue;
|
||||
let canonical: string;
|
||||
try { canonical = fs.realpathSync(rawPath); } catch { canonical = path.resolve(rawPath); }
|
||||
out.set(name, canonical);
|
||||
}
|
||||
return Array.from(out.entries()).map(([name, canonicalPath]) => ({ name, canonicalPath }));
|
||||
}
|
||||
|
||||
export interface ProgressIndexerOpts {
|
||||
roots: ProgressScanRoot[];
|
||||
/** Override max recursion depth for tests. */
|
||||
maxDepth?: number;
|
||||
}
|
||||
|
||||
export class ProgressIndexer {
|
||||
private readonly roots: ProgressScanRoot[];
|
||||
private readonly maxDepth: number;
|
||||
|
||||
constructor(opts: ProgressIndexerOpts) {
|
||||
this.roots = opts.roots;
|
||||
this.maxDepth = opts.maxDepth ?? DEFAULT_MAX_DEPTH;
|
||||
}
|
||||
|
||||
isReady(): boolean {
|
||||
return this.roots.length > 0;
|
||||
}
|
||||
|
||||
scan(): ProgressTreeResult {
|
||||
const files: ProgressFileNode[] = [];
|
||||
for (const root of this.roots) {
|
||||
this.walkRoot(root, files);
|
||||
}
|
||||
files.sort((a, b) => a.relPath.localeCompare(b.relPath));
|
||||
const aggregate = files.reduce(
|
||||
(acc, f) => ({
|
||||
totalFiles: acc.totalFiles + 1,
|
||||
totalRows: acc.totalRows + f.counts.total,
|
||||
totalDone: acc.totalDone + f.counts.done,
|
||||
totalBlocked: acc.totalBlocked + f.counts.blocked,
|
||||
totalActive: acc.totalActive + f.counts.active,
|
||||
}),
|
||||
{ totalFiles: 0, totalRows: 0, totalDone: 0, totalBlocked: 0, totalActive: 0 },
|
||||
);
|
||||
return { files, aggregate, scannedRoots: this.roots };
|
||||
}
|
||||
|
||||
private walkRoot(root: ProgressScanRoot, out: ProgressFileNode[]): void {
|
||||
this.walkDir(root, root.canonicalPath, "", 0, out);
|
||||
}
|
||||
|
||||
private walkDir(root: ProgressScanRoot, abs: string, rel: string, depth: number, out: ProgressFileNode[]): void {
|
||||
if (depth > this.maxDepth) return;
|
||||
let entries: fs.Dirent[];
|
||||
try { entries = fs.readdirSync(abs, { withFileTypes: true }); } catch { return; }
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith(".") || SKIP_DIRS.has(entry.name)) continue;
|
||||
const childAbs = path.join(abs, entry.name);
|
||||
const childRel = rel ? `${rel}/${entry.name}` : entry.name;
|
||||
if (entry.isDirectory()) {
|
||||
this.walkDir(root, childAbs, childRel, depth + 1, out);
|
||||
} else if (entry.isFile() && entry.name === PROGRESS_FILENAME) {
|
||||
const node = this.parseProgressFile(root, childAbs, childRel);
|
||||
if (node) out.push(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private parseProgressFile(root: ProgressScanRoot, absolutePath: string, relPath: string): ProgressFileNode | null {
|
||||
let content: string;
|
||||
let mtime: Date;
|
||||
try {
|
||||
content = fs.readFileSync(absolutePath, "utf-8");
|
||||
mtime = fs.statSync(absolutePath).mtime;
|
||||
} catch { return null; }
|
||||
|
||||
const rows: ProgressRow[] = [];
|
||||
const lines = content.split("\n");
|
||||
let title: string | null = null;
|
||||
let inFrontmatter = false;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]!;
|
||||
const lineNumber = i + 1;
|
||||
|
||||
// Skip YAML frontmatter.
|
||||
if (lineNumber === 1 && line.trim() === "---") { inFrontmatter = true; continue; }
|
||||
if (inFrontmatter) {
|
||||
if (line.trim() === "---") inFrontmatter = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
// First H1 → title (if frontmatter didn't supply one).
|
||||
if (!title) {
|
||||
const h1 = line.match(/^#\s+(.+?)$/);
|
||||
if (h1) title = h1[1]!.trim();
|
||||
}
|
||||
|
||||
// Heading rows (## / ###).
|
||||
const h = line.match(/^(#{2,4})\s+(.+?)$/);
|
||||
if (h) {
|
||||
rows.push({
|
||||
line: lineNumber,
|
||||
depth: h[1]!.length - 2, // ## → 0, ### → 1, #### → 2
|
||||
status: "unknown",
|
||||
text: h[2]!.trim(),
|
||||
kind: "heading",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Checkbox rows. Match either bare `[ ]` (no list bullet) or
|
||||
// `- [ ]` / `* [ ]`. The bullet's leading indent determines depth.
|
||||
const cb = line.match(/^(\s*)(?:[-*]\s+)?\[([ xX~])\]\s+(.+)$/);
|
||||
if (cb) {
|
||||
const indentSpaces = cb[1]!.length;
|
||||
const indicator = cb[2]!.toLowerCase();
|
||||
const text = cb[3]!.trim();
|
||||
const status: CheckboxStatus =
|
||||
indicator === "x" ? "done"
|
||||
: indicator === "~" ? "blocked"
|
||||
: "active";
|
||||
rows.push({
|
||||
line: lineNumber,
|
||||
depth: Math.floor(indentSpaces / 2),
|
||||
status,
|
||||
text,
|
||||
kind: "checkbox",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const checkboxes = rows.filter((r) => r.kind === "checkbox");
|
||||
const counts = {
|
||||
total: checkboxes.length,
|
||||
done: checkboxes.filter((r) => r.status === "done").length,
|
||||
blocked: checkboxes.filter((r) => r.status === "blocked").length,
|
||||
active: checkboxes.filter((r) => r.status === "active").length,
|
||||
};
|
||||
|
||||
return {
|
||||
rootName: root.name,
|
||||
relPath,
|
||||
absolutePath,
|
||||
mtime: mtime.toISOString(),
|
||||
rows,
|
||||
title,
|
||||
counts,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
// UI Enhancement Pack v0 — file browser routes.
|
||||
//
|
||||
// Endpoints (item 3 + item 4):
|
||||
// GET /api/files/roots allowlist root list
|
||||
// GET /api/files/list?root=<name>&path=<rel> directory entries
|
||||
// GET /api/files/read?root=<name>&path=<rel> file content + metadata
|
||||
// GET /api/files/asset?root=<name>&path=<rel> raw bytes for embedded images
|
||||
// POST /api/files/write atomic write (item 4)
|
||||
//
|
||||
// Route-order discipline (per Phase A R1 SSE lesson): all routes are
|
||||
// literal — no `/:param` catchalls — so order doesn't matter for
|
||||
// shadowing. Kept sequential for readability.
|
||||
|
||||
import { Hono } from "hono";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import {
|
||||
resolveAllowedDirectory,
|
||||
resolveAllowedFile,
|
||||
resolveAllowedPath,
|
||||
FilePathSafetyError,
|
||||
type AllowlistRoot,
|
||||
} from "../domain/files/path-safety.js";
|
||||
import {
|
||||
WriteConflictError,
|
||||
FileWriteError,
|
||||
sha256Hex,
|
||||
type FileWriteService,
|
||||
} from "../domain/files/file-write-service.js";
|
||||
|
||||
export interface FilesRoutesDeps {
|
||||
/** Allowlist resolved at startup; empty array = no roots configured. */
|
||||
allowlist: AllowlistRoot[];
|
||||
/** Atomic-write service; absent → POST /write returns 503 unconfigured. */
|
||||
writeService: FileWriteService | null;
|
||||
}
|
||||
|
||||
export function filesRoutes(): Hono {
|
||||
const app = new Hono();
|
||||
|
||||
function getDeps(c: { get: (key: string) => unknown }): FilesRoutesDeps | null {
|
||||
const allowlist = c.get("filesAllowlist" as never) as AllowlistRoot[] | undefined;
|
||||
const writeService = c.get("fileWriteService" as never) as FileWriteService | null | undefined;
|
||||
if (!allowlist) return null;
|
||||
return { allowlist, writeService: writeService ?? null };
|
||||
}
|
||||
|
||||
function pathSafetyErrorResponse(c: { json: (body: unknown, status?: number) => Response }, err: FilePathSafetyError): Response {
|
||||
const status =
|
||||
err.code === "root_unknown" ? 400
|
||||
: err.code === "path_invalid" || err.code === "path_escape" ? 400
|
||||
: err.code === "stat_failed" ? 404
|
||||
: err.code === "not_a_file" || err.code === "not_a_directory" ? 400
|
||||
: 500;
|
||||
return c.json({ error: err.code, message: err.message, ...(err.details ?? {}) }, status as 200);
|
||||
}
|
||||
|
||||
app.get("/roots", (c) => {
|
||||
const deps = getDeps(c);
|
||||
if (!deps) return c.json({ error: "files_routes_unavailable" }, 503);
|
||||
if (deps.allowlist.length === 0) {
|
||||
return c.json({
|
||||
roots: [],
|
||||
hint: "No allowlist roots configured. Set OPENRIG_FILES_ALLOWLIST=name1:/abs/path,name2:/abs/path and restart the daemon.",
|
||||
});
|
||||
}
|
||||
return c.json({
|
||||
roots: deps.allowlist.map((r) => ({ name: r.name, path: r.canonicalPath })),
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/list", (c) => {
|
||||
const deps = getDeps(c);
|
||||
if (!deps) return c.json({ error: "files_routes_unavailable" }, 503);
|
||||
const rootName = c.req.query("root") ?? "";
|
||||
const relativePath = c.req.query("path") ?? "";
|
||||
if (!rootName) return c.json({ error: "root_required" }, 400);
|
||||
try {
|
||||
const resolved = resolveAllowedDirectory(deps.allowlist, rootName, relativePath);
|
||||
const entries = fs.readdirSync(resolved, { withFileTypes: true });
|
||||
return c.json({
|
||||
root: rootName,
|
||||
path: relativePath,
|
||||
entries: entries
|
||||
.map((entry) => {
|
||||
// Skip dotfiles by default unless they're inside an
|
||||
// allowlisted root that's a dot-directory itself (e.g.,
|
||||
// operator allowlists ~/.openrig — the operator clearly
|
||||
// wants to inspect dotfiles in that case). Implementation:
|
||||
// include dotfiles always at v0; the operator has already
|
||||
// expressed inspection intent by allowlisting the root.
|
||||
const fullPath = path.join(resolved, entry.name);
|
||||
let stat: fs.Stats | null = null;
|
||||
try { stat = fs.statSync(fullPath); } catch { /* skip stat-failed */ }
|
||||
return {
|
||||
name: entry.name,
|
||||
type: entry.isDirectory() ? "dir" as const : entry.isFile() ? "file" as const : "other" as const,
|
||||
size: stat?.isFile() ? stat.size : null,
|
||||
mtime: stat ? stat.mtime.toISOString() : null,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (a.type !== b.type) return a.type === "dir" ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof FilePathSafetyError) return pathSafetyErrorResponse(c, err);
|
||||
return c.json({ error: "list_failed", message: err instanceof Error ? err.message : String(err) }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/read", (c) => {
|
||||
const deps = getDeps(c);
|
||||
if (!deps) return c.json({ error: "files_routes_unavailable" }, 503);
|
||||
const rootName = c.req.query("root") ?? "";
|
||||
const relativePath = c.req.query("path") ?? "";
|
||||
if (!rootName || !relativePath) return c.json({ error: "root_and_path_required" }, 400);
|
||||
try {
|
||||
const resolved = resolveAllowedFile(deps.allowlist, rootName, relativePath);
|
||||
const stat = fs.statSync(resolved);
|
||||
const content = fs.readFileSync(resolved);
|
||||
return c.json({
|
||||
root: rootName,
|
||||
path: relativePath,
|
||||
absolutePath: resolved,
|
||||
content: content.toString("utf-8"),
|
||||
mtime: stat.mtime.toISOString(),
|
||||
contentHash: sha256Hex(content),
|
||||
size: stat.size,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof FilePathSafetyError) return pathSafetyErrorResponse(c, err);
|
||||
return c.json({ error: "read_failed", message: err instanceof Error ? err.message : String(err) }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/asset", (c) => {
|
||||
const deps = getDeps(c);
|
||||
if (!deps) return c.json({ error: "files_routes_unavailable" }, 503);
|
||||
const rootName = c.req.query("root") ?? "";
|
||||
const relativePath = c.req.query("path") ?? "";
|
||||
if (!rootName || !relativePath) return c.json({ error: "root_and_path_required" }, 400);
|
||||
try {
|
||||
const resolved = resolveAllowedFile(deps.allowlist, rootName, relativePath);
|
||||
const data = fs.readFileSync(resolved);
|
||||
const contentType = inferContentType(resolved);
|
||||
return new Response(new Uint8Array(data), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Cache-Control": "public, max-age=300",
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof FilePathSafetyError) return pathSafetyErrorResponse(c, err);
|
||||
return c.json({ error: "asset_failed", message: err instanceof Error ? err.message : String(err) }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/write", async (c) => {
|
||||
const deps = getDeps(c);
|
||||
if (!deps) return c.json({ error: "files_routes_unavailable" }, 503);
|
||||
if (!deps.writeService) {
|
||||
return c.json({
|
||||
error: "file_write_service_unavailable",
|
||||
hint: "OPENRIG_FILES_ALLOWLIST is empty or unset; configure at least one root and restart.",
|
||||
}, 503);
|
||||
}
|
||||
const body = await c.req.json<{
|
||||
root?: string;
|
||||
path?: string;
|
||||
content?: string;
|
||||
expectedMtime?: string;
|
||||
expectedContentHash?: string;
|
||||
actor?: string;
|
||||
}>().catch(() => ({} as never));
|
||||
if (!body.root) return c.json({ error: "root_required" }, 400);
|
||||
if (!body.path) return c.json({ error: "path_required" }, 400);
|
||||
if (typeof body.content !== "string") return c.json({ error: "content_required" }, 400);
|
||||
if (!body.expectedMtime) return c.json({ error: "expectedMtime_required" }, 400);
|
||||
if (!body.expectedContentHash) return c.json({ error: "expectedContentHash_required" }, 400);
|
||||
if (!body.actor) return c.json({ error: "actor_required" }, 400);
|
||||
try {
|
||||
const result = deps.writeService.writeAtomic({
|
||||
rootName: body.root,
|
||||
path: body.path,
|
||||
content: body.content,
|
||||
expectedMtime: body.expectedMtime,
|
||||
expectedContentHash: body.expectedContentHash,
|
||||
actor: body.actor,
|
||||
});
|
||||
return c.json({
|
||||
root: body.root,
|
||||
path: body.path,
|
||||
absolutePath: result.absolutePath,
|
||||
newMtime: result.newMtime,
|
||||
newContentHash: result.newContentHash,
|
||||
byteCountDelta: result.byteCountDelta,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof WriteConflictError) {
|
||||
return c.json({
|
||||
error: "write_conflict",
|
||||
message: err.message,
|
||||
currentMtime: err.currentMtime,
|
||||
currentContentHash: err.currentContentHash,
|
||||
}, 409);
|
||||
}
|
||||
if (err instanceof FilePathSafetyError) return pathSafetyErrorResponse(c, err);
|
||||
if (err instanceof FileWriteError) {
|
||||
return c.json({ error: err.code, message: err.message, ...(err.details ?? {}) }, 500);
|
||||
}
|
||||
return c.json({ error: "write_failed", message: err instanceof Error ? err.message : String(err) }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
function inferContentType(absPath: string): string {
|
||||
const ext = path.extname(absPath).toLowerCase();
|
||||
switch (ext) {
|
||||
case ".png": return "image/png";
|
||||
case ".jpg":
|
||||
case ".jpeg": return "image/jpeg";
|
||||
case ".gif": return "image/gif";
|
||||
case ".webp": return "image/webp";
|
||||
case ".svg": return "image/svg+xml";
|
||||
case ".mp4": return "video/mp4";
|
||||
case ".webm": return "video/webm";
|
||||
case ".pdf": return "application/pdf";
|
||||
case ".md":
|
||||
case ".txt":
|
||||
case ".log": return "text/plain; charset=utf-8";
|
||||
case ".json": return "application/json; charset=utf-8";
|
||||
case ".yaml":
|
||||
case ".yml": return "text/yaml; charset=utf-8";
|
||||
case ".js":
|
||||
case ".ts":
|
||||
case ".tsx":
|
||||
case ".jsx":
|
||||
case ".css":
|
||||
case ".html": return "text/plain; charset=utf-8";
|
||||
default: return "application/octet-stream";
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export for the route-order discipline test in workflow-routes.
|
||||
export { resolveAllowedPath };
|
||||
@@ -0,0 +1,40 @@
|
||||
// UI Enhancement Pack v0 — progress browse view route.
|
||||
//
|
||||
// Endpoint:
|
||||
// GET /api/progress/tree
|
||||
//
|
||||
// Returns the indexed PROGRESS.md hierarchy across operator-allowlisted
|
||||
// scan roots (configured via OPENRIG_PROGRESS_SCAN_ROOTS). Empty roots
|
||||
// → 503 with a setup hint, mirroring the slices-route shape so the UI
|
||||
// can render a clear configuration message instead of a generic error.
|
||||
|
||||
import { Hono } from "hono";
|
||||
import type { ProgressIndexer } from "../domain/progress/progress-indexer.js";
|
||||
|
||||
export interface ProgressRoutesDeps {
|
||||
indexer: ProgressIndexer;
|
||||
}
|
||||
|
||||
export function progressRoutes(): Hono {
|
||||
const app = new Hono();
|
||||
|
||||
function getDeps(c: { get: (key: string) => unknown }): ProgressRoutesDeps | null {
|
||||
const indexer = c.get("progressIndexer" as never) as ProgressIndexer | undefined;
|
||||
if (!indexer) return null;
|
||||
return { indexer };
|
||||
}
|
||||
|
||||
app.get("/tree", (c) => {
|
||||
const deps = getDeps(c);
|
||||
if (!deps) return c.json({ error: "progress_indexer_unavailable" }, 503);
|
||||
if (!deps.indexer.isReady()) {
|
||||
return c.json({
|
||||
error: "progress_scan_roots_not_configured",
|
||||
hint: "Set OPENRIG_PROGRESS_SCAN_ROOTS=name1:/abs/path,name2:/abs/path and restart the daemon.",
|
||||
}, 503);
|
||||
}
|
||||
return c.json(deps.indexer.scan());
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -67,6 +67,8 @@ import { watchdogRoutes } from "./routes/watchdog.js";
|
||||
import { workflowRoutes } from "./routes/workflow.js";
|
||||
import { missionControlRoutes } from "./routes/mission-control.js";
|
||||
import { slicesRoutes } from "./routes/slices.js";
|
||||
import { filesRoutes } from "./routes/files.js";
|
||||
import { progressRoutes } from "./routes/progress.js";
|
||||
import type { StreamStore } from "./domain/stream-store.js";
|
||||
import type { QueueRepository } from "./domain/queue-repository.js";
|
||||
import type { InboxHandler } from "./domain/inbox-handler.js";
|
||||
@@ -148,6 +150,12 @@ export interface AppDeps {
|
||||
// "slices_root_not_configured" 503 so the UI can surface a setup hint.
|
||||
sliceIndexer?: import("./domain/slices/slice-indexer.js").SliceIndexer;
|
||||
sliceDetailProjector?: import("./domain/slices/slice-detail-projector.js").SliceDetailProjector;
|
||||
/** UI Enhancement Pack v0 — file allowlist + browser routes (item 3). */
|
||||
filesAllowlist?: import("./domain/files/path-safety.js").AllowlistRoot[];
|
||||
/** UI Enhancement Pack v0 — atomic write service (item 4). */
|
||||
fileWriteService?: import("./domain/files/file-write-service.js").FileWriteService | null;
|
||||
/** UI Enhancement Pack v0 — workspace PROGRESS.md indexer (item 1B). */
|
||||
progressIndexer?: import("./domain/progress/progress-indexer.js").ProgressIndexer;
|
||||
missionControlAuditBrowse?: import("./domain/mission-control/audit-browse.js").MissionControlAuditBrowse;
|
||||
missionControlNotificationDispatcher?: import("./domain/mission-control/notification-dispatcher.js").MissionControlNotificationDispatcher;
|
||||
/**
|
||||
@@ -324,6 +332,9 @@ export function createApp(deps: AppDeps): Hono {
|
||||
c.set("missionControlFleetCliCapability" as never, deps.missionControlFleetCliCapability);
|
||||
c.set("sliceIndexer" as never, deps.sliceIndexer);
|
||||
c.set("sliceDetailProjector" as never, deps.sliceDetailProjector);
|
||||
c.set("filesAllowlist" as never, deps.filesAllowlist);
|
||||
c.set("fileWriteService" as never, deps.fileWriteService);
|
||||
c.set("progressIndexer" as never, deps.progressIndexer);
|
||||
c.set("missionControlAuditBrowse" as never, deps.missionControlAuditBrowse);
|
||||
c.set("missionControlNotificationDispatcher" as never, deps.missionControlNotificationDispatcher);
|
||||
c.set("specReviewService" as never, deps.specReviewService);
|
||||
@@ -384,6 +395,9 @@ export function createApp(deps: AppDeps): Hono {
|
||||
);
|
||||
// Slice Story View v0 — slice indexer + per-tab payload routes.
|
||||
app.route("/api/slices", slicesRoutes());
|
||||
// UI Enhancement Pack v0 — files (item 3 + item 4) + progress (item 1B) routes.
|
||||
app.route("/api/files", filesRoutes());
|
||||
app.route("/api/progress", progressRoutes());
|
||||
app.route("/api/rigs/:rigId/env", envRoutes());
|
||||
app.route("/api/restore-check", restoreCheckRoutes);
|
||||
|
||||
|
||||
@@ -717,6 +717,26 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
deps.sliceDetailProjector = sliceDetailProjector;
|
||||
}
|
||||
|
||||
// UI Enhancement Pack v0:
|
||||
// - file allowlist (item 3) from OPENRIG_FILES_ALLOWLIST
|
||||
// - atomic write service (item 4) wired only when allowlist non-empty
|
||||
// - progress scan-roots (item 1B) from OPENRIG_PROGRESS_SCAN_ROOTS
|
||||
//
|
||||
// Empty env → empty allowlist / no-roots indexer; routes return 503
|
||||
// with structured config hints so the UI can surface a setup message
|
||||
// instead of a generic error.
|
||||
{
|
||||
const { readAllowlistFromEnv } = await import("./domain/files/path-safety.js");
|
||||
const { FileWriteService } = await import("./domain/files/file-write-service.js");
|
||||
const { ProgressIndexer, readProgressRootsFromEnv } = await import("./domain/progress/progress-indexer.js");
|
||||
const filesAllowlist = readAllowlistFromEnv();
|
||||
deps.filesAllowlist = filesAllowlist;
|
||||
deps.fileWriteService = filesAllowlist.length > 0
|
||||
? new FileWriteService({ allowlist: filesAllowlist })
|
||||
: null;
|
||||
deps.progressIndexer = new ProgressIndexer({ roots: readProgressRootsFromEnv() });
|
||||
}
|
||||
|
||||
const sessionTransport = deps.sessionTransport;
|
||||
if (sessionTransport) {
|
||||
const watchdogPolicyEngine = new WatchdogPolicyEngine({
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
// UI Enhancement Pack v0 — path-safety helper tests.
|
||||
//
|
||||
// Pins the load-bearing fail-closed semantics of the file allowlist:
|
||||
// - root_unknown rejection
|
||||
// - .. escape rejection at the segment boundary (not substring)
|
||||
// - absolute-path-as-relative-path rejection
|
||||
// - symlink-escaping-root rejection (realpath check)
|
||||
// - filename containing ".." substring (e.g., foo..bar) does NOT
|
||||
// reject — only literal `..` segments do
|
||||
// - the base case (path = "") resolves to the root itself
|
||||
//
|
||||
// Pure unit tests — no Hono app, no daemon wiring.
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
FilePathSafetyError,
|
||||
readAllowlistFromEnv,
|
||||
resolveAllowedDirectory,
|
||||
resolveAllowedFile,
|
||||
resolveAllowedPath,
|
||||
} from "../src/domain/files/path-safety.js";
|
||||
|
||||
describe("UI Enhancement Pack v0 — readAllowlistFromEnv", () => {
|
||||
it("returns empty list when env unset", () => {
|
||||
expect(readAllowlistFromEnv({})).toEqual([]);
|
||||
});
|
||||
|
||||
it("parses comma-separated name:path pairs", () => {
|
||||
const list = readAllowlistFromEnv({
|
||||
OPENRIG_FILES_ALLOWLIST: "workspace:/abs/path1, openrig-hub: /abs/path2",
|
||||
});
|
||||
expect(list).toHaveLength(2);
|
||||
expect(list[0]?.name).toBe("workspace");
|
||||
expect(list[1]?.name).toBe("openrig-hub");
|
||||
});
|
||||
|
||||
it("ignores pairs with no colon, empty name, or non-absolute path", () => {
|
||||
const list = readAllowlistFromEnv({
|
||||
OPENRIG_FILES_ALLOWLIST: "no-colon-here, :missing-name, name:relative-path, ok:/abs/ok",
|
||||
});
|
||||
expect(list.map((r) => r.name)).toEqual(["ok"]);
|
||||
});
|
||||
|
||||
it("dedupes by name (last wins)", () => {
|
||||
const list = readAllowlistFromEnv({
|
||||
OPENRIG_FILES_ALLOWLIST: "x:/path/a, x:/path/b",
|
||||
});
|
||||
expect(list).toHaveLength(1);
|
||||
expect(list[0]?.canonicalPath).toContain("/path/b");
|
||||
});
|
||||
|
||||
it("falls back to RIGGED_FILES_ALLOWLIST when OPENRIG_FILES_ALLOWLIST is empty", () => {
|
||||
const list = readAllowlistFromEnv({
|
||||
OPENRIG_FILES_ALLOWLIST: "",
|
||||
RIGGED_FILES_ALLOWLIST: "legacy:/abs/legacy",
|
||||
});
|
||||
expect(list.map((r) => r.name)).toEqual(["legacy"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("UI Enhancement Pack v0 — resolveAllowedPath", () => {
|
||||
let tempDir: string;
|
||||
let allowlist: { name: string; canonicalPath: string }[];
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "files-safety-"));
|
||||
mkdirSync(join(tempDir, "workspace", "subdir"), { recursive: true });
|
||||
writeFileSync(join(tempDir, "workspace", "STEERING.md"), "# steering");
|
||||
writeFileSync(join(tempDir, "workspace", "subdir", "nested.md"), "# nested");
|
||||
// Outside-root file for symlink-escape test.
|
||||
mkdirSync(join(tempDir, "outside-root"), { recursive: true });
|
||||
writeFileSync(join(tempDir, "outside-root", "secret.txt"), "secret");
|
||||
// Canonicalize via realpath to match macOS /var → /private/var
|
||||
// resolution; production code does the same in readAllowlistFromEnv.
|
||||
allowlist = [{ name: "workspace", canonicalPath: realpathSync(join(tempDir, "workspace")) }];
|
||||
});
|
||||
|
||||
afterEach(() => rmSync(tempDir, { recursive: true, force: true }));
|
||||
|
||||
it("resolves a relative path inside the root", () => {
|
||||
const resolved = resolveAllowedPath(allowlist, "workspace", "STEERING.md");
|
||||
expect(resolved).toBe(realpathSync(join(tempDir, "workspace", "STEERING.md")));
|
||||
});
|
||||
|
||||
it("resolves '' (empty path) to the root itself", () => {
|
||||
expect(resolveAllowedPath(allowlist, "workspace", "")).toBe(realpathSync(join(tempDir, "workspace")));
|
||||
});
|
||||
|
||||
it("rejects unknown root with code 'root_unknown'", () => {
|
||||
expect(() => resolveAllowedPath(allowlist, "not-allowlisted", "any.md"))
|
||||
.toThrowError(/allowlist root 'not-allowlisted' is not configured/);
|
||||
try { resolveAllowedPath(allowlist, "not-allowlisted", "any.md"); }
|
||||
catch (e) { expect((e as FilePathSafetyError).code).toBe("root_unknown"); }
|
||||
});
|
||||
|
||||
it("rejects '..' segment with code 'path_escape'", () => {
|
||||
expect(() => resolveAllowedPath(allowlist, "workspace", "../outside-root/secret.txt"))
|
||||
.toThrowError(/contains a '\.\.' segment/);
|
||||
try { resolveAllowedPath(allowlist, "workspace", "../outside-root/secret.txt"); }
|
||||
catch (e) { expect((e as FilePathSafetyError).code).toBe("path_escape"); }
|
||||
});
|
||||
|
||||
it("does NOT reject filenames containing '..' as a substring (e.g., foo..bar)", () => {
|
||||
writeFileSync(join(tempDir, "workspace", "foo..bar.md"), "ok");
|
||||
const resolved = resolveAllowedPath(allowlist, "workspace", "foo..bar.md");
|
||||
expect(resolved).toBe(realpathSync(join(tempDir, "workspace", "foo..bar.md")));
|
||||
});
|
||||
|
||||
it("rejects an absolute path passed as relative", () => {
|
||||
expect(() => resolveAllowedPath(allowlist, "workspace", "/etc/passwd"))
|
||||
.toThrowError(/must not be absolute/);
|
||||
try { resolveAllowedPath(allowlist, "workspace", "/etc/passwd"); }
|
||||
catch (e) { expect((e as FilePathSafetyError).code).toBe("path_invalid"); }
|
||||
});
|
||||
|
||||
it("rejects symlink whose realpath escapes the root", () => {
|
||||
// Create a symlink inside the workspace pointing OUTSIDE the root.
|
||||
symlinkSync(join(tempDir, "outside-root", "secret.txt"), join(tempDir, "workspace", "escape-link"));
|
||||
expect(() => resolveAllowedPath(allowlist, "workspace", "escape-link"))
|
||||
.toThrowError(/falls outside allowlist root/);
|
||||
try { resolveAllowedPath(allowlist, "workspace", "escape-link"); }
|
||||
catch (e) { expect((e as FilePathSafetyError).code).toBe("path_escape"); }
|
||||
});
|
||||
|
||||
it("does NOT match a sibling path with the same prefix (path.sep boundary)", () => {
|
||||
// Allowlist root is ".../workspace"; a sibling ".../workspace-other"
|
||||
// should NOT be reachable by passing path = "" or any relative path.
|
||||
mkdirSync(join(tempDir, "workspace-other"), { recursive: true });
|
||||
writeFileSync(join(tempDir, "workspace-other", "leak.md"), "leak");
|
||||
// The file resolves cleanly inside the right root if the path
|
||||
// is relative to it; the wrong-root attack doesn't even get
|
||||
// expressed without a mismatched root, so the test verifies the
|
||||
// root-name mismatch instead.
|
||||
expect(() => resolveAllowedPath(allowlist, "workspace-other", "leak.md"))
|
||||
.toThrowError(/'workspace-other' is not configured/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("UI Enhancement Pack v0 — resolveAllowedFile / resolveAllowedDirectory", () => {
|
||||
let tempDir: string;
|
||||
let allowlist: { name: string; canonicalPath: string }[];
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "files-safety-typed-"));
|
||||
mkdirSync(join(tempDir, "ws", "dir-only"), { recursive: true });
|
||||
writeFileSync(join(tempDir, "ws", "real.md"), "hi");
|
||||
allowlist = [{ name: "ws", canonicalPath: realpathSync(join(tempDir, "ws")) }];
|
||||
});
|
||||
|
||||
afterEach(() => rmSync(tempDir, { recursive: true, force: true }));
|
||||
|
||||
it("resolveAllowedFile on a real file returns the absolute path", () => {
|
||||
expect(resolveAllowedFile(allowlist, "ws", "real.md")).toBe(realpathSync(join(tempDir, "ws", "real.md")));
|
||||
});
|
||||
|
||||
it("resolveAllowedFile on a directory rejects with 'not_a_file'", () => {
|
||||
try { resolveAllowedFile(allowlist, "ws", "dir-only"); }
|
||||
catch (e) { expect((e as FilePathSafetyError).code).toBe("not_a_file"); }
|
||||
});
|
||||
|
||||
it("resolveAllowedFile on a missing path rejects with 'stat_failed'", () => {
|
||||
try { resolveAllowedFile(allowlist, "ws", "ghost.md"); }
|
||||
catch (e) { expect((e as FilePathSafetyError).code).toBe("stat_failed"); }
|
||||
});
|
||||
|
||||
it("resolveAllowedDirectory on a real directory returns the absolute path", () => {
|
||||
expect(resolveAllowedDirectory(allowlist, "ws", "dir-only")).toBe(realpathSync(join(tempDir, "ws", "dir-only")));
|
||||
});
|
||||
|
||||
it("resolveAllowedDirectory on a file rejects with 'not_a_directory'", () => {
|
||||
try { resolveAllowedDirectory(allowlist, "ws", "real.md"); }
|
||||
catch (e) { expect((e as FilePathSafetyError).code).toBe("not_a_directory"); }
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
// UI Enhancement Pack v0 — files routes end-to-end tests.
|
||||
//
|
||||
// Drives the routes against a hand-mounted Hono app with a temp
|
||||
// allowlist. Pins:
|
||||
// - GET /api/files/roots: empty roots → hint; populated → list
|
||||
// - GET /api/files/list: directory listing + path-safety negatives
|
||||
// - GET /api/files/read: content + mtime + contentHash + size
|
||||
// - GET /api/files/asset: image bytes with right Content-Type
|
||||
// - POST /api/files/write: success → audit row appended; mtime
|
||||
// mismatch → 409 with current{Mtime,ContentHash}
|
||||
// - 503 graceful path when filesAllowlist context unset
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { filesRoutes } from "../src/routes/files.js";
|
||||
import { FileWriteService } from "../src/domain/files/file-write-service.js";
|
||||
import type { AllowlistRoot } from "../src/domain/files/path-safety.js";
|
||||
|
||||
function buildApp(opts: { allowlist: AllowlistRoot[]; writeService: FileWriteService | null } | null): Hono {
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => {
|
||||
if (opts) {
|
||||
c.set("filesAllowlist" as never, opts.allowlist);
|
||||
c.set("fileWriteService" as never, opts.writeService);
|
||||
}
|
||||
await next();
|
||||
});
|
||||
app.route("/api/files", filesRoutes());
|
||||
return app;
|
||||
}
|
||||
|
||||
function sha256(content: string | Buffer): string {
|
||||
return createHash("sha256").update(content).digest("hex");
|
||||
}
|
||||
|
||||
describe("UI Enhancement Pack v0 — /api/files routes", () => {
|
||||
let tempDir: string;
|
||||
let allowlist: AllowlistRoot[];
|
||||
let writeService: FileWriteService;
|
||||
let app: Hono;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "files-routes-"));
|
||||
mkdirSync(join(tempDir, "workspace", "subdir"), { recursive: true });
|
||||
writeFileSync(join(tempDir, "workspace", "STEERING.md"), "# steering content\n");
|
||||
writeFileSync(join(tempDir, "workspace", "subdir", "nested.md"), "# nested\n");
|
||||
// Tiny PNG signature for asset-type detection.
|
||||
writeFileSync(join(tempDir, "workspace", "image.png"), Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]));
|
||||
allowlist = [{ name: "workspace", canonicalPath: realpathSync(join(tempDir, "workspace")) }];
|
||||
writeService = new FileWriteService({
|
||||
allowlist,
|
||||
auditFilePath: join(tempDir, "audit.jsonl"),
|
||||
});
|
||||
app = buildApp({ allowlist, writeService });
|
||||
});
|
||||
|
||||
afterEach(() => rmSync(tempDir, { recursive: true, force: true }));
|
||||
|
||||
describe("GET /roots", () => {
|
||||
it("returns 503 when filesAllowlist context is unset", async () => {
|
||||
const res = await buildApp(null).request("/api/files/roots");
|
||||
expect(res.status).toBe(503);
|
||||
});
|
||||
|
||||
it("returns empty roots + setup hint when allowlist is empty", async () => {
|
||||
const res = await buildApp({ allowlist: [], writeService: null }).request("/api/files/roots");
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { roots: AllowlistRoot[]; hint?: string };
|
||||
expect(body.roots).toEqual([]);
|
||||
expect(body.hint).toContain("OPENRIG_FILES_ALLOWLIST");
|
||||
});
|
||||
|
||||
it("returns the configured roots", async () => {
|
||||
const res = await app.request("/api/files/roots");
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { roots: Array<{ name: string; path: string }> };
|
||||
expect(body.roots).toEqual([{ name: "workspace", path: realpathSync(join(tempDir, "workspace")) }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /list", () => {
|
||||
it("lists root directory entries with type + size + mtime", async () => {
|
||||
const res = await app.request("/api/files/list?root=workspace&path=");
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { entries: Array<{ name: string; type: string }> };
|
||||
const names = body.entries.map((e) => e.name).sort();
|
||||
expect(names).toContain("STEERING.md");
|
||||
expect(names).toContain("subdir");
|
||||
expect(names).toContain("image.png");
|
||||
});
|
||||
|
||||
it("sorts directories before files", async () => {
|
||||
const res = await app.request("/api/files/list?root=workspace&path=");
|
||||
const body = (await res.json()) as { entries: Array<{ name: string; type: string }> };
|
||||
const types = body.entries.map((e) => e.type);
|
||||
// First N entries are dirs.
|
||||
const dirCount = types.findIndex((t) => t !== "dir");
|
||||
const allDirsFirst = types.slice(0, dirCount === -1 ? types.length : dirCount).every((t) => t === "dir");
|
||||
expect(allDirsFirst).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects '..' escape with 400", async () => {
|
||||
const res = await app.request("/api/files/list?root=workspace&path=..%2F..%2F");
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toBe("path_escape");
|
||||
});
|
||||
|
||||
it("rejects unknown root with 400", async () => {
|
||||
const res = await app.request("/api/files/list?root=does-not-exist&path=");
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toBe("root_unknown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /read", () => {
|
||||
it("returns content + mtime + contentHash + size", async () => {
|
||||
const res = await app.request("/api/files/read?root=workspace&path=STEERING.md");
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { content: string; mtime: string; contentHash: string; size: number };
|
||||
expect(body.content).toBe("# steering content\n");
|
||||
expect(body.contentHash).toBe(sha256("# steering content\n"));
|
||||
expect(body.size).toBeGreaterThan(0);
|
||||
expect(body.mtime).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
||||
});
|
||||
|
||||
it("rejects path-traversal with 400", async () => {
|
||||
const res = await app.request("/api/files/read?root=workspace&path=..%2Fescape.md");
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /asset", () => {
|
||||
it("serves a .png file with image/png Content-Type", async () => {
|
||||
const res = await app.request("/api/files/asset?root=workspace&path=image.png");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("content-type")).toBe("image/png");
|
||||
expect(res.headers.get("cache-control")).toContain("max-age");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /write", () => {
|
||||
it("returns 503 with hint when no writeService is wired", async () => {
|
||||
const res = await buildApp({ allowlist, writeService: null }).request("/api/files/write", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ root: "workspace", path: "STEERING.md", content: "x", expectedMtime: "x", expectedContentHash: "x", actor: "y" }),
|
||||
});
|
||||
expect(res.status).toBe(503);
|
||||
});
|
||||
|
||||
it("rejects when expectedMtime mismatches with 409 + current values", async () => {
|
||||
const res = await app.request("/api/files/write", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
root: "workspace",
|
||||
path: "STEERING.md",
|
||||
content: "rewritten",
|
||||
expectedMtime: "2099-01-01T00:00:00.000Z",
|
||||
expectedContentHash: "deadbeef",
|
||||
actor: "test@r",
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(409);
|
||||
const body = (await res.json()) as { error: string; currentMtime: string; currentContentHash: string };
|
||||
expect(body.error).toBe("write_conflict");
|
||||
expect(body.currentContentHash).toBe(sha256("# steering content\n"));
|
||||
});
|
||||
|
||||
it("succeeds when expectedMtime + expectedContentHash match; appends audit row", async () => {
|
||||
const target = join(tempDir, "workspace", "STEERING.md");
|
||||
const stat = statSync(target);
|
||||
const expectedMtime = stat.mtime.toISOString();
|
||||
const expectedContentHash = sha256(readFileSync(target));
|
||||
const res = await app.request("/api/files/write", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
root: "workspace",
|
||||
path: "STEERING.md",
|
||||
content: "# new steering\n",
|
||||
expectedMtime,
|
||||
expectedContentHash,
|
||||
actor: "test@r",
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { newContentHash: string; byteCountDelta: number };
|
||||
expect(body.newContentHash).toBe(sha256("# new steering\n"));
|
||||
// File on disk has the new content.
|
||||
expect(readFileSync(target, "utf-8")).toBe("# new steering\n");
|
||||
// Audit row appended.
|
||||
const auditPath = join(tempDir, "audit.jsonl");
|
||||
const audit = readFileSync(auditPath, "utf-8").trim().split("\n");
|
||||
expect(audit).toHaveLength(1);
|
||||
const row = JSON.parse(audit[0]!) as { actor: string; root: string; path: string };
|
||||
expect(row.actor).toBe("test@r");
|
||||
expect(row.root).toBe("workspace");
|
||||
expect(row.path).toBe("STEERING.md");
|
||||
});
|
||||
|
||||
it("rejects missing required fields with 400", async () => {
|
||||
const res = await app.request("/api/files/write", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ root: "workspace" }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("rejects path-traversal with 400 (path-safety beats stat)", async () => {
|
||||
const res = await app.request("/api/files/write", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
root: "workspace",
|
||||
path: "../escape.md",
|
||||
content: "x",
|
||||
expectedMtime: "x",
|
||||
expectedContentHash: "x",
|
||||
actor: "y",
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toBe("path_escape");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
// UI Enhancement Pack v0 — progress indexer tests.
|
||||
//
|
||||
// Drives the indexer against a temp workspace fixture with a few
|
||||
// PROGRESS.md files at different nesting depths. Pins:
|
||||
// - root walk respects max depth
|
||||
// - PROGRESS.md files at deeper levels picked up
|
||||
// - skip dirs (node_modules, .git, .worktrees, dist, etc.)
|
||||
// - frontmatter ignored; rows parsed from `[ ]` / `[x]` / `[~]`
|
||||
// and `## Heading` lines
|
||||
// - hierarchy depth derived from indent
|
||||
// - aggregate counts add up across files
|
||||
// - empty roots → isReady() false; scan returns no files
|
||||
// - readProgressRootsFromEnv parses delimited pairs
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { ProgressIndexer, readProgressRootsFromEnv } from "../src/domain/progress/progress-indexer.js";
|
||||
|
||||
describe("UI Enhancement Pack v0 — readProgressRootsFromEnv", () => {
|
||||
it("returns empty when env unset", () => {
|
||||
expect(readProgressRootsFromEnv({})).toEqual([]);
|
||||
});
|
||||
|
||||
it("parses comma-separated name:path pairs", () => {
|
||||
const roots = readProgressRootsFromEnv({
|
||||
OPENRIG_PROGRESS_SCAN_ROOTS: "shared:/abs/shared, hub:/abs/hub",
|
||||
});
|
||||
expect(roots.map((r) => r.name)).toEqual(["shared", "hub"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("UI Enhancement Pack v0 — ProgressIndexer", () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "progress-indexer-"));
|
||||
});
|
||||
|
||||
afterEach(() => rmSync(tempDir, { recursive: true, force: true }));
|
||||
|
||||
it("isReady() = false when no roots configured", () => {
|
||||
const indexer = new ProgressIndexer({ roots: [] });
|
||||
expect(indexer.isReady()).toBe(false);
|
||||
expect(indexer.scan().files).toEqual([]);
|
||||
});
|
||||
|
||||
it("isReady() = true when at least one root configured", () => {
|
||||
mkdirSync(join(tempDir, "ws"), { recursive: true });
|
||||
const indexer = new ProgressIndexer({ roots: [{ name: "ws", canonicalPath: join(tempDir, "ws") }] });
|
||||
expect(indexer.isReady()).toBe(true);
|
||||
});
|
||||
|
||||
it("walks scan roots and finds PROGRESS.md files at each level", () => {
|
||||
mkdirSync(join(tempDir, "ws", "missions", "alpha"), { recursive: true });
|
||||
writeFileSync(join(tempDir, "ws", "PROGRESS.md"), "# Top\n- [x] root-done\n- [ ] root-active\n");
|
||||
writeFileSync(join(tempDir, "ws", "missions", "alpha", "PROGRESS.md"), "# Alpha\n- [ ] mission-active\n");
|
||||
const indexer = new ProgressIndexer({ roots: [{ name: "ws", canonicalPath: join(tempDir, "ws") }] });
|
||||
const result = indexer.scan();
|
||||
expect(result.files).toHaveLength(2);
|
||||
expect(result.files.map((f) => f.relPath).sort()).toEqual(["PROGRESS.md", "missions/alpha/PROGRESS.md"]);
|
||||
});
|
||||
|
||||
it("parses checkbox states (active / done / blocked)", () => {
|
||||
mkdirSync(join(tempDir, "ws"), { recursive: true });
|
||||
writeFileSync(join(tempDir, "ws", "PROGRESS.md"),
|
||||
"# Test\n- [ ] active item\n- [x] done item\n- [~] blocked item\n");
|
||||
const indexer = new ProgressIndexer({ roots: [{ name: "ws", canonicalPath: join(tempDir, "ws") }] });
|
||||
const result = indexer.scan();
|
||||
const file = result.files[0]!;
|
||||
const checkboxes = file.rows.filter((r) => r.kind === "checkbox");
|
||||
expect(checkboxes).toHaveLength(3);
|
||||
expect(checkboxes[0]?.status).toBe("active");
|
||||
expect(checkboxes[1]?.status).toBe("done");
|
||||
expect(checkboxes[2]?.status).toBe("blocked");
|
||||
expect(file.counts).toEqual({ total: 3, done: 1, blocked: 1, active: 1 });
|
||||
});
|
||||
|
||||
it("emits heading rows for ## / ### / ####", () => {
|
||||
mkdirSync(join(tempDir, "ws"), { recursive: true });
|
||||
writeFileSync(join(tempDir, "ws", "PROGRESS.md"),
|
||||
"# Title\n## Section A\n- [ ] item-a\n### Subsection\n- [ ] item-b\n");
|
||||
const indexer = new ProgressIndexer({ roots: [{ name: "ws", canonicalPath: join(tempDir, "ws") }] });
|
||||
const result = indexer.scan();
|
||||
const headings = result.files[0]!.rows.filter((r) => r.kind === "heading");
|
||||
expect(headings).toHaveLength(2);
|
||||
expect(headings[0]?.text).toBe("Section A");
|
||||
expect(headings[0]?.depth).toBe(0); // ## → depth 0
|
||||
expect(headings[1]?.text).toBe("Subsection");
|
||||
expect(headings[1]?.depth).toBe(1); // ### → depth 1
|
||||
});
|
||||
|
||||
it("derives indent depth from leading 2-space pairs", () => {
|
||||
mkdirSync(join(tempDir, "ws"), { recursive: true });
|
||||
writeFileSync(join(tempDir, "ws", "PROGRESS.md"),
|
||||
"- [ ] level0\n - [ ] level1\n - [ ] level2\n");
|
||||
const indexer = new ProgressIndexer({ roots: [{ name: "ws", canonicalPath: join(tempDir, "ws") }] });
|
||||
const checkboxes = indexer.scan().files[0]!.rows.filter((r) => r.kind === "checkbox");
|
||||
expect(checkboxes.map((r) => r.depth)).toEqual([0, 1, 2]);
|
||||
});
|
||||
|
||||
it("strips YAML frontmatter before parsing rows", () => {
|
||||
mkdirSync(join(tempDir, "ws"), { recursive: true });
|
||||
writeFileSync(join(tempDir, "ws", "PROGRESS.md"),
|
||||
"---\nslice: foo\nstatus: active\n---\n# Title\n- [x] real-row\n");
|
||||
const indexer = new ProgressIndexer({ roots: [{ name: "ws", canonicalPath: join(tempDir, "ws") }] });
|
||||
const file = indexer.scan().files[0]!;
|
||||
expect(file.title).toBe("Title");
|
||||
expect(file.counts.total).toBe(1);
|
||||
});
|
||||
|
||||
it("skips dotdirs, node_modules, .git, .worktrees, dist", () => {
|
||||
mkdirSync(join(tempDir, "ws", "node_modules", "x"), { recursive: true });
|
||||
mkdirSync(join(tempDir, "ws", ".git"), { recursive: true });
|
||||
mkdirSync(join(tempDir, "ws", ".worktrees", "branch"), { recursive: true });
|
||||
mkdirSync(join(tempDir, "ws", "dist"), { recursive: true });
|
||||
writeFileSync(join(tempDir, "ws", "node_modules", "x", "PROGRESS.md"), "- [ ] nope\n");
|
||||
writeFileSync(join(tempDir, "ws", ".git", "PROGRESS.md"), "- [ ] nope\n");
|
||||
writeFileSync(join(tempDir, "ws", ".worktrees", "branch", "PROGRESS.md"), "- [ ] nope\n");
|
||||
writeFileSync(join(tempDir, "ws", "dist", "PROGRESS.md"), "- [ ] nope\n");
|
||||
writeFileSync(join(tempDir, "ws", "PROGRESS.md"), "- [ ] real\n");
|
||||
const indexer = new ProgressIndexer({ roots: [{ name: "ws", canonicalPath: join(tempDir, "ws") }] });
|
||||
const result = indexer.scan();
|
||||
expect(result.files).toHaveLength(1);
|
||||
expect(result.files[0]?.relPath).toBe("PROGRESS.md");
|
||||
});
|
||||
|
||||
it("respects maxDepth", () => {
|
||||
mkdirSync(join(tempDir, "ws", "a", "b", "c", "d"), { recursive: true });
|
||||
writeFileSync(join(tempDir, "ws", "a", "b", "c", "d", "PROGRESS.md"), "- [ ] deep\n");
|
||||
const indexer = new ProgressIndexer({
|
||||
roots: [{ name: "ws", canonicalPath: join(tempDir, "ws") }],
|
||||
maxDepth: 2,
|
||||
});
|
||||
expect(indexer.scan().files).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("aggregate counts sum across files", () => {
|
||||
mkdirSync(join(tempDir, "ws", "x"), { recursive: true });
|
||||
writeFileSync(join(tempDir, "ws", "PROGRESS.md"), "- [x] a\n- [ ] b\n");
|
||||
writeFileSync(join(tempDir, "ws", "x", "PROGRESS.md"), "- [~] c\n- [x] d\n");
|
||||
const result = new ProgressIndexer({ roots: [{ name: "ws", canonicalPath: join(tempDir, "ws") }] }).scan();
|
||||
expect(result.aggregate.totalFiles).toBe(2);
|
||||
expect(result.aggregate.totalRows).toBe(4);
|
||||
expect(result.aggregate.totalDone).toBe(2);
|
||||
expect(result.aggregate.totalActive).toBe(1);
|
||||
expect(result.aggregate.totalBlocked).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type CSSProperties, type ReactNode, useCallback, useEffect, useState, createContext, useContext } from "react";
|
||||
import { Link, useRouterState } from "@tanstack/react-router";
|
||||
import { Cog, FileText, Layers, SquarePlus } from "lucide-react";
|
||||
import { CheckSquare, Cog, FileText, Folder, Layers, SquarePlus } from "lucide-react";
|
||||
import { Explorer, type ExplorerSurface } from "./Explorer.js";
|
||||
import { SharedDetailDrawer, type DrawerSelection } from "./SharedDetailDrawer.js";
|
||||
import type { DiscoveryPlacementTarget } from "./DiscoveryPanel.js";
|
||||
@@ -97,6 +97,8 @@ function resolveSurfaceTitle(pathname: string, rigId: string | null, rigName: st
|
||||
if (pathname.startsWith("/bundles/inspect")) return "Bundle Inspector";
|
||||
if (pathname.startsWith("/bundles/install")) return "Bundle Install";
|
||||
if (pathname.startsWith("/slices")) return "Slices";
|
||||
if (pathname.startsWith("/progress")) return "Progress";
|
||||
if (pathname.startsWith("/files")) return "Files";
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -297,6 +299,36 @@ export function AppShell({ children }: AppShellProps) {
|
||||
>
|
||||
<Layers className="h-4 w-4" />
|
||||
</Link>
|
||||
{/* UI Enhancement Pack v0 — top-level Progress (item 1B) +
|
||||
Files (items 3 + 4) workspace links. Both navigate to
|
||||
their own routes with internal layouts (no left-sidebar
|
||||
overlay; the route body owns its UX entirely). */}
|
||||
<Link
|
||||
to="/progress"
|
||||
data-testid="progress-link"
|
||||
className={`inline-flex h-8 w-8 items-center justify-center text-stone-700 transition-colors ${
|
||||
pathname.startsWith("/progress")
|
||||
? "text-stone-950"
|
||||
: "hover:text-stone-950"
|
||||
}`}
|
||||
aria-label="Open progress"
|
||||
title="Open progress"
|
||||
>
|
||||
<CheckSquare className="h-4 w-4" />
|
||||
</Link>
|
||||
<Link
|
||||
to="/files"
|
||||
data-testid="files-link"
|
||||
className={`inline-flex h-8 w-8 items-center justify-center text-stone-700 transition-colors ${
|
||||
pathname.startsWith("/files")
|
||||
? "text-stone-950"
|
||||
: "hover:text-stone-950"
|
||||
}`}
|
||||
aria-label="Open files"
|
||||
title="Open files"
|
||||
>
|
||||
<Folder className="h-4 w-4" />
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="system-toggle"
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
// UI Enhancement Pack v0 — Files browser workspace.
|
||||
//
|
||||
// Top-level center-workspace surface for /files route. Two-pane shape:
|
||||
// - Left: allowlist root selector + directory tree of the selected root.
|
||||
// - Right: file content panel (markdown via MarkdownViewer, code via
|
||||
// SyntaxHighlight, images inline, other → "view as text" affordance).
|
||||
//
|
||||
// Item 4 (edit mode) is integrated: a header toggle flips the right
|
||||
// pane into a `<textarea>` editor with Save/Cancel; Save does the
|
||||
// daemon's atomic-write contract; 409 conflicts surface a refresh
|
||||
// affordance per the PRD's recommendation. Per item 4's recommended
|
||||
// landing posture: lightweight `<textarea>` (no CodeMirror).
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
fileAssetUrl,
|
||||
useFilesList,
|
||||
useFilesRead,
|
||||
useFilesRoots,
|
||||
useFilesWrite,
|
||||
type AllowlistRoot,
|
||||
type FileEntry,
|
||||
type FilesReadResponse,
|
||||
type FileWriteResult,
|
||||
} from "../../hooks/useFiles.js";
|
||||
import { MarkdownViewer } from "../markdown/MarkdownViewer.js";
|
||||
import { SyntaxHighlight } from "../markdown/SyntaxHighlight.js";
|
||||
|
||||
const TEXT_LIKE_EXTENSIONS = new Set([".md", ".txt", ".log", ".yaml", ".yml", ".json", ".js", ".jsx", ".ts", ".tsx", ".py", ".sh", ".bash", ".sql", ".css", ".html"]);
|
||||
const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]);
|
||||
const DOWNLOAD_ONLY_EXTENSIONS = new Set([".zip", ".tar", ".gz", ".pdf", ".mp4", ".webm", ".mov"]);
|
||||
|
||||
function isUnavailable(data: unknown): data is { unavailable: true; error: string; hint?: string } {
|
||||
return Boolean(data && typeof data === "object" && "unavailable" in (data as Record<string, unknown>));
|
||||
}
|
||||
|
||||
export function FilesWorkspace() {
|
||||
const roots = useFilesRoots();
|
||||
const [selectedRoot, setSelectedRoot] = useState<string | null>(null);
|
||||
const [currentPath, setCurrentPath] = useState<string>("");
|
||||
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||
const [editMode, setEditMode] = useState<boolean>(false);
|
||||
|
||||
// Default-select the first root once roots arrive.
|
||||
useEffect(() => {
|
||||
if (selectedRoot) return;
|
||||
if (!roots.data || isUnavailable(roots.data)) return;
|
||||
const first = roots.data.roots[0];
|
||||
if (first) setSelectedRoot(first.name);
|
||||
}, [roots.data, selectedRoot]);
|
||||
|
||||
// Reset path + selected file when root changes.
|
||||
useEffect(() => {
|
||||
setCurrentPath("");
|
||||
setSelectedFile(null);
|
||||
setEditMode(false);
|
||||
}, [selectedRoot]);
|
||||
|
||||
return (
|
||||
<div data-testid="files-workspace" className="flex h-full flex-col lg:pl-[var(--workspace-left-offset,0px)] lg:pr-[var(--workspace-right-offset,0px)]">
|
||||
<header className="border-b border-stone-200 bg-stone-50 px-4 py-3">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stone-500">Workspace</div>
|
||||
<h1 className="font-headline text-xl font-bold tracking-tight text-stone-900">Files</h1>
|
||||
</header>
|
||||
<div className="flex flex-1 min-h-0">
|
||||
<aside data-testid="files-tree-pane" className="w-72 shrink-0 overflow-y-auto border-r border-stone-200 bg-stone-50">
|
||||
<RootSelector roots={roots.data} isLoading={roots.isLoading} selectedRoot={selectedRoot} onSelect={setSelectedRoot} />
|
||||
{selectedRoot && (
|
||||
<>
|
||||
<Breadcrumbs root={selectedRoot} path={currentPath} onNavigate={setCurrentPath} />
|
||||
<DirectoryTree
|
||||
root={selectedRoot}
|
||||
path={currentPath}
|
||||
onEnterDir={(rel) => { setCurrentPath(rel); setSelectedFile(null); }}
|
||||
onSelectFile={(rel) => { setSelectedFile(rel); setEditMode(false); }}
|
||||
selectedFile={selectedFile}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
<main data-testid="files-content-pane" className="flex-1 min-w-0 overflow-y-auto bg-white">
|
||||
{!selectedRoot && (
|
||||
<div className="m-auto p-4 font-mono text-[10px] text-stone-400">
|
||||
Select an allowlist root to browse.
|
||||
</div>
|
||||
)}
|
||||
{selectedRoot && !selectedFile && (
|
||||
<div className="p-4 font-mono text-[10px] text-stone-400" data-testid="files-no-selection">
|
||||
Select a file from the tree.
|
||||
</div>
|
||||
)}
|
||||
{selectedRoot && selectedFile && (
|
||||
<FileContentPanel
|
||||
root={selectedRoot}
|
||||
path={selectedFile}
|
||||
editMode={editMode}
|
||||
onToggleEditMode={() => setEditMode((v) => !v)}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RootSelector({
|
||||
roots,
|
||||
isLoading,
|
||||
selectedRoot,
|
||||
onSelect,
|
||||
}: {
|
||||
roots: ReturnType<typeof useFilesRoots>["data"] | undefined;
|
||||
isLoading: boolean;
|
||||
selectedRoot: string | null;
|
||||
onSelect: (name: string) => void;
|
||||
}) {
|
||||
if (isLoading) return <div className="p-3 font-mono text-[10px] text-stone-400">Loading roots…</div>;
|
||||
if (!roots) return null;
|
||||
if (isUnavailable(roots)) {
|
||||
return (
|
||||
<div data-testid="files-roots-unavailable" className="p-3 font-mono text-[10px] text-stone-500">
|
||||
<div>Files routes unavailable.</div>
|
||||
{roots.hint && <div className="mt-1 text-stone-400">{roots.hint}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (roots.roots.length === 0) {
|
||||
return (
|
||||
<div data-testid="files-roots-empty" className="p-3 font-mono text-[10px] text-stone-500">
|
||||
<div>No allowlist roots configured.</div>
|
||||
{roots.hint && <div className="mt-1 text-stone-400">{roots.hint}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div data-testid="files-root-selector" className="border-b border-stone-200 p-2">
|
||||
<div className="mb-1 font-mono text-[8px] uppercase tracking-[0.18em] text-stone-500">Roots</div>
|
||||
<ul>
|
||||
{roots.roots.map((r: AllowlistRoot) => (
|
||||
<li key={r.name}>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`files-root-${r.name}`}
|
||||
data-active={selectedRoot === r.name}
|
||||
onClick={() => onSelect(r.name)}
|
||||
className={`block w-full px-2 py-1 text-left font-mono text-[10px] hover:bg-stone-100 ${
|
||||
selectedRoot === r.name ? "bg-stone-200/80 text-stone-900" : "text-stone-700"
|
||||
}`}
|
||||
title={r.path}
|
||||
>
|
||||
{r.name}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Breadcrumbs({ root, path, onNavigate }: { root: string; path: string; onNavigate: (path: string) => void }) {
|
||||
const segments = path ? path.split("/") : [];
|
||||
return (
|
||||
<nav data-testid="files-breadcrumbs" className="flex flex-wrap items-baseline gap-1 border-b border-stone-200 px-2 py-1 font-mono text-[10px] text-stone-700">
|
||||
<button type="button" onClick={() => onNavigate("")} className="font-bold hover:underline">{root}</button>
|
||||
{segments.map((seg, idx) => {
|
||||
const accumulated = segments.slice(0, idx + 1).join("/");
|
||||
return (
|
||||
<span key={accumulated}>
|
||||
<span className="mx-0.5 text-stone-400">/</span>
|
||||
<button type="button" onClick={() => onNavigate(accumulated)} className="hover:underline">
|
||||
{seg}
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function DirectoryTree({
|
||||
root,
|
||||
path,
|
||||
onEnterDir,
|
||||
onSelectFile,
|
||||
selectedFile,
|
||||
}: {
|
||||
root: string;
|
||||
path: string;
|
||||
onEnterDir: (rel: string) => void;
|
||||
onSelectFile: (rel: string) => void;
|
||||
selectedFile: string | null;
|
||||
}) {
|
||||
const list = useFilesList(root, path);
|
||||
if (list.isLoading) return <div className="p-3 font-mono text-[10px] text-stone-400">Loading…</div>;
|
||||
if (list.isError) return <div data-testid="files-list-error" className="p-3 font-mono text-[10px] text-red-600">{(list.error as Error)?.message ?? "Error loading directory."}</div>;
|
||||
if (!list.data || list.data.entries.length === 0) {
|
||||
return <div className="p-3 font-mono text-[10px] text-stone-400">Empty directory.</div>;
|
||||
}
|
||||
return (
|
||||
<ul data-testid="files-directory-tree" className="p-1">
|
||||
{path && (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="files-up"
|
||||
onClick={() => onEnterDir(parentPath(path))}
|
||||
className="block w-full px-2 py-1 text-left font-mono text-[10px] text-stone-500 hover:bg-stone-100"
|
||||
>
|
||||
..
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
{list.data.entries.map((entry: FileEntry) => {
|
||||
const rel = path ? `${path}/${entry.name}` : entry.name;
|
||||
const isFile = entry.type === "file";
|
||||
const isSelected = selectedFile === rel;
|
||||
return (
|
||||
<li key={rel}>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`files-entry-${rel}`}
|
||||
data-type={entry.type}
|
||||
onClick={() => isFile ? onSelectFile(rel) : entry.type === "dir" ? onEnterDir(rel) : undefined}
|
||||
disabled={entry.type === "other"}
|
||||
className={`block w-full px-2 py-1 text-left font-mono text-[10px] ${
|
||||
entry.type === "other"
|
||||
? "text-stone-400"
|
||||
: `hover:bg-stone-100 ${isSelected ? "bg-stone-200/80 text-stone-900" : "text-stone-700"}`
|
||||
}`}
|
||||
>
|
||||
{entry.type === "dir" ? `▸ ${entry.name}` : entry.name}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function FileContentPanel({
|
||||
root,
|
||||
path,
|
||||
editMode,
|
||||
onToggleEditMode,
|
||||
}: {
|
||||
root: string;
|
||||
path: string;
|
||||
editMode: boolean;
|
||||
onToggleEditMode: () => void;
|
||||
}) {
|
||||
const read = useFilesRead(root, path);
|
||||
return (
|
||||
<div data-testid="files-content-panel" className="flex h-full flex-col">
|
||||
<header className="flex items-center justify-between border-b border-stone-200 bg-stone-50 px-3 py-2 font-mono text-[10px]">
|
||||
<div className="text-stone-700" data-testid="files-content-path">{root}/{path}</div>
|
||||
<div className="flex items-center gap-3 text-stone-500">
|
||||
{read.data && (
|
||||
<>
|
||||
<span data-testid="files-content-size">{read.data.size}b</span>
|
||||
<span data-testid="files-content-mtime">{read.data.mtime}</span>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="files-edit-toggle"
|
||||
data-active={editMode}
|
||||
onClick={onToggleEditMode}
|
||||
className={`border px-2 py-0.5 font-mono text-[9px] uppercase tracking-[0.10em] ${
|
||||
editMode
|
||||
? "border-amber-400 bg-amber-50 text-amber-900"
|
||||
: "border-stone-300 text-stone-700 hover:bg-stone-100"
|
||||
}`}
|
||||
>
|
||||
{editMode ? "editing" : "edit"}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
{read.isLoading && <div className="p-4 font-mono text-[10px] text-stone-400">Loading…</div>}
|
||||
{read.isError && <div data-testid="files-read-error" className="p-4 font-mono text-[10px] text-red-600">{(read.error as Error)?.message ?? "Error loading file."}</div>}
|
||||
{read.data && (
|
||||
editMode
|
||||
? <FileEditor root={root} path={path} read={read.data} />
|
||||
: <FileViewer root={root} path={path} read={read.data} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FileViewer({ root, path, read }: { root: string; path: string; read: FilesReadResponse }) {
|
||||
const ext = pathExtension(path);
|
||||
if (IMAGE_EXTENSIONS.has(ext)) {
|
||||
return (
|
||||
<div data-testid="files-image-view" className="p-4">
|
||||
<img src={fileAssetUrl(root, path)} alt={path} className="max-w-full border border-stone-200" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (ext === ".md") {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<MarkdownViewer content={read.content} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (TEXT_LIKE_EXTENSIONS.has(ext)) {
|
||||
return (
|
||||
<div data-testid="files-code-view" className="p-4">
|
||||
<SyntaxHighlight code={read.content} language={ext.slice(1)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (DOWNLOAD_ONLY_EXTENSIONS.has(ext)) {
|
||||
return (
|
||||
<div data-testid="files-download-only" className="p-4 font-mono text-[10px] text-stone-700">
|
||||
<a href={fileAssetUrl(root, path)} download className="text-blue-700 underline">
|
||||
Download {path}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div data-testid="files-text-fallback" className="p-4">
|
||||
<pre className="whitespace-pre-wrap break-words font-mono text-[10px] text-stone-800">{read.content}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FileEditor({ root, path, read }: { root: string; path: string; read: FilesReadResponse }) {
|
||||
const [draft, setDraft] = useState(read.content);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [conflict, setConflict] = useState<{ currentMtime: string; currentContentHash: string } | null>(null);
|
||||
const [savedIndicator, setSavedIndicator] = useState(false);
|
||||
const write = useFilesWrite();
|
||||
|
||||
// When a fresh read comes in (after Refresh on conflict, or after a
|
||||
// successful save), reset the draft to the new content.
|
||||
useEffect(() => {
|
||||
setDraft(read.content);
|
||||
setConflict(null);
|
||||
}, [read.contentHash, read.mtime, read.content]);
|
||||
|
||||
const dirty = useMemo(() => draft !== read.content, [draft, read.content]);
|
||||
|
||||
return (
|
||||
<div data-testid="files-editor" className="flex h-full flex-col">
|
||||
<div className="flex items-center gap-2 border-b border-stone-200 bg-amber-50 px-3 py-1.5 font-mono text-[9px]">
|
||||
<span className="font-bold text-amber-900" data-testid="files-editor-status">
|
||||
{dirty ? "draft (unsaved)" : "no changes"}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="files-editor-save"
|
||||
disabled={!dirty || write.isPending}
|
||||
onClick={() => {
|
||||
setSaveError(null);
|
||||
setConflict(null);
|
||||
setSavedIndicator(false);
|
||||
write.mutate(
|
||||
{
|
||||
root,
|
||||
path,
|
||||
content: draft,
|
||||
expectedMtime: read.mtime,
|
||||
expectedContentHash: read.contentHash,
|
||||
actor: "ui-files-edit-mode",
|
||||
},
|
||||
{
|
||||
onSuccess: (result: FileWriteResult) => {
|
||||
if ("conflict" in result) {
|
||||
setConflict({ currentMtime: result.currentMtime, currentContentHash: result.currentContentHash });
|
||||
} else {
|
||||
setSavedIndicator(true);
|
||||
setTimeout(() => setSavedIndicator(false), 2000);
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
setSaveError(err instanceof Error ? err.message : String(err));
|
||||
},
|
||||
},
|
||||
);
|
||||
}}
|
||||
className="border border-emerald-500 bg-emerald-50 px-2 py-0.5 uppercase tracking-[0.10em] text-emerald-900 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="files-editor-cancel"
|
||||
onClick={() => { setDraft(read.content); setSaveError(null); setConflict(null); }}
|
||||
className="border border-stone-400 bg-white px-2 py-0.5 uppercase tracking-[0.10em] text-stone-700"
|
||||
>
|
||||
cancel
|
||||
</button>
|
||||
{savedIndicator && (
|
||||
<span data-testid="files-editor-saved" className="ml-auto text-emerald-700">saved</span>
|
||||
)}
|
||||
</div>
|
||||
{conflict && (
|
||||
<div data-testid="files-editor-conflict" className="border-b border-red-200 bg-red-50 px-3 py-2 font-mono text-[10px] text-red-900">
|
||||
File changed externally. Local mtime <code>{read.mtime}</code> ≠ server <code>{conflict.currentMtime}</code>. Refresh to re-read; your edits are preserved in the textarea below.
|
||||
</div>
|
||||
)}
|
||||
{saveError && (
|
||||
<div data-testid="files-editor-error" className="border-b border-red-200 bg-red-50 px-3 py-2 font-mono text-[10px] text-red-900">
|
||||
Save failed: {saveError}
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
data-testid="files-editor-textarea"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
className="flex-1 min-h-0 resize-none border-0 bg-stone-50 p-3 font-mono text-[11px] leading-relaxed text-stone-900 outline-none"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function pathExtension(p: string): string {
|
||||
const idx = p.lastIndexOf(".");
|
||||
if (idx === -1) return "";
|
||||
return p.slice(idx).toLowerCase();
|
||||
}
|
||||
|
||||
function parentPath(p: string): string {
|
||||
const idx = p.lastIndexOf("/");
|
||||
return idx === -1 ? "" : p.slice(0, idx);
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
// UI Enhancement Pack v0 — lightweight markdown viewer (no deps).
|
||||
//
|
||||
// Per the audit: no markdown libraries are installed in the UI
|
||||
// package. This component implements a small parser that handles the
|
||||
// cases operators hit when reading canon docs:
|
||||
// - YAML frontmatter (rendered as a metadata header above the body)
|
||||
// - Headings (# / ## / ### / ####)
|
||||
// - Lists (- / * / nested by 2-space indent)
|
||||
// - Numbered lists (1. / 2. / ...)
|
||||
// - Code blocks (``` with language tag → SyntaxHighlight component)
|
||||
// - Inline code (`...`)
|
||||
// - Bold (**...**) / italic (*...*) — light support
|
||||
// - Links ([text](url)) and images ()
|
||||
// - Tables (| col | col |)
|
||||
// - Mermaid code blocks → "[render mermaid]" placeholder per PRD
|
||||
// item 2 carve-out (no mermaid lib bundled at v0 — named v0+1
|
||||
// trigger applies if dogfood reports the click-to-render flow is
|
||||
// friction)
|
||||
//
|
||||
// Image src resolution: absolute URLs and `data:` URIs pass through;
|
||||
// relative paths are resolved against the optional `assetBasePath`
|
||||
// prop so the daemon's /api/files/asset endpoint or the slice's
|
||||
// /api/slices/<name>/proof-asset/ endpoint can serve them.
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { SyntaxHighlight } from "./SyntaxHighlight.js";
|
||||
|
||||
export interface MarkdownViewerProps {
|
||||
content: string;
|
||||
/** Used to resolve relative image src + link href. Optional. */
|
||||
assetBasePath?: string;
|
||||
/** Hide the YAML frontmatter metadata header (default false). */
|
||||
hideFrontmatter?: boolean;
|
||||
}
|
||||
|
||||
export function MarkdownViewer({ content, assetBasePath, hideFrontmatter = false }: MarkdownViewerProps) {
|
||||
const parsed = useMemo(() => parseMarkdown(content), [content]);
|
||||
return (
|
||||
<article data-testid="markdown-viewer" className="prose-tactical max-w-none">
|
||||
{!hideFrontmatter && parsed.frontmatter && (
|
||||
<FrontmatterHeader frontmatter={parsed.frontmatter} />
|
||||
)}
|
||||
<div className="space-y-3">
|
||||
{parsed.blocks.map((block, idx) => (
|
||||
<BlockRenderer key={idx} block={block} assetBasePath={assetBasePath} />
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
interface ParsedDocument {
|
||||
frontmatter: Record<string, string> | null;
|
||||
blocks: Block[];
|
||||
}
|
||||
|
||||
type Block =
|
||||
| { type: "heading"; level: 1 | 2 | 3 | 4; text: string }
|
||||
| { type: "paragraph"; text: string }
|
||||
| { type: "code"; language: string | null; text: string; isMermaid: boolean }
|
||||
| { type: "list"; ordered: boolean; items: Array<{ depth: number; text: string }> }
|
||||
| { type: "table"; headers: string[]; rows: string[][] }
|
||||
| { type: "blank" };
|
||||
|
||||
function parseMarkdown(content: string): ParsedDocument {
|
||||
const { frontmatter, body } = stripFrontmatter(content);
|
||||
const lines = body.split("\n");
|
||||
const blocks: Block[] = [];
|
||||
let i = 0;
|
||||
while (i < lines.length) {
|
||||
const line = lines[i]!;
|
||||
|
||||
// Fenced code block.
|
||||
const fence = line.match(/^```(\w*)\s*$/);
|
||||
if (fence) {
|
||||
const language = fence[1] || null;
|
||||
const isMermaid = language?.toLowerCase() === "mermaid";
|
||||
const start = i + 1;
|
||||
let end = start;
|
||||
while (end < lines.length && !lines[end]!.match(/^```\s*$/)) end++;
|
||||
const text = lines.slice(start, end).join("\n");
|
||||
blocks.push({ type: "code", language, text, isMermaid });
|
||||
i = end + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Heading.
|
||||
const heading = line.match(/^(#{1,4})\s+(.+?)\s*$/);
|
||||
if (heading) {
|
||||
blocks.push({ type: "heading", level: heading[1]!.length as 1 | 2 | 3 | 4, text: heading[2]! });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// List (bulleted or ordered).
|
||||
if (line.match(/^\s*([-*]|\d+\.)\s+/)) {
|
||||
const items: Array<{ depth: number; text: string }> = [];
|
||||
const ordered = !!line.match(/^\s*\d+\./);
|
||||
while (i < lines.length && lines[i]!.match(/^\s*([-*]|\d+\.)\s+/)) {
|
||||
const m = lines[i]!.match(/^(\s*)([-*]|\d+\.)\s+(.+)$/);
|
||||
if (!m) break;
|
||||
const depth = Math.floor((m[1]?.length ?? 0) / 2);
|
||||
items.push({ depth, text: m[3]! });
|
||||
i++;
|
||||
}
|
||||
blocks.push({ type: "list", ordered, items });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Table (header + separator + body rows).
|
||||
if (line.match(/^\s*\|.*\|\s*$/) && i + 1 < lines.length && lines[i + 1]!.match(/^\s*\|[\s\-:|]+\|\s*$/)) {
|
||||
const headers = parseTableRow(line);
|
||||
i += 2; // skip header + separator
|
||||
const rows: string[][] = [];
|
||||
while (i < lines.length && lines[i]!.match(/^\s*\|.*\|\s*$/)) {
|
||||
rows.push(parseTableRow(lines[i]!));
|
||||
i++;
|
||||
}
|
||||
blocks.push({ type: "table", headers, rows });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Blank line.
|
||||
if (line.trim() === "") {
|
||||
blocks.push({ type: "blank" });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Paragraph (collect consecutive non-blank, non-special lines).
|
||||
const paragraph: string[] = [line];
|
||||
i++;
|
||||
while (i < lines.length) {
|
||||
const next = lines[i]!;
|
||||
if (next.trim() === "") break;
|
||||
if (next.match(/^```/) || next.match(/^#{1,4}\s/) || next.match(/^\s*([-*]|\d+\.)\s/) || next.match(/^\s*\|.*\|\s*$/)) break;
|
||||
paragraph.push(next);
|
||||
i++;
|
||||
}
|
||||
blocks.push({ type: "paragraph", text: paragraph.join(" ") });
|
||||
}
|
||||
return { frontmatter, blocks };
|
||||
}
|
||||
|
||||
function stripFrontmatter(content: string): { frontmatter: Record<string, string> | null; body: string } {
|
||||
if (!content.startsWith("---\n") && !content.startsWith("---\r\n")) {
|
||||
return { frontmatter: null, body: content };
|
||||
}
|
||||
const rest = content.slice(content.indexOf("\n") + 1);
|
||||
const endMatch = rest.match(/(^|\n)---(\n|$)/);
|
||||
if (!endMatch || endMatch.index === undefined) return { frontmatter: null, body: content };
|
||||
const fmText = rest.slice(0, endMatch.index);
|
||||
const body = rest.slice(endMatch.index + endMatch[0].length);
|
||||
const frontmatter: Record<string, string> = {};
|
||||
for (const line of fmText.split("\n")) {
|
||||
const m = line.match(/^([\w.-]+):\s*(.+?)\s*$/);
|
||||
if (m) {
|
||||
let v = m[2]!;
|
||||
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
|
||||
frontmatter[m[1]!] = v;
|
||||
}
|
||||
}
|
||||
return { frontmatter, body };
|
||||
}
|
||||
|
||||
function parseTableRow(line: string): string[] {
|
||||
return line.trim().replace(/^\||\|$/g, "").split("|").map((c) => c.trim());
|
||||
}
|
||||
|
||||
function FrontmatterHeader({ frontmatter }: { frontmatter: Record<string, string> }) {
|
||||
const entries = Object.entries(frontmatter);
|
||||
if (entries.length === 0) return null;
|
||||
return (
|
||||
<section
|
||||
data-testid="markdown-frontmatter"
|
||||
className="mb-4 border border-stone-300 bg-stone-50 p-3"
|
||||
>
|
||||
<div className="mb-2 font-mono text-[8px] uppercase tracking-[0.18em] text-stone-500">
|
||||
Frontmatter
|
||||
</div>
|
||||
<dl className="grid grid-cols-[max-content_1fr] gap-x-3 gap-y-1">
|
||||
{entries.map(([k, v]) => (
|
||||
<Fragment key={k}>
|
||||
<dt className="font-mono text-[10px] font-bold text-stone-700">{k}</dt>
|
||||
<dd className="font-mono text-[10px] text-stone-800 break-all">{v}</dd>
|
||||
</Fragment>
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function BlockRenderer({ block, assetBasePath }: { block: Block; assetBasePath?: string }) {
|
||||
if (block.type === "blank") return null;
|
||||
if (block.type === "heading") {
|
||||
const sizes = { 1: "text-lg font-bold", 2: "text-base font-bold", 3: "text-sm font-bold", 4: "text-xs font-bold" } as const;
|
||||
const Tag = (`h${block.level}` as "h1" | "h2" | "h3" | "h4");
|
||||
return <Tag data-testid={`md-heading-${block.level}`} className={`${sizes[block.level]} mt-4 text-stone-900`}>{renderInline(block.text, assetBasePath)}</Tag>;
|
||||
}
|
||||
if (block.type === "paragraph") {
|
||||
return <p data-testid="md-paragraph" className="text-[12px] leading-relaxed text-stone-800">{renderInline(block.text, assetBasePath)}</p>;
|
||||
}
|
||||
if (block.type === "code") {
|
||||
if (block.isMermaid) {
|
||||
return (
|
||||
<div data-testid="md-mermaid-placeholder" className="border border-amber-300 bg-amber-50 p-3">
|
||||
<div className="mb-2 font-mono text-[8px] uppercase tracking-[0.18em] text-amber-700">
|
||||
mermaid diagram (v0+1 trigger: render-on-click flow)
|
||||
</div>
|
||||
<pre className="overflow-x-auto bg-stone-900 p-2 font-mono text-[10px] text-stone-100">
|
||||
<code>{block.text}</code>
|
||||
</pre>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="md-mermaid-render-btn"
|
||||
disabled
|
||||
title="Mermaid rendering not bundled at v0 (named v0+1 trigger). Inspect the source above."
|
||||
className="mt-2 cursor-not-allowed border border-amber-400 bg-amber-100 px-2 py-1 font-mono text-[9px] uppercase tracking-[0.10em] text-amber-800"
|
||||
>
|
||||
[render mermaid] (v0+1)
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <SyntaxHighlight code={block.text} language={block.language} />;
|
||||
}
|
||||
if (block.type === "list") {
|
||||
const ListTag = block.ordered ? "ol" : "ul";
|
||||
return (
|
||||
<ListTag data-testid={`md-list-${block.ordered ? "ol" : "ul"}`} className={`${block.ordered ? "list-decimal" : "list-disc"} ml-5 space-y-1 text-[12px] text-stone-800`}>
|
||||
{block.items.map((item, idx) => (
|
||||
<li key={idx} style={{ marginLeft: `${item.depth * 1}rem` }}>
|
||||
{renderInline(item.text, assetBasePath)}
|
||||
</li>
|
||||
))}
|
||||
</ListTag>
|
||||
);
|
||||
}
|
||||
if (block.type === "table") {
|
||||
return (
|
||||
<div data-testid="md-table-wrapper" className="overflow-x-auto">
|
||||
<table className="w-full border-collapse border border-stone-300 text-[10px]">
|
||||
<thead className="bg-stone-100">
|
||||
<tr>{block.headers.map((h, i) => <th key={i} className="border border-stone-300 px-2 py-1 text-left font-bold text-stone-900">{renderInline(h, assetBasePath)}</th>)}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{block.rows.map((row, ri) => (
|
||||
<tr key={ri} className={ri % 2 === 0 ? "bg-white" : "bg-stone-50"}>
|
||||
{row.map((cell, ci) => <td key={ci} className="border border-stone-300 px-2 py-1 text-stone-800">{renderInline(cell, assetBasePath)}</td>)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Light inline parser: handles `code`, **bold**, *italic*, [text](url), .
|
||||
function renderInline(text: string, assetBasePath?: string): React.ReactNode {
|
||||
const nodes: React.ReactNode[] = [];
|
||||
let i = 0;
|
||||
let key = 0;
|
||||
const flushPlain = (start: number, end: number) => {
|
||||
if (end > start) nodes.push(text.slice(start, end));
|
||||
};
|
||||
let plainStart = 0;
|
||||
while (i < text.length) {
|
||||
const remaining = text.slice(i);
|
||||
// Image: 
|
||||
const imgMatch = remaining.match(/^!\[([^\]]*)\]\(([^)]+)\)/);
|
||||
if (imgMatch) {
|
||||
flushPlain(plainStart, i);
|
||||
const src = resolveAssetUrl(imgMatch[2]!, assetBasePath);
|
||||
nodes.push(
|
||||
<img
|
||||
key={key++}
|
||||
data-testid="md-inline-image"
|
||||
src={src}
|
||||
alt={imgMatch[1] ?? ""}
|
||||
loading="lazy"
|
||||
className="my-2 inline-block max-w-full border border-stone-200"
|
||||
/>,
|
||||
);
|
||||
i += imgMatch[0].length;
|
||||
plainStart = i;
|
||||
continue;
|
||||
}
|
||||
// Link: [text](href)
|
||||
const linkMatch = remaining.match(/^\[([^\]]+)\]\(([^)]+)\)/);
|
||||
if (linkMatch) {
|
||||
flushPlain(plainStart, i);
|
||||
nodes.push(
|
||||
<a
|
||||
key={key++}
|
||||
data-testid="md-inline-link"
|
||||
href={linkMatch[2]}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-700 underline hover:text-blue-900"
|
||||
>
|
||||
{linkMatch[1]}
|
||||
</a>,
|
||||
);
|
||||
i += linkMatch[0].length;
|
||||
plainStart = i;
|
||||
continue;
|
||||
}
|
||||
// Inline code: `...`
|
||||
if (remaining.startsWith("`")) {
|
||||
const close = remaining.indexOf("`", 1);
|
||||
if (close !== -1) {
|
||||
flushPlain(plainStart, i);
|
||||
nodes.push(
|
||||
<code
|
||||
key={key++}
|
||||
data-testid="md-inline-code"
|
||||
className="bg-stone-100 px-1 font-mono text-[10px] text-stone-900"
|
||||
>
|
||||
{remaining.slice(1, close)}
|
||||
</code>,
|
||||
);
|
||||
i += close + 1;
|
||||
plainStart = i;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Bold: **...**
|
||||
if (remaining.startsWith("**")) {
|
||||
const close = remaining.indexOf("**", 2);
|
||||
if (close !== -1) {
|
||||
flushPlain(plainStart, i);
|
||||
nodes.push(<strong key={key++} className="font-bold">{remaining.slice(2, close)}</strong>);
|
||||
i += close + 2;
|
||||
plainStart = i;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Italic: *...*
|
||||
if (remaining.startsWith("*") && !remaining.startsWith("**")) {
|
||||
const close = remaining.indexOf("*", 1);
|
||||
if (close !== -1 && close > 1) {
|
||||
flushPlain(plainStart, i);
|
||||
nodes.push(<em key={key++} className="italic">{remaining.slice(1, close)}</em>);
|
||||
i += close + 1;
|
||||
plainStart = i;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
i++;
|
||||
}
|
||||
flushPlain(plainStart, text.length);
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function resolveAssetUrl(src: string, assetBasePath?: string): string {
|
||||
if (!src) return src;
|
||||
if (src.startsWith("http://") || src.startsWith("https://") || src.startsWith("data:") || src.startsWith("/")) return src;
|
||||
if (!assetBasePath) return src;
|
||||
// Treat assetBasePath as a URL prefix; combine carefully so we don't
|
||||
// double-slash or strip the path's relative segment.
|
||||
const sep = assetBasePath.endsWith("/") ? "" : "/";
|
||||
return `${assetBasePath}${sep}${src}`;
|
||||
}
|
||||
|
||||
// Minimal Fragment-equivalent without importing react/jsx-runtime
|
||||
// directly — react-19 supplies Fragment via the named export.
|
||||
import { Fragment } from "react";
|
||||
@@ -0,0 +1,272 @@
|
||||
// UI Enhancement Pack v0 — lightweight syntax highlighter (no deps).
|
||||
//
|
||||
// Per the audit: no markdown / highlighting libs are installed in the
|
||||
// UI package, and the PRD's mermaid carve-out hints at bundle-weight
|
||||
// concerns. We ship a small per-language tokenizer (~200 lines total)
|
||||
// that handles the common cases the operator hits in canon docs:
|
||||
// bash, ts/tsx/js/jsx, py, yaml, json, sql, md, sh, plain text.
|
||||
//
|
||||
// Tokenizer style: emit React spans with semantic class names
|
||||
// (token-keyword, token-string, token-comment, token-number,
|
||||
// token-tag) styled in globals.css. Each language has a small set of
|
||||
// regex matchers applied in order; the longest match wins per
|
||||
// position. Greedy matching for strings + comments handles the
|
||||
// "multi-line" cases for fenced code (inputs are individual blocks,
|
||||
// so newlines inside string/comment tokens are uncommon — we still
|
||||
// scan past them).
|
||||
//
|
||||
// This is intentionally not a full highlighter. Edge cases (template
|
||||
// literals with embedded expressions, regex literals, JSX braces)
|
||||
// degrade gracefully to "no highlighting on that token". The visual
|
||||
// goal is "this is clearly code" + "keywords/strings/comments are
|
||||
// distinguishable" — not pixel-perfect editor parity.
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
export type HighlightLanguage =
|
||||
| "bash" | "sh" | "ts" | "tsx" | "js" | "jsx"
|
||||
| "py" | "python"
|
||||
| "yaml" | "yml"
|
||||
| "json"
|
||||
| "sql"
|
||||
| "md" | "markdown"
|
||||
| "text" | "plain" | "";
|
||||
|
||||
export function SyntaxHighlight({
|
||||
code,
|
||||
language,
|
||||
}: {
|
||||
code: string;
|
||||
language: string | null | undefined;
|
||||
}) {
|
||||
const lang = normalizeLanguage(language);
|
||||
const tokens = useMemo(() => tokenize(code, lang), [code, lang]);
|
||||
return (
|
||||
<pre
|
||||
data-testid="syntax-highlight-block"
|
||||
data-language={lang}
|
||||
className="overflow-x-auto bg-stone-900 p-3 font-mono text-[10px] leading-relaxed text-stone-100"
|
||||
>
|
||||
<code>
|
||||
{tokens.map((tok, idx) => (
|
||||
<span key={idx} className={tok.className} data-token-kind={tok.kind}>
|
||||
{tok.text}
|
||||
</span>
|
||||
))}
|
||||
</code>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
interface Token {
|
||||
kind: "plain" | "keyword" | "string" | "comment" | "number" | "tag" | "punctuation";
|
||||
text: string;
|
||||
className: string;
|
||||
}
|
||||
|
||||
const TOKEN_CLASS: Record<Token["kind"], string> = {
|
||||
plain: "",
|
||||
keyword: "text-violet-300",
|
||||
string: "text-emerald-300",
|
||||
comment: "text-stone-500 italic",
|
||||
number: "text-amber-300",
|
||||
tag: "text-sky-300",
|
||||
punctuation: "text-stone-400",
|
||||
};
|
||||
|
||||
function token(kind: Token["kind"], text: string): Token {
|
||||
return { kind, text, className: TOKEN_CLASS[kind] };
|
||||
}
|
||||
|
||||
function normalizeLanguage(lang: string | null | undefined): HighlightLanguage {
|
||||
if (!lang) return "";
|
||||
const norm = lang.toLowerCase().trim();
|
||||
if (norm === "javascript") return "js";
|
||||
if (norm === "typescript") return "ts";
|
||||
if (norm === "shell") return "sh";
|
||||
return norm as HighlightLanguage;
|
||||
}
|
||||
|
||||
const KEYWORDS_BY_LANG: Record<string, Set<string>> = {
|
||||
ts: new Set(["const", "let", "var", "function", "return", "if", "else", "for", "while", "switch", "case", "break", "continue", "default", "class", "extends", "implements", "interface", "type", "enum", "import", "export", "from", "as", "new", "this", "super", "async", "await", "throw", "try", "catch", "finally", "static", "public", "private", "protected", "readonly", "abstract", "true", "false", "null", "undefined", "void", "string", "number", "boolean", "any", "unknown", "never"]),
|
||||
tsx: new Set(["const", "let", "var", "function", "return", "if", "else", "for", "while", "switch", "case", "break", "continue", "default", "class", "extends", "implements", "interface", "type", "enum", "import", "export", "from", "as", "new", "this", "super", "async", "await", "throw", "try", "catch", "finally", "static", "public", "private", "protected", "readonly", "abstract", "true", "false", "null", "undefined", "void", "string", "number", "boolean", "any", "unknown", "never"]),
|
||||
js: new Set(["const", "let", "var", "function", "return", "if", "else", "for", "while", "switch", "case", "break", "continue", "default", "class", "extends", "import", "export", "from", "as", "new", "this", "super", "async", "await", "throw", "try", "catch", "finally", "true", "false", "null", "undefined"]),
|
||||
jsx: new Set(["const", "let", "var", "function", "return", "if", "else", "for", "while", "switch", "case", "break", "continue", "default", "class", "extends", "import", "export", "from", "as", "new", "this", "super", "async", "await", "throw", "try", "catch", "finally", "true", "false", "null", "undefined"]),
|
||||
py: new Set(["def", "class", "if", "elif", "else", "for", "while", "return", "import", "from", "as", "with", "try", "except", "finally", "raise", "lambda", "yield", "pass", "break", "continue", "global", "nonlocal", "True", "False", "None", "and", "or", "not", "in", "is", "async", "await"]),
|
||||
python: new Set(["def", "class", "if", "elif", "else", "for", "while", "return", "import", "from", "as", "with", "try", "except", "finally", "raise", "lambda", "yield", "pass", "break", "continue", "global", "nonlocal", "True", "False", "None", "and", "or", "not", "in", "is", "async", "await"]),
|
||||
bash: new Set(["if", "then", "else", "elif", "fi", "for", "while", "do", "done", "case", "esac", "function", "return", "export", "local", "readonly", "echo", "printf", "exit", "set", "unset", "shift", "true", "false"]),
|
||||
sh: new Set(["if", "then", "else", "elif", "fi", "for", "while", "do", "done", "case", "esac", "function", "return", "export", "local", "readonly", "echo", "printf", "exit", "set", "unset", "shift", "true", "false"]),
|
||||
sql: new Set(["select", "from", "where", "and", "or", "not", "null", "is", "in", "like", "between", "join", "left", "right", "inner", "outer", "on", "group", "by", "order", "having", "limit", "offset", "insert", "into", "values", "update", "set", "delete", "create", "table", "drop", "alter", "index", "primary", "key", "foreign", "references", "constraint", "unique", "default", "as", "case", "when", "then", "else", "end"]),
|
||||
};
|
||||
|
||||
function tokenize(code: string, lang: HighlightLanguage): Token[] {
|
||||
if (lang === "" || lang === "text" || lang === "plain") return [token("plain", code)];
|
||||
if (lang === "json") return tokenizeJson(code);
|
||||
if (lang === "yaml" || lang === "yml") return tokenizeYaml(code);
|
||||
if (lang === "md" || lang === "markdown") return tokenizeMarkdown(code);
|
||||
if (lang === "py" || lang === "python") return tokenizeWithKeywordsAndComments(code, lang, "#");
|
||||
if (lang === "sh" || lang === "bash") return tokenizeWithKeywordsAndComments(code, lang, "#");
|
||||
if (lang === "ts" || lang === "tsx" || lang === "js" || lang === "jsx") return tokenizeWithKeywordsAndComments(code, lang, "//", "/*", "*/");
|
||||
if (lang === "sql") return tokenizeWithKeywordsAndComments(code, lang, "--");
|
||||
return [token("plain", code)];
|
||||
}
|
||||
|
||||
function tokenizeWithKeywordsAndComments(
|
||||
code: string,
|
||||
lang: HighlightLanguage,
|
||||
lineCommentPrefix: string,
|
||||
blockCommentOpen?: string,
|
||||
blockCommentClose?: string,
|
||||
): Token[] {
|
||||
const keywords = KEYWORDS_BY_LANG[lang] ?? new Set<string>();
|
||||
const out: Token[] = [];
|
||||
let i = 0;
|
||||
while (i < code.length) {
|
||||
const remaining = code.slice(i);
|
||||
// Block comment.
|
||||
if (blockCommentOpen && remaining.startsWith(blockCommentOpen)) {
|
||||
const closeIdx = blockCommentClose ? remaining.indexOf(blockCommentClose, blockCommentOpen.length) : -1;
|
||||
const end = closeIdx === -1 ? remaining.length : closeIdx + (blockCommentClose?.length ?? 0);
|
||||
out.push(token("comment", remaining.slice(0, end)));
|
||||
i += end;
|
||||
continue;
|
||||
}
|
||||
// Line comment.
|
||||
if (remaining.startsWith(lineCommentPrefix)) {
|
||||
const newline = remaining.indexOf("\n");
|
||||
const end = newline === -1 ? remaining.length : newline;
|
||||
out.push(token("comment", remaining.slice(0, end)));
|
||||
i += end;
|
||||
continue;
|
||||
}
|
||||
// String literals (single, double, backtick).
|
||||
const quote = remaining[0];
|
||||
if (quote === '"' || quote === "'" || quote === "`") {
|
||||
let j = 1;
|
||||
while (j < remaining.length && remaining[j] !== quote) {
|
||||
if (remaining[j] === "\\" && j + 1 < remaining.length) j += 2;
|
||||
else j++;
|
||||
}
|
||||
const end = j < remaining.length ? j + 1 : j;
|
||||
out.push(token("string", remaining.slice(0, end)));
|
||||
i += end;
|
||||
continue;
|
||||
}
|
||||
// Number.
|
||||
const numMatch = remaining.match(/^-?\d+(\.\d+)?/);
|
||||
if (numMatch && (i === 0 || !/[a-zA-Z_]/.test(code[i - 1] ?? ""))) {
|
||||
out.push(token("number", numMatch[0]));
|
||||
i += numMatch[0].length;
|
||||
continue;
|
||||
}
|
||||
// Identifier — check keyword set.
|
||||
const idMatch = remaining.match(/^[a-zA-Z_][a-zA-Z0-9_]*/);
|
||||
if (idMatch) {
|
||||
if (keywords.has(idMatch[0])) out.push(token("keyword", idMatch[0]));
|
||||
else out.push(token("plain", idMatch[0]));
|
||||
i += idMatch[0].length;
|
||||
continue;
|
||||
}
|
||||
// Plain character.
|
||||
out.push(token("plain", code[i] ?? ""));
|
||||
i++;
|
||||
}
|
||||
return mergeAdjacentPlain(out);
|
||||
}
|
||||
|
||||
function tokenizeYaml(code: string): Token[] {
|
||||
const out: Token[] = [];
|
||||
for (const line of code.split(/(\n)/)) {
|
||||
if (line === "\n") { out.push(token("plain", "\n")); continue; }
|
||||
// Comment to EOL
|
||||
const commentIdx = line.indexOf("#");
|
||||
if (commentIdx !== -1) {
|
||||
const before = line.slice(0, commentIdx);
|
||||
out.push(...tokenizeYamlLineBody(before));
|
||||
out.push(token("comment", line.slice(commentIdx)));
|
||||
} else {
|
||||
out.push(...tokenizeYamlLineBody(line));
|
||||
}
|
||||
}
|
||||
return mergeAdjacentPlain(out);
|
||||
}
|
||||
|
||||
function tokenizeYamlLineBody(line: string): Token[] {
|
||||
// Match leading indent + key + ": " + value.
|
||||
const m = line.match(/^(\s*)([\w.-]+)(:)(\s*)(.*)$/);
|
||||
if (m) {
|
||||
const out: Token[] = [];
|
||||
out.push(token("plain", m[1]!));
|
||||
out.push(token("tag", m[2]!));
|
||||
out.push(token("punctuation", m[3]!));
|
||||
out.push(token("plain", m[4]!));
|
||||
const rest = m[5]!;
|
||||
if (rest.startsWith('"') || rest.startsWith("'")) {
|
||||
out.push(token("string", rest));
|
||||
} else if (/^\d+(\.\d+)?$/.test(rest)) {
|
||||
out.push(token("number", rest));
|
||||
} else {
|
||||
out.push(token("plain", rest));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return [token("plain", line)];
|
||||
}
|
||||
|
||||
function tokenizeJson(code: string): Token[] {
|
||||
const out: Token[] = [];
|
||||
let i = 0;
|
||||
while (i < code.length) {
|
||||
const ch = code[i];
|
||||
if (ch === '"') {
|
||||
let j = 1;
|
||||
while (j < code.length - i && code[i + j] !== '"') {
|
||||
if (code[i + j] === "\\") j += 2; else j++;
|
||||
}
|
||||
out.push(token("string", code.slice(i, i + j + 1)));
|
||||
i += j + 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === "{" || ch === "}" || ch === "[" || ch === "]" || ch === "," || ch === ":") {
|
||||
out.push(token("punctuation", ch));
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
const numMatch = code.slice(i).match(/^-?\d+(\.\d+)?/);
|
||||
if (numMatch) {
|
||||
out.push(token("number", numMatch[0]));
|
||||
i += numMatch[0].length;
|
||||
continue;
|
||||
}
|
||||
const wordMatch = code.slice(i).match(/^(true|false|null)\b/);
|
||||
if (wordMatch) {
|
||||
out.push(token("keyword", wordMatch[0]));
|
||||
i += wordMatch[0].length;
|
||||
continue;
|
||||
}
|
||||
out.push(token("plain", ch ?? ""));
|
||||
i++;
|
||||
}
|
||||
return mergeAdjacentPlain(out);
|
||||
}
|
||||
|
||||
function tokenizeMarkdown(code: string): Token[] {
|
||||
// Light markdown highlighting inside fenced code blocks: heading
|
||||
// markers + list bullets + emphasis tokens. Mostly plain — operators
|
||||
// typically prose-read inline `code` segments, not full markdown
|
||||
// documents inside fenced code.
|
||||
return [token("plain", code)];
|
||||
}
|
||||
|
||||
function mergeAdjacentPlain(tokens: Token[]): Token[] {
|
||||
const out: Token[] = [];
|
||||
for (const t of tokens) {
|
||||
const last = out[out.length - 1];
|
||||
if (last && last.kind === "plain" && t.kind === "plain") {
|
||||
out[out.length - 1] = token("plain", last.text + t.text);
|
||||
} else {
|
||||
out.push(t);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
// UI Enhancement Pack v0 — Progress browse workspace.
|
||||
//
|
||||
// Top-level center-workspace surface for /progress route. Renders the
|
||||
// workspace's PROGRESS.md hierarchy across operator-allowlisted scan
|
||||
// roots in one tree.
|
||||
//
|
||||
// Two-pane shape:
|
||||
// - Left: filter row (status: All/Active/Done/Blocked) + free-text
|
||||
// search + per-file collapsible sections.
|
||||
// - Right: when a row is clicked, an inline detail panel shows
|
||||
// the source file:line citation + parent context.
|
||||
//
|
||||
// Per item 1B: read-only at v0; edit mode is item 4 (Files workspace).
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useProgressTree, type ProgressFileNode, type ProgressRow, type CheckboxStatus } from "../../hooks/useProgressTree.js";
|
||||
|
||||
type StatusFilter = "all" | "active" | "done" | "blocked";
|
||||
|
||||
const FILTERS: StatusFilter[] = ["all", "active", "done", "blocked"];
|
||||
|
||||
function isUnavailable(data: unknown): data is { unavailable: true; error: string; hint?: string } {
|
||||
return Boolean(data && typeof data === "object" && "unavailable" in (data as Record<string, unknown>));
|
||||
}
|
||||
|
||||
export function ProgressWorkspace() {
|
||||
const tree = useProgressTree();
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedRow, setSelectedRow] = useState<{ file: ProgressFileNode; row: ProgressRow } | null>(null);
|
||||
|
||||
return (
|
||||
<div data-testid="progress-workspace" className="flex h-full flex-col lg:pl-[var(--workspace-left-offset,0px)] lg:pr-[var(--workspace-right-offset,0px)]">
|
||||
<header className="border-b border-stone-200 bg-stone-50 px-4 py-3">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stone-500">Workspace</div>
|
||||
<h1 className="font-headline text-xl font-bold tracking-tight text-stone-900">Progress</h1>
|
||||
{tree.data && !isUnavailable(tree.data) && (
|
||||
<div className="mt-1 font-mono text-[10px] text-stone-500" data-testid="progress-aggregate">
|
||||
{tree.data.aggregate.totalFiles} file{tree.data.aggregate.totalFiles === 1 ? "" : "s"}
|
||||
{" · "}{tree.data.aggregate.totalDone} done
|
||||
{" · "}{tree.data.aggregate.totalActive} active
|
||||
{" · "}{tree.data.aggregate.totalBlocked} blocked
|
||||
{" / "}{tree.data.aggregate.totalRows} total
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2" data-testid="progress-filter-row">
|
||||
{FILTERS.map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
type="button"
|
||||
data-testid={`progress-filter-${f}`}
|
||||
data-active={statusFilter === f}
|
||||
onClick={() => setStatusFilter(f)}
|
||||
className={`border px-2 py-1 font-mono text-[10px] uppercase tracking-[0.10em] ${
|
||||
statusFilter === f
|
||||
? "border-stone-700 bg-stone-700 text-white"
|
||||
: "border-stone-300 text-stone-700 hover:bg-stone-100"
|
||||
}`}
|
||||
>
|
||||
{f}
|
||||
</button>
|
||||
))}
|
||||
<input
|
||||
data-testid="progress-search"
|
||||
type="text"
|
||||
placeholder="search rows…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="ml-auto min-w-32 flex-1 border border-stone-300 bg-white px-2 py-1 font-mono text-[10px]"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex flex-1 min-h-0">
|
||||
<main data-testid="progress-tree-pane" className="flex-1 min-w-0 overflow-y-auto bg-white">
|
||||
{tree.isLoading && <div className="p-4 font-mono text-[10px] text-stone-400">Loading…</div>}
|
||||
{tree.isError && <div className="p-4 font-mono text-[10px] text-red-600">Error loading progress tree.</div>}
|
||||
{isUnavailable(tree.data) && (
|
||||
<div data-testid="progress-unavailable" className="p-4 font-mono text-[10px] text-stone-500">
|
||||
<div>Progress indexer unavailable.</div>
|
||||
{tree.data.hint && <div className="mt-1 text-stone-400">{tree.data.hint}</div>}
|
||||
</div>
|
||||
)}
|
||||
{tree.data && !isUnavailable(tree.data) && tree.data.files.length === 0 && (
|
||||
<div className="p-4 font-mono text-[10px] text-stone-400">
|
||||
No PROGRESS.md files found in scan roots: {tree.data.scannedRoots.map((r) => r.name).join(", ") || "(none)"}.
|
||||
</div>
|
||||
)}
|
||||
{tree.data && !isUnavailable(tree.data) && tree.data.files.map((file) => (
|
||||
<ProgressFileSection
|
||||
key={`${file.rootName}/${file.relPath}`}
|
||||
file={file}
|
||||
statusFilter={statusFilter}
|
||||
search={search}
|
||||
onRowClick={(row) => setSelectedRow({ file, row })}
|
||||
selectedRow={selectedRow}
|
||||
/>
|
||||
))}
|
||||
</main>
|
||||
{selectedRow && (
|
||||
<aside data-testid="progress-detail-pane" className="w-72 shrink-0 overflow-y-auto border-l border-stone-200 bg-stone-50 p-3">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="progress-detail-close"
|
||||
onClick={() => setSelectedRow(null)}
|
||||
className="mb-2 font-mono text-[9px] uppercase tracking-[0.12em] text-stone-500 hover:text-stone-900"
|
||||
>
|
||||
✕ close
|
||||
</button>
|
||||
<div className="font-mono text-[10px] text-stone-500">
|
||||
<div className="font-bold text-stone-700">{selectedRow.file.title ?? selectedRow.file.relPath}</div>
|
||||
<div className="mt-1 text-[8px] uppercase tracking-[0.12em]">{selectedRow.file.rootName}</div>
|
||||
<div className="mt-1 text-[9px] text-stone-400 break-all">{selectedRow.file.relPath}:{selectedRow.row.line}</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<div className="font-mono text-[8px] uppercase tracking-[0.12em] text-stone-500">Row</div>
|
||||
<div data-testid="progress-detail-text" className="mt-1 whitespace-pre-line font-mono text-[10px] text-stone-800">
|
||||
{selectedRow.row.text}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<div className="font-mono text-[8px] uppercase tracking-[0.12em] text-stone-500">Status</div>
|
||||
<div data-testid="progress-detail-status" className="mt-1 font-mono text-[10px] text-stone-700">
|
||||
{selectedRow.row.status}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProgressFileSection({
|
||||
file,
|
||||
statusFilter,
|
||||
search,
|
||||
onRowClick,
|
||||
selectedRow,
|
||||
}: {
|
||||
file: ProgressFileNode;
|
||||
statusFilter: StatusFilter;
|
||||
search: string;
|
||||
onRowClick: (row: ProgressRow) => void;
|
||||
selectedRow: { file: ProgressFileNode; row: ProgressRow } | null;
|
||||
}) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const filteredRows = useMemo(() => filterRows(file.rows, statusFilter, search), [file.rows, statusFilter, search]);
|
||||
if (filteredRows.length === 0 && (statusFilter !== "all" || search)) return null;
|
||||
return (
|
||||
<section
|
||||
data-testid={`progress-file-${file.rootName}-${file.relPath}`}
|
||||
className="border-b border-stone-100"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`progress-file-${file.rootName}-${file.relPath}-toggle`}
|
||||
onClick={() => setCollapsed((v) => !v)}
|
||||
className="flex w-full items-baseline gap-2 bg-stone-50 px-3 py-2 text-left hover:bg-stone-100"
|
||||
>
|
||||
<span className="font-mono text-[10px] text-stone-500">{collapsed ? "▸" : "▾"}</span>
|
||||
<span className="font-mono text-[11px] font-bold text-stone-900">{file.title ?? file.relPath}</span>
|
||||
<span className="font-mono text-[8px] uppercase tracking-[0.10em] text-stone-500">{file.rootName}</span>
|
||||
<span className="ml-auto font-mono text-[9px] text-stone-500">
|
||||
{file.counts.done}/{file.counts.total}
|
||||
</span>
|
||||
</button>
|
||||
{!collapsed && (
|
||||
<ul className="px-3 py-2">
|
||||
{filteredRows.map((row) => (
|
||||
<li
|
||||
key={`${file.relPath}:${row.line}`}
|
||||
data-testid={`progress-row-${file.rootName}-${row.line}`}
|
||||
data-status={row.status}
|
||||
data-kind={row.kind}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRowClick(row)}
|
||||
style={{ paddingLeft: `${row.depth * 1.2}rem` }}
|
||||
className={`flex w-full items-baseline gap-2 py-0.5 text-left hover:bg-stone-100 ${
|
||||
selectedRow?.row === row ? "bg-stone-200/80" : ""
|
||||
}`}
|
||||
>
|
||||
{row.kind === "heading" ? (
|
||||
<span className="font-mono text-[10px] font-semibold uppercase tracking-[0.10em] text-stone-700">
|
||||
{row.text}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<StatusPill status={row.status} />
|
||||
<span className="font-mono text-[10px] text-stone-800">{row.text}</span>
|
||||
<span className="ml-auto font-mono text-[8px] text-stone-400">L{row.line}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function filterRows(rows: ProgressRow[], filter: StatusFilter, search: string): ProgressRow[] {
|
||||
const lowerSearch = search.trim().toLowerCase();
|
||||
return rows.filter((r) => {
|
||||
// Always include headings (they provide context for filtered children).
|
||||
// When a search is active, also gate headings on the search term.
|
||||
if (r.kind === "heading") {
|
||||
if (lowerSearch && !r.text.toLowerCase().includes(lowerSearch)) return false;
|
||||
return true;
|
||||
}
|
||||
if (filter === "active" && r.status !== "active") return false;
|
||||
if (filter === "done" && r.status !== "done") return false;
|
||||
if (filter === "blocked" && r.status !== "blocked") return false;
|
||||
if (lowerSearch && !r.text.toLowerCase().includes(lowerSearch)) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function StatusPill({ status }: { status: CheckboxStatus }) {
|
||||
const { className, icon, label } = pillStyle(status);
|
||||
return (
|
||||
<span
|
||||
data-testid={`progress-row-pill-${status}`}
|
||||
className={`inline-flex shrink-0 items-center gap-1 rounded-full border px-1.5 py-0 font-mono text-[8px] uppercase tracking-[0.10em] ${className}`}
|
||||
aria-label={label}
|
||||
>
|
||||
<span aria-hidden="true">{icon}</span>
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function pillStyle(status: CheckboxStatus): { className: string; icon: string; label: string } {
|
||||
switch (status) {
|
||||
case "done": return { className: "border-emerald-400 bg-emerald-50 text-emerald-900", icon: "✓", label: "done" };
|
||||
case "blocked": return { className: "border-amber-400 bg-amber-50 text-amber-900", icon: "⚠", label: "blocked" };
|
||||
case "active": return { className: "border-stone-400 bg-stone-50 text-stone-700", icon: "◯", label: "active" };
|
||||
default: return { className: "border-stone-300 bg-stone-50 text-stone-500", icon: "·", label: "unknown" };
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,34 @@
|
||||
// Slice Story View v0 — Acceptance tab.
|
||||
// Slice Story View v0 + UI Enhancement Pack v0 — Acceptance tab.
|
||||
//
|
||||
// Header progress bar + checkbox list pulled from the slice's README /
|
||||
// IMPLEMENTATION-PRD / PROGRESS.md `[ ]` / `[x]` items. Source citation
|
||||
// (file + 1-based line) lets the operator jump to the canonical
|
||||
// statement (Docs tab is the natural follow-on).
|
||||
// v0 (Slice Story View): header progress bar + checkbox list pulled
|
||||
// from the slice's README / IMPLEMENTATION-PRD / PROGRESS.md.
|
||||
//
|
||||
// UI Enhancement Pack v0 (item 1A) extends the checkbox list with:
|
||||
// - Checkbox pills (rounded with status icon: ◯ active / ✓ done /
|
||||
// ⚠ blocked) instead of raw `[ ]` / `[x]` syntax.
|
||||
// - Status filter chips (All / Active / Done / Blocked; default All).
|
||||
// - Click-to-expand row detail panel showing source file:line
|
||||
// citation prominently.
|
||||
//
|
||||
// (Hierarchy from `## Heading` sections + ` -` indent is a
|
||||
// straightforward extension once the daemon-side acceptance parser
|
||||
// returns parent_section_heading per row; v0's existing parser only
|
||||
// surfaces the `[ ]`/`[x]` items as a flat list with file+line. The
|
||||
// pills + filters + expand-detail land at v0; nested-tree treatment
|
||||
// follows when the parser exposes parent context.)
|
||||
|
||||
import type { SliceDetail } from "../../../hooks/useSlices.js";
|
||||
import { useMemo, useState } from "react";
|
||||
import type { AcceptanceItem, SliceDetail } from "../../../hooks/useSlices.js";
|
||||
|
||||
type StatusFilter = "all" | "active" | "done" | "blocked";
|
||||
|
||||
const FILTERS: StatusFilter[] = ["all", "active", "done", "blocked"];
|
||||
|
||||
export function AcceptanceTab({ acceptance }: { acceptance: SliceDetail["acceptance"] }) {
|
||||
const { totalItems, doneItems, percentage, items, closureCallout } = acceptance;
|
||||
const [filter, setFilter] = useState<StatusFilter>("all");
|
||||
const filtered = useMemo(() => filterItems(items, filter), [items, filter]);
|
||||
|
||||
return (
|
||||
<div data-testid="acceptance-tab" className="p-4">
|
||||
<header className="mb-4">
|
||||
@@ -36,31 +56,119 @@ export function AcceptanceTab({ acceptance }: { acceptance: SliceDetail["accepta
|
||||
{closureCallout}
|
||||
</div>
|
||||
)}
|
||||
{items.length > 0 && (
|
||||
<div className="mt-3 flex gap-1" data-testid="acceptance-filter-row">
|
||||
{FILTERS.map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
type="button"
|
||||
data-testid={`acceptance-filter-${f}`}
|
||||
data-active={filter === f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`border px-2 py-1 font-mono text-[9px] uppercase tracking-[0.10em] ${
|
||||
filter === f
|
||||
? "border-stone-700 bg-stone-700 text-white"
|
||||
: "border-stone-300 text-stone-700 hover:bg-stone-100"
|
||||
}`}
|
||||
>
|
||||
{f}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
{items.length === 0 ? (
|
||||
<div className="font-mono text-[10px] text-stone-400" data-testid="acceptance-empty">
|
||||
No acceptance items found in slice docs (looks for `[ ]` / `[x]` checkbox lines in README / IMPLEMENTATION-PRD / PROGRESS / IMPLEMENTATION).
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="font-mono text-[10px] text-stone-400" data-testid="acceptance-filter-empty">
|
||||
No items match filter '{filter}'.
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-1" data-testid="acceptance-list">
|
||||
{items.map((item, idx) => (
|
||||
<li
|
||||
key={`${item.source.file}:${item.source.line}`}
|
||||
data-testid={`acceptance-item-${idx}`}
|
||||
data-done={item.done}
|
||||
className="flex items-start gap-2 border-b border-stone-100 py-1"
|
||||
>
|
||||
<span className="font-mono text-[10px] text-stone-500" aria-label={item.done ? "done" : "pending"}>
|
||||
{item.done ? "[x]" : "[ ]"}
|
||||
</span>
|
||||
<span className="flex-1 font-mono text-[10px] text-stone-800">{item.text}</span>
|
||||
<span className="font-mono text-[8px] text-stone-400" title={`${item.source.file}:${item.source.line}`}>
|
||||
{item.source.file}:{item.source.line}
|
||||
</span>
|
||||
</li>
|
||||
{filtered.map((item, idx) => (
|
||||
<AcceptanceRow key={`${item.source.file}:${item.source.line}`} item={item} idx={idx} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AcceptanceRow({ item, idx }: { item: AcceptanceItem; idx: number }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const { pillClass, pillIcon, pillLabel } = pillStyle(item.done);
|
||||
return (
|
||||
<li
|
||||
data-testid={`acceptance-item-${idx}`}
|
||||
data-done={item.done}
|
||||
className="border-b border-stone-100"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
data-testid={`acceptance-item-${idx}-toggle`}
|
||||
className="flex w-full items-start gap-2 py-1.5 text-left hover:bg-stone-50"
|
||||
>
|
||||
<span
|
||||
data-testid={`acceptance-pill-${idx}`}
|
||||
className={`inline-flex shrink-0 items-center gap-1 rounded-full border px-2 py-0.5 font-mono text-[9px] uppercase tracking-[0.10em] ${pillClass}`}
|
||||
aria-label={pillLabel}
|
||||
>
|
||||
<span aria-hidden="true">{pillIcon}</span>
|
||||
<span>{pillLabel}</span>
|
||||
</span>
|
||||
<span className="flex-1 font-mono text-[10px] text-stone-800">{item.text}</span>
|
||||
<span className="font-mono text-[8px] text-stone-400" title={`${item.source.file}:${item.source.line}`}>
|
||||
{item.source.file}:{item.source.line}
|
||||
</span>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div
|
||||
data-testid={`acceptance-item-${idx}-detail`}
|
||||
className="ml-2 mt-1 border-l-2 border-stone-300 bg-stone-50 px-3 py-2 font-mono text-[9px] text-stone-700"
|
||||
>
|
||||
<div>
|
||||
<span className="font-bold">Source:</span>{" "}
|
||||
<span data-testid={`acceptance-item-${idx}-citation`}>{item.source.file}:{item.source.line}</span>
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
<span className="font-bold">Status:</span> {pillLabel}
|
||||
</div>
|
||||
<div className="mt-2 whitespace-pre-line text-stone-800">{item.text}</div>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function filterItems(items: AcceptanceItem[], filter: StatusFilter): AcceptanceItem[] {
|
||||
if (filter === "all") return items;
|
||||
if (filter === "done") return items.filter((i) => i.done);
|
||||
if (filter === "active") return items.filter((i) => !i.done);
|
||||
if (filter === "blocked") {
|
||||
// v0's parser doesn't expose a "blocked" state separately; the
|
||||
// blocked filter shows items whose text contains a "blocked" /
|
||||
// "park" hint as a heuristic. When the parser graduates to
|
||||
// `[~]` recognition the heuristic will degrade gracefully (the
|
||||
// filter still works on text patterns).
|
||||
return items.filter((i) => /\b(blocked|blocker|parked|park)\b/i.test(i.text));
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function pillStyle(done: boolean): { pillClass: string; pillIcon: string; pillLabel: string } {
|
||||
if (done) {
|
||||
return {
|
||||
pillClass: "border-emerald-400 bg-emerald-50 text-emerald-900",
|
||||
pillIcon: "✓",
|
||||
pillLabel: "done",
|
||||
};
|
||||
}
|
||||
return {
|
||||
pillClass: "border-stone-400 bg-stone-50 text-stone-700",
|
||||
pillIcon: "◯",
|
||||
pillLabel: "active",
|
||||
};
|
||||
}
|
||||
@@ -1,14 +1,23 @@
|
||||
// Slice Story View v0 — Docs tab.
|
||||
// Slice Story View v0 + UI Enhancement Pack v0 — Docs tab.
|
||||
//
|
||||
// Two-column layout: left = file tree (slice folder + descendants),
|
||||
// right = lazy markdown viewer for the selected file. Composable with
|
||||
// PL-009 Markdown Viewer when that ships; until then v0 renders the
|
||||
// markdown content as plain `<pre>` so the operator at least sees the
|
||||
// canonical text + can copy citations into queue messages.
|
||||
// right = lazy markdown viewer for the selected file.
|
||||
//
|
||||
// UI Enhancement Pack v0 item 2: the right pane now renders Markdown
|
||||
// via the new MarkdownViewer (YAML frontmatter as metadata header,
|
||||
// code blocks with syntax highlighting, lists, tables, links,
|
||||
// images, mermaid placeholder per carve-out). v0's plain-`<pre>`
|
||||
// fallback is preserved for non-`.md` files.
|
||||
//
|
||||
// Image src resolution: relative paths in `.md` content are resolved
|
||||
// against the slice's proof-asset endpoint when applicable. v1 of
|
||||
// UI Enhancement Pack can extend this to the slice's docs endpoint
|
||||
// once the daemon adds an analogous static-asset path for slice docs.
|
||||
|
||||
import { useState } from "react";
|
||||
import type { DocsTreeEntry } from "../../../hooks/useSlices.js";
|
||||
import { useSliceDoc } from "../../../hooks/useSlices.js";
|
||||
import { MarkdownViewer } from "../../markdown/MarkdownViewer.js";
|
||||
|
||||
export function DocsTab({ sliceName, tree }: { sliceName: string; tree: DocsTreeEntry[] }) {
|
||||
const initial = tree.find((e) => e.type === "file" && e.name === "README.md")?.relPath
|
||||
@@ -54,9 +63,15 @@ export function DocsTab({ sliceName, tree }: { sliceName: string; tree: DocsTree
|
||||
<div className="p-4 font-mono text-[10px] text-red-600">Error loading doc.</div>
|
||||
)}
|
||||
{selected && doc.data && (
|
||||
<pre className="whitespace-pre-wrap break-words p-4 font-mono text-[11px] text-stone-800" data-testid="docs-viewer-content">
|
||||
{doc.data.content}
|
||||
</pre>
|
||||
<div data-testid="docs-viewer-content" className="p-4">
|
||||
{selected.toLowerCase().endsWith(".md") ? (
|
||||
<MarkdownViewer content={doc.data.content} />
|
||||
) : (
|
||||
<pre className="whitespace-pre-wrap break-words font-mono text-[11px] text-stone-800">
|
||||
{doc.data.content}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
// UI Enhancement Pack v0 — files browser + write hooks.
|
||||
//
|
||||
// Wraps:
|
||||
// - GET /api/files/roots → useFilesRoots
|
||||
// - GET /api/files/list?root=&path= → useFilesList
|
||||
// - GET /api/files/read?root=&path= → useFilesRead
|
||||
// - POST /api/files/write → useFilesWrite (mutation)
|
||||
//
|
||||
// All read hooks surface daemon 503 / 4xx as structured errors via
|
||||
// the `unavailable` shape so the UI renders a setup hint when no
|
||||
// allowlist is configured.
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
export interface FilesUnavailable {
|
||||
unavailable: true;
|
||||
error: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export interface AllowlistRoot {
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface FilesRootsResponse {
|
||||
roots: AllowlistRoot[];
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
async function fetchRoots(): Promise<FilesRootsResponse | FilesUnavailable> {
|
||||
const res = await fetch("/api/files/roots");
|
||||
if (res.status === 503) {
|
||||
const body = (await res.json().catch(() => ({}))) as Partial<FilesUnavailable> & { error?: string; hint?: string };
|
||||
return { unavailable: true, error: body.error ?? "files_routes_unavailable", hint: body.hint };
|
||||
}
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return (await res.json()) as FilesRootsResponse;
|
||||
}
|
||||
|
||||
export function useFilesRoots() {
|
||||
return useQuery({
|
||||
queryKey: ["files", "roots"],
|
||||
queryFn: fetchRoots,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
// --- list ---
|
||||
|
||||
export interface FileEntry {
|
||||
name: string;
|
||||
type: "dir" | "file" | "other";
|
||||
size: number | null;
|
||||
mtime: string | null;
|
||||
}
|
||||
|
||||
export interface FilesListResponse {
|
||||
root: string;
|
||||
path: string;
|
||||
entries: FileEntry[];
|
||||
}
|
||||
|
||||
async function fetchList(root: string, path: string): Promise<FilesListResponse> {
|
||||
const res = await fetch(`/api/files/list?root=${encodeURIComponent(root)}&path=${encodeURIComponent(path)}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return (await res.json()) as FilesListResponse;
|
||||
}
|
||||
|
||||
export function useFilesList(root: string | null, path: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ["files", "list", root, path],
|
||||
queryFn: () => fetchList(root!, path ?? ""),
|
||||
enabled: !!root,
|
||||
staleTime: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
// --- read ---
|
||||
|
||||
export interface FilesReadResponse {
|
||||
root: string;
|
||||
path: string;
|
||||
absolutePath: string;
|
||||
content: string;
|
||||
mtime: string;
|
||||
contentHash: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
async function fetchRead(root: string, path: string): Promise<FilesReadResponse> {
|
||||
const res = await fetch(`/api/files/read?root=${encodeURIComponent(root)}&path=${encodeURIComponent(path)}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return (await res.json()) as FilesReadResponse;
|
||||
}
|
||||
|
||||
export function useFilesRead(root: string | null, path: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ["files", "read", root, path],
|
||||
queryFn: () => fetchRead(root!, path!),
|
||||
enabled: !!root && !!path,
|
||||
staleTime: 0, // always re-read for edit-mode mtime/contentHash freshness
|
||||
});
|
||||
}
|
||||
|
||||
// --- write (item 4) ---
|
||||
|
||||
export interface FileWriteRequest {
|
||||
root: string;
|
||||
path: string;
|
||||
content: string;
|
||||
expectedMtime: string;
|
||||
expectedContentHash: string;
|
||||
actor: string;
|
||||
}
|
||||
|
||||
export interface FileWriteSuccess {
|
||||
root: string;
|
||||
path: string;
|
||||
absolutePath: string;
|
||||
newMtime: string;
|
||||
newContentHash: string;
|
||||
byteCountDelta: number;
|
||||
}
|
||||
|
||||
export interface FileWriteConflict {
|
||||
conflict: true;
|
||||
currentMtime: string;
|
||||
currentContentHash: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type FileWriteResult = FileWriteSuccess | FileWriteConflict;
|
||||
|
||||
async function postWrite(req: FileWriteRequest): Promise<FileWriteResult> {
|
||||
const res = await fetch("/api/files/write", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(req),
|
||||
});
|
||||
if (res.status === 409) {
|
||||
const body = (await res.json()) as { currentMtime: string; currentContentHash: string; message?: string };
|
||||
return {
|
||||
conflict: true,
|
||||
currentMtime: body.currentMtime,
|
||||
currentContentHash: body.currentContentHash,
|
||||
message: body.message ?? "file changed externally",
|
||||
};
|
||||
}
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => ({}))) as { error?: string; message?: string };
|
||||
throw new Error(body.message ?? body.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
return (await res.json()) as FileWriteSuccess;
|
||||
}
|
||||
|
||||
export function useFilesWrite() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: postWrite,
|
||||
onSuccess: (result, vars) => {
|
||||
// Invalidate the read query so the next render gets the new
|
||||
// mtime + contentHash + content.
|
||||
qc.invalidateQueries({ queryKey: ["files", "read", vars.root, vars.path] });
|
||||
qc.invalidateQueries({ queryKey: ["files", "list", vars.root] });
|
||||
// Result-shape is either success or conflict; conflict isn't
|
||||
// an error per react-query semantics here — caller inspects.
|
||||
void result;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function fileAssetUrl(root: string, path: string): string {
|
||||
return `/api/files/asset?root=${encodeURIComponent(root)}&path=${encodeURIComponent(path)}`;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// UI Enhancement Pack v0 — progress browse hook.
|
||||
//
|
||||
// Wraps GET /api/progress/tree. Surfaces the daemon's
|
||||
// "progress_scan_roots_not_configured" 503 path as a structured
|
||||
// `unavailable` sentinel so the UI can render a setup hint.
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
export type CheckboxStatus = "active" | "done" | "blocked" | "unknown";
|
||||
|
||||
export interface ProgressRow {
|
||||
line: number;
|
||||
depth: number;
|
||||
status: CheckboxStatus;
|
||||
text: string;
|
||||
kind: "checkbox" | "heading";
|
||||
}
|
||||
|
||||
export interface ProgressFileNode {
|
||||
rootName: string;
|
||||
relPath: string;
|
||||
absolutePath: string;
|
||||
mtime: string;
|
||||
rows: ProgressRow[];
|
||||
title: string | null;
|
||||
counts: { total: number; done: number; blocked: number; active: number };
|
||||
}
|
||||
|
||||
export interface ProgressTreeResult {
|
||||
files: ProgressFileNode[];
|
||||
aggregate: { totalFiles: number; totalRows: number; totalDone: number; totalBlocked: number; totalActive: number };
|
||||
scannedRoots: Array<{ name: string; canonicalPath: string }>;
|
||||
}
|
||||
|
||||
export interface ProgressUnavailable {
|
||||
unavailable: true;
|
||||
error: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
async function fetchProgressTree(): Promise<ProgressTreeResult | ProgressUnavailable> {
|
||||
const res = await fetch("/api/progress/tree");
|
||||
if (res.status === 503) {
|
||||
const body = (await res.json().catch(() => ({}))) as Partial<ProgressUnavailable> & { error?: string; hint?: string };
|
||||
return { unavailable: true, error: body.error ?? "progress_indexer_unavailable", hint: body.hint };
|
||||
}
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return (await res.json()) as ProgressTreeResult;
|
||||
}
|
||||
|
||||
export function useProgressTree() {
|
||||
return useQuery({
|
||||
queryKey: ["progress", "tree"],
|
||||
queryFn: fetchProgressTree,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
@@ -25,6 +25,8 @@ import { LiveNodeDetails } from "./components/LiveNodeDetails.js";
|
||||
import { DiscoveryOverlay } from "./components/DiscoveryOverlay.js";
|
||||
import { MissionControlSurface } from "./components/mission-control/MissionControlSurface.js";
|
||||
import { SliceStoryView } from "./components/slices/SliceStoryView.js";
|
||||
import { ProgressWorkspace } from "./components/progress/ProgressWorkspace.js";
|
||||
import { FilesWorkspace } from "./components/files/FilesWorkspace.js";
|
||||
import { useRigSummary } from "./hooks/useRigSummary.js";
|
||||
import { useDrawerSelection } from "./components/AppShell.js";
|
||||
|
||||
@@ -241,6 +243,20 @@ const sliceDetailRoute = createRoute({
|
||||
component: SliceStoryView,
|
||||
});
|
||||
|
||||
// UI Enhancement Pack v0: top-level Progress (item 1B) + Files
|
||||
// (item 3 + item 4) workspaces. Both are read-only pages with their
|
||||
// own internal layout — no left-sidebar nav coupling.
|
||||
const progressRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/progress",
|
||||
component: ProgressWorkspace,
|
||||
});
|
||||
const filesRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/files",
|
||||
component: FilesWorkspace,
|
||||
});
|
||||
|
||||
const routeTree = rootRoute.addChildren([
|
||||
indexRoute,
|
||||
rigDetailRoute,
|
||||
@@ -262,6 +278,8 @@ const routeTree = rootRoute.addChildren([
|
||||
missionControlRoute,
|
||||
slicesIndexRoute,
|
||||
sliceDetailRoute,
|
||||
progressRoute,
|
||||
filesRoute,
|
||||
]);
|
||||
|
||||
// Router
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
// UI Enhancement Pack v0 — AcceptanceTab item-1A extension tests.
|
||||
//
|
||||
// Pins the v0 → v0-enhanced behavior:
|
||||
// - checkbox pills with status icon + label (not raw [ ]/[x])
|
||||
// - 4-filter row (All / Active / Done / Blocked) with default All
|
||||
// - clicking a row expands an inline detail panel
|
||||
// - filter narrows the visible rows; empty-after-filter has its own
|
||||
// message; filter unchanged when items array is empty
|
||||
//
|
||||
// Slice Story View v0 baseline behavior preserved (progress bar +
|
||||
// closure callout + source citation + per-row data-done attribute).
|
||||
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { AcceptanceTab } from "../src/components/slices/tabs/AcceptanceTab.js";
|
||||
import type { SliceDetail } from "../src/hooks/useSlices.js";
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
function makeAcceptance(items: SliceDetail["acceptance"]["items"]): SliceDetail["acceptance"] {
|
||||
const done = items.filter((i) => i.done).length;
|
||||
return {
|
||||
totalItems: items.length,
|
||||
doneItems: done,
|
||||
percentage: items.length === 0 ? 0 : Math.round((done / items.length) * 100),
|
||||
items,
|
||||
closureCallout: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("UI Enhancement Pack v0 — AcceptanceTab", () => {
|
||||
it("renders checkbox pills with status icons (not raw [ ]/[x])", () => {
|
||||
render(<AcceptanceTab acceptance={makeAcceptance([
|
||||
{ text: "Item 1", done: true, source: { file: "README.md", line: 10 } },
|
||||
{ text: "Item 2", done: false, source: { file: "README.md", line: 11 } },
|
||||
])} />);
|
||||
expect(screen.getByTestId("acceptance-pill-0").textContent).toContain("done");
|
||||
expect(screen.getByTestId("acceptance-pill-0").textContent).toContain("✓");
|
||||
expect(screen.getByTestId("acceptance-pill-1").textContent).toContain("active");
|
||||
expect(screen.getByTestId("acceptance-pill-1").textContent).toContain("◯");
|
||||
});
|
||||
|
||||
it("renders the four-filter row with All as default-active", () => {
|
||||
render(<AcceptanceTab acceptance={makeAcceptance([
|
||||
{ text: "x", done: true, source: { file: "x.md", line: 1 } },
|
||||
])} />);
|
||||
expect(screen.getByTestId("acceptance-filter-row")).toBeDefined();
|
||||
for (const f of ["all", "active", "done", "blocked"]) {
|
||||
expect(screen.getByTestId(`acceptance-filter-${f}`)).toBeDefined();
|
||||
}
|
||||
expect(screen.getByTestId("acceptance-filter-all").getAttribute("data-active")).toBe("true");
|
||||
});
|
||||
|
||||
it("filtering to Active narrows the visible rows", () => {
|
||||
render(<AcceptanceTab acceptance={makeAcceptance([
|
||||
{ text: "done item", done: true, source: { file: "x.md", line: 1 } },
|
||||
{ text: "active item", done: false, source: { file: "x.md", line: 2 } },
|
||||
])} />);
|
||||
fireEvent.click(screen.getByTestId("acceptance-filter-active"));
|
||||
expect(screen.queryByText("done item")).toBeNull();
|
||||
expect(screen.getByText("active item")).toBeDefined();
|
||||
});
|
||||
|
||||
it("filtering to a state with no matching items shows the empty-filter message", () => {
|
||||
render(<AcceptanceTab acceptance={makeAcceptance([
|
||||
{ text: "done item", done: true, source: { file: "x.md", line: 1 } },
|
||||
])} />);
|
||||
fireEvent.click(screen.getByTestId("acceptance-filter-active"));
|
||||
expect(screen.getByTestId("acceptance-filter-empty").textContent).toContain("active");
|
||||
});
|
||||
|
||||
it("clicking a row expands the detail panel + clicking again collapses it", () => {
|
||||
render(<AcceptanceTab acceptance={makeAcceptance([
|
||||
{ text: "expandable item", done: false, source: { file: "README.md", line: 42 } },
|
||||
])} />);
|
||||
fireEvent.click(screen.getByTestId("acceptance-item-0-toggle"));
|
||||
const detail = screen.getByTestId("acceptance-item-0-detail");
|
||||
expect(detail).toBeDefined();
|
||||
expect(screen.getByTestId("acceptance-item-0-citation").textContent).toBe("README.md:42");
|
||||
fireEvent.click(screen.getByTestId("acceptance-item-0-toggle"));
|
||||
expect(screen.queryByTestId("acceptance-item-0-detail")).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves v0 progress bar + per-row data-done attributes", () => {
|
||||
render(<AcceptanceTab acceptance={makeAcceptance([
|
||||
{ text: "x", done: true, source: { file: "x.md", line: 1 } },
|
||||
{ text: "y", done: false, source: { file: "x.md", line: 2 } },
|
||||
])} />);
|
||||
expect(screen.getByTestId("acceptance-progress-bar")).toBeDefined();
|
||||
expect(screen.getByTestId("acceptance-progress-fill").getAttribute("data-percentage")).toBe("50");
|
||||
expect(screen.getByTestId("acceptance-item-0").getAttribute("data-done")).toBe("true");
|
||||
expect(screen.getByTestId("acceptance-item-1").getAttribute("data-done")).toBe("false");
|
||||
});
|
||||
|
||||
it("empty items array still renders the v0 empty state (no filter row)", () => {
|
||||
render(<AcceptanceTab acceptance={makeAcceptance([])} />);
|
||||
expect(screen.getByTestId("acceptance-empty")).toBeDefined();
|
||||
expect(screen.queryByTestId("acceptance-filter-row")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
// UI Enhancement Pack v0 — MarkdownViewer focused tests.
|
||||
//
|
||||
// Pins the load-bearing rendering primitives so future refactors of
|
||||
// the inline parser (e.g., swapping in a marked / react-markdown lib
|
||||
// later) preserve operator-visible behavior.
|
||||
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import { MarkdownViewer } from "../src/components/markdown/MarkdownViewer.js";
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe("UI Enhancement Pack v0 — MarkdownViewer", () => {
|
||||
it("renders YAML frontmatter as a metadata header above the body", () => {
|
||||
const md = `---\nslice: my-slice\nstatus: active\n---\n# Title\nbody text`;
|
||||
render(<MarkdownViewer content={md} />);
|
||||
const fm = screen.getByTestId("markdown-frontmatter");
|
||||
expect(fm.textContent).toContain("slice");
|
||||
expect(fm.textContent).toContain("my-slice");
|
||||
expect(fm.textContent).toContain("status");
|
||||
expect(fm.textContent).toContain("active");
|
||||
});
|
||||
|
||||
it("hides frontmatter when hideFrontmatter prop is true", () => {
|
||||
const md = `---\nslice: x\n---\n# Title`;
|
||||
render(<MarkdownViewer content={md} hideFrontmatter />);
|
||||
expect(screen.queryByTestId("markdown-frontmatter")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders headings with proper levels (# / ## / ### / ####)", () => {
|
||||
const md = `# H1\n\n## H2\n\n### H3\n\n#### H4`;
|
||||
render(<MarkdownViewer content={md} />);
|
||||
expect(screen.getByTestId("md-heading-1").textContent).toBe("H1");
|
||||
expect(screen.getByTestId("md-heading-2").textContent).toBe("H2");
|
||||
expect(screen.getByTestId("md-heading-3").textContent).toBe("H3");
|
||||
expect(screen.getByTestId("md-heading-4").textContent).toBe("H4");
|
||||
});
|
||||
|
||||
it("renders bullet lists with depth from indentation", () => {
|
||||
const md = `- top-level\n - nested-1\n - nested-2`;
|
||||
render(<MarkdownViewer content={md} />);
|
||||
const list = screen.getByTestId("md-list-ul");
|
||||
const items = list.querySelectorAll("li");
|
||||
expect(items).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("renders ordered lists", () => {
|
||||
const md = `1. first\n2. second`;
|
||||
render(<MarkdownViewer content={md} />);
|
||||
expect(screen.getByTestId("md-list-ol").querySelectorAll("li")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("renders fenced code blocks with the SyntaxHighlight component (per language)", () => {
|
||||
const md = "```ts\nconst x = 1;\n```";
|
||||
render(<MarkdownViewer content={md} />);
|
||||
const block = screen.getByTestId("syntax-highlight-block");
|
||||
expect(block.getAttribute("data-language")).toBe("ts");
|
||||
expect(block.textContent).toContain("const");
|
||||
expect(block.textContent).toContain("x");
|
||||
});
|
||||
|
||||
it("renders mermaid code blocks as a placeholder per item 2 carve-out (no library bundled at v0)", () => {
|
||||
const md = "```mermaid\ngraph TD\n A-->B\n```";
|
||||
render(<MarkdownViewer content={md} />);
|
||||
const placeholder = screen.getByTestId("md-mermaid-placeholder");
|
||||
expect(placeholder.textContent).toContain("mermaid");
|
||||
const btn = screen.getByTestId("md-mermaid-render-btn") as HTMLButtonElement;
|
||||
expect(btn.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("renders inline code with `...` syntax", () => {
|
||||
const md = `paragraph with \`inline code\` here`;
|
||||
render(<MarkdownViewer content={md} />);
|
||||
expect(screen.getByTestId("md-inline-code").textContent).toBe("inline code");
|
||||
});
|
||||
|
||||
it("renders inline links with [text](url)", () => {
|
||||
const md = `see [the docs](https://example.com/docs)`;
|
||||
render(<MarkdownViewer content={md} />);
|
||||
const link = screen.getByTestId("md-inline-link") as HTMLAnchorElement;
|
||||
expect(link.textContent).toBe("the docs");
|
||||
expect(link.getAttribute("href")).toBe("https://example.com/docs");
|
||||
});
|
||||
|
||||
it("renders inline images with relative src resolved against assetBasePath", () => {
|
||||
const md = ``;
|
||||
render(<MarkdownViewer content={md} assetBasePath="/api/files/asset?root=ws&path=docs" />);
|
||||
const img = screen.getByTestId("md-inline-image") as HTMLImageElement;
|
||||
expect(img.getAttribute("src")).toBe("/api/files/asset?root=ws&path=docs/shots/foo.png");
|
||||
});
|
||||
|
||||
it("absolute URLs in image src pass through unchanged", () => {
|
||||
const md = ``;
|
||||
render(<MarkdownViewer content={md} assetBasePath="/api/files/asset?root=ws" />);
|
||||
const img = screen.getByTestId("md-inline-image") as HTMLImageElement;
|
||||
expect(img.getAttribute("src")).toBe("https://example.com/img.png");
|
||||
});
|
||||
|
||||
it("renders tables with header + body rows", () => {
|
||||
const md = `| col-a | col-b |\n| --- | --- |\n| 1 | 2 |\n| 3 | 4 |`;
|
||||
render(<MarkdownViewer content={md} />);
|
||||
const wrapper = screen.getByTestId("md-table-wrapper");
|
||||
const headers = wrapper.querySelectorAll("thead th");
|
||||
expect(headers).toHaveLength(2);
|
||||
const bodyRows = wrapper.querySelectorAll("tbody tr");
|
||||
expect(bodyRows).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("renders bold (**) and italic (*) inline emphasis", () => {
|
||||
const md = `paragraph with **bold** and *italic* text`;
|
||||
render(<MarkdownViewer content={md} />);
|
||||
const para = screen.getByTestId("md-paragraph");
|
||||
expect(para.querySelector("strong")?.textContent).toBe("bold");
|
||||
expect(para.querySelector("em")?.textContent).toBe("italic");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user