diff --git a/Cargo.lock b/Cargo.lock index db2053f9..5a2856fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10982,6 +10982,7 @@ dependencies = [ name = "yaak-app-client" version = "0.0.0" dependencies = [ + "base64 0.22.1", "charset", "chrono", "cookie", diff --git a/apps/yaak-client/components/ContextMenus.tsx b/apps/yaak-client/components/ContextMenus.tsx new file mode 100644 index 00000000..b9e15773 --- /dev/null +++ b/apps/yaak-client/components/ContextMenus.tsx @@ -0,0 +1,23 @@ +import { useAtomValue } from "jotai"; +import { contextMenusAtom, hideContextMenu } from "../lib/contextMenu"; +import { ContextMenu } from "./core/Dropdown"; +import { ErrorBoundary } from "./ErrorBoundary"; + +/** Renders menus opened by {@link showContextMenu}, the way {@link Dialogs} renders dialogs. */ +export function ContextMenus() { + const menus = useAtomValue(contextMenusAtom); + return ( + <> + {menus.map(({ id, items, triggerPosition, triggerRect }) => ( + + hideContextMenu(id)} + /> + + ))} + + ); +} diff --git a/apps/yaak-client/components/LargeValueDialog.tsx b/apps/yaak-client/components/LargeValueDialog.tsx new file mode 100644 index 00000000..ecafea00 --- /dev/null +++ b/apps/yaak-client/components/LargeValueDialog.tsx @@ -0,0 +1,274 @@ +import { formatSize } from "@yaakapp-internal/lib/formatSize"; +import { Banner, Button, HStack, LoadingIcon } from "@yaakapp-internal/ui"; +import type { ReactNode } from "react"; +import { lazy, Suspense, useEffect, useMemo, useState } from "react"; +import type { SniffedValue } from "./core/Editor/sniffValue"; +import { showDialog } from "../lib/dialog"; +import { decodeValue, largeValueActions } from "../lib/largeValue"; +import { Dropdown } from "./core/Dropdown"; +import { AudioViewer } from "./responseViewers/AudioViewer"; +import { ImageViewer } from "./responseViewers/ImageViewer"; +import { SvgViewer } from "./responseViewers/SvgViewer"; +import { VideoViewer } from "./responseViewers/VideoViewer"; + +const PdfViewer = lazy(() => + import("./responseViewers/PdfViewer").then((m) => ({ default: m.PdfViewer })), +); + +interface Props { + /** The whole value, exactly as it reads in the document */ + text: string; + sniffed: SniffedValue | null; +} + +/** + * Shows a value the editor collapsed, in whatever viewer its type calls for. + * + * The editor only ever read the value's head, so this is where it is decoded in full — on an + * explicit click, behind a spinner, rather than during layout. + */ +export function LargeValueDialog({ text, sniffed }: Props) { + const viewer = sniffed == null ? null : viewerFor(sniffed.mime); + const decoded = useDecoded(text, viewer == null ? null : sniffed); + + // Nothing recognised it, so there is nothing to decode it into + if (sniffed == null || viewer == null) { + return ( + + {sniffed != null && ( + + {sniffed.label} content cannot be previewed, so it is shown as it appears in the + response. + + )} + + ); + } + + if (decoded == null) { + return ( + + + + ); + } + + if ("error" in decoded) { + return ( + + + Failed to decode this {sniffed.label} value: {decoded.error} + + + ); + } + + const { bytes } = decoded; + + switch (viewer) { + case "svg": + return ; + case "image": + return ( +
+ +
+ ); + case "audio": + return ; + case "video": + return ; + case "pdf": + return ( + }> + + + ); + case "text": + return ; + } +} + +/** + * Decoding megabytes is worth doing once, not on every render — and the viewers below key their + * blob URLs off the string, so a fresh one each time would rebuild them for nothing. + */ +function DecodedSvg({ bytes }: { bytes: Uint8Array }) { + const text = useMemo(() => new TextDecoder().decode(bytes), [bytes]); + return ; +} + +function DecodedText({ bytes }: { bytes: Uint8Array }) { + const text = useMemo(() => new TextDecoder().decode(bytes), [bytes]); + return ; +} + +/** + * The same menu the tag in the editor carries, minus the one entry that would only reopen this. + * + * It lives in the dialog's title rather than above the content, which would cost a band of + * whitespace the width of the dialog to hold one small button. + */ +function LargeValueActions({ text, sniffed }: Props) { + const items = useMemo( + () => + largeValueActions({ + value: () => text, + sniffed, + // The tag copies only what it hides; in here, what you see is the whole value + copyText: () => text, + }), + [text, sniffed], + ); + + return ( + + + + ); +} + +type Viewer = "image" | "svg" | "audio" | "video" | "pdf" | "text"; + +/** Which viewer a media type calls for, or null if none of them can show it. */ +function viewerFor(mime: string): Viewer | null { + if (mime.startsWith("image/svg")) return "svg"; + if (mime.startsWith("image/")) return "image"; + if (mime.startsWith("audio/")) return "audio"; + if (mime.startsWith("video/")) return "video"; + if (mime === "application/pdf") return "pdf"; + if (mime.startsWith("text/") || /\b(json|xml|javascript|csv)\b/.test(mime)) return "text"; + return null; +} + +type Decoded = { bytes: Uint8Array } | { error: string }; + +/** + * The value's bytes, once they exist. + * + * Decoding a few megabytes takes long enough to be seen, so it happens in an effect rather than + * during render — the dialog paints with a spinner first, instead of opening late. + */ +function useDecoded(text: string, sniffed: SniffedValue | null): Decoded | null { + const [decoded, setDecoded] = useState(null); + + useEffect(() => { + if (sniffed == null) return; + + setDecoded(null); + let cancelled = false; + const timer = setTimeout(() => { + let result: Decoded; + try { + result = { bytes: decodeValue(text, sniffed) }; + } catch (err) { + result = { error: err instanceof Error ? err.message : String(err) }; + } + if (!cancelled) setDecoded(result); + }); + + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [text, sniffed]); + + return decoded; +} + +/** A copy, since a `Uint8Array` may be a view onto a larger buffer */ +function toArrayBuffer(bytes: Uint8Array): ArrayBuffer { + return bytes.slice().buffer; +} + +/** Characters shown at once. A page this size lays out instantly once its lines are short. */ +const PAGE_CHARS = 50_000; + +/** Where a line is broken. The long-line cost this whole feature avoids is per line. */ +const WRAP_CHARS = 120; + +/** + * The raw text, in pages of short lines. + * + * What is left when nothing can render the value. Handing it to an editor whole would walk + * straight back into the layout stall that collapsed it in the first place, so it is paged and + * hard-wrapped instead: no line is ever long, and no page is ever big. + */ +function PagedText({ text, children }: { text: string; children?: ReactNode }) { + const [page, setPage] = useState(0); + const pages = Math.max(1, Math.ceil(text.length / PAGE_CHARS)); + const from = page * PAGE_CHARS; + const to = Math.min(from + PAGE_CHARS, text.length); + const body = useMemo(() => wrap(text.slice(from, to)), [text, from, to]); + + return ( +
+
{children}
+
{body}
+ + + {(to - from).toLocaleString()} of {text.length.toLocaleString()} characters + + {pages > 1 && ( + + + + Page {page + 1} of {pages.toLocaleString()} + + + + )} + +
+ ); +} + +/** Breaks every line at {@link WRAP_CHARS}, leaving the ones already shorter alone. */ +function wrap(text: string): string { + const lines: string[] = []; + for (const line of text.split("\n")) { + if (line.length <= WRAP_CHARS) { + lines.push(line); + continue; + } + for (let i = 0; i < line.length; i += WRAP_CHARS) { + lines.push(line.slice(i, i + WRAP_CHARS)); + } + } + return lines.join("\n"); +} + +export function showLargeValueDialog({ text, sniffed }: Props) { + showDialog({ + id: "large-value", + size: "lg", + className: "h-[calc(100vh-10rem)]", + // Everything in one line: the same words the tag uses, and the same menu it carries. A + // separate description and a row of its own for the button cost three bands to say this. + title: ( + + + {sniffed == null ? "Hidden Value" : sniffed.label} · {formatSize(text.length)} + + + + ), + render: () => , + }); +} diff --git a/apps/yaak-client/components/core/Dropdown.tsx b/apps/yaak-client/components/core/Dropdown.tsx index ff8d4c38..e6de0c6c 100644 --- a/apps/yaak-client/components/core/Dropdown.tsx +++ b/apps/yaak-client/components/core/Dropdown.tsx @@ -233,23 +233,33 @@ export const Dropdown = forwardRef(function Dropdown export interface ContextMenuProps { triggerPosition: { x: number; y: number } | null; + /** + * The box the menu belongs to, when the trigger has one. + * + * Placement aligns to the trigger's left or right edge depending on the room beside it, so a + * menu opened from an element wants its real rect. Without one the position is treated as a + * zero-width point, which is right for a menu opened at the cursor and wrong for one opened + * from a button. + */ + triggerRect?: Pick; className?: string; items: DropdownProps["items"]; onClose: () => void; } export const ContextMenu = forwardRef(function ContextMenu( - { triggerPosition, className, items, onClose }, + { triggerPosition, triggerRect, className, items, onClose }, ref, ) { const triggerShape = useMemo( - () => ({ - top: triggerPosition?.y ?? 0, - bottom: triggerPosition?.y ?? 0, - left: triggerPosition?.x ?? 0, - right: triggerPosition?.x ?? 0, - }), - [triggerPosition], + () => + triggerRect ?? { + top: triggerPosition?.y ?? 0, + bottom: triggerPosition?.y ?? 0, + left: triggerPosition?.x ?? 0, + right: triggerPosition?.x ?? 0, + }, + [triggerPosition, triggerRect], ); if (triggerPosition == null) return null; @@ -259,6 +269,9 @@ export const ContextMenu = forwardRef(function Co isOpen={true} // Always open because we return null if not className={className} defaultSelectedIndex={null} + // A menu opened from an element points back at it. One opened at the cursor has nothing + // to point at, so it goes without. + showTriangle={triggerRect != null} ref={ref} items={items} onClose={onClose} diff --git a/apps/yaak-client/components/core/Editor/Editor.css b/apps/yaak-client/components/core/Editor/Editor.css index ebe82f12..bc473ab7 100644 --- a/apps/yaak-client/components/core/Editor/Editor.css +++ b/apps/yaak-client/components/core/Editor/Editor.css @@ -121,16 +121,24 @@ @apply cursor-default; } - /* An icon button any tag can carry, like the copy on a large value */ + /* The chevron on a tag that opens a menu. The tag itself is the button, so this is + only the affordance that says so. */ .tag-action { - @apply inline-flex items-center align-middle ml-1 cursor-pointer!; - @apply text-text-subtlest hover:text-text; + @apply inline-flex items-center align-middle ml-1; + @apply text-text-subtlest; svg { @apply w-[0.9em] h-[0.9em]; } } + /* A preview of the value a tag stands for. Sized before the image loads, so arriving + never changes the line's height. */ + .tag-thumbnail { + @apply inline-block align-middle mr-1 rounded-xs; + @apply w-[1.1em] h-[1.1em] object-cover bg-surface-highlight; + } + .fn { @apply inline-block; .fn-inner { diff --git a/apps/yaak-client/components/core/Editor/largeValues.test.ts b/apps/yaak-client/components/core/Editor/largeValues.test.ts index 3eeac361..59a6bd81 100644 --- a/apps/yaak-client/components/core/Editor/largeValues.test.ts +++ b/apps/yaak-client/components/core/Editor/largeValues.test.ts @@ -1,15 +1,15 @@ import { EditorState } from "@codemirror/state"; import { jsonc } from "@shopify/lang-jsonc"; import { text } from "./text/extension"; -import { describe, expect, test, vi } from "vite-plus/test"; +import { describe, expect, test } from "vite-plus/test"; import { + COLLAPSE_MEDIA_CHARS, COLLAPSE_TOKEN_CHARS, - largeValueField, + collapseDecorations, largeValues, MAX_VISIBLE_LINE_CHARS, } from "./largeValues"; - -vi.mock("../../../lib/copy", () => ({ copyToClipboard: () => {} })); +import type { SniffedValue } from "./sniffValue"; const BIG = "A".repeat(1_000_000); @@ -19,9 +19,19 @@ const jsonState = (doc: string) => EditorState.create({ doc, extensions: [jsonc( /** Without a grammar, so only the column rule applies */ const plainState = (doc: string) => EditorState.create({ doc, extensions: largeValues }); +/** + * The decorations as if the whole document were on screen. + * + * In the editor the plugin passes the viewport instead, which is the same call with narrower + * ranges — the rules themselves don't know the difference. + */ +function decorationsFor(state: EditorState) { + return collapseDecorations(state, [{ from: 0, to: state.doc.length }]); +} + function collapsedRanges(state: EditorState) { const ranges: { from: number; to: number }[] = []; - const iter = state.field(largeValueField).decorations.iter(); + const iter = decorationsFor(state).iter(); while (iter.value != null) { ranges.push({ from: iter.from, to: iter.to }); iter.next(); @@ -191,6 +201,67 @@ describe("undelimited tokens", () => { }); }); +describe("sniffing the collapsed value", () => { + /** The widget standing in for each collapse, in document order */ + function widgets(state: EditorState) { + const found: { valueFrom: number; sniffed: SniffedValue | null }[] = []; + const iter = decorationsFor(state).iter(); + while (iter.value != null) { + found.push(iter.value.spec.widget); + iter.next(); + } + return found; + } + + /** A base64 value that starts with a PNG signature and runs on well past the limits */ + function pngBase64(length = 1_000_000) { + const bytes = new Uint8Array(length); + bytes.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary); + } + + test("names the format of a base64 value inside JSON", () => { + const state = jsonState(`{"image":"${pngBase64()}"}`); + expect(widgets(state)[0]?.sniffed).toMatchObject({ mime: "image/png", label: "PNG" }); + }); + + test("names the format of a data URI", () => { + const doc = `{"image":"data:image/jpeg;base64,${BIG}"}`; + expect(widgets(jsonState(doc))[0]?.sniffed).toMatchObject({ label: "JPEG" }); + }); + + test("names nothing when the value is just text", () => { + expect(widgets(jsonState(`{"image":"${BIG}"}`))[0]?.sniffed).toBeNull(); + expect(widgets(plainState(BIG))[0]?.sniffed).toBeNull(); + }); + + test("reads a column cut from the start of its line, not from the cut", () => { + // A body that is nothing but one base64 blob: the tail is hidden, but the value it + // belongs to begins at the start of the line, which is where the signature is + const state = plainState(pngBase64()); + const [widget] = widgets(state); + + expect(widget?.valueFrom).toBe(0); + expect(widget?.sniffed).toMatchObject({ label: "PNG" }); + }); + + test("reads a token collapse from the value itself", () => { + const doc = `{"image":"${pngBase64()}"}`; + const [widget] = widgets(jsonState(doc)); + + // Just past the opening quote, so the signature is the first thing it sees + expect(widget?.valueFrom).toBe(doc.indexOf('"', doc.indexOf("image") + 6) + 1); + expect(widget?.sniffed).toMatchObject({ label: "PNG" }); + }); + + test("names nothing when a column cut lands mid-line after other text", () => { + const state = plainState(`some prefix text ${pngBase64()}`); + expect(widgets(state)[0]?.sniffed).toBeNull(); + }); +}); + describe("recomputing", () => { test("updates when the document changes", () => { const state = plainState('{"image":"short"}'); @@ -202,3 +273,101 @@ describe("recomputing", () => { expect(collapsedRanges(next)).toHaveLength(1); }); }); + +describe("media collapsing, below the length rules", () => { + /** A real PNG signature, at a size that no length rule would touch */ + function png(bytes: number) { + const b = new Uint8Array(bytes); + b.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + let binary = ""; + for (const byte of b) binary += String.fromCharCode(byte); + return btoa(binary); + } + + /** Well under every length threshold, so only the media rule can collapse it */ + const SMALL = png(3_000); + + test("collapses a small image on a short line", () => { + expect(SMALL.length).toBeLessThan(MAX_VISIBLE_LINE_CHARS); + expect(SMALL.length).toBeLessThan(COLLAPSE_TOKEN_CHARS); + + const state = jsonState(`{\n "avatar": "${SMALL}"\n}`); + const ranges = collapsedRanges(state); + + expect(ranges).toHaveLength(1); + expect(state.sliceDoc(ranges[0]!.from, ranges[0]!.to)).toBe(SMALL); + }); + + test("names it, so the tag can say what it is", () => { + const state = jsonState(`{"avatar":"${SMALL}"}`); + const iter = decorationsFor(state).iter(); + expect(iter.value?.spec.widget.sniffed).toMatchObject({ mime: "image/png", label: "PNG" }); + }); + + test("leaves a value of the same size alone when nothing recognises it", () => { + // The only difference from the case above is what the bytes turn out to be + const prose = "x".repeat(SMALL.length); + expect(collapsedRanges(jsonState(`{"note":"${prose}"}`))).toEqual([]); + }); + + test("leaves anything under the media threshold alone, recognised or not", () => { + const tiny = png(400); + expect(tiny.length).toBeLessThan(COLLAPSE_MEDIA_CHARS); + expect(collapsedRanges(jsonState(`{"icon":"${tiny}"}`))).toEqual([]); + }); + + test("leaves text alone even when its first bytes match a signature", () => { + // `Qk0` decodes to `BM`, the whole of the BMP signature. Two bytes come up by chance often + // enough that the sniff alone must not be allowed to hide something. + const prose = `Qk0 ${"the quick brown fox jumps over the lazy dog. ".repeat(40)}`; + expect(prose.length).toBeGreaterThan(COLLAPSE_MEDIA_CHARS); + expect(collapsedRanges(jsonState(`{"note":"${prose}"}`))).toEqual([]); + }); + + test("still collapses it once it is long enough to be a rendering problem", () => { + // Past the length rule the sniff no longer decides anything, so this is hidden for its size + const prose = "Qk0 " + "words and more words ".repeat(COLLAPSE_TOKEN_CHARS); + expect(collapsedRanges(jsonState(`{"note":"${prose}"}`))).toHaveLength(1); + }); + + test("collapses a data URI on a short line", () => { + const state = jsonState(`{"avatar":"data:image/jpeg;base64,${SMALL}"}`); + expect(collapsedRanges(state)).toHaveLength(1); + }); + + test("still leaves an ordinary document completely untouched", () => { + const doc = Array.from({ length: 5_000 }, (_, i) => ` { "id": ${i}, "name": "item" },`).join( + "\n", + ); + expect(collapsedRanges(jsonState(doc))).toEqual([]); + }); +}); + +describe("viewport scoping", () => { + const doc = () => { + const value = "A".repeat(COLLAPSE_TOKEN_CHARS * 2); + return `{\n${["a", "b", "c"].map((k) => ` "${k}": "${value}"`).join(",\n")}\n}`; + }; + + test("decorates only the ranges it is given", () => { + const state = jsonState(doc()); + const secondLine = state.doc.line(3); + + const all = collapseDecorations(state, [{ from: 0, to: state.doc.length }]); + const one = collapseDecorations(state, [{ from: secondLine.from, to: secondLine.to }]); + + expect(all.size).toBe(3); + expect(one.size).toBe(1); + }); + + test("covers a line the range only partly overlaps", () => { + // The viewport can start mid-line; the collapse still has to span the whole value + const state = jsonState(doc()); + const line = state.doc.line(2); + const partial = collapseDecorations(state, [{ from: line.from + 5, to: line.from + 6 }]); + + expect(partial.size).toBe(1); + const iter = partial.iter(); + expect(iter.to).toBeGreaterThan(line.from + 6); + }); +}); diff --git a/apps/yaak-client/components/core/Editor/largeValues.ts b/apps/yaak-client/components/core/Editor/largeValues.ts index 564e86a6..d9635acc 100644 --- a/apps/yaak-client/components/core/Editor/largeValues.ts +++ b/apps/yaak-client/components/core/Editor/largeValues.ts @@ -1,11 +1,12 @@ import { ensureSyntaxTree, syntaxTree } from "@codemirror/language"; import { formatSize } from "@yaakapp-internal/lib/formatSize"; import type { EditorState, Extension, Range } from "@codemirror/state"; -import { StateField } from "@codemirror/state"; import type { Tree as SyntaxTree } from "@lezer/common"; -import type { DecorationSet } from "@codemirror/view"; -import { Decoration, EditorView, WidgetType } from "@codemirror/view"; -import { copyToClipboard } from "../../../lib/copy"; +import type { DecorationSet, ViewUpdate } from "@codemirror/view"; +import { Decoration, EditorView, ViewPlugin, WidgetType } from "@codemirror/view"; +import { fireAndForget } from "../../../lib/fireAndForget"; +import type { SniffedValue } from "./sniffValue"; +import { isEncodedRun, SNIFF_HEAD_CHARS, sniffValue } from "./sniffValue"; /** * How much of a line may be rendered before the rest is collapsed. @@ -17,53 +18,131 @@ import { copyToClipboard } from "../../../lib/copy"; export const MAX_VISIBLE_LINE_CHARS = 10_000; /** - * A quoted value longer than this on an over-long line is collapsed whole, ahead of the column - * cut, so the structure around it stays visible. Needs a grammar to find. + * A quoted value longer than this is collapsed whole, whatever it turns out to hold. Long + * enough that nothing a person might actually read is ever hidden on length alone. */ export const COLLAPSE_TOKEN_CHARS = 5_000; /** - * Keeps over-long lines from reaching layout, which is what makes the editor stall on a - * base64 blob or a minified payload. + * A quoted value longer than this is collapsed too, but only when {@link sniffValue} can say + * what it is. Encoded media is never worth reading, so once we can name it and offer a viewer, + * a tag beats a wall of base64 well below the length that would make it a rendering problem — + * this covers the avatars and icons that make up most base64 in real payloads. Anything we + * can't identify stays visible until it's big enough to be a problem on its own. + */ +export const COLLAPSE_MEDIA_CHARS = 1_000; + +/** + * Collapses what can't be read: over-long lines, which stall layout, and encoded media, which + * is only noise on screen. * - * Cost tracks the length of the longest line, not the size of the document. A 1 MB response of - * ordinary multi-line JSON renders fine, while the same 1 MB on a single line stalls the UI, - * because soft wrap has to measure the whole line end to end to find its break points. + * Layout cost tracks the length of the longest line, not the size of the document. A 1 MB + * response of ordinary multi-line JSON renders fine, while the same 1 MB on a single line stalls + * the UI, because soft wrap has to measure the whole line end to end to find its break points. * Measured in WKWebView, the engine macOS ships: 221 ms per render pass at 1 MB and 968 ms at * 3 MB, against 51 ms and 138 ms once collapsed, with soft wrap left on. * - * Two rules, applied only to lines over {@link MAX_VISIBLE_LINE_CHARS}, so ordinary documents - * are untouched: + * Three rules: * * 1. Collapse quoted values over {@link COLLAPSE_TOKEN_CHARS} whole, so a base64 string reads - * as `"image": "999.8 KB hidden…"` with its key intact. A minified body with several large values - * keeps every one of its keys. Needs a grammar to find the value. - * 2. Collapse whatever is still past the column limit. This needs no grammar, so it covers - * plain text, undelimited tokens, and any line that is simply long. + * as `"image": "PNG · 999.8 KB"` with its key intact. A minified body with several large + * values keeps every one of its keys. Needs a grammar to find the value. + * 2. Collapse quoted values over {@link COLLAPSE_MEDIA_CHARS} that sniff as a known format, + * which is what catches an ordinary embedded image long before it is a rendering problem. + * 3. On a line still over {@link MAX_VISIBLE_LINE_CHARS}, collapse whatever is left past the + * column limit. This needs no grammar, so it covers plain text, undelimited tokens, and any + * line that is simply long. + * + * Only what the viewport covers is examined, and only lines long enough to hold a collapse are + * looked at, so a document of ordinary short lines costs a length check per visible line and + * nothing else — no grammar is consulted and no tree is walked. * * Nothing leaves the document. Copy, filter and save all still see the full text, and the tag - * carries a button that copies exactly what it hides. + * itself is a button, opening a menu that views, copies or saves exactly what it stands for. + * + * The value's own head says what it is — a `data:` URI names its media type, and raw base64 + * gives its format up in the first dozen bytes — so the tag can name the format and open the + * value in the matching viewer without any of it being read. See {@link sniffValue}. * * Read-only editors only. Hiding part of a document someone is editing would mean editing * text they can't see. */ -/** A hidden range. The tag stands in for exactly this text, and the dialog shows it. */ +/** + * A hidden range, the value it belongs to, and what that value turned out to be. + * + * The hidden range and the value differ under the column rule, where the value starts at the + * beginning of the line but only what runs past the limit is hidden. The tag stands in for + * [from, to) and copies exactly that; the viewer needs the whole value, [valueFrom, to). + */ interface Collapse { from: number; to: number; + valueFrom: number; + sniffed: SniffedValue | null; +} + +/** + * Lucide's `chevron-down`, inlined because the widget builds its DOM synchronously and + * rendering React here would leave it empty while CodeMirror measures line heights. + */ +const CHEVRON_DOWN = + ''; + +/** + * Marks the tag as the thing that opens the menu. + * + * The whole tag is the target rather than the chevron alone: there is only one action, and a + * label you can't click next to a hit area a few pixels wide is a worse button than the tag + * itself. The chevron stays as the affordance that says so. + * + * A span with a role rather than a real button, because a button's box model makes the line + * taller — the one thing this extension exists to keep from happening. + */ +function makeTagButton(el: HTMLElement, title: string, onOpen: (at: DOMRect) => void) { + el.role = "button"; + el.ariaHasPopup = "menu"; + el.tabIndex = 0; + el.title = title; + el.ariaLabel = title; + + // Keep the editor from moving the cursor when the tag is pressed + el.addEventListener("mousedown", (e) => { + e.preventDefault(); + e.stopPropagation(); + }); + el.addEventListener("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + onOpen(el.getBoundingClientRect()); + }); + el.addEventListener("keydown", (e) => { + if (e.key !== "Enter" && e.key !== " ") return; + e.preventDefault(); + e.stopPropagation(); + onOpen(el.getBoundingClientRect()); + }); } class LargeValueWidget extends WidgetType { constructor( private readonly from: number, private readonly to: number, + private readonly valueFrom: number, + private readonly sniffed: SniffedValue | null, ) { super(); } eq(other: LargeValueWidget) { - return other.from === this.from && other.to === this.to; + return ( + other.from === this.from && + other.to === this.to && + other.valueFrom === this.valueFrom && + other.sniffed?.mime === this.sniffed?.mime + ); } toDOM(view: EditorView) { @@ -75,68 +154,166 @@ class LargeValueWidget extends WidgetType { // tag-sized border is meant to be near invisible. el.className = "x-theme-templateTag x-theme-templateTag--secondary template-tag"; + const thumbnail = buildThumbnail(view, this.valueFrom, this.to, this.sniffed); + if (thumbnail != null) { + el.appendChild(thumbnail); + } + const label = document.createElement("span"); - label.textContent = `${formatSize(length)} hidden…`; + // A named type reads as a stand-in for the value, so it only needs to say what and how big. + // Without a name there is nothing to show but the fact that something is missing. + label.textContent = + this.sniffed == null + ? `${formatSize(length)} hidden…` + : `${this.sniffed.label} · ${formatSize(length)}`; el.appendChild(label); - // A span rather than a button: a button's box model makes the line taller. It keeps the - // role and name so assistive tech still sees it as an action. - const copy = document.createElement("span"); - copy.role = "button"; - copy.className = "tag-action"; - copy.title = `Copy hidden text (${length.toLocaleString()} characters)`; - copy.ariaLabel = copy.title; - // Lucide's `copy` icon, inlined because the widget builds its DOM synchronously and - // rendering React here would leave it empty while CodeMirror measures line heights - copy.innerHTML = - '' + - ''; - // Keep the editor from moving the cursor when the button is pressed - copy.addEventListener("mousedown", (e) => { - e.preventDefault(); - e.stopPropagation(); - }); - copy.addEventListener("click", (e) => { - e.preventDefault(); - e.stopPropagation(); - copyToClipboard(view.state.sliceDoc(this.from, this.to)); - }); - el.appendChild(copy); + const chevron = document.createElement("span"); + chevron.className = "tag-action"; + chevron.ariaHidden = "true"; + chevron.innerHTML = CHEVRON_DOWN; + el.appendChild(chevron); + + makeTagButton( + el, + this.sniffed == null ? "Value actions" : `${this.sniffed.label} actions`, + (rect) => fireAndForget(this.openMenu(view, rect)), + ); return el; } + /** + * Copy, view and save, in a menu opened against the button. + * + * Everything the menu needs is pulled in on click. The editor loads on every response, and + * neither the React menu nor the viewers behind the dialog are worth carrying until someone + * asks for them. + */ + private async openMenu( + view: EditorView, + rect: Pick, + ) { + const [{ showContextMenu }, { showLargeValueDialog }, { encodingLabel, largeValueActions }] = + await Promise.all([ + import("../../../lib/contextMenu"), + import("../../LargeValueDialog"), + import("../../../lib/largeValue"), + ]); + + const { sniffed } = this; + // Sliced when an action runs rather than now, so opening the menu never touches the value + const value = () => view.state.sliceDoc(this.valueFrom, this.to); + const head = view.state.sliceDoc( + this.valueFrom, + Math.min(this.valueFrom + SNIFF_HEAD_CHARS, this.to), + ); + + showContextMenu({ + id: "large-value", + // The whole tag, so the menu can align to whichever edge has room beside it + triggerPosition: { x: rect.left, y: rect.bottom }, + triggerRect: rect, + items: [ + { type: "separator", label: encodingLabel(head, sniffed, this.to - this.from) }, + ...largeValueActions({ + value, + sniffed, + // Exactly what the tag stands in for, which under the column rule is only the tail + copyText: () => view.state.sliceDoc(this.from, this.to), + onView: () => showLargeValueDialog({ text: value(), sniffed }), + }), + ], + }); + } + ignoreEvent() { return false; } } +/** + * How much encoded image a tag will preview. Roughly 3 MB of file, base64 being 4 bytes to 3. + * + * The decode runs on WebKit's image thread, but the bitmap it produces is sized by the image's + * own dimensions, not by the box we draw it in — a 4000×3000 photo costs 48 MB however small the + * thumbnail. So there has to be a limit, even though encoded size is only a proxy for the one + * that matters: a well-compressed photo can decode larger than a lossless screenshot twice its + * file size. This is set high enough to cover ordinary screenshots and wallpapers, since a tag + * that previews some images and not others is worse than one that previews none. Only widgets in + * the viewport are ever built, so a screenful is the most that decode at once. + */ +const THUMBNAIL_MAX_CHARS = 4_000_000; + +/** + * A preview of the value, at a size fixed before it loads. + * + * The box is a fixed square from the moment it's inserted, so the image arriving never changes + * the line's height or the tag's width. A growing line box is the layout cost this whole + * extension exists to avoid, and an image of unknown dimensions is the classic way to cause one. + * + * The src is the value's own text handed straight to the decoder as a data URI. Decoding the + * base64 ourselves first would mean walking megabytes on the main thread, which is the one + * thing that must not happen here. + */ +function buildThumbnail( + view: EditorView, + valueFrom: number, + to: number, + sniffed: SniffedValue | null, +): HTMLElement | null { + if (sniffed == null || !sniffed.mime.startsWith("image/")) return null; + if (sniffed.encoding !== "base64") return null; + if (to - valueFrom > THUMBNAIL_MAX_CHARS) return null; + + const img = document.createElement("img"); + img.className = "tag-thumbnail"; + img.alt = ""; + img.ariaHidden = "true"; + img.decoding = "async"; + // A magic number can be wrong, and a data URI can lie. Drop the box rather than leave a + // broken-image glyph sitting in the tag. + img.addEventListener("error", () => img.remove()); + // Read the document and build the data URI after the widget is measured and on screen + requestAnimationFrame(() => { + if (!img.isConnected) return; + const payload = view.state.sliceDoc(valueFrom + sniffed.offset, to); + img.src = `data:${sniffed.mime};base64,${payload}`; + }); + return img; +} + interface Line { from: number; to: number; } -/** Lines long enough to be a problem. Most documents have none, and we stop there. */ -function findLongLines(text: string): Line[] { - if (text.length <= MAX_VISIBLE_LINE_CHARS) { - return []; // No line can be longer than the whole text +/** + * The lines the given ranges touch, in document order and each listed once. + * + * Only lines long enough to hold a collapse are returned. That check is what keeps this free on + * ordinary documents: no grammar is consulted and no tree is walked for a screen of short lines. + */ +function collapsibleLines(state: EditorState, ranges: readonly { from: number; to: number }[]) { + const lines: Line[] = []; + let lastFrom = -1; + + for (const range of ranges) { + let pos = range.from; + for (;;) { + const line = state.doc.lineAt(pos); + if (line.from > lastFrom) { + lastFrom = line.from; + if (line.to - line.from >= COLLAPSE_MEDIA_CHARS) { + lines.push({ from: line.from, to: line.to }); + } + } + if (line.to >= range.to) break; + pos = line.to + 1; + } } - const lines: Line[] = []; - let from = 0; - for (;;) { - const newline = text.indexOf("\n", from); - const to = newline < 0 ? text.length : newline; - if (to - from > MAX_VISIBLE_LINE_CHARS) { - lines.push({ from, to }); - } - if (newline < 0) { - return lines; - } - from = newline + 1; - } + return lines; } /** @@ -148,33 +325,44 @@ function findLongLines(text: string): Line[] { */ const PARSE_TIMEOUT_MS = 100; -/** The parsed tree covering the long lines, as far as parsing got in the time allowed. */ -function treeForLongLines(state: EditorState, longLines: Line[]): SyntaxTree { - const lastLine = longLines[longLines.length - 1]; - if (lastLine == null) { +/** + * The parsed tree covering these lines. + * + * Parsing is only ever forced for a line long enough to stall layout, where finding the values + * inside it is what saves the frame. For everything else we take whatever the background parse + * has reached and decorate again when it advances — a forced parse runs from the top of the + * document, so making one happen on every scroll would cost far more than the tags are worth. + */ +function treeForLines(state: EditorState, lines: Line[]): SyntaxTree { + const last = lines[lines.length - 1]; + const stalls = lines.some((l) => l.to - l.from > MAX_VISIBLE_LINE_CHARS); + if (last == null || !stalls) { return syntaxTree(state); } - return ensureSyntaxTree(state, lastLine.to, PARSE_TIMEOUT_MS) ?? syntaxTree(state); + return ensureSyntaxTree(state, last.to, PARSE_TIMEOUT_MS) ?? syntaxTree(state); } const QUOTES = ['"', "'", "`"]; /** - * Quoted values on this line big enough to collapse whole, in document order. + * Quoted values on this line worth collapsing, in document order. * * Everything between the quotes goes, so a long string reads as `"image": ""` and the key * still tells you what it is. Undelimited tokens are left to the column cut instead: replacing * one whole would leave the line with nothing on it but a tag. */ -function findLargeTokens(state: EditorState, tree: SyntaxTree, line: Line): Collapse[] { +function findCollapsibleTokens(state: EditorState, tree: SyntaxTree, line: Line): Collapse[] { const collapses: Collapse[] = []; + // Length alone only hides things on a line that would stall without it. On a line that renders + // fine, being long is not a reason to hide something we can't even name. + const stalls = line.to - line.from > MAX_VISIBLE_LINE_CHARS; tree.iterate({ from: line.from, to: line.to, enter: (node) => { - // A node this small can't contain anything worth collapsing - if (node.to - node.from < COLLAPSE_TOKEN_CHARS) return false; + // A node this small can't contain anything worth collapsing, and neither can its children + if (node.to - node.from < COLLAPSE_MEDIA_CHARS) return false; // Only leaves, so we collapse the string itself rather than the object holding it if (node.node.firstChild != null) return true; @@ -182,9 +370,25 @@ function findLargeTokens(state: EditorState, tree: SyntaxTree, line: Line): Coll const to = Math.min(node.to, line.to); const open = state.sliceDoc(from, from + 1); const quoted = to - from >= 2 && QUOTES.includes(open) && state.sliceDoc(to - 1, to) === open; + if (!quoted) return false; - if (quoted && to - 1 > from + 1) { - collapses.push({ from: from + 1, to: to - 1 }); + // Everything inside the quotes. The whole value is hidden, so it is its own value range + const valueFrom = from + 1; + const valueTo = to - 1; + if (valueTo <= valueFrom) return false; + + // The head is all it takes, so this never reads the value it describes + const head = state.sliceDoc(valueFrom, Math.min(valueFrom + SNIFF_HEAD_CHARS, valueTo)); + const sniffed = sniffValue(head); + + // Big enough to be a rendering problem on its own, or something we can both name and be + // sure is encoded — a short magic number matches by chance, and hiding text would be worse + // than showing base64 + const oversized = stalls && valueTo - valueFrom >= COLLAPSE_TOKEN_CHARS; + const media = + sniffed != null && sniffed.encoding === "base64" && isEncodedRun(head, sniffed.offset); + if (oversized || media) { + collapses.push({ from: valueFrom, to: valueTo, valueFrom, sniffed }); } return false; }, @@ -218,7 +422,11 @@ function findColumnCut(line: Line, tokens: Collapse[]): number { } function collapsesForLine(state: EditorState, tree: SyntaxTree, line: Line): Collapse[] { - const tokens = findLargeTokens(state, tree, line); + const tokens = findCollapsibleTokens(state, tree, line); + if (line.to - line.from <= MAX_VISIBLE_LINE_CHARS) { + return tokens; // Short enough to render, so there is nothing left to cut + } + const cut = findColumnCut(line, tokens); if (cut < 0) { return tokens; @@ -226,56 +434,69 @@ function collapsesForLine(state: EditorState, tree: SyntaxTree, line: Line): Col // The cut always lands in a visible stretch, so it never splits a token collapse const kept = tokens.filter((t) => t.to <= cut); - kept.push({ from: cut, to: line.to }); + // Only the tail is hidden, but the value it belongs to runs from the start of the line — a + // body that is nothing but one base64 blob is still recognisable from there + const head = state.sliceDoc(line.from, Math.min(line.from + SNIFF_HEAD_CHARS, line.to)); + kept.push({ from: cut, to: line.to, valueFrom: line.from, sniffed: sniffValue(head) }); return kept; } -function buildDecorations(state: EditorState, longLines: Line[]): DecorationSet { - if (longLines.length === 0) { +/** + * The collapses covering the given document ranges. + * + * Kept separate from the plugin so the rules can be exercised against a state directly, with no + * view and no DOM. + */ +export function collapseDecorations( + state: EditorState, + ranges: readonly { from: number; to: number }[], +): DecorationSet { + const lines = collapsibleLines(state, ranges); + if (lines.length === 0) { return Decoration.none; } - const tree = treeForLongLines(state, longLines); - const ranges: Range[] = []; - for (const line of longLines) { - for (const { from, to } of collapsesForLine(state, tree, line)) { - ranges.push(Decoration.replace({ widget: new LargeValueWidget(from, to) }).range(from, to)); + const tree = treeForLines(state, lines); + const decorations: Range[] = []; + for (const line of lines) { + for (const { from, to, valueFrom, sniffed } of collapsesForLine(state, tree, line)) { + const widget = new LargeValueWidget(from, to, valueFrom, sniffed); + decorations.push(Decoration.replace({ widget }).range(from, to)); } } - return Decoration.set(ranges); + return Decoration.set(decorations); } -interface LargeValueState { - longLines: Line[]; - decorations: DecorationSet; -} +/** + * Scoped to the viewport, so the cost is a screenful however big the document is. + * + * Recomputed when the document changes, when the viewport moves, and when background parsing + * advances — a value can only be found once the grammar has reached it. + */ +const largeValuePlugin = ViewPlugin.fromClass( + class { + decorations: DecorationSet; -export const largeValueField = StateField.define({ - create(state) { - const longLines = findLongLines(state.doc.toString()); - return { longLines, decorations: buildDecorations(state, longLines) }; - }, - - update(value, tr) { - if (tr.docChanged) { - const longLines = findLongLines(tr.state.doc.toString()); - return { longLines, decorations: buildDecorations(tr.state, longLines) }; + constructor(view: EditorView) { + this.decorations = collapseDecorations(view.state, view.visibleRanges); } - // Parsing is incremental, so a long line may only become a known token later. Documents - // with no long line can never gain a collapse, so they skip this entirely. - if (value.longLines.length > 0 && syntaxTree(tr.startState) !== syntaxTree(tr.state)) { - return { ...value, decorations: buildDecorations(tr.state, value.longLines) }; - } - return value; - }, - provide: (f) => [ - EditorView.decorations.from(f, (v) => v.decorations), + update(update: ViewUpdate) { + if ( + update.docChanged || + update.viewportChanged || + syntaxTree(update.startState) !== syntaxTree(update.state) + ) { + this.decorations = collapseDecorations(update.view.state, update.view.visibleRanges); + } + } + }, + { + decorations: (v) => v.decorations, // Step the cursor over a placeholder instead of stranding it inside - EditorView.atomicRanges.of( - (view) => view.state.field(f, false)?.decorations ?? Decoration.none, - ), - ], -}); + provide: (plugin) => + EditorView.atomicRanges.of((view) => view.plugin(plugin)?.decorations ?? Decoration.none), + }, +); -export const largeValues: Extension = [largeValueField]; +export const largeValues: Extension = [largeValuePlugin]; diff --git a/apps/yaak-client/components/core/Editor/sniffValue.test.ts b/apps/yaak-client/components/core/Editor/sniffValue.test.ts new file mode 100644 index 00000000..0e55936b --- /dev/null +++ b/apps/yaak-client/components/core/Editor/sniffValue.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, test } from "vite-plus/test"; +import { decodeBase64Prefix, labelForMime, SNIFF_HEAD_CHARS, sniffValue } from "./sniffValue"; + +/** A base64 value that starts with `magic` and runs on for a while, as a real one would */ +function base64Of(magic: number[], length = 2_000): string { + const bytes = new Uint8Array(length); + bytes.set(magic); + for (let i = magic.length; i < length; i++) { + bytes[i] = i % 251; + } + let binary = ""; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary); +} + +/** One character, one byte. These are all ASCII signatures. */ +function ascii(s: string): number[] { + const bytes: number[] = []; + for (let i = 0; i < s.length; i++) { + bytes.push(s.charCodeAt(i)); + } + return bytes; +} + +/** Four bytes no signature looks at: a RIFF chunk length, or an ISO-BMFF box size */ +const IGNORED = [0x00, 0x00, 0x00, 0x20]; + +const PNG = [0x89, ...ascii("PNG"), 0x0d, 0x0a, 0x1a, 0x0a]; +const JPEG = [0xff, 0xd8, 0xff, 0xe0]; +const PDF = ascii("%PDF-1.7"); + +describe("magic numbers", () => { + const cases: [string, number[], string, string][] = [ + ["PNG", PNG, "image/png", "PNG"], + ["JPEG", JPEG, "image/jpeg", "JPEG"], + ["GIF", ascii("GIF89a"), "image/gif", "GIF"], + ["PDF", PDF, "application/pdf", "PDF"], + ["WEBP", [...ascii("RIFF"), ...IGNORED, ...ascii("WEBP")], "image/webp", "WEBP"], + ["WAV", [...ascii("RIFF"), ...IGNORED, ...ascii("WAVE")], "audio/wav", "WAV"], + ["ZIP", [...ascii("PK"), 0x03, 0x04], "application/zip", "ZIP"], + ["GZIP", [0x1f, 0x8b, 0x08], "application/gzip", "GZIP"], + ["MP3", [...ascii("ID3"), 0x04], "audio/mpeg", "MP3"], + ["OGG", ascii("OggS"), "audio/ogg", "OGG"], + ["FLAC", ascii("fLaC"), "audio/flac", "FLAC"], + ["WEBM", [0x1a, 0x45, 0xdf, 0xa3], "video/webm", "WEBM"], + ["MP4", [...IGNORED, ...ascii("ftypisom")], "video/mp4", "MP4"], + ["AVIF", [...IGNORED, ...ascii("ftypavif")], "image/avif", "AVIF"], + ["HEIC", [...IGNORED, ...ascii("ftypheic")], "image/heic", "HEIC"], + ["M4A", [...IGNORED, ...ascii("ftypM4A ")], "audio/mp4", "M4A"], + ["MOV", [...IGNORED, ...ascii("ftypqt ")], "video/quicktime", "MOV"], + ["BMP", ascii("BMxx"), "image/bmp", "BMP"], + ["TIFF", [...ascii("II"), 0x2a, 0x00], "image/tiff", "TIFF"], + ]; + + for (const [name, magic, mime, label] of cases) { + test(`recognises ${name}`, () => { + expect(sniffValue(base64Of(magic))).toEqual({ mime, label, offset: 0, encoding: "base64" }); + }); + } + + test("recognises nothing in arbitrary base64", () => { + expect(sniffValue(btoa("just some text that happens to be encoded"))).toBeNull(); + }); + + test("recognises nothing in text that isn't base64", () => { + expect(sniffValue("the quick brown fox jumps over the lazy dog".repeat(10))).toBeNull(); + }); + + test("recognises nothing in a JWT-shaped value", () => { + expect(sniffValue(`${btoa('{"alg":"HS256"}')}.${btoa('{"sub":"1"}')}.c2ln`)).toBeNull(); + }); + + test("reads the url-safe alphabet", () => { + const urlSafe = base64Of(PNG).replaceAll("+", "-").replaceAll("/", "_"); + expect(sniffValue(urlSafe)?.label).toBe("PNG"); + }); + + test("ignores leading whitespace, and counts it in the offset", () => { + expect(sniffValue(` ${base64Of(PNG)}`)).toMatchObject({ label: "PNG", offset: 3 }); + }); +}); + +describe("data URIs", () => { + test("takes the declared type over the bytes", () => { + const uri = `data:image/svg+xml;base64,${btoa("")}`; + expect(sniffValue(uri)).toEqual({ + mime: "image/svg+xml", + label: "SVG", + offset: "data:image/svg+xml;base64,".length, + encoding: "base64", + }); + }); + + test("points past the header, so the payload can be decoded from there", () => { + const payload = base64Of(PNG); + const uri = `data:image/png;base64,${payload}`; + const sniffed = sniffValue(uri)!; + expect(uri.slice(sniffed.offset, sniffed.offset + 8)).toBe(payload.slice(0, 8)); + }); + + test("falls back to the bytes when the declared type says nothing", () => { + const uri = `data:application/octet-stream;base64,${base64Of(PDF)}`; + expect(sniffValue(uri)).toMatchObject({ mime: "application/pdf", label: "PDF" }); + }); + + test("handles a header with no type at all", () => { + expect(sniffValue(`data:;base64,${base64Of(JPEG)}`)).toMatchObject({ mime: "image/jpeg" }); + }); + + test("handles extra parameters", () => { + const uri = `data:text/plain;charset=utf-8;base64,${btoa("hello")}`; + expect(sniffValue(uri)).toMatchObject({ mime: "text/plain", encoding: "base64" }); + }); + + test("handles a percent-encoded payload", () => { + expect(sniffValue("data:text/html,%3Ch1%3Ehi%3C/h1%3E")).toEqual({ + mime: "text/html", + label: "HTML", + offset: "data:text/html,".length, + encoding: "percent", + }); + }); + + test("defaults a bare percent-encoded payload to text", () => { + expect(sniffValue("data:,hello%20there")).toMatchObject({ + mime: "text/plain", + encoding: "percent", + }); + }); +}); + +describe("decoding only the head", () => { + test("reads no more of the value than the bytes asked for", () => { + const big = base64Of(PNG, 3_000_000); + // Truncating to the head must not change what is found, which is the whole point: + // callers only ever hand it a slice + expect(sniffValue(big.slice(0, SNIFF_HEAD_CHARS))).toEqual(sniffValue(big)); + }); + + test("decodes whole 4-character groups only", () => { + // 12 bytes needs 16 characters; a 15-character slice yields the 12 bytes of 3 whole groups + expect(decodeBase64Prefix(base64Of(PNG), 0, 12)).toHaveLength(12); + expect(decodeBase64Prefix(base64Of(PNG).slice(0, 15), 0, 12)).toHaveLength(9); + }); + + test("declines a value too short to hold a group", () => { + expect(decodeBase64Prefix("abc", 0, 12)).toBeNull(); + }); + + test("declines text outside the alphabet", () => { + expect(decodeBase64Prefix("hello world, not base64!", 0, 12)).toBeNull(); + }); +}); + +describe("labels", () => { + test.each([ + ["image/png", "PNG"], + ["image/svg+xml", "SVG"], + ["text/plain", "TEXT"], + ["application/vnd.ms-excel", "MS-EXCEL"], + ["image/x-icon", "ICON"], + ["application/octet-stream", "APPLICATION"], + ["font/woff2", "WOFF2"], + ["application/vnd.openxmlformats-officedocument.wordprocessingml.document", "DOCUMENT"], + ])("%s reads as %s", (mime, label) => { + expect(labelForMime(mime)).toBe(label); + }); +}); diff --git a/apps/yaak-client/components/core/Editor/sniffValue.ts b/apps/yaak-client/components/core/Editor/sniffValue.ts new file mode 100644 index 00000000..6a23a6a6 --- /dev/null +++ b/apps/yaak-client/components/core/Editor/sniffValue.ts @@ -0,0 +1,219 @@ +/** + * What a collapsed value turns out to be. + * + * Sniffing reads the head of the value only. A `data:` URI states its own media type, and raw + * base64 gives up its type in the first few bytes, so neither needs the rest — which is the + * point, since the whole reason these values are collapsed is that touching all of one costs + * enough to stall the UI. + */ +export interface SniffedValue { + /** Full media type, e.g. `image/png`. What the dialog routes on. */ + mime: string; + /** A word for the tag, e.g. `PNG`. */ + label: string; + /** Where the encoded payload starts in the value, past any `data:` header. */ + offset: number; + encoding: "base64" | "percent"; +} + +/** + * How much of a value {@link sniffValue} needs. Enough for a `data:` header of realistic + * length, and far more than the 24 base64 characters the magic numbers are read from. + */ +export const SNIFF_HEAD_CHARS = 256; + +/** Bytes the longest signature needs: `RIFF????WEBP`, and an ISO-BMFF brand at offset 8. */ +const SNIFF_BYTES = 12; + +/** `data:[][;…],` — the payload follows the comma. */ +const DATA_URI = /^data:([^,;]*)((?:;[^,;]*)*),/; + +/** Media types that name no format, so the bytes are worth a look even when one is declared. */ +const UNINFORMATIVE = ["", "application/octet-stream", "binary/octet-stream"]; + +interface Signature { + label: string; + mime: string; + offset?: number; + /** Byte values, where `null` matches anything */ + magic: readonly (number | null)[]; +} + +/** + * A magic number written as the ASCII it reads as, with `?` for a byte that varies. + * + * Indexed rather than iterated, because a signature is a sequence of bytes: one character here + * is one byte, and splitting into code points would be the wrong unit for that. + */ +function ascii(pattern: string): (number | null)[] { + const bytes: (number | null)[] = []; + for (let i = 0; i < pattern.length; i++) { + bytes.push(pattern[i] === "?" ? null : pattern.charCodeAt(i)); + } + return bytes; +} + +const SIGNATURES: readonly Signature[] = [ + { label: "PNG", mime: "image/png", magic: [0x89, ...ascii("PNG"), 0x0d, 0x0a, 0x1a, 0x0a] }, + { label: "JPEG", mime: "image/jpeg", magic: [0xff, 0xd8, 0xff] }, + { label: "GIF", mime: "image/gif", magic: ascii("GIF8") }, + { label: "BMP", mime: "image/bmp", magic: ascii("BM") }, + { label: "WEBP", mime: "image/webp", magic: ascii("RIFF????WEBP") }, + { label: "TIFF", mime: "image/tiff", magic: [...ascii("II"), 0x2a, 0x00] }, + { label: "TIFF", mime: "image/tiff", magic: [...ascii("MM"), 0x00, 0x2a] }, + { label: "ICO", mime: "image/x-icon", magic: [0x00, 0x00, 0x01, 0x00] }, + { label: "PDF", mime: "application/pdf", magic: ascii("%PDF-") }, + { label: "WAV", mime: "audio/wav", magic: ascii("RIFF????WAVE") }, + { label: "AVI", mime: "video/x-msvideo", magic: ascii("RIFF????AVI ") }, + { label: "MP3", mime: "audio/mpeg", magic: ascii("ID3") }, + // A bare MPEG frame header: 11 sync bits, then a layer III / II / I version pair + { label: "MP3", mime: "audio/mpeg", magic: [0xff, 0xfb] }, + { label: "MP3", mime: "audio/mpeg", magic: [0xff, 0xf3] }, + { label: "MP3", mime: "audio/mpeg", magic: [0xff, 0xf2] }, + { label: "OGG", mime: "audio/ogg", magic: ascii("OggS") }, + { label: "FLAC", mime: "audio/flac", magic: ascii("fLaC") }, + { label: "WEBM", mime: "video/webm", magic: [0x1a, 0x45, 0xdf, 0xa3] }, + { label: "ZIP", mime: "application/zip", magic: [...ascii("PK"), 0x03, 0x04] }, + { label: "GZIP", mime: "application/gzip", magic: [0x1f, 0x8b] }, + { label: "7Z", mime: "application/x-7z-compressed", magic: [...ascii("7z"), 0xbc, 0xaf, 0x27] }, + { label: "RAR", mime: "application/vnd.rar", magic: ascii("Rar!") }, + { label: "GLTF", mime: "model/gltf-binary", magic: ascii("glTF") }, +]; + +/** + * ISO base media files all start `????ftyp`, so the format is in the brand that follows rather + * than in the signature itself. Anything unlisted is some flavour of MP4. + */ +const ISO_BRANDS: Record = { + avif: { mime: "image/avif", label: "AVIF" }, + avis: { mime: "image/avif", label: "AVIF" }, + heic: { mime: "image/heic", label: "HEIC" }, + heix: { mime: "image/heic", label: "HEIC" }, + hevc: { mime: "image/heic", label: "HEIC" }, + mif1: { mime: "image/heif", label: "HEIF" }, + msf1: { mime: "image/heif", label: "HEIF" }, + "M4A ": { mime: "audio/mp4", label: "M4A" }, + "qt ": { mime: "video/quicktime", label: "MOV" }, +}; + +function matches(bytes: Uint8Array, { magic, offset = 0 }: Signature): boolean { + if (bytes.length < offset + magic.length) return false; + return magic.every((b, i) => b == null || bytes[offset + i] === b); +} + +function readAscii(bytes: Uint8Array, from: number, length: number): string { + return String.fromCharCode(...bytes.subarray(from, from + length)); +} + +/** The format the first bytes of a file identify it as, or null if they identify nothing. */ +export function sniffBytes(bytes: Uint8Array): { mime: string; label: string } | null { + if (readAscii(bytes, 4, 4) === "ftyp") { + return ISO_BRANDS[readAscii(bytes, 8, 4)] ?? { mime: "video/mp4", label: "MP4" }; + } + const sig = SIGNATURES.find((s) => matches(bytes, s)); + return sig == null ? null : { mime: sig.mime, label: sig.label }; +} + +/** + * The first `bytes` bytes of a base64 payload, or null if it isn't base64 after all. + * + * Only the characters those bytes need are decoded. The trailing partial group is dropped + * rather than padded, since `atob` rejects a group of one or two characters outright and we + * have no use for the byte a three-character group would add. + */ +export function decodeBase64Prefix(text: string, offset: number, bytes: number): Uint8Array | null { + const wanted = Math.ceil(bytes / 3) * 4; + let b64 = text.slice(offset, offset + wanted); + b64 = b64.slice(0, b64.length - (b64.length % 4)); + if (b64.length === 0) return null; + + // The URL-safe alphabet stands for the same bytes + b64 = b64.replaceAll("-", "+").replaceAll("_", "/"); + if (!/^[A-Za-z0-9+/]+$/.test(b64)) return null; + + try { + const binary = atob(b64); + const out = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + out[i] = binary.charCodeAt(i); + } + return out; + } catch { + return null; + } +} + +/** + * What a value is, from its head alone, or null if nothing recognises it. + * + * `head` need only be the first {@link SNIFF_HEAD_CHARS} characters — pass more and the rest is + * ignored. The offsets in the result are relative to the start of the whole value. + */ +export function sniffValue(head: string): SniffedValue | null { + const lead = head.length - head.trimStart().length; + const value = head.slice(lead); + + const dataUri = DATA_URI.exec(value); + if (dataUri != null) { + const declared = dataUri[1]!.toLowerCase(); + const offset = lead + dataUri[0].length; + if (!dataUri[2]!.split(";").includes("base64")) { + // A percent-encoded payload states its own type or is text by definition + const mime = declared === "" ? "text/plain" : declared; + return { mime, label: labelForMime(mime), offset, encoding: "percent" }; + } + if (!UNINFORMATIVE.includes(declared)) { + return { mime: declared, label: labelForMime(declared), offset, encoding: "base64" }; + } + return sniffBase64At(head, offset); + } + + return sniffBase64At(head, lead); +} + +function sniffBase64At(head: string, offset: number): SniffedValue | null { + const bytes = decodeBase64Prefix(head, offset, SNIFF_BYTES); + const sniffed = bytes == null ? null : sniffBytes(bytes); + return sniffed == null ? null : { ...sniffed, offset, encoding: "base64" }; +} + +/** The shortest run of base64 that {@link isEncodedRun} will accept as convincing */ +const ENCODED_RUN_CHARS = 64; + +/** + * Whether a value is one unbroken run of base64, rather than text that merely opens like it. + * + * A magic number is only a few bytes, so short ones match by chance — `BM` is two, which comes + * up about once in every 65,000 values. That was harmless while a value had to be long enough + * to hurt rendering before anything was hidden, and the sniff only chose the label. It is not + * harmless when the sniff itself decides to hide something, so that rule asks for this as well: + * prose leaves the alphabet within a few characters, and an encoded blob never does. + */ +export function isEncodedRun(head: string, offset: number): boolean { + const run = head.slice(offset); + return run.length >= ENCODED_RUN_CHARS && /^[A-Za-z0-9+/\-_]+={0,2}$/.test(run); +} + +/** Subtypes whose own name makes a poor label */ +const LABEL_OVERRIDES: Record = { + "text/plain": "TEXT", +}; + +/** + * A word short enough for a tag. The distinguishing part of a subtype is its last dotted + * segment before any `+suffix`, so `image/svg+xml` reads as SVG and `application/vnd.ms-excel` + * as MS-EXCEL. Anything still too long falls back to the top-level type. + */ +export function labelForMime(mime: string): string { + const override = LABEL_OVERRIDES[mime]; + if (override != null) { + return override; + } + + const [type, subtype] = mime.split("/"); + const word = (subtype ?? "").split("+")[0]!.split(".").pop()!.replace(/^x-/, ""); + if (word.length > 0 && word.length <= 10 && word !== "octet-stream") { + return word.toUpperCase(); + } + return (type ?? mime).toUpperCase(); +} diff --git a/apps/yaak-client/components/responseViewers/AudioViewer.tsx b/apps/yaak-client/components/responseViewers/AudioViewer.tsx index de903c23..c4c99e47 100644 --- a/apps/yaak-client/components/responseViewers/AudioViewer.tsx +++ b/apps/yaak-client/components/responseViewers/AudioViewer.tsx @@ -4,23 +4,26 @@ import { useEffect, useState } from "react"; interface Props { bodyPath?: string; data?: Uint8Array; + mimeType?: string; } -export function AudioViewer({ bodyPath, data }: Props) { +export function AudioViewer({ bodyPath, data, mimeType }: Props) { const [src, setSrc] = useState(); useEffect(() => { if (bodyPath) { setSrc(convertFileSrc(bodyPath)); } else if (data) { - const blob = new Blob([new Uint8Array(data)], { type: "audio/mpeg" }); + // The type matters here in a way it doesn't for an image: a media element goes by what + // the blob declares rather than sniffing it, so an Ogg labelled as MP3 won't play + const blob = new Blob([new Uint8Array(data)], { type: mimeType ?? "audio/mpeg" }); const url = URL.createObjectURL(blob); setSrc(url); return () => URL.revokeObjectURL(url); } else { setSrc(undefined); } - }, [bodyPath, data]); + }, [bodyPath, data, mimeType]); // oxlint-disable-next-line jsx-a11y/media-has-caption return