mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-10 03:31:40 -10:00
merge(0.3.1): slice 15 founder-walk-file-link-primitive — FileLink primitive + image-rendering via canonical FileViewer.inferKind path
Closes walk items 6 (images show as binary) + 11 (referenced files universally not clickable). WAVE 2 CLOSE — slices 12 + 13 + 14 + 15 all merged.
What ships:
- NEW `<FileLink>` primitive at `packages/ui/src/components/ui/FileLink.tsx` — thin wrapper over existing FileReferenceTrigger with simpler API (path + optional root/absolutePath/kind/readPath). Default testId; default children = path string.
- ScopePages SliceArtifactsTab file rows wrap each `entry.type='file'` in FileLink with absolutePath = `${detail.slicePath}/${entry.relPath}`. Directories stay inert (no drawer trigger).
- Walk-item-6 fix: image extensions auto-detect via FileViewer.inferKind (already shipped); callsite routing through FileLink → drawer makes the canonical image-rendering path the default. NO new viewer kind; NO new SharedDetailDrawer selection type.
Scope-cuts (driver-judgment; endorsed):
- ProofImageViewer rename DEFERRED — multiple callers tightly coupled to `proofAssetUrl(sliceName, relPath)`. FileViewer image-kind path is the canonical going-forward surface; ProofImageViewer remains for proof-asset-specific modal contexts. Founder note ("Proof" misleading for non-proof images) flagged for follow-up if usage warrants.
- DocsTab intentionally NOT converted — split-view local-tree + right-pane viewer is the correct UX for that surface; replacing with drawer-trigger would worsen affordance. Documented in slice-walk packet.
- Exhaustive audit-and-replace DEFERRED — primitive + highest-impact surface (ScopePages SliceArtifactsTab) ships; remaining audit-list captured in slice-walk.md for incremental pattern application.
Test coverage:
- file-link.test.tsx (NEW): 6/6 PASS — default-testId; custom children; custom testId; click→setSelection; absolutePath+readPath+kind threading; walk-item-6 no-spurious-kind discriminator (verifies FileLink does NOT inject kind; FileViewer.inferKind owns image detection)
- Full daemon suite: 3379/3379 passed / 1 skipped / 0 failed
- UI typecheck on touched files: zero new errors
- git diff --check clean
Branch commits:
- be2ea65 feat(ui): FileLink primitive + ScopePages SliceArtifactsTab integration
Triple-guard:
- redo3-driver-3 driver (Phase D + scope-cut judgment calls flagged transparently)
- redo3-guard-3 guard (1 narrow re-review pass; CLEAR; walk-item-6 discriminator coverage real)
- velocity-qa VM dev-server walk: deferred to founder host walk on refreshed VM for visual HG-3 (image renders as image) + HG-5 (artifacts files clickable opens drawer)
HG ledger: HG-1/2/3/5/6/7 PASS code-level; HG-4 (DocsTab) judgment-call NOT CONVERTED (split-view correct as-is).
Walk-item phrasing discipline applied; slice id token retained as stable identifier.
WAVE 2 STATE POST-MERGE:
- ✅ Slice 12 (mission/slice Overview+Progress content) at 08017ab
- ✅ Slice 13 (per-mission topology spec graph) at 5973140
- ✅ Slice 14 (table active/idle + terminal click) at 82d4e9f
- ✅ Slice 15 (FileLink primitive + image rendering) at <this commit>
- All 4 wave-2 slices merged. Wave 3 dispatches next: slices 16 (typography-and-vellum) + 18 (for-you-dismiss-and-skills bundled with Plugins-sidebar per founder-walk amendment) + 19 (library-and-project-density).
This commit is contained in:
commit
b1e04b8750
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