diff --git a/scripts/screenshot-file-viewer.mjs b/scripts/screenshot-file-viewer.mjs new file mode 100644 index 00000000000..8623eb86a3d --- /dev/null +++ b/scripts/screenshot-file-viewer.mjs @@ -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); +}); diff --git a/scripts/screenshot-one.mjs b/scripts/screenshot-one.mjs new file mode 100644 index 00000000000..cadc79fa5e7 --- /dev/null +++ b/scripts/screenshot-one.mjs @@ -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}`); diff --git a/ui/src/components/CommandPalette.test.tsx b/ui/src/components/CommandPalette.test.tsx index 11303f2943c..37c50dd6064 100644 --- a/ui/src/components/CommandPalette.test.tsx +++ b/ui/src/components/CommandPalette.test.tsx @@ -52,6 +52,7 @@ const navigateState = vi.hoisted(() => ({ vi.mock("@/lib/router", () => ({ useNavigate: () => navigateState.navigate, + useLocation: () => ({ pathname: "/", search: "", hash: "" }), })); vi.mock("../api/issues", () => ({ diff --git a/ui/src/components/FileViewerSheet.test.ts b/ui/src/components/FileViewerSheet.test.ts new file mode 100644 index 00000000000..c6d761fd107 --- /dev/null +++ b/ui/src/components/FileViewerSheet.test.ts @@ -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."); + }); +}); diff --git a/ui/src/components/FileViewerSheet.tsx b/ui/src/components/FileViewerSheet.tsx index a5df7730187..4fa160a5433 100644 --- a/ui/src/components/FileViewerSheet.tsx +++ b/ui/src/components/FileViewerSheet.tsx @@ -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: