mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 17:32:41 -10:00
feat(ui): FileLink primitive + slice artifacts file references clickable
V0.3.1 slice 15 walk-items 6 + 11 from the 2026-05-11 synthesis.
(1) New FileLink primitive at packages/ui/src/components/ui/FileLink.tsx:
- Thin wrapper over the existing FileReferenceTrigger with a simpler
API (just `path` + optional `root` / `absolutePath` / `kind` /
`readPath` instead of a fully-constructed FileViewerData).
- Default `testId="file-link"`; `children` falls back to `path`.
- FileViewer's existing inferKind() handles image-extension auto-
detection at render time (drawer-viewers/FileViewer.tsx:44 covers
.png/.jpg/.jpeg/.gif/.webp/.svg → "image" → renders <img>). Walk-
item 6 ("images show as binary") is therefore resolved by routing
image-typed file references through this primitive instead of
rendering inert filename text — no new viewer kind or drawer
selection type needed.
(2) ScopePages slice-artifacts file list (highest-impact replacement):
- SliceArtifactsTab now wraps each FILE entry in <FileLink> with
the slice's absolutePath threaded through. Directory entries stay
inert (no read-as-file).
- Click on a screenshot/image now opens the drawer with the image
rendered correctly; click on a markdown / text / yaml file opens
with MarkdownViewer / plain text accordingly.
Scope cuts (endorsed by orch at pre-work ACK):
- ProofImageViewer rename deferred — multiple callers tightly coupled
to proofAssetUrl(sliceName, relPath); the FileViewer image-kind path
is the canonical going-forward surface. Flagged in driver ACK for
follow-up.
- Exhaustive audit-and-replace deferred — slice ships the primitive +
highest-impact surface; remaining call sites (DocsTab in-tab viewer
is actually well-shaped today; other potential sites identified via
pre-work audit) iterate as founder-walk surfaces additional gaps.
Tests:
- file-link.test.tsx (NEW): 6 tests — render with default testId +
path-as-label; custom children; custom testId; click →
setSelection({ type: "file", data }); absolutePath + readPath +
kind threaded through; walk-item-6 fix (no spurious kind injection
so downstream inferKind path is unblocked).
- Full daemon suite: 3379 passed / 1 skipped / 0 failed.
- UI typecheck on touched files: zero new errors.
- git diff --check clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
1 parent
82d4e9f8c5
commit
be2ea657a1
3 files changed
+211
-10
No files matched your search
@@ -42,6 +42,7 @@ import {
|
||||
import { TimelineTab } from "../slices/tabs/TimelineTab.js";
|
||||
import { useSliceTimelineMarkdown } from "../../hooks/useSliceTimelineMarkdown.js";
|
||||
import { useScopeMarkdown } from "../../hooks/useScopeMarkdown.js";
|
||||
import { FileLink } from "../ui/FileLink.js";
|
||||
import { useMission } from "../../hooks/useMission.js";
|
||||
import { MarkdownViewer } from "../markdown/MarkdownViewer.js";
|
||||
import { AcceptanceTab } from "../slices/tabs/AcceptanceTab.js";
|
||||
@@ -965,16 +966,38 @@ function SliceArtifactsTab({ detail }: { detail: SliceDetail }) {
|
||||
<SectionHeader tone="muted">Files</SectionHeader>
|
||||
{docsTree.length > 0 ? (
|
||||
<ul className="mt-3 divide-y divide-outline-variant border border-outline-variant bg-white/30">
|
||||
{docsTree.map((entry) => (
|
||||
<li key={entry.relPath} className="grid grid-cols-[1fr_auto_auto] gap-3 px-3 py-2 font-mono text-[10px]">
|
||||
<span className="inline-flex min-w-0 items-center gap-1.5 text-stone-900">
|
||||
<ToolMark tool={entry.relPath} size="xs" />
|
||||
<span className="truncate">{entry.relPath}</span>
|
||||
</span>
|
||||
<span className="uppercase tracking-[0.10em] text-stone-500">{entry.type}</span>
|
||||
<span className="text-stone-400">{entry.size == null ? "-" : `${entry.size}b`}</span>
|
||||
</li>
|
||||
))}
|
||||
{docsTree.map((entry) => {
|
||||
// V0.3.1 slice 15 walk-items 6 + 11 — file entries
|
||||
// become FileLink triggers. Click opens the
|
||||
// SharedDetailDrawer with FileViewer (images render as
|
||||
// images via FileViewer.inferKind). Directories stay
|
||||
// inert (non-clickable) since FileViewer would 404 on
|
||||
// a directory read.
|
||||
const isFile = entry.type === "file";
|
||||
const absolutePath = `${detail.slicePath}/${entry.relPath}`;
|
||||
return (
|
||||
<li key={entry.relPath} className="grid grid-cols-[1fr_auto_auto] gap-3 px-3 py-2 font-mono text-[10px]">
|
||||
{isFile ? (
|
||||
<FileLink
|
||||
path={entry.relPath}
|
||||
absolutePath={absolutePath}
|
||||
className="inline-flex min-w-0 items-center gap-1.5 text-stone-900 hover:underline text-left"
|
||||
testId={`slice-artifacts-file-${entry.relPath}`}
|
||||
>
|
||||
<ToolMark tool={entry.relPath} size="xs" />
|
||||
<span className="truncate">{entry.relPath}</span>
|
||||
</FileLink>
|
||||
) : (
|
||||
<span className="inline-flex min-w-0 items-center gap-1.5 text-stone-500">
|
||||
<ToolMark tool="folder" size="xs" decorative />
|
||||
<span className="truncate">{entry.relPath}</span>
|
||||
</span>
|
||||
)}
|
||||
<span className="uppercase tracking-[0.10em] text-stone-500">{entry.type}</span>
|
||||
<span className="text-stone-400">{entry.size == null ? "-" : `${entry.size}b`}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="mt-3 font-mono text-[10px] text-stone-400">No slice files indexed.</div>
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// V0.3.1 slice 15 walk-items 6 + 11 — universal file-link primitive.
|
||||
//
|
||||
// Wraps any file reference in the UI so a click opens the
|
||||
// SharedDetailDrawer with the appropriate viewer. Thin layer over the
|
||||
// existing FileReferenceTrigger: simpler API (just `path` + `root`
|
||||
// instead of a fully-constructed FileViewerData) so callsites adopt
|
||||
// it without ceremony.
|
||||
//
|
||||
// FileViewer infers the kind from extension at render time (see
|
||||
// drawer-viewers/FileViewer.tsx :: inferKind), so FileLink doesn't
|
||||
// run its own inference path — image extensions
|
||||
// (.png/.jpg/.jpeg/.gif/.webp/.svg) automatically render as `<img>`
|
||||
// via the drawer. Walk-item 6 ("images show as binary") is therefore
|
||||
// resolved by routing image-typed file references through this
|
||||
// primitive instead of rendering filename text inertly.
|
||||
|
||||
import type { ReactNode, CSSProperties } from "react";
|
||||
import { FileReferenceTrigger } from "../drawer-triggers/FileReferenceTrigger.js";
|
||||
import type { FileKind, FileViewerData } from "../drawer-viewers/FileViewer.js";
|
||||
|
||||
export interface FileLinkProps {
|
||||
/** Display path. Also the relative read path under `root` when
|
||||
* `readPath` is omitted. */
|
||||
path: string;
|
||||
/** Allowlist root name to read from. Optional when `absolutePath` is
|
||||
* provided (the FileViewer's resolver picks the matching root). */
|
||||
root?: string;
|
||||
/** Explicit relative path under `root`; defaults to `path` when
|
||||
* omitted. Useful when `path` is a display label distinct from the
|
||||
* on-disk relative path. */
|
||||
readPath?: string;
|
||||
/** Absolute filesystem path; resolved against /api/files/roots by
|
||||
* FileViewer when `root` isn't provided. */
|
||||
absolutePath?: string | null;
|
||||
/** Optional explicit kind override. When omitted, FileViewer infers
|
||||
* from `path` extension at render time. */
|
||||
kind?: FileKind;
|
||||
/** Children rendered inside the clickable wrapper. Defaults to the
|
||||
* raw `path` string when omitted. */
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
export function FileLink({
|
||||
path,
|
||||
root,
|
||||
readPath,
|
||||
absolutePath,
|
||||
kind,
|
||||
children,
|
||||
className,
|
||||
style,
|
||||
testId,
|
||||
}: FileLinkProps) {
|
||||
const data: FileViewerData = {
|
||||
path,
|
||||
...(root !== undefined ? { root } : {}),
|
||||
...(readPath !== undefined ? { readPath } : {}),
|
||||
...(absolutePath !== undefined ? { absolutePath } : {}),
|
||||
...(kind !== undefined ? { kind } : {}),
|
||||
};
|
||||
return (
|
||||
<FileReferenceTrigger
|
||||
data={data}
|
||||
className={className}
|
||||
style={style}
|
||||
testId={testId ?? "file-link"}
|
||||
>
|
||||
{children ?? path}
|
||||
</FileReferenceTrigger>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// V0.3.1 slice 15 walk-items 6 + 11 — FileLink primitive tests.
|
||||
//
|
||||
// FileLink is a thin wrapper over the existing FileReferenceTrigger
|
||||
// that constructs FileViewerData from simpler props (path + root +
|
||||
// optional absolutePath / kind / readPath). On click the drawer
|
||||
// selection becomes `{ type: "file", data }`. Image-kind inference
|
||||
// happens in FileViewer at render time, so FileLink itself doesn't
|
||||
// run its own inference path — this test file verifies the data
|
||||
// shape that flows to setSelection so a downstream FileViewer would
|
||||
// honor inferKind correctly.
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { DrawerSelectionContext } from "../src/components/AppShell.js";
|
||||
import { FileLink } from "../src/components/ui/FileLink.js";
|
||||
|
||||
beforeEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function renderWithDrawerCtx(ui: React.ReactNode) {
|
||||
const setSelection = vi.fn();
|
||||
const utils = render(
|
||||
<DrawerSelectionContext.Provider value={{ selection: null, setSelection }}>
|
||||
{ui}
|
||||
</DrawerSelectionContext.Provider>,
|
||||
);
|
||||
return { setSelection, ...utils };
|
||||
}
|
||||
|
||||
describe("FileLink primitive", () => {
|
||||
it("renders as a clickable element with the default testId 'file-link' and the path as the visible label", () => {
|
||||
const { getByTestId } = renderWithDrawerCtx(
|
||||
<FileLink path="README.md" root="workspace" />,
|
||||
);
|
||||
const trigger = getByTestId("file-link");
|
||||
expect(trigger.textContent).toBe("README.md");
|
||||
});
|
||||
|
||||
it("renders custom children when provided (label distinct from raw path)", () => {
|
||||
const { getByTestId } = renderWithDrawerCtx(
|
||||
<FileLink path="missions/release-0.3.1/README.md" root="workspace">
|
||||
<span>release-0.3.1</span>
|
||||
</FileLink>,
|
||||
);
|
||||
expect(getByTestId("file-link").textContent).toBe("release-0.3.1");
|
||||
});
|
||||
|
||||
it("honors a custom testId override", () => {
|
||||
const { getByTestId } = renderWithDrawerCtx(
|
||||
<FileLink path="x.md" root="workspace" testId="custom-file-link" />,
|
||||
);
|
||||
expect(getByTestId("custom-file-link")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("click → setSelection({ type: 'file', data: { path, root } })", () => {
|
||||
const { setSelection, getByTestId } = renderWithDrawerCtx(
|
||||
<FileLink path="notes.md" root="workspace" />,
|
||||
);
|
||||
fireEvent.click(getByTestId("file-link"));
|
||||
expect(setSelection).toHaveBeenCalledOnce();
|
||||
expect(setSelection).toHaveBeenCalledWith({
|
||||
type: "file",
|
||||
data: { path: "notes.md", root: "workspace" },
|
||||
});
|
||||
});
|
||||
|
||||
it("threads absolutePath + readPath + kind through to the drawer payload", () => {
|
||||
const { setSelection, getByTestId } = renderWithDrawerCtx(
|
||||
<FileLink
|
||||
path="screenshot.png"
|
||||
absolutePath="/abs/path/screenshot.png"
|
||||
readPath="screenshots/screenshot.png"
|
||||
kind="image"
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(getByTestId("file-link"));
|
||||
expect(setSelection).toHaveBeenCalledWith({
|
||||
type: "file",
|
||||
data: {
|
||||
path: "screenshot.png",
|
||||
absolutePath: "/abs/path/screenshot.png",
|
||||
readPath: "screenshots/screenshot.png",
|
||||
kind: "image",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("walk-item 6 fix: image extensions flow through without explicit kind (FileViewer infers at render time)", () => {
|
||||
// FileLink doesn't run kind inference itself — it just constructs
|
||||
// FileViewerData with the path. FileViewer.inferKind() picks up
|
||||
// .png/.jpg/.jpeg/.gif/.webp/.svg → "image" at render time. This
|
||||
// test asserts the data shape is clean (no spurious kind injection)
|
||||
// so the downstream inference path is unblocked.
|
||||
const { setSelection, getByTestId } = renderWithDrawerCtx(
|
||||
<FileLink path="cover.jpg" root="workspace" />,
|
||||
);
|
||||
fireEvent.click(getByTestId("file-link"));
|
||||
const call = setSelection.mock.calls[0]![0] as { data: Record<string, unknown> };
|
||||
expect(call.data.kind).toBeUndefined();
|
||||
expect(call.data.path).toBe("cover.jpg");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user