mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 02:26:12 +02:00
Polish file viewer UX review punchlist (PAP-1962)
Addresses UX review feedback on PAP-1963: - Fix denial-body leak for too_large/size codes (describeDenial no longer echoes fallback for known codes; the call site passes empty fallback so unknown codes show a generic message). - Sticky line-number gutter with solid code-bg fill; highlight border moves from absolute-positioned sibling onto the sticky gutter so the amber marker stays pinned when users scroll long lines horizontally. - Swap FileSearch copy-link icon for Link2. - WorkspaceFileLink now sets a real deep-link href so modifier-click opens the file viewer in a new tab instead of scrolling to the top. - Collapse duplicate title/description in the sheet header when the basename equals the full path (e.g. .env.production). - Drop the duplicate title/helper block from OpenFilePrompt (sheet header already carries the title + description). - Replace ArrowLeft with Cloud for the remote-workspace state. - Retire arbitrary text-[12.5px]/[11px]/[0.78em] sizes in favor of text-xs. - Unify 404 copy on "active workspace" everywhere. - Raise chip-row contrast by using bg-muted instead of bg-background. Also commits previously untracked visual-regression infra: - Storybook stories for 12 file viewer states - screenshot-file-viewer.mjs / screenshot-one.mjs - describeDenial unit tests Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
4bad0cf17f
commit
9b58daba6e
8 files changed
+710
-39
No files matched your search
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env node
|
||||
// Screenshots every FileViewerSheet Storybook state at desktop + mobile viewports.
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { chromium } from "@playwright/test";
|
||||
|
||||
const BASE = "http://localhost:6006/iframe.html";
|
||||
const OUT_DIR = path.resolve(process.argv[2] || "/tmp/pap-1963");
|
||||
|
||||
const SHOTS = [
|
||||
{ id: "components-file-viewer-sheet--text-content-with-highlighted-line", label: "content-highlighted-line" },
|
||||
{ id: "components-file-viewer-sheet--text-content-long-path-truncation", label: "content-long-path" },
|
||||
{ id: "components-file-viewer-sheet--open-file-prompt", label: "open-file-prompt" },
|
||||
{ id: "components-file-viewer-sheet--loading-spinner", label: "loading-spinner" },
|
||||
{ id: "components-file-viewer-sheet--error-not-found-with-fallback", label: "error-not-found" },
|
||||
{ id: "components-file-viewer-sheet--error-no-workspace", label: "error-no-workspace" },
|
||||
{ id: "components-file-viewer-sheet--error-outside-workspace", label: "error-outside-workspace" },
|
||||
{ id: "components-file-viewer-sheet--error-denied-sensitive", label: "error-denied" },
|
||||
{ id: "components-file-viewer-sheet--remote-workspace", label: "remote-workspace" },
|
||||
{ id: "components-file-viewer-sheet--workspace-archived", label: "workspace-archived" },
|
||||
{ id: "components-file-viewer-sheet--binary-unsupported", label: "binary-unsupported" },
|
||||
{ id: "components-file-viewer-sheet--too-large-to-preview", label: "too-large" },
|
||||
];
|
||||
|
||||
const VIEWPORTS = [
|
||||
{ name: "desktop", width: 1440, height: 900 },
|
||||
{ name: "mobile", width: 390, height: 844 },
|
||||
];
|
||||
|
||||
async function run() {
|
||||
await fs.mkdir(OUT_DIR, { recursive: true });
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
for (const vp of VIEWPORTS) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: vp.width, height: vp.height },
|
||||
deviceScaleFactor: 2,
|
||||
});
|
||||
const page = await context.newPage();
|
||||
for (const shot of SHOTS) {
|
||||
const url = `${BASE}?id=${shot.id}&viewMode=story&args=`;
|
||||
console.log(`[${vp.name}] → ${shot.label}`);
|
||||
await page.goto(url, { waitUntil: "domcontentloaded" });
|
||||
// Give the sheet animation + potential network stubs ~1.5s to settle.
|
||||
await page.waitForTimeout(shot.label === "loading-spinner" ? 900 : 1600);
|
||||
const out = path.join(OUT_DIR, `${vp.name}-${shot.label}.png`);
|
||||
await page.screenshot({ path: out, fullPage: false });
|
||||
}
|
||||
// Extra capture: mobile view story (its own viewport story) as a cross-check.
|
||||
if (vp.name === "mobile") {
|
||||
await page.goto(
|
||||
`${BASE}?id=components-file-viewer-sheet--mobile-view&viewMode=story&args=`,
|
||||
{ waitUntil: "domcontentloaded" },
|
||||
);
|
||||
await page.waitForTimeout(1600);
|
||||
await page.screenshot({
|
||||
path: path.join(OUT_DIR, `mobile-story-variant.png`),
|
||||
fullPage: false,
|
||||
});
|
||||
}
|
||||
await context.close();
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
console.log(`\nScreenshots written to ${OUT_DIR}`);
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env node
|
||||
// Screenshot a single story id at desktop 1440x900.
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { chromium } from "@playwright/test";
|
||||
|
||||
const [, , storyId, out, widthArg, heightArg, waitArg] = process.argv;
|
||||
const width = Number(widthArg) || 1440;
|
||||
const height = Number(heightArg) || 900;
|
||||
const waitMs = Number(waitArg) || 2500;
|
||||
const url = `http://localhost:6006/iframe.html?id=${storyId}&viewMode=story`;
|
||||
|
||||
await fs.mkdir(path.dirname(path.resolve(out)), { recursive: true });
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
const ctx = await browser.newContext({
|
||||
viewport: { width, height },
|
||||
deviceScaleFactor: 2,
|
||||
});
|
||||
const page = await ctx.newPage();
|
||||
await page.goto(url, { waitUntil: "networkidle" });
|
||||
await page.waitForTimeout(waitMs);
|
||||
await page.screenshot({ path: out, fullPage: false });
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
console.log(`Wrote ${out}`);
|
||||
@@ -52,6 +52,7 @@ const navigateState = vi.hoisted(() => ({
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
useNavigate: () => navigateState.navigate,
|
||||
useLocation: () => ({ pathname: "/", search: "", hash: "" }),
|
||||
}));
|
||||
|
||||
vi.mock("../api/issues", () => ({
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describeDenial } from "./FileViewerSheet";
|
||||
|
||||
describe("describeDenial", () => {
|
||||
it("returns the curated body for too_large regardless of fallback", () => {
|
||||
expect(describeDenial("too_large", "").body).toBe(
|
||||
"This file exceeds the supported preview size.",
|
||||
);
|
||||
expect(describeDenial("too_large", "ignored fallback").body).toBe(
|
||||
"This file exceeds the supported preview size.",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not leak the raw denial code as body when fallback is empty", () => {
|
||||
for (const code of [
|
||||
"denied_by_policy_sensitive",
|
||||
"outside_workspace_root",
|
||||
"workspace_archived",
|
||||
"binary_unsupported",
|
||||
"remote_preview_unsupported",
|
||||
]) {
|
||||
const { body } = describeDenial(code, "");
|
||||
expect(body).not.toBe(code);
|
||||
expect(body).not.toMatch(/^[a-z_]+$/);
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to the generic message for unknown codes with empty fallback", () => {
|
||||
const { body, title } = describeDenial("", "");
|
||||
expect(title).toBe("Can't preview this file");
|
||||
expect(body).toBe("The viewer was unable to load this file.");
|
||||
});
|
||||
|
||||
it("prefers a human-readable server message for unknown codes", () => {
|
||||
const { body } = describeDenial("unknown_code", "Server refused the request.");
|
||||
expect(body).toBe("Server refused the request.");
|
||||
});
|
||||
});
|
||||
@@ -9,13 +9,14 @@ import {
|
||||
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
Ban,
|
||||
Check,
|
||||
Cloud,
|
||||
Copy,
|
||||
FileCode2,
|
||||
FileSearch,
|
||||
FolderOpen,
|
||||
Link2,
|
||||
Loader2,
|
||||
Lock,
|
||||
RefreshCcw,
|
||||
@@ -112,7 +113,7 @@ function middleTruncatePath(path: string, maxLen = 80): string {
|
||||
return `${head}…${tail}`;
|
||||
}
|
||||
|
||||
function describeDenial(code: string, fallback: string): { title: string; body: string; icon: ReactNode } {
|
||||
export function describeDenial(code: string, fallback: string): { title: string; body: string; icon: ReactNode } {
|
||||
const lower = code.toLowerCase();
|
||||
if (lower.includes("policy") || lower.includes("denied") || lower.includes("sensitive")) {
|
||||
return {
|
||||
@@ -146,7 +147,7 @@ function describeDenial(code: string, fallback: string): { title: string; body:
|
||||
return {
|
||||
icon: <AlertTriangle aria-hidden="true" className="h-6 w-6 text-amber-500" />,
|
||||
title: "File is too large to preview",
|
||||
body: fallback || "This file exceeds the supported preview size.",
|
||||
body: "This file exceeds the supported preview size.",
|
||||
};
|
||||
}
|
||||
if (lower.includes("binary") || lower.includes("unsupported")) {
|
||||
@@ -214,20 +215,15 @@ function OpenFilePrompt({ onSubmit }: OpenFilePromptProps) {
|
||||
|
||||
return (
|
||||
<form className="space-y-4 p-6" onSubmit={handleSubmit}>
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="paperclip-file-viewer-input" className="text-sm font-medium text-foreground">
|
||||
Open file in this issue
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Type a workspace-relative path, for example <code className="font-mono text-[0.78em]">ui/src/pages/IssueDetail.tsx:42</code>.
|
||||
</p>
|
||||
</div>
|
||||
<label htmlFor="paperclip-file-viewer-input" className="sr-only">
|
||||
Workspace-relative file path
|
||||
</label>
|
||||
<input
|
||||
ref={inputRef}
|
||||
id="paperclip-file-viewer-input"
|
||||
type="text"
|
||||
className="w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
placeholder="path/to/file.ext or path/to/file.ext:42"
|
||||
placeholder="e.g. ui/src/pages/IssueDetail.tsx:42"
|
||||
value={value}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
autoComplete="off"
|
||||
@@ -340,7 +336,7 @@ function FileContentViewer({ content, highlightedLine, onLoaded }: FileContentVi
|
||||
tabIndex={0}
|
||||
className="paperclip-file-viewer-code flex-1 overflow-auto bg-[var(--paperclip-code-bg,theme(colors.muted.DEFAULT))] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
|
||||
>
|
||||
<pre className="m-0 font-mono text-[12.5px] leading-5">
|
||||
<pre className="m-0 font-mono text-xs leading-5">
|
||||
{lines.map((lineText, index) => {
|
||||
const lineNumber = index + 1;
|
||||
const isHighlighted = lineNumber === highlightedLine;
|
||||
@@ -350,21 +346,17 @@ function FileContentViewer({ content, highlightedLine, onLoaded }: FileContentVi
|
||||
ref={isHighlighted ? highlightedLineRef : undefined}
|
||||
data-line-number={lineNumber}
|
||||
className={cn(
|
||||
"relative flex whitespace-pre",
|
||||
"flex whitespace-pre",
|
||||
isHighlighted && "bg-[var(--paperclip-code-highlight-bg,rgba(250,204,21,0.12))]",
|
||||
)}
|
||||
>
|
||||
{isHighlighted ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute left-0 top-0 bottom-0 w-0.5 bg-[var(--paperclip-code-highlight-border,rgb(234,179,8))]"
|
||||
/>
|
||||
) : null}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"shrink-0 select-none px-3 text-right text-[var(--paperclip-code-gutter-fg,theme(colors.muted.foreground))] opacity-70",
|
||||
isHighlighted && "opacity-100",
|
||||
"sticky left-0 z-10 shrink-0 select-none px-3 text-right text-[var(--paperclip-code-gutter-fg,theme(colors.muted.foreground))] opacity-70",
|
||||
"bg-[var(--paperclip-code-bg,theme(colors.muted.DEFAULT))]",
|
||||
isHighlighted &&
|
||||
"opacity-100 bg-[var(--paperclip-code-highlight-bg,rgba(250,204,21,0.12))] border-l-2 border-[var(--paperclip-code-highlight-border,rgb(234,179,8))]",
|
||||
)}
|
||||
style={{ width: gutterWidth }}
|
||||
>
|
||||
@@ -531,6 +523,7 @@ export function FileViewerSheet({
|
||||
const description = state
|
||||
? middleTruncatePath(state.path)
|
||||
: "Enter a workspace-relative path to preview.";
|
||||
const showDescription = state ? description !== title : true;
|
||||
|
||||
return (
|
||||
<Sheet open={computedOpen} onOpenChange={handleOpenChange}>
|
||||
@@ -550,7 +543,10 @@ export function FileViewerSheet({
|
||||
</SheetTitle>
|
||||
<SheetDescription
|
||||
id={FILE_VIEWER_DESCRIBED_BY_ID}
|
||||
className="truncate font-mono text-[11px]"
|
||||
className={cn(
|
||||
"truncate font-mono text-xs",
|
||||
!showDescription && "sr-only",
|
||||
)}
|
||||
title={state?.path}
|
||||
>
|
||||
{description}
|
||||
@@ -580,7 +576,7 @@ export function FileViewerSheet({
|
||||
title="Copy link"
|
||||
className="h-7 w-7"
|
||||
>
|
||||
{copiedField === "link" ? <Check className="h-4 w-4 text-green-500" /> : <FileSearch className="h-4 w-4" />}
|
||||
{copiedField === "link" ? <Check className="h-4 w-4 text-green-500" /> : <Link2 className="h-4 w-4" />}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
@@ -596,25 +592,25 @@ export function FileViewerSheet({
|
||||
</div>
|
||||
</div>
|
||||
{resolvedResource ? (
|
||||
<div className="flex flex-wrap items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<div className="flex flex-wrap items-center gap-1 text-xs text-muted-foreground">
|
||||
<span
|
||||
className="inline-flex items-center rounded-full border border-border bg-background px-2 py-0.5"
|
||||
className="inline-flex items-center rounded-full border border-border bg-muted px-2 py-0.5"
|
||||
title={resolvedResource.workspaceLabel}
|
||||
>
|
||||
From {resolvedResource.workspaceLabel}
|
||||
</span>
|
||||
{resolvedResource.previewKind ? (
|
||||
<span className="inline-flex items-center rounded-full border border-border bg-background px-2 py-0.5 capitalize">
|
||||
<span className="inline-flex items-center rounded-full border border-border bg-muted px-2 py-0.5 capitalize">
|
||||
{resolvedResource.previewKind}
|
||||
</span>
|
||||
) : null}
|
||||
{formatBytes(resolvedResource.byteSize) ? (
|
||||
<span className="inline-flex items-center rounded-full border border-border bg-background px-2 py-0.5">
|
||||
<span className="inline-flex items-center rounded-full border border-border bg-muted px-2 py-0.5">
|
||||
{formatBytes(resolvedResource.byteSize)}
|
||||
</span>
|
||||
) : null}
|
||||
{state?.line ? (
|
||||
<span className="inline-flex items-center rounded-full border border-border bg-background px-2 py-0.5">
|
||||
<span className="inline-flex items-center rounded-full border border-border bg-muted px-2 py-0.5">
|
||||
Line {state.line}
|
||||
{state.column ? `, Col ${state.column}` : ""}
|
||||
</span>
|
||||
@@ -688,11 +684,7 @@ function FileViewerBody({
|
||||
<FileViewerStateView
|
||||
icon={<FileSearch aria-hidden="true" className="h-6 w-6 text-muted-foreground" />}
|
||||
title="File not found"
|
||||
body={
|
||||
onFallbackToProject
|
||||
? "That file was not found in the active workspace."
|
||||
: "That file was not found in this workspace."
|
||||
}
|
||||
body="That file was not found in the active workspace."
|
||||
actions={
|
||||
<>
|
||||
{onFallbackToProject ? (
|
||||
@@ -738,7 +730,7 @@ function FileViewerBody({
|
||||
if (resource.kind === "remote_resource") {
|
||||
return (
|
||||
<FileViewerStateView
|
||||
icon={<ArrowLeft aria-hidden="true" className="h-6 w-6 text-muted-foreground" />}
|
||||
icon={<Cloud aria-hidden="true" className="h-6 w-6 text-muted-foreground" />}
|
||||
title="Remote workspace preview coming soon"
|
||||
body="This workspace is hosted remotely; inline previews are not supported yet."
|
||||
/>
|
||||
@@ -746,7 +738,7 @@ function FileViewerBody({
|
||||
}
|
||||
|
||||
if (!canPreview) {
|
||||
const denial = describeDenial(resource.denialReason ?? "", resource.denialReason ?? "");
|
||||
const denial = describeDenial(resource.denialReason ?? "", "");
|
||||
return <FileViewerStateView icon={denial.icon} title={denial.title} body={denial.body} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ vi.mock("@/lib/router", () => ({
|
||||
}: { children: ReactNode; to: string } & React.ComponentProps<"a">) => (
|
||||
<a href={to} {...props}>{children}</a>
|
||||
),
|
||||
useLocation: () => ({ pathname: "/", search: "", hash: "" }),
|
||||
}));
|
||||
|
||||
vi.mock("../api/issues", () => ({
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import type { MouseEvent, ReactNode } from "react";
|
||||
import { FileCode2 } from "lucide-react";
|
||||
import { useLocation } from "@/lib/router";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ParsedWorkspaceFileRef } from "@/lib/workspace-file-parser";
|
||||
import { formatWorkspaceFileRefDisplay } from "@/lib/workspace-file-parser";
|
||||
import { useFileViewer } from "@/context/FileViewerContext";
|
||||
import {
|
||||
useFileViewer,
|
||||
writeFileViewerStateToSearch,
|
||||
} from "@/context/FileViewerContext";
|
||||
|
||||
export interface WorkspaceFileLinkProps {
|
||||
workspaceFileRef: ParsedWorkspaceFileRef;
|
||||
@@ -25,6 +29,7 @@ export function WorkspaceFileLink({
|
||||
title,
|
||||
}: WorkspaceFileLinkProps) {
|
||||
const viewer = useFileViewer();
|
||||
const location = useLocation();
|
||||
const display = typeof label !== "undefined" ? label : formatWorkspaceFileRefDisplay(workspaceFileRef);
|
||||
const canOpen = !!(onOpen || viewer);
|
||||
const lineSuffix = workspaceFileRef.line
|
||||
@@ -37,10 +42,21 @@ export function WorkspaceFileLink({
|
||||
? `Open ${workspaceFileRef.path}${lineSuffix} in the file viewer`
|
||||
: `Workspace file ${workspaceFileRef.path}${lineSuffix}`);
|
||||
|
||||
const deepLinkSearch = writeFileViewerStateToSearch(location.search, {
|
||||
path: workspaceFileRef.path,
|
||||
line: workspaceFileRef.line ?? null,
|
||||
column: workspaceFileRef.column ?? null,
|
||||
workspace: "auto",
|
||||
});
|
||||
const href = canOpen
|
||||
? `${location.pathname}${deepLinkSearch}${location.hash}`
|
||||
: "#";
|
||||
|
||||
const handleClick = (event: MouseEvent<HTMLAnchorElement>) => {
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
|
||||
return;
|
||||
}
|
||||
if (event.button !== 0) return;
|
||||
if (!canOpen) return;
|
||||
event.preventDefault();
|
||||
if (onOpen) onOpen(workspaceFileRef);
|
||||
@@ -49,14 +65,14 @@ export function WorkspaceFileLink({
|
||||
|
||||
return (
|
||||
<a
|
||||
href="#"
|
||||
href={href}
|
||||
role={canOpen ? "button" : undefined}
|
||||
data-workspace-file-link="true"
|
||||
data-workspace-file-path={workspaceFileRef.path}
|
||||
aria-label={ariaLabel}
|
||||
title={tooltip}
|
||||
className={cn(
|
||||
"paperclip-workspace-file-link inline-flex items-center gap-1 rounded-sm border border-border bg-muted/60 px-1.5 py-0.5 font-mono text-[0.78em] leading-tight text-foreground/90 align-baseline no-underline hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
|
||||
"paperclip-workspace-file-link inline-flex items-center gap-1 rounded-sm border border-border bg-muted/60 px-1.5 py-0.5 font-mono text-xs leading-tight text-foreground/90 align-baseline no-underline hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
|
||||
className,
|
||||
)}
|
||||
onClick={handleClick}
|
||||
|
||||
@@ -0,0 +1,523 @@
|
||||
import { useLayoutEffect, useState, type ReactNode } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type {
|
||||
ResolvedWorkspaceResource,
|
||||
WorkspaceFileContent,
|
||||
WorkspaceFileSelector,
|
||||
} from "@paperclipai/shared";
|
||||
import { FileViewerProvider } from "@/context/FileViewerContext";
|
||||
import { FileViewerSheet } from "@/components/FileViewerSheet";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
|
||||
const ISSUE_ID = "issue-storybook-file-viewer";
|
||||
|
||||
const SAMPLE_CODE = `import { useMemo } from "react";
|
||||
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface FileViewerSheetProps {
|
||||
issueId: string;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a right-side sheet that previews a workspace file.
|
||||
* The viewer is URL-addressable via ?file=&line=&column=&workspace=.
|
||||
*/
|
||||
export function FileViewerSheet({
|
||||
issueId,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: FileViewerSheetProps) {
|
||||
const isOpen = typeof open === "boolean" ? open : true;
|
||||
|
||||
const classes = useMemo(
|
||||
() =>
|
||||
cn(
|
||||
"flex h-full w-full flex-col gap-0 p-0",
|
||||
"sm:max-w-[min(900px,90vw)]",
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<Sheet open={isOpen} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="right" className={classes}>
|
||||
{/* header, metadata chips, content region */}
|
||||
<p>Workspace file contents would render here.</p>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
`;
|
||||
|
||||
function buildResource(
|
||||
overrides: Partial<ResolvedWorkspaceResource> = {},
|
||||
): ResolvedWorkspaceResource {
|
||||
return {
|
||||
kind: "file",
|
||||
provider: "git_worktree",
|
||||
title: "FileViewerSheet.tsx",
|
||||
displayPath: "ui/src/components/FileViewerSheet.tsx",
|
||||
workspaceLabel: "PAP-1953 isolated worktree",
|
||||
workspaceKind: "execution_workspace",
|
||||
workspaceId: "ws-exec-pap-1953",
|
||||
contentType: "text/tsx",
|
||||
byteSize: SAMPLE_CODE.length,
|
||||
previewKind: "text",
|
||||
denialReason: null,
|
||||
capabilities: { preview: true, download: false, listChildren: false },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildContent(
|
||||
resource: ResolvedWorkspaceResource,
|
||||
data = SAMPLE_CODE,
|
||||
): WorkspaceFileContent {
|
||||
return {
|
||||
resource,
|
||||
content: { encoding: "utf8", data },
|
||||
};
|
||||
}
|
||||
|
||||
function StoryFrame({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="relative min-h-screen bg-background text-foreground">
|
||||
<div className="mx-auto max-w-3xl space-y-4 p-6 text-sm">
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Host issue page (preview)
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold">
|
||||
PAP-1963 — Phase 4: UX visual review file viewer implementation
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
This frame represents the underlying issue detail page so the sheet
|
||||
overlay can be evaluated in context.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-border bg-muted/40 p-4 text-xs text-muted-foreground">
|
||||
The workspace file viewer opens as a right-side sheet, pushing a URL
|
||||
entry. Use browser back to close.
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface HydratorProps {
|
||||
seed?: (client: ReturnType<typeof useQueryClient>) => void;
|
||||
stubFetch?: () => (() => void);
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function FileViewerHydrator({ seed, stubFetch, children }: HydratorProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [ready] = useState(() => {
|
||||
seed?.(queryClient);
|
||||
return true;
|
||||
});
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!stubFetch) return;
|
||||
const teardown = stubFetch();
|
||||
return () => teardown();
|
||||
}, [stubFetch]);
|
||||
|
||||
return ready ? <>{children}</> : null;
|
||||
}
|
||||
|
||||
type ViewerRenderProps = {
|
||||
path: string;
|
||||
line?: number | null;
|
||||
column?: number | null;
|
||||
workspace?: WorkspaceFileSelector;
|
||||
showPromptWhenEmpty?: boolean;
|
||||
};
|
||||
|
||||
function FileViewerShell({
|
||||
path,
|
||||
line = null,
|
||||
column = null,
|
||||
workspace = "auto",
|
||||
showPromptWhenEmpty = false,
|
||||
seed,
|
||||
stubFetch,
|
||||
}: ViewerRenderProps & Omit<HydratorProps, "children">) {
|
||||
return (
|
||||
<FileViewerProvider issueId={ISSUE_ID}>
|
||||
<FileViewerHydrator seed={seed} stubFetch={stubFetch}>
|
||||
<StoryFrame>
|
||||
<FileViewerSheet
|
||||
issueId={ISSUE_ID}
|
||||
state={
|
||||
showPromptWhenEmpty
|
||||
? null
|
||||
: { path, line, column, workspace }
|
||||
}
|
||||
showPromptWhenEmpty={showPromptWhenEmpty}
|
||||
open
|
||||
/>
|
||||
</StoryFrame>
|
||||
</FileViewerHydrator>
|
||||
</FileViewerProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function seedContent(
|
||||
queryClient: ReturnType<typeof useQueryClient>,
|
||||
path: string,
|
||||
workspace: WorkspaceFileSelector,
|
||||
resource: ResolvedWorkspaceResource,
|
||||
content: WorkspaceFileContent | null,
|
||||
) {
|
||||
queryClient.setQueryData(
|
||||
queryKeys.issues.fileResource(ISSUE_ID, path, workspace),
|
||||
resource,
|
||||
);
|
||||
if (content) {
|
||||
queryClient.setQueryData(
|
||||
queryKeys.issues.fileResourceContent(ISSUE_ID, path, workspace),
|
||||
content,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function buildFetchStub(
|
||||
resolver: (url: URL) => Response | Promise<Response> | null,
|
||||
) {
|
||||
return () => {
|
||||
if (typeof window === "undefined") return () => undefined;
|
||||
const originalFetch = window.fetch.bind(window);
|
||||
window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const rawUrl =
|
||||
typeof input === "string"
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.href
|
||||
: input.url;
|
||||
const url = new URL(rawUrl, window.location.origin);
|
||||
const maybe = resolver(url);
|
||||
if (maybe) return maybe;
|
||||
return originalFetch(input, init);
|
||||
};
|
||||
return () => {
|
||||
window.fetch = originalFetch;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const meta: Meta = {
|
||||
title: "Components/File Viewer Sheet",
|
||||
component: FileViewerSheet,
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
a11y: { test: "todo" },
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj;
|
||||
|
||||
export const TextContentWithHighlightedLine: Story = {
|
||||
name: "Text content — highlighted line",
|
||||
render: () => {
|
||||
const resource = buildResource();
|
||||
const content = buildContent(resource);
|
||||
return (
|
||||
<FileViewerShell
|
||||
path="ui/src/components/FileViewerSheet.tsx"
|
||||
line={17}
|
||||
column={3}
|
||||
workspace="execution"
|
||||
seed={(client) =>
|
||||
seedContent(
|
||||
client,
|
||||
"ui/src/components/FileViewerSheet.tsx",
|
||||
"execution",
|
||||
resource,
|
||||
content,
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const TextContentLongPathTruncation: Story = {
|
||||
name: "Text content — very long path",
|
||||
render: () => {
|
||||
const longPath =
|
||||
"packages/backend/src/features/workspaces/resolvers/resolve-workspace-file-resource-from-issue-context.ts";
|
||||
const resource = buildResource({
|
||||
title: "resolve-workspace-file-resource-from-issue-context.ts",
|
||||
displayPath: longPath,
|
||||
});
|
||||
const content = buildContent(resource, SAMPLE_CODE);
|
||||
return (
|
||||
<FileViewerShell
|
||||
path={longPath}
|
||||
line={42}
|
||||
workspace="auto"
|
||||
seed={(client) =>
|
||||
seedContent(client, longPath, "auto", resource, content)
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const OpenFilePrompt: Story = {
|
||||
name: "Open file prompt (no selection)",
|
||||
render: () => (
|
||||
<FileViewerShell
|
||||
path=""
|
||||
workspace="auto"
|
||||
showPromptWhenEmpty
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const LoadingSpinner: Story = {
|
||||
name: "Loading — 400ms+ spinner",
|
||||
render: () => {
|
||||
return (
|
||||
<FileViewerShell
|
||||
path="ui/src/components/FileViewerSheet.tsx"
|
||||
workspace="auto"
|
||||
stubFetch={buildFetchStub((url) => {
|
||||
if (url.pathname.includes("/file-resources/")) {
|
||||
return new Promise<Response>(() => {
|
||||
/* never resolves — freezes query in pending */
|
||||
});
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const ErrorNotFoundWithFallback: Story = {
|
||||
name: "Error — file not found (project fallback)",
|
||||
render: () => (
|
||||
<FileViewerShell
|
||||
path="missing/area/not-found.tsx"
|
||||
workspace="execution"
|
||||
stubFetch={buildFetchStub((url) => {
|
||||
if (url.pathname.includes("/file-resources/resolve")) {
|
||||
return Response.json(
|
||||
{ error: "No file exists at that path.", code: "not_found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const ErrorNoWorkspace: Story = {
|
||||
name: "Error — no workspace",
|
||||
render: () => (
|
||||
<FileViewerShell
|
||||
path="ui/src/components/FileViewerSheet.tsx"
|
||||
workspace="auto"
|
||||
stubFetch={buildFetchStub((url) => {
|
||||
if (url.pathname.includes("/file-resources/resolve")) {
|
||||
return Response.json(
|
||||
{
|
||||
error: "This issue has no project or execution workspace.",
|
||||
code: "no_workspace",
|
||||
},
|
||||
{ status: 422 },
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const ErrorOutsideWorkspace: Story = {
|
||||
name: "Error — path outside workspace",
|
||||
render: () => (
|
||||
<FileViewerShell
|
||||
path="../../../etc/passwd"
|
||||
workspace="auto"
|
||||
stubFetch={buildFetchStub((url) => {
|
||||
if (url.pathname.includes("/file-resources/resolve")) {
|
||||
return Response.json(
|
||||
{
|
||||
error: "Path escapes workspace root.",
|
||||
code: "outside_workspace_root",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const ErrorDeniedSensitive: Story = {
|
||||
name: "Error — denied by policy",
|
||||
render: () => (
|
||||
<FileViewerShell
|
||||
path=".env.production"
|
||||
workspace="auto"
|
||||
stubFetch={buildFetchStub((url) => {
|
||||
if (url.pathname.includes("/file-resources/resolve")) {
|
||||
return Response.json(
|
||||
{
|
||||
error: "Blocked by workspace file policy.",
|
||||
code: "denied_by_policy_sensitive",
|
||||
},
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const RemoteWorkspace: Story = {
|
||||
name: "Remote workspace (preview unsupported)",
|
||||
render: () => {
|
||||
const resource = buildResource({
|
||||
kind: "remote_resource",
|
||||
provider: "remote_managed",
|
||||
workspaceLabel: "Cloud worker (remote)",
|
||||
workspaceKind: "execution_workspace",
|
||||
previewKind: "unsupported",
|
||||
capabilities: { preview: false, download: false, listChildren: false },
|
||||
});
|
||||
return (
|
||||
<FileViewerShell
|
||||
path="ui/src/components/FileViewerSheet.tsx"
|
||||
workspace="auto"
|
||||
seed={(client) =>
|
||||
seedContent(
|
||||
client,
|
||||
"ui/src/components/FileViewerSheet.tsx",
|
||||
"auto",
|
||||
resource,
|
||||
null,
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const WorkspaceArchived: Story = {
|
||||
name: "Workspace archived / cleaned up",
|
||||
render: () => (
|
||||
<FileViewerShell
|
||||
path="ui/src/components/FileViewerSheet.tsx"
|
||||
workspace="execution"
|
||||
stubFetch={buildFetchStub((url) => {
|
||||
if (url.pathname.includes("/file-resources/resolve")) {
|
||||
return Response.json(
|
||||
{
|
||||
error: "This worktree has been cleaned up.",
|
||||
code: "workspace_archived",
|
||||
},
|
||||
{ status: 410 },
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const BinaryUnsupported: Story = {
|
||||
name: "Binary / unsupported file type",
|
||||
render: () => {
|
||||
const resource = buildResource({
|
||||
title: "logo.woff2",
|
||||
displayPath: "ui/public/fonts/logo.woff2",
|
||||
contentType: "font/woff2",
|
||||
previewKind: "unsupported",
|
||||
capabilities: { preview: false, download: false, listChildren: false },
|
||||
denialReason: "binary_unsupported",
|
||||
});
|
||||
return (
|
||||
<FileViewerShell
|
||||
path="ui/public/fonts/logo.woff2"
|
||||
workspace="auto"
|
||||
seed={(client) =>
|
||||
seedContent(
|
||||
client,
|
||||
"ui/public/fonts/logo.woff2",
|
||||
"auto",
|
||||
resource,
|
||||
null,
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const TooLargeToPreview: Story = {
|
||||
name: "Too large to preview",
|
||||
render: () => {
|
||||
const resource = buildResource({
|
||||
title: "dataset.csv",
|
||||
displayPath: "data/exports/dataset.csv",
|
||||
contentType: "text/csv",
|
||||
byteSize: 48_000_000,
|
||||
previewKind: "unsupported",
|
||||
capabilities: { preview: false, download: false, listChildren: false },
|
||||
denialReason: "too_large",
|
||||
});
|
||||
return (
|
||||
<FileViewerShell
|
||||
path="data/exports/dataset.csv"
|
||||
workspace="auto"
|
||||
seed={(client) =>
|
||||
seedContent(client, "data/exports/dataset.csv", "auto", resource, null)
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const MobileView: Story = {
|
||||
name: "Mobile — 390×844 highlighted line",
|
||||
parameters: {
|
||||
viewport: {
|
||||
defaultViewport: "mobile",
|
||||
},
|
||||
},
|
||||
render: () => {
|
||||
const resource = buildResource();
|
||||
const content = buildContent(resource);
|
||||
return (
|
||||
<FileViewerShell
|
||||
path="ui/src/components/FileViewerSheet.tsx"
|
||||
line={17}
|
||||
column={3}
|
||||
workspace="execution"
|
||||
seed={(client) =>
|
||||
seedContent(
|
||||
client,
|
||||
"ui/src/components/FileViewerSheet.tsx",
|
||||
"execution",
|
||||
resource,
|
||||
content,
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user