From 50d6f5e2df3dc81a086227b186ebf8ba3e11cbe2 Mon Sep 17 00:00:00 2001 From: carlos--33 Date: Wed, 12 Aug 2026 17:42:51 +0200 Subject: [PATCH] feat(desktop): open message attachments in a resizable file viewer Shared attachments could only be downloaded, so reading a .md, a script or a config meant saving it and leaving Buzz. Clicking a viewable attachment now opens it in the right-side auxiliary pane, one tab per file, in channels, DMs and threads alike. Implementation notes: - Reuses the existing auxiliary-pane shell, so the viewer inherits the drag-to-resize handle, width persistence and single-slot behaviour of the thread and activity panels. Opening another panel supersedes the viewer and closing it hands the slot back. - Viewability is decided from the filename extension, not the imeta MIME: text and source files carry no magic bytes, so the relay stores them as application/octet-stream (see validate_file_content). - Highlighting resolves through Shiki's own grammar ids and aliases instead of a hand-maintained language table; Shiki is already a static dependency of the markdown renderer. - Bytes travel over the existing fetch_media_bytes IPC command, because a webview fetch to the relay is refused by Cloudflare Access. - Binary versus text is sniffed from the bytes (NUL in the head), never from the sender-controlled MIME. - Tabs live in a module-level store because the open action fires inside the memoized markdown renderer, where a prop callback would re-render the whole timeline; the store resets on community switch. Non-viewable types keep their previous download-on-click behaviour. Signed-off-by: carlos--33 --- AGENTS.md | 1 + desktop/playwright.config.ts | 2 + .../src/features/channels/ui/ChannelPane.tsx | 28 +- .../features/channels/ui/ChannelScreen.tsx | 10 +- .../features/communities/useCommunityInit.ts | 2 + .../features/fileViewer/downloadAttachment.ts | 16 + .../fileViewClassification.test.mjs | 130 ++++++++ .../fileViewer/fileViewClassification.ts | 98 ++++++ .../fileViewer/fileViewerContent.test.mjs | 54 +++ .../features/fileViewer/fileViewerContent.ts | 46 +++ .../fileViewer/fileViewerStore.test.mjs | 130 ++++++++ .../features/fileViewer/fileViewerStore.ts | 139 ++++++++ .../fileViewer/ui/FileViewerPanel.tsx | 307 ++++++++++++++++++ .../fileViewer/useFileViewerAuxiliaryPanel.ts | 42 +++ .../fileViewer/useFileViewerContent.ts | 55 ++++ .../features/fileViewer/useFileViewerState.ts | 16 + .../src/shared/styles/globals/scrollbars.css | 22 ++ desktop/src/shared/ui/markdown.tsx | 5 +- desktop/src/shared/ui/markdown/FileCard.tsx | 87 ++--- .../src/shared/ui/markdownFileCard.test.mjs | 1 + desktop/src/shared/ui/markdownFileCard.ts | 9 +- .../tests/e2e/file-viewer-screenshots.spec.ts | 189 +++++++++++ desktop/tests/e2e/file-viewer.spec.ts | 220 +++++++++++++ 23 files changed, 1565 insertions(+), 44 deletions(-) create mode 100644 desktop/src/features/fileViewer/downloadAttachment.ts create mode 100644 desktop/src/features/fileViewer/fileViewClassification.test.mjs create mode 100644 desktop/src/features/fileViewer/fileViewClassification.ts create mode 100644 desktop/src/features/fileViewer/fileViewerContent.test.mjs create mode 100644 desktop/src/features/fileViewer/fileViewerContent.ts create mode 100644 desktop/src/features/fileViewer/fileViewerStore.test.mjs create mode 100644 desktop/src/features/fileViewer/fileViewerStore.ts create mode 100644 desktop/src/features/fileViewer/ui/FileViewerPanel.tsx create mode 100644 desktop/src/features/fileViewer/useFileViewerAuxiliaryPanel.ts create mode 100644 desktop/src/features/fileViewer/useFileViewerContent.ts create mode 100644 desktop/src/features/fileViewer/useFileViewerState.ts create mode 100644 desktop/tests/e2e/file-viewer-screenshots.spec.ts create mode 100644 desktop/tests/e2e/file-viewer.spec.ts diff --git a/AGENTS.md b/AGENTS.md index 2d3939bbb36..f3c0bd0201b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -509,6 +509,7 @@ reconnects preserve pending avatar verification work): - `clearSearchHitEventCache()` — search result event cache - `clearMarkdownNodeCache()` — markdown parse-node cache - `resetLinkPreviewTitleCache()` — link preview title cache (Buzz entity titles come from relay events) +- `resetFileViewerStore()` — attachment file-viewer tabs and panel state **If you add a new module-level cache, Map, or class instance that holds community-scoped data, you must add its reset to `resetCommunityState()`.** diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index e930f0ef612..b79ef7541c9 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -58,6 +58,8 @@ export default defineConfig({ "**/doctor-cta-screenshots.spec.ts", "**/pubkey-display-screenshots.spec.ts", "**/file-attachment.spec.ts", + "**/file-viewer.spec.ts", + "**/file-viewer-screenshots.spec.ts", "**/image-attachment-gallery.spec.ts", "**/composer-image-draw.spec.ts", "**/video-attachment.spec.ts", diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 8fc7cfaf51a..280162f7b52 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -35,6 +35,12 @@ import { getThreadPanelLayout } from "@/features/channels/lib/threadPanelLayout" import { useThreadViewMode } from "@/features/channels/lib/threadViewModePreference"; import { useThreadViewModeSwitch } from "@/features/channels/ui/useThreadViewModeSwitch"; import { useFocusDrawerPresence } from "@/features/channels/ui/useFocusDrawerPresence"; +import { + registerFileViewerHost, + selectActiveFileViewerTab, +} from "@/features/fileViewer/fileViewerStore"; +import { useFileViewerState } from "@/features/fileViewer/useFileViewerState"; +import { FileViewerPanel } from "@/features/fileViewer/ui/FileViewerPanel"; import { useChannelWorkingAgentPubkeys } from "@/features/agents/agentWorkingSignal"; import { useCardMintJobs } from "@/features/agents/cardMintStore"; import { BotActivityComposerAction } from "@/features/channels/ui/BotActivityBar"; @@ -456,6 +462,11 @@ export const ChannelPane = React.memo(function ChannelPane({ onExternalTargetResolved: onThreadScrollTargetResolved, onModeChange: markExitComplete, }); + const fileViewerSnapshot = useFileViewerState(); + const activeFileViewerTab = selectActiveFileViewerTab(fileViewerSnapshot); + // Register a viewer host so FileCards in this pane open the panel instead of + // falling back to download. + React.useEffect(() => registerFileViewerHost(), []); const selectedAgent = React.useMemo( () => agentSessionSelection.resolveSelectedAgentSession({ @@ -468,7 +479,8 @@ export const ChannelPane = React.memo(function ChannelPane({ ); const hasSplitAuxiliaryPane = useSplitAuxiliaryPane && - (channelManagementOpen || + (activeFileViewerTab !== null || + channelManagementOpen || Boolean(threadHeadMessage) || shouldShowThreadSkeleton || Boolean(activeChannel && selectedAgent) || @@ -753,7 +765,19 @@ export const ChannelPane = React.memo(function ChannelPane({ * frozen snapshot because the panel is fully prop-driven. */} - {channelManagementOpen && activeChannel ? ( + {activeFileViewerTab ? ( + wrapAux( + , + "file-viewer-panel", + ) + ) : channelManagementOpen && activeChannel ? ( ` link: that navigates the webview to the blob URL, + * which escapes to the OS browser and lands on a corporate CDN interstitial. + * The Rust command fetches inside the app's tunnel and opens a save dialog. + */ +export function downloadAttachment(url: string, filename: string): void { + invokeTauri("download_file", { filename, url }).catch((error: unknown) => { + toast.error(error instanceof Error ? error.message : "Download failed"); + }); +} diff --git a/desktop/src/features/fileViewer/fileViewClassification.test.mjs b/desktop/src/features/fileViewer/fileViewClassification.test.mjs new file mode 100644 index 00000000000..6864de5897e --- /dev/null +++ b/desktop/src/features/fileViewer/fileViewClassification.test.mjs @@ -0,0 +1,130 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { classifyFileView } from "./fileViewClassification.ts"; + +test("markdown extensions render as markdown regardless of MIME", () => { + assert.deepEqual(classifyFileView("README.md", "application/octet-stream"), { + kind: "markdown", + }); + assert.deepEqual(classifyFileView("GUIDE.markdown"), { kind: "markdown" }); + assert.deepEqual(classifyFileView("Page.MDX"), { kind: "markdown" }); +}); +test("code extensions map to Shiki language ids", () => { + assert.deepEqual(classifyFileView("apply-config.sh"), { + kind: "code", + language: "shellscript", + }); + assert.deepEqual(classifyFileView("main.rs"), { + kind: "code", + language: "rust", + }); + assert.deepEqual(classifyFileView("data.yml"), { + kind: "code", + language: "yaml", + }); + assert.deepEqual(classifyFileView("Config.TOML"), { + kind: "code", + language: "toml", + }); +}); + +// The Shiki registry is what makes the long tail work without a hand table; +// these are languages my original map never listed. +test("languages beyond the hand-written set resolve from the Shiki registry", () => { + for (const [filename, language] of [ + ["main.zig", "zig"], + ["cluster.tf", "terraform"], + ["query.sparql", "sparql"], + // `dockerfile` is an alias; the grammar id is `docker`. + ["Dockerfile.dockerfile", "docker"], + ["notebook.jl", "julia"], + ["module.erl", "erlang"], + ["view.hbs", "handlebars"], + ["schema.prisma", "prisma"], + ]) { + assert.deepEqual( + classifyFileView(filename), + { kind: "code", language }, + filename, + ); + } +}); + +// Extensions Shiki does not alias; regressions here silently lose highlighting. +test("override table covers extensions Shiki does not alias", () => { + assert.deepEqual(classifyFileView("stack.h"), { + kind: "code", + language: "c", + }); + assert.deepEqual(classifyFileView("stack.hpp"), { + kind: "code", + language: "cpp", + }); + assert.deepEqual(classifyFileView("fix.patch"), { + kind: "code", + language: "diff", + }); + assert.deepEqual(classifyFileView("server.mjs"), { + kind: "code", + language: "javascript", + }); +}); + +// Real filenames an agent delivered in a channel — these must be viewable +// even though the relay stored them as `application/octet-stream`. +test("agent-delivered attachments classify from the filename, not the MIME", () => { + assert.deepEqual( + classifyFileView("power_law_btc_analysis.py", "application/octet-stream"), + { kind: "code", language: "python" }, + ); + assert.deepEqual( + classifyFileView("market_breadth_history.json", "application/octet-stream"), + { kind: "code", language: "json" }, + ); + assert.deepEqual( + classifyFileView( + "architecture-hermes-agent-organization.md", + "application/octet-stream", + ), + { kind: "markdown" }, + ); +}); + +test("plain-text extensions render as text", () => { + assert.deepEqual(classifyFileView("build.log"), { kind: "text" }); + assert.deepEqual(classifyFileView("data.csv"), { kind: "text" }); +}); + +test("binary/container types are not viewable", () => { + assert.deepEqual(classifyFileView("Q3-budget.pdf", "application/pdf"), { + kind: "none", + }); + assert.deepEqual(classifyFileView("archive.zip", "application/zip"), { + kind: "none", + }); + assert.deepEqual(classifyFileView("photo.png", "image/png"), { + kind: "none", + }); +}); + +test("unknown extension falls back to the imeta MIME", () => { + assert.deepEqual(classifyFileView("notes", "text/markdown"), { + kind: "markdown", + }); + assert.deepEqual(classifyFileView("payload", "application/json"), { + kind: "code", + language: "json", + }); + assert.deepEqual(classifyFileView("report", "text/plain; charset=utf-8"), { + kind: "text", + }); + assert.deepEqual(classifyFileView("blob", "application/octet-stream"), { + kind: "none", + }); + assert.deepEqual(classifyFileView("blob"), { kind: "none" }); +}); + +test("a trailing dot yields no extension and defers to MIME", () => { + assert.deepEqual(classifyFileView("weird.", "text/plain"), { kind: "text" }); +}); diff --git a/desktop/src/features/fileViewer/fileViewClassification.ts b/desktop/src/features/fileViewer/fileViewClassification.ts new file mode 100644 index 00000000000..e3ec8764166 --- /dev/null +++ b/desktop/src/features/fileViewer/fileViewClassification.ts @@ -0,0 +1,98 @@ +import { bundledLanguagesInfo } from "shiki"; + +/** + * Decide how a message attachment should render in the file viewer panel. + * + * Keyed on the filename extension, not the imeta MIME: text and source files + * have no magic bytes, so uploads routinely arrive as + * `application/octet-stream` (see `validate_file_content` in buzz-media). A + * MIME fallback covers extension-less names. + * + * Languages resolve through Shiki's own grammar ids and aliases instead of a + * hand-maintained table; only extensions Shiki does not alias are listed here. + */ + +export type FileViewKind = + | { kind: "markdown" } + | { kind: "code"; language: string } + | { kind: "text" } + | { kind: "none" }; + +const MARKDOWN_EXTENSIONS: Record = { + markdown: true, + md: true, + mdx: true, +}; + +/** + * Extensions Shiki neither uses as a grammar id nor lists as an alias, mapped + * to the grammar that should highlight them. + */ +const EXTENSION_LANGUAGE_OVERRIDES: Record = { + cjs: "javascript", + h: "c", + hpp: "cpp", + mjs: "javascript", + patch: "diff", +}; + +/** Extensions rendered as plain text — no grammar, no highlighting. */ +const TEXT_EXTENSIONS: Record = { + cfg: true, + conf: true, + csv: true, + env: true, + gitignore: true, + lock: true, + log: true, + text: true, + tsv: true, + txt: true, +}; + +/** Extension → Shiki grammar id, built from grammar ids and aliases on first use. */ +let languageByExtension: Map | null = null; + +function resolveShikiLanguage(ext: string): string | undefined { + if (!languageByExtension) { + languageByExtension = new Map(); + for (const language of bundledLanguagesInfo) { + languageByExtension.set(language.id, language.id); + for (const alias of language.aliases ?? []) { + languageByExtension.set(alias, language.id); + } + } + } + return languageByExtension.get(ext); +} + +export function classifyFileView( + filename: string, + mime?: string, +): FileViewKind { + const dot = filename.lastIndexOf("."); + const ext = + dot === -1 || dot === filename.length - 1 + ? null + : filename.slice(dot + 1).toLowerCase(); + + if (ext) { + if (MARKDOWN_EXTENSIONS[ext]) return { kind: "markdown" }; + // Plain-text extensions win over Shiki: several (`log`, `csv`) exist as + // grammars whose highlighting adds noise rather than meaning. + if (TEXT_EXTENSIONS[ext]) return { kind: "text" }; + const language = + EXTENSION_LANGUAGE_OVERRIDES[ext] ?? resolveShikiLanguage(ext); + if (language) return { kind: "code", language }; + } + + // Extension unknown or absent: fall back to the imeta MIME. Generic + // container types (octet-stream, pdf, zip…) stay non-viewable. + const normalizedMime = mime?.split(";")[0].trim().toLowerCase(); + if (normalizedMime === "text/markdown") return { kind: "markdown" }; + if (normalizedMime === "application/json") + return { kind: "code", language: "json" }; + if (normalizedMime?.startsWith("text/")) return { kind: "text" }; + + return { kind: "none" }; +} diff --git a/desktop/src/features/fileViewer/fileViewerContent.test.mjs b/desktop/src/features/fileViewer/fileViewerContent.test.mjs new file mode 100644 index 00000000000..6ba7d8d1a1d --- /dev/null +++ b/desktop/src/features/fileViewer/fileViewerContent.test.mjs @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + decodeFileViewerContent, + MAX_FILE_PREVIEW_BYTES, +} from "./fileViewerContent.ts"; + +const encode = (text) => new TextEncoder().encode(text); + +test("UTF-8 text decodes, including multi-byte characters", () => { + assert.deepEqual(decodeFileViewerContent(encode("# Titre\n\néàü 🐝")), { + status: "text", + text: "# Titre\n\néàü 🐝", + }); +}); + +test("empty file decodes to empty text rather than binary", () => { + assert.deepEqual(decodeFileViewerContent(new Uint8Array(0)), { + status: "text", + text: "", + }); +}); + +// The imeta MIME is sender-controlled, so the binary decision must come from +// the bytes. Losing this check renders a blob as garbled text. +test("a NUL byte in the sniffed head marks the file binary", () => { + const bytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x00, 0x1a, 0x0a]); + assert.deepEqual(decodeFileViewerContent(bytes), { status: "binary" }); +}); + +test("a NUL just inside the sniff window is still caught", () => { + const bytes = new Uint8Array(9000).fill(0x61); + bytes[8191] = 0x00; + assert.deepEqual(decodeFileViewerContent(bytes), { status: "binary" }); +}); + +// The sniff is bounded so a large text file stays cheap; a NUL past the window +// is accepted as text by design. +test("a NUL past the sniff window does not mark the file binary", () => { + const bytes = new Uint8Array(9000).fill(0x61); + bytes[8192] = 0x00; + assert.equal(decodeFileViewerContent(bytes).status, "text"); +}); + +test("bytes over the preview cap are reported too-large, not decoded", () => { + const bytes = new Uint8Array(MAX_FILE_PREVIEW_BYTES + 1).fill(0x61); + assert.deepEqual(decodeFileViewerContent(bytes), { status: "too-large" }); +}); + +test("bytes exactly at the preview cap still decode", () => { + const bytes = new Uint8Array(MAX_FILE_PREVIEW_BYTES).fill(0x61); + assert.equal(decodeFileViewerContent(bytes).status, "text"); +}); diff --git a/desktop/src/features/fileViewer/fileViewerContent.ts b/desktop/src/features/fileViewer/fileViewerContent.ts new file mode 100644 index 00000000000..6db3154d0be --- /dev/null +++ b/desktop/src/features/fileViewer/fileViewerContent.ts @@ -0,0 +1,46 @@ +/** + * Byte-level decision for the file viewer: is a fetched attachment previewable + * text, and if so what is its text? + * + * Pure and separate from the fetching hook so the checks that keep untrusted + * bytes out of the renderer are unit-testable. + */ + +/** + * Largest preview we decode. Well under the 50 MiB IPC transfer cap: a + * multi-megabyte string through react-markdown or Shiki would freeze the UI. + */ +export const MAX_FILE_PREVIEW_BYTES = 2 * 1024 * 1024; + +/** Bytes sniffed for a NUL before deciding a file is binary. */ +const BINARY_SNIFF_BYTES = 8192; + +export type FileViewerContent = + | { status: "loading" } + | { status: "error"; message: string } + | { status: "too-large" } + | { status: "binary" } + | { status: "text"; text: string }; + +export type DecodedFileViewerContent = Extract< + FileViewerContent, + { status: "too-large" | "binary" | "text" } +>; + +/** + * Classify fetched attachment bytes. + * + * A NUL byte in the head means binary — the same sniff git uses. The imeta + * MIME is sender-controlled, so the decision comes from the bytes. + */ +export function decodeFileViewerContent( + bytes: Uint8Array, +): DecodedFileViewerContent { + if (bytes.byteLength > MAX_FILE_PREVIEW_BYTES) { + return { status: "too-large" }; + } + if (bytes.subarray(0, BINARY_SNIFF_BYTES).includes(0)) { + return { status: "binary" }; + } + return { status: "text", text: new TextDecoder("utf-8").decode(bytes) }; +} diff --git a/desktop/src/features/fileViewer/fileViewerStore.test.mjs b/desktop/src/features/fileViewer/fileViewerStore.test.mjs new file mode 100644 index 00000000000..7f79b111905 --- /dev/null +++ b/desktop/src/features/fileViewer/fileViewerStore.test.mjs @@ -0,0 +1,130 @@ +import assert from "node:assert/strict"; +import { beforeEach, test } from "node:test"; + +import { + activateFileViewerTab, + closeFileViewer, + closeFileViewerTab, + getFileViewerState, + hasFileViewerHost, + openFileViewerTab, + registerFileViewerHost, + resetFileViewerStore, + subscribeFileViewer, +} from "./fileViewerStore.ts"; + +const TAB_A = { + filename: "README.md", + mime: "text/markdown", + url: "https://r/media/a.md", +}; +const TAB_B = { + filename: "apply.sh", + mime: "application/octet-stream", + url: "https://r/media/b.sh", +}; +const TAB_C = { filename: "notes.txt", url: "https://r/media/c.txt" }; + +beforeEach(() => { + resetFileViewerStore(); +}); + +test("openFileViewerTab adds a tab, activates it, and opens the panel", () => { + openFileViewerTab(TAB_A); + const state = getFileViewerState(); + assert.equal(state.isOpen, true); + assert.equal(state.activeUrl, TAB_A.url); + assert.deepEqual(state.tabs, [TAB_A]); +}); + +test("re-opening the same URL activates the existing tab without duplicating", () => { + openFileViewerTab(TAB_A); + openFileViewerTab(TAB_B); + openFileViewerTab({ ...TAB_A, filename: "renamed.md" }); + const state = getFileViewerState(); + assert.equal(state.tabs.length, 2); + assert.equal(state.activeUrl, TAB_A.url); + // Original tab metadata wins — the URL is content-addressed. + assert.equal(state.tabs[0].filename, "README.md"); +}); + +test("activateFileViewerTab switches only to a known tab", () => { + openFileViewerTab(TAB_A); + openFileViewerTab(TAB_B); + activateFileViewerTab(TAB_A.url); + assert.equal(getFileViewerState().activeUrl, TAB_A.url); + activateFileViewerTab("https://r/media/unknown.bin"); + assert.equal(getFileViewerState().activeUrl, TAB_A.url); +}); + +test("closing the active tab activates its right neighbor, else the left one", () => { + openFileViewerTab(TAB_A); + openFileViewerTab(TAB_B); + openFileViewerTab(TAB_C); + activateFileViewerTab(TAB_B.url); + closeFileViewerTab(TAB_B.url); + assert.equal(getFileViewerState().activeUrl, TAB_C.url); + closeFileViewerTab(TAB_C.url); + assert.equal(getFileViewerState().activeUrl, TAB_A.url); +}); + +test("closing an inactive tab keeps the active tab", () => { + openFileViewerTab(TAB_A); + openFileViewerTab(TAB_B); + closeFileViewerTab(TAB_A.url); + const state = getFileViewerState(); + assert.equal(state.activeUrl, TAB_B.url); + assert.deepEqual(state.tabs, [TAB_B]); +}); + +test("closing the last tab closes the panel", () => { + openFileViewerTab(TAB_A); + closeFileViewerTab(TAB_A.url); + const state = getFileViewerState(); + assert.equal(state.isOpen, false); + assert.equal(state.activeUrl, null); + assert.deepEqual(state.tabs, []); +}); + +test("closeFileViewer hides the panel but keeps tabs for a later reopen", () => { + openFileViewerTab(TAB_A); + openFileViewerTab(TAB_B); + closeFileViewer(); + const closed = getFileViewerState(); + assert.equal(closed.isOpen, false); + assert.equal(closed.tabs.length, 2); + openFileViewerTab(TAB_A); + const reopened = getFileViewerState(); + assert.equal(reopened.isOpen, true); + assert.equal(reopened.tabs.length, 2); +}); + +test("resetFileViewerStore drops all tabs (community switch)", () => { + openFileViewerTab(TAB_A); + resetFileViewerStore(); + const state = getFileViewerState(); + assert.deepEqual(state, { activeUrl: null, isOpen: false, tabs: [] }); +}); + +test("subscribers are notified on every state change", () => { + let calls = 0; + const unsubscribe = subscribeFileViewer(() => { + calls += 1; + }); + openFileViewerTab(TAB_A); + closeFileViewer(); + unsubscribe(); + closeFileViewerTab(TAB_A.url); + assert.equal(calls, 2); +}); + +test("host registration reports availability and unwinds on cleanup", () => { + assert.equal(hasFileViewerHost(), false); + const unregisterFirst = registerFileViewerHost(); + const unregisterSecond = registerFileViewerHost(); + assert.equal(hasFileViewerHost(), true); + unregisterFirst(); + assert.equal(hasFileViewerHost(), true); + unregisterSecond(); + assert.equal(hasFileViewerHost(), false); +}); diff --git a/desktop/src/features/fileViewer/fileViewerStore.ts b/desktop/src/features/fileViewer/fileViewerStore.ts new file mode 100644 index 00000000000..513a5a51077 --- /dev/null +++ b/desktop/src/features/fileViewer/fileViewerStore.ts @@ -0,0 +1,139 @@ +/** + * Open-tab state for the message-attachment file viewer. + * + * Lives outside React because the open action fires from inside the memoized + * markdown renderer (`FileCard`): threading a callback through props there + * would make every timeline row re-render (see the `React.memo` note in + * AGENTS.md). + * + * Tabs are keyed by the attachment's Blossom URL, which is content-addressed + * (`/media/{sha256}.{ext}`), so one URL means one file and reopening an + * attachment activates its existing tab. + * + * Community-scoped: `resetFileViewerStore` is wired into + * `resetCommunityState()` so tabs never leak across communities. + */ + +export type FileViewerTab = { + filename: string; + /** imeta `m` MIME, when the message carried one. */ + mime?: string; + /** imeta `size` in bytes, when the message carried one. */ + size?: number; + /** Blossom media URL — the tab identity. */ + url: string; +}; + +export type FileViewerState = { + /** URL of the tab on screen. Survives a close so reopening restores it. */ + activeUrl: string | null; + /** Whether the panel is visible. Tabs survive a close. */ + isOpen: boolean; + tabs: readonly FileViewerTab[]; +}; + +const INITIAL_STATE: FileViewerState = { + activeUrl: null, + isOpen: false, + tabs: [], +}; + +// Reference-stable snapshot for useSyncExternalStore: a fresh object per read +// would re-render subscribers forever. +let state: FileViewerState = INITIAL_STATE; + +const listeners = new Set<() => void>(); + +/** + * Mounted panel hosts. `FileCard` checks this before opening: with no host + * (e.g. a forum post route) it falls back to downloading rather than opening + * a panel nothing would render. + */ +let hostCount = 0; + +function setState(next: FileViewerState): void { + state = next; + for (const listener of listeners) listener(); +} + +export function subscribeFileViewer(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +export function getFileViewerState(): FileViewerState { + return state; +} + +/** The tab the panel should render, or `null` when nothing is on screen. */ +export function selectActiveFileViewerTab( + snapshot: FileViewerState, +): FileViewerTab | null { + if (!snapshot.isOpen) return null; + return snapshot.tabs.find((tab) => tab.url === snapshot.activeUrl) ?? null; +} + +/** Register a mounted panel host. Returns the matching unregister cleanup. */ +export function registerFileViewerHost(): () => void { + hostCount += 1; + return () => { + hostCount -= 1; + }; +} + +export function hasFileViewerHost(): boolean { + return hostCount > 0; +} + +/** + * Open an attachment: adds a tab when the URL is new, activates it, and opens + * the panel. An existing tab keeps its original metadata — the URL already + * pins the bytes. + */ +export function openFileViewerTab(tab: FileViewerTab): void { + const exists = state.tabs.some((t) => t.url === tab.url); + if (exists && state.isOpen && state.activeUrl === tab.url) return; + setState({ + activeUrl: tab.url, + isOpen: true, + tabs: exists ? state.tabs : [...state.tabs, tab], + }); +} + +export function activateFileViewerTab(url: string): void { + if (url === state.activeUrl) return; + if (!state.tabs.some((t) => t.url === url)) return; + setState({ ...state, activeUrl: url }); +} + +/** + * Close one tab. Closing the active tab activates its right neighbour, else + * its left one; closing the last tab closes the panel. + */ +export function closeFileViewerTab(url: string): void { + const index = state.tabs.findIndex((t) => t.url === url); + if (index === -1) return; + const tabs = state.tabs.filter((t) => t.url !== url); + if (tabs.length === 0) { + setState({ activeUrl: null, isOpen: false, tabs: [] }); + return; + } + const activeUrl = + state.activeUrl === url + ? tabs[Math.min(index, tabs.length - 1)].url + : state.activeUrl; + setState({ ...state, activeUrl, tabs }); +} + +/** Hide the panel, keeping tabs so a later open restores them. */ +export function closeFileViewer(): void { + if (!state.isOpen) return; + setState({ ...state, isOpen: false }); +} + +/** Community switch: drop every tab. Called from `resetCommunityState`. */ +export function resetFileViewerStore(): void { + setState(INITIAL_STATE); +} diff --git a/desktop/src/features/fileViewer/ui/FileViewerPanel.tsx b/desktop/src/features/fileViewer/ui/FileViewerPanel.tsx new file mode 100644 index 00000000000..192456b2d9e --- /dev/null +++ b/desktop/src/features/fileViewer/ui/FileViewerPanel.tsx @@ -0,0 +1,307 @@ +import * as React from "react"; +import { Copy, Download, FileText, Loader2, X } from "lucide-react"; +import { toast } from "sonner"; + +import { copyTextToSystemClipboard } from "@/shared/api/tauriMedia"; +import { + AuxiliaryPanel, + AuxiliaryPanelBody, + AuxiliaryPanelHeader, + AuxiliaryPanelHeaderActions, + AuxiliaryPanelHeaderGroup, + type AuxiliaryPanelLayout, +} from "@/shared/layout/AuxiliaryPanel"; +import { cn } from "@/shared/lib/cn"; +import { Button } from "@/shared/ui/button"; +import { Markdown } from "@/shared/ui/markdown"; +import { SyntaxHighlightedCode } from "@/shared/ui/markdown/CodeBlock"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; + +import { downloadAttachment } from "../downloadAttachment"; +import { classifyFileView } from "../fileViewClassification"; +import type { FileViewerContent } from "../fileViewerContent"; +import { + activateFileViewerTab, + closeFileViewer, + closeFileViewerTab, + type FileViewerTab, + selectActiveFileViewerTab, +} from "../fileViewerStore"; +import { useFileViewerContent } from "../useFileViewerContent"; +import { useFileViewerState } from "../useFileViewerState"; + +type FileViewerPanelProps = { + isSinglePanelView: boolean; + layout: AuxiliaryPanelLayout; + transparentChrome?: boolean; + widthPx: number; +}; + +const TAB_FOCUS_CLASS = + "rounded-sm focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"; + +/** One chip per open file. This is the panel's only title surface. */ +function FileViewerTabStrip({ + activeUrl, + tabs, +}: { + activeUrl: string | null; + tabs: readonly FileViewerTab[]; +}) { + const activeTabRef = React.useRef(null); + // The strip scrolls, and no other chrome names the open file, so an activated + // tab must never stay outside the visible run. + // biome-ignore lint/correctness/useExhaustiveDependencies: activeUrl triggers the scroll; the effect reads only the ref + React.useEffect(() => { + activeTabRef.current?.scrollIntoView({ + block: "nearest", + inline: "nearest", + }); + }, [activeUrl]); + + return ( +
+ {tabs.map((tab) => { + const isActive = tab.url === activeUrl; + return ( + + + + + + ); + })} +
+ ); +} + +/** Shown when a file cannot be previewed; download stays available. */ +function FileViewerFallback({ + message, + tab, +}: { + message: string; + tab: FileViewerTab; +}) { + return ( +
+ + + +

{message}

+ +
+ ); +} + +function FileViewerBody({ + content, + tab, +}: { + content: FileViewerContent; + tab: FileViewerTab; +}) { + if (content.status === "loading") { + return ( +
+ +
+ ); + } + if (content.status === "error") { + return ; + } + if (content.status === "too-large") { + return ( + + ); + } + if (content.status === "binary") { + return ( + + ); + } + + const view = classifyFileView(tab.filename, tab.mime); + if (view.kind === "markdown") { + return ( +
+ +
+ ); + } + // Shiki degrades to uncolored lines for unknown languages and long files, so + // plain text can share this path. + return ( +
+      
+    
+ ); +} + +/** Copies the previewed text; rendered only once the text has loaded. */ +function FileViewerCopyAction({ text }: { text: string }) { + const [isCopying, setIsCopying] = React.useState(false); + + return ( + + + + + Copy + + ); +} + +/** + * Right-side panel previewing message attachments, one tab per open file. + * Mounted by `ChannelPane` in the same resizable shell as the thread and + * activity panels. + */ +export function FileViewerPanel({ + isSinglePanelView, + layout, + transparentChrome = false, + widthPx, +}: FileViewerPanelProps) { + const snapshot = useFileViewerState(); + const activeTab = selectActiveFileViewerTab(snapshot); + // Hoisted above the body so the header's Copy action can reach the text. + const content = useFileViewerContent(activeTab?.url ?? null, activeTab?.size); + + if (!activeTab) return null; + + return ( + + + + + + {content.status === "text" ? ( + + ) : null} + + + + + Download + + + {/* + * The docked header floats over the scrolling body, so the rule + * between tabs and file has to live here — on the body it would + * scroll away. + */} + + ); +} diff --git a/desktop/src/features/fileViewer/useFileViewerAuxiliaryPanel.ts b/desktop/src/features/fileViewer/useFileViewerAuxiliaryPanel.ts new file mode 100644 index 00000000000..91dfa5e7dbf --- /dev/null +++ b/desktop/src/features/fileViewer/useFileViewerAuxiliaryPanel.ts @@ -0,0 +1,42 @@ +import * as React from "react"; + +import { closeFileViewer, selectActiveFileViewerTab } from "./fileViewerStore"; +import { useFileViewerState } from "./useFileViewerState"; + +type AuxiliaryPanelKeys = { + agentSession: string | null | undefined; + channelManagement: boolean; + profile: string | null | undefined; + thread: string | null | undefined; +}; + +/** + * Whether the file viewer currently claims the channel's auxiliary-pane slot. + * + * Also closes the viewer when another auxiliary panel opens or retargets: the + * slot holds one panel and the viewer branch renders first, so a newly opened + * thread/profile/activity panel would otherwise stay hidden behind it. Only + * transitions close the viewer — panels already open on mount (restored from + * the URL) leave it alone. + */ +export function useFileViewerAuxiliaryPanel( + panelKeys: AuxiliaryPanelKeys, +): boolean { + const snapshot = useFileViewerState(); + const previousKeysRef = React.useRef(panelKeys); + const { agentSession, channelManagement, profile, thread } = panelKeys; + + React.useEffect(() => { + const previous = previousKeysRef.current; + const next = { agentSession, channelManagement, profile, thread }; + previousKeysRef.current = next; + const otherPanelOpened = + (next.thread && next.thread !== previous.thread) || + (next.agentSession && next.agentSession !== previous.agentSession) || + (next.profile && next.profile !== previous.profile) || + (next.channelManagement && !previous.channelManagement); + if (otherPanelOpened) closeFileViewer(); + }, [agentSession, channelManagement, profile, thread]); + + return selectActiveFileViewerTab(snapshot) !== null; +} diff --git a/desktop/src/features/fileViewer/useFileViewerContent.ts b/desktop/src/features/fileViewer/useFileViewerContent.ts new file mode 100644 index 00000000000..519af5c2757 --- /dev/null +++ b/desktop/src/features/fileViewer/useFileViewerContent.ts @@ -0,0 +1,55 @@ +import { useQuery } from "@tanstack/react-query"; + +import { fetchMediaBytes } from "@/shared/api/tauriMedia"; + +import { + type DecodedFileViewerContent, + decodeFileViewerContent, + type FileViewerContent, + MAX_FILE_PREVIEW_BYTES, +} from "./fileViewerContent"; + +/** + * Fetch and decode an attachment for the viewer panel. + * + * Bytes travel over the `fetch_media_bytes` IPC path (Rust reqwest through the + * VPN tunnel) because a webview `fetch` to the relay is refused by Cloudflare + * Access — see `mediaUrl.ts`. Blossom URLs are content-addressed, so a result + * is never revalidated (`staleTime: Infinity`) and is dropped 5 minutes after + * the last viewer closes (`gcTime`). + */ +export function useFileViewerContent( + /** `null` disables the fetch — the panel calls this before its own guard. */ + url: string | null, + declaredSize?: number, +): FileViewerContent { + const oversized = + declaredSize != null && declaredSize > MAX_FILE_PREVIEW_BYTES; + const query = useQuery({ + enabled: url !== null && !oversized, + gcTime: 5 * 60 * 1000, + queryFn: async (): Promise => { + // Unreachable while `enabled` gates on a non-null URL; narrows the type + // without an assertion. + if (url === null) throw new Error("no file selected"); + return decodeFileViewerContent(await fetchMediaBytes(url)); + }, + queryKey: ["file-viewer-content", url], + retry: 1, + staleTime: Number.POSITIVE_INFINITY, + }); + + // Skip the fetch entirely when the imeta size already rules out a preview. + if (oversized) return { status: "too-large" }; + if (query.isPending) return { status: "loading" }; + if (query.isError) { + return { + status: "error", + message: + query.error instanceof Error + ? query.error.message + : "Failed to load file", + }; + } + return query.data; +} diff --git a/desktop/src/features/fileViewer/useFileViewerState.ts b/desktop/src/features/fileViewer/useFileViewerState.ts new file mode 100644 index 00000000000..1a0a3ac010c --- /dev/null +++ b/desktop/src/features/fileViewer/useFileViewerState.ts @@ -0,0 +1,16 @@ +import * as React from "react"; + +import { + type FileViewerState, + getFileViewerState, + subscribeFileViewer, +} from "./fileViewerStore"; + +/** Reactive file-viewer snapshot for panel hosts and the panel itself. */ +export function useFileViewerState(): FileViewerState { + return React.useSyncExternalStore( + subscribeFileViewer, + getFileViewerState, + getFileViewerState, + ); +} diff --git a/desktop/src/shared/styles/globals/scrollbars.css b/desktop/src/shared/styles/globals/scrollbars.css index 3d6fa154022..33722cb257e 100644 --- a/desktop/src/shared/styles/globals/scrollbars.css +++ b/desktop/src/shared/styles/globals/scrollbars.css @@ -97,3 +97,25 @@ .buzz-content-scrollbar::-webkit-scrollbar-thumb:active { background-color: hsl(var(--border) / 0.9); } + +/* + * The file-viewer tab strip scrolls horizontally inside a 36px header row, so + * a default scrollbar would be nearly as tall as the tabs themselves. Keep the + * bar to a hairline: the active tab is scrolled into view programmatically, so + * this is an overflow hint rather than the primary way to reach a tab. + * + * Same reasoning as the note above — no `scrollbar-color`, or the engine drops + * these pseudo styles for overlay scrollbars. + */ +.buzz-file-viewer-tabs-scrollbar::-webkit-scrollbar { + height: 4px; +} + +.buzz-file-viewer-tabs-scrollbar::-webkit-scrollbar-track { + background: transparent; +} + +.buzz-file-viewer-tabs-scrollbar::-webkit-scrollbar-thumb { + background-color: hsl(var(--foreground) / 0.15); + border-radius: 9999px; +} diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index 433a8ce6e1c..fc1dca3bbec 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -1343,9 +1343,8 @@ function createMarkdownComponents( label, ); if (card) { - return ( - - ); + // `ResolvedFileCard` matches FileCard's props exactly. + return ; } // Intercept `buzz://message?channel=…&id=…` links so a click navigates diff --git a/desktop/src/shared/ui/markdown/FileCard.tsx b/desktop/src/shared/ui/markdown/FileCard.tsx index 889847193ec..f45ffcbce1c 100644 --- a/desktop/src/shared/ui/markdown/FileCard.tsx +++ b/desktop/src/shared/ui/markdown/FileCard.tsx @@ -1,8 +1,12 @@ import * as React from "react"; import { Download, FileText } from "lucide-react"; -import { toast } from "sonner"; -import { invokeTauri } from "@/shared/api/tauri"; +import { downloadAttachment } from "@/features/fileViewer/downloadAttachment"; +import { classifyFileView } from "@/features/fileViewer/fileViewClassification"; +import { + hasFileViewerHost, + openFileViewerTab, +} from "@/features/fileViewer/fileViewerStore"; import { useSmoothCorners } from "@/shared/ui/smoothCorners"; /** Human-readable byte size: "820 B", "12.4 KB", "3.1 MB". */ @@ -20,58 +24,67 @@ function formatFileSize(bytes: number): string { } /** - * File card for a generic (non-image, non-video) attachment: icon, filename, - * size, and a download action. + * Card for a generic (non-image, non-video) attachment: icon, filename, size, + * and a download action. * - * Downloads go through the native `download_file` Tauri command (HTTP inside - * the app's tunnel + a save dialog), not a plain `` link. A bare - * link navigates the webview to the blob URL, which escapes to the OS browser - * and gets bounced to a corporate CDN interstitial ("browser not supported"). - * The native command mirrors the image-download path. + * Clicking a viewable file opens the file-viewer panel. Non-viewable types, and + * surfaces with no mounted viewer host (e.g. forum routes), download instead. */ export function FileCard({ href, filename, + mime, size, }: { href: string; filename: string; + mime?: string; size?: number; }) { const cardRef = React.useRef(null); const sizeLabel = size != null ? formatFileSize(size) : ""; useSmoothCorners(cardRef); + const isViewable = classifyFileView(filename, mime).kind !== "none"; return ( - + {sizeLabel ? ( + + {sizeLabel} + + ) : null} + + + + ); } diff --git a/desktop/src/shared/ui/markdownFileCard.test.mjs b/desktop/src/shared/ui/markdownFileCard.test.mjs index 3fd9c035f05..82315f598ca 100644 --- a/desktop/src/shared/ui/markdownFileCard.test.mjs +++ b/desktop/src/shared/ui/markdownFileCard.test.mjs @@ -46,6 +46,7 @@ test("resolveFileCard: builds a card for a generic file, preferring imeta filena assert.deepEqual(card, { href: PDF_URL, filename: "Q3-budget.pdf", + mime: "application/pdf", size: 2048, }); }); diff --git a/desktop/src/shared/ui/markdownFileCard.ts b/desktop/src/shared/ui/markdownFileCard.ts index 05b4f677e56..94ee0ce7a85 100644 --- a/desktop/src/shared/ui/markdownFileCard.ts +++ b/desktop/src/shared/ui/markdownFileCard.ts @@ -14,6 +14,8 @@ export type FileCardImetaEntry = { export type ResolvedFileCard = { href: string; filename: string; + /** imeta `m` MIME, forwarded so the card can classify viewability. */ + mime?: string; size?: number; }; @@ -142,5 +144,10 @@ export function resolveFileCard( } const filename = entry.filename || childText.trim() || href.split("/").pop() || "file"; - return { href: rewriteRelayUrl(href), filename, size: entry.size }; + return { + href: rewriteRelayUrl(href), + filename, + mime: entry.m, + size: entry.size, + }; } diff --git a/desktop/tests/e2e/file-viewer-screenshots.spec.ts b/desktop/tests/e2e/file-viewer-screenshots.spec.ts new file mode 100644 index 00000000000..ea6424b7b55 --- /dev/null +++ b/desktop/tests/e2e/file-viewer-screenshots.spec.ts @@ -0,0 +1,189 @@ +import { expect, test } from "@playwright/test"; +import type { Page } from "@playwright/test"; + +import { waitForAnimations } from "../helpers/animations"; +import { installMockBridge } from "../helpers/bridge"; + +// Screenshot coverage for the attachment file viewer. Each shot is scoped to +// a distinct state (markdown render, code render, two-tab strip) so the PNGs +// are never byte-identical — see AGENTS.md "Distinct states". + +const MARKDOWN_SHA = "a".repeat(64); +const MARKDOWN_URL = `http://localhost:3000/media/${MARKDOWN_SHA}.md`; +const MARKDOWN_BODY = [ + "# Release notes", + "", + "Shipped the **file viewer** panel.", + "", + "## Highlights", + "", + "- Tabs for every opened attachment", + "- Drag the divider to resize", + "- Markdown and code rendering", + "", + "| Surface | Supported |", + "| --- | --- |", + "| Channels | yes |", + "| DMs | yes |", + "| Threads | yes |", +].join("\n"); + +const SCRIPT_SHA = "b".repeat(64); +const SCRIPT_URL = `http://localhost:3000/media/${SCRIPT_SHA}.sh`; +const SCRIPT_BODY = [ + "#!/usr/bin/env bash", + "# Apply the non-secret configuration.", + "set -euo pipefail", + "", + 'C="docker compose exec -T hermes hermes"', + "", + 'echo "==> Buzz: transport and access"', + "$C config set gateway.platforms.buzz.enabled true", + "$C config set gateway.platforms.buzz.extra.poll_interval 4", +].join("\n"); + +// A third file with a long name, so the strip overflows and its scrollbar and +// active-tab fill can be inspected — the real-world case is an agent +// delivering a bundle of files at once. +const DATA_SHA = "c".repeat(64); +const DATA_URL = `http://localhost:3000/media/${DATA_SHA}.json`; +const DATA_BODY = JSON.stringify( + { generated: "2026-08-12", rows: [{ date: "2026-08-01", breadth: 0.62 }] }, + null, + 2, +); + +function imetaTag(url: string, mime: string, sha: string, filename: string) { + return [ + "imeta", + `url ${url}`, + `m ${mime}`, + `x ${sha}`, + `filename ${filename}`, + ]; +} + +async function emitBundleMessage(page: Page) { + await page.waitForFunction( + () => + typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" && + (window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ + channelName: "general", + }) ?? + false), + ); + await page.evaluate( + ({ dataUrl, markdownUrl, scriptUrl, tags }) => { + const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__; + if (!emit) throw new Error("Mock message emitter is unavailable."); + emit({ + channelName: "general", + content: [ + "Here is the release bundle", + "", + `[release-notes.md](${markdownUrl})`, + "", + `[apply-config.sh](${scriptUrl})`, + "", + `[market_breadth_history.json](${dataUrl})`, + ].join("\n"), + extraTags: tags, + }); + }, + { + dataUrl: DATA_URL, + markdownUrl: MARKDOWN_URL, + scriptUrl: SCRIPT_URL, + tags: [ + imetaTag( + MARKDOWN_URL, + "text/markdown", + MARKDOWN_SHA, + "release-notes.md", + ), + imetaTag(SCRIPT_URL, "application/x-sh", SCRIPT_SHA, "apply-config.sh"), + imetaTag( + DATA_URL, + "application/octet-stream", + DATA_SHA, + "market_breadth_history.json", + ), + ], + }, + ); +} + +test("file viewer states", async ({ page }) => { + await installMockBridge(page); + await page.route(MARKDOWN_URL, (route) => + route.fulfill({ body: MARKDOWN_BODY, contentType: "text/markdown" }), + ); + await page.route(SCRIPT_URL, (route) => + route.fulfill({ body: SCRIPT_BODY, contentType: "text/x-shellscript" }), + ); + await page.route(DATA_URL, (route) => + route.fulfill({ body: DATA_BODY, contentType: "application/octet-stream" }), + ); + + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await emitBundleMessage(page); + + // 1. File cards in the timeline, before anything is opened. + const cards = page.getByTestId("file-card"); + await expect(cards).toHaveCount(3); + await waitForAnimations(page); + await page.screenshot({ + path: "test-results/file-viewer/01-file-cards.png", + clip: { height: 340, width: 700, x: 380, y: 380 }, + }); + + // 2. Markdown rendered in the viewer panel. + await cards.first().click(); + const panel = page.getByTestId("file-viewer-panel"); + await expect( + panel.getByRole("heading", { name: "Release notes" }), + ).toBeVisible(); + await waitForAnimations(page); + await panel.screenshot({ + path: "test-results/file-viewer/02-markdown.png", + }); + + // 3. Second file opened: two tabs, syntax-highlighted shell script. + await cards.nth(1).click(); + await expect(page.getByTestId("file-viewer-tab")).toHaveCount(2); + await expect(page.getByTestId("file-viewer-code")).toContainText( + "set -euo pipefail", + ); + await waitForAnimations(page); + await panel.screenshot({ + path: "test-results/file-viewer/03-code-two-tabs.png", + }); + + // 4. Resized wide, back on the markdown tab. + await page.getByTestId("file-viewer-tab").first().click(); + await expect(page.getByTestId("file-viewer-markdown")).toBeVisible(); + const handle = page.getByTestId("right-auxiliary-pane-resize-handle"); + const box = await handle.boundingBox(); + if (!box) throw new Error("Resize handle has no bounding box."); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.mouse.move(box.x - 240, box.y + box.height / 2, { steps: 12 }); + await page.mouse.up(); + await waitForAnimations(page); + await page.screenshot({ + path: "test-results/file-viewer/04-resized-wide.png", + }); + + // 5. Three files open in a narrow panel: the strip overflows, so this is + // where the hairline scrollbar and the filled active chip are legible. + await page.getByTestId("right-auxiliary-pane-resize-handle").dblclick(); + await cards.nth(2).click(); + await expect(page.getByTestId("file-viewer-tab")).toHaveCount(3); + await expect(page.getByTestId("file-viewer-code")).toContainText("breadth"); + await waitForAnimations(page); + await panel.screenshot({ + path: "test-results/file-viewer/05-three-tabs-overflow.png", + }); +}); diff --git a/desktop/tests/e2e/file-viewer.spec.ts b/desktop/tests/e2e/file-viewer.spec.ts new file mode 100644 index 00000000000..fe88e4de40f --- /dev/null +++ b/desktop/tests/e2e/file-viewer.spec.ts @@ -0,0 +1,220 @@ +import { expect, test } from "@playwright/test"; +import type { Page } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; + +// Exercises the attachment file-viewer contract through the mock Tauri +// bridge: clicking a viewable FileCard (markdown/code/text) opens the +// right-side viewer panel with one tab per file, while non-viewable types +// keep the legacy download-on-click behavior. Content bytes travel through +// the mocked `fetch_media_bytes` command, which fetches the URL in-browser — +// each test routes its media URLs to canned bodies. + +const MARKDOWN_SHA = "a".repeat(64); +const MARKDOWN_URL = `http://localhost:3000/media/${MARKDOWN_SHA}.md`; +const MARKDOWN_BODY = "# Release notes\n\nShipped the **file viewer** panel."; + +const SCRIPT_SHA = "b".repeat(64); +const SCRIPT_URL = `http://localhost:3000/media/${SCRIPT_SHA}.sh`; +const SCRIPT_BODY = "#!/usr/bin/env bash\necho 'hello viewer'\n"; + +const PDF_SHA = "c".repeat(64); +const PDF_URL = `http://localhost:3000/media/${PDF_SHA}.pdf`; + +function imetaTag(url: string, mime: string, sha: string, filename: string) { + return [ + "imeta", + `url ${url}`, + `m ${mime}`, + `x ${sha}`, + `filename ${filename}`, + ]; +} + +async function emitAttachmentMessage( + page: Page, + args: { content: string; extraTags: string[][] }, +) { + await page.waitForFunction( + () => + typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" && + (window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ + channelName: "general", + }) ?? + false), + ); + return page.evaluate(({ content, extraTags }) => { + const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__; + if (!emit) throw new Error("Mock message emitter is unavailable."); + return emit({ channelName: "general", content, extraTags }).id; + }, args); +} + +async function invokedCommandCount(page: Page, command: string) { + return page.evaluate( + (name) => + ( + (window as Window & { __BUZZ_E2E_COMMANDS__?: string[] }) + .__BUZZ_E2E_COMMANDS__ ?? [] + ).filter((invoked) => invoked === name).length, + command, + ); +} + +test.beforeEach(async ({ page }) => { + await installMockBridge(page); + await page.route(MARKDOWN_URL, (route) => + route.fulfill({ body: MARKDOWN_BODY, contentType: "text/markdown" }), + ); + await page.route(SCRIPT_URL, (route) => + route.fulfill({ body: SCRIPT_BODY, contentType: "text/x-shellscript" }), + ); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); +}); + +test("clicking a markdown FileCard opens the viewer, not a download", async ({ + page, +}) => { + await emitAttachmentMessage(page, { + content: `Here are the notes\n\n[release-notes.md](${MARKDOWN_URL})`, + extraTags: [ + imetaTag(MARKDOWN_URL, "text/markdown", MARKDOWN_SHA, "release-notes.md"), + ], + }); + + await page.getByTestId("file-card").last().click(); + + const panel = page.getByTestId("file-viewer-panel"); + await expect(panel).toBeVisible(); + // Rendered markdown — the heading exists as a heading, not literal `#`. + await expect( + panel.getByRole("heading", { name: "Release notes" }), + ).toBeVisible(); + await expect(page.getByTestId("file-viewer-tab")).toHaveText( + "release-notes.md", + ); + expect(await invokedCommandCount(page, "download_file")).toBe(0); + // The tab strip is the panel's only title surface; a separate filename + // heading would just repeat it. A rule must sit between the tabs and the + // file, spanning the panel — assert geometry, not mere DOM presence. + await expect(panel.getByRole("heading", { level: 2 })).toHaveCount(0); + const divider = page.getByTestId("file-viewer-header-divider"); + await expect(divider).toBeVisible(); + const dividerBox = await divider.boundingBox(); + const tabBox = await page + .getByTestId("file-viewer-tab") + .first() + .boundingBox(); + const bodyBox = await page.getByTestId("file-viewer-markdown").boundingBox(); + const panelBox = await panel.boundingBox(); + if (!dividerBox || !tabBox || !bodyBox || !panelBox) { + throw new Error("file viewer layout boxes are unavailable"); + } + expect(dividerBox.y).toBeGreaterThan(tabBox.y + tabBox.height); + expect(dividerBox.y).toBeLessThanOrEqual(bodyBox.y); + expect(dividerBox.width).toBeGreaterThan(panelBox.width - 2); + + // Copy puts the decoded file text on the clipboard. + await page.getByTestId("file-viewer-copy").click(); + await expect + .poll(() => invokedCommandCount(page, "copy_text_to_clipboard")) + .toBe(1); + + // The explicit download affordance still downloads. + await page.getByTestId("file-viewer-download").click(); + await expect.poll(() => invokedCommandCount(page, "download_file")).toBe(1); +}); + +test("opening a second file adds a tab; closing tabs restores and empties", async ({ + page, +}) => { + await emitAttachmentMessage(page, { + content: `Bundle\n\n[release-notes.md](${MARKDOWN_URL})\n\n[apply.sh](${SCRIPT_URL})`, + extraTags: [ + imetaTag(MARKDOWN_URL, "text/markdown", MARKDOWN_SHA, "release-notes.md"), + imetaTag(SCRIPT_URL, "application/x-sh", SCRIPT_SHA, "apply.sh"), + ], + }); + + const cards = page.getByTestId("file-card"); + await cards.first().click(); + await expect(page.getByTestId("file-viewer-tab")).toHaveCount(1); + await cards.nth(1).click(); + + const tabs = page.getByTestId("file-viewer-tab"); + await expect(tabs).toHaveCount(2); + // Second file is active: shell script renders through the code path. + await expect(page.getByTestId("file-viewer-code")).toContainText( + "echo 'hello viewer'", + ); + + // The strip scrolls inside a 36px header row, so its scrollbar must never + // claim more than a hairline of that row. A horizontal scrollbar that + // reserves space shows up as offsetHeight - clientHeight; overlay + // scrollbars report 0, and both satisfy the cap. + const scrollbarHeight = await page + .getByTestId("file-viewer-tab-strip") + .evaluate((el) => el.offsetHeight - el.clientHeight); + expect(scrollbarHeight).toBeLessThanOrEqual(4); + + // Switch back to the markdown tab. + await tabs.first().click(); + await expect(page.getByTestId("file-viewer-markdown")).toBeVisible(); + + // Closing the active tab activates the neighbor. + await page.getByTestId("file-viewer-tab-close").first().click(); + await expect(tabs).toHaveCount(1); + await expect(page.getByTestId("file-viewer-code")).toBeVisible(); + + // Closing the last tab closes the panel. + await page.getByTestId("file-viewer-tab-close").click(); + await expect(page.getByTestId("file-viewer-panel")).toHaveCount(0); +}); + +test("non-viewable attachments keep download-on-click", async ({ page }) => { + await emitAttachmentMessage(page, { + content: `Budget\n\n[q3-budget.pdf](${PDF_URL})`, + extraTags: [imetaTag(PDF_URL, "application/pdf", PDF_SHA, "q3-budget.pdf")], + }); + + await page.getByTestId("file-card").last().click(); + + await expect.poll(() => invokedCommandCount(page, "download_file")).toBe(1); + await expect(page.getByTestId("file-viewer-panel")).toHaveCount(0); +}); + +test("opening a thread supersedes the viewer panel", async ({ page }) => { + const messageId = await emitAttachmentMessage(page, { + content: `Notes\n\n[release-notes.md](${MARKDOWN_URL})`, + extraTags: [ + imetaTag(MARKDOWN_URL, "text/markdown", MARKDOWN_SHA, "release-notes.md"), + ], + }); + + await page.getByTestId("file-card").last().click(); + await expect(page.getByTestId("file-viewer-panel")).toBeVisible(); + + // Reply in thread → the thread panel takes the aux slot. + const row = page.locator(`[data-message-id="${messageId}"]`); + await row.hover(); + await page.getByTestId(`reply-message-${messageId}`).click(); + + await expect(page.getByTestId("message-thread-panel")).toBeVisible(); + await expect(page.getByTestId("file-viewer-panel")).toHaveCount(0); + + // Opening a file from inside the thread borrows the slot, and closing the + // viewer hands it back — the thread route state is never discarded. + await page + .getByTestId("message-thread-panel") + .getByTestId("file-card") + .first() + .click(); + await expect(page.getByTestId("file-viewer-panel")).toBeVisible(); + await expect(page.getByTestId("message-thread-panel")).toHaveCount(0); + + await page.getByTestId("auxiliary-panel-close").click(); + await expect(page.getByTestId("message-thread-panel")).toBeVisible(); + await expect(page.getByTestId("file-viewer-panel")).toHaveCount(0); +});