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 (
+
+ );
+}
+
+/** 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 ;
diff --git a/apps/yaak-client/components/responseViewers/ImageViewer.tsx b/apps/yaak-client/components/responseViewers/ImageViewer.tsx
index 4b2c0e1b..4ebd86a7 100644
--- a/apps/yaak-client/components/responseViewers/ImageViewer.tsx
+++ b/apps/yaak-client/components/responseViewers/ImageViewer.tsx
@@ -2,7 +2,7 @@ import { convertFileSrc } from "@tauri-apps/api/core";
import classNames from "classnames";
import { useEffect, useState } from "react";
-type Props = { className?: string } & (
+type Props = { className?: string; mimeType?: string } & (
| {
bodyPath: string;
}
@@ -11,7 +11,7 @@ type Props = { className?: string } & (
}
);
-export function ImageViewer({ className, ...props }: Props) {
+export function ImageViewer({ className, mimeType, ...props }: Props) {
const [src, setSrc] = useState();
const bodyPath = "bodyPath" in props ? props.bodyPath : null;
const data = "data" in props ? props.data : null;
@@ -20,14 +20,14 @@ export function ImageViewer({ className, ...props }: Props) {
if (bodyPath != null) {
setSrc(convertFileSrc(bodyPath));
} else if (data != null) {
- const blob = new Blob([data], { type: "image/png" });
+ const blob = new Blob([data], { type: mimeType ?? "image/png" });
const url = URL.createObjectURL(blob);
setSrc(url);
return () => URL.revokeObjectURL(url);
} else {
setSrc(undefined);
}
- }, [bodyPath, data]);
+ }, [bodyPath, data, mimeType]);
return (
(null);
const [numPages, setNumPages] = useState();
- const [src, setSrc] = useState();
const { width: containerWidth } = useContainerSize(containerRef);
- useEffect(() => {
+ // During render, not in an effect: an effect leaves the first paint with no file, and
+ // `Document` renders its "Failed to load PDF file" state for that frame before recovering
+ const src = useMemo(() => {
if (bodyPath) {
- setSrc(convertFileSrc(bodyPath));
- } else if (data) {
+ return convertFileSrc(bodyPath);
+ }
+ if (data) {
// Create a copy to avoid "Buffer is already detached" errors
// This happens when the ArrayBuffer is transferred/detached elsewhere
- const dataCopy = new Uint8Array(data);
- setSrc({ data: dataCopy });
- } else {
- setSrc(undefined);
+ return { data: new Uint8Array(data) };
}
+ return undefined;
}, [bodyPath, data]);
const onDocumentLoadSuccess = ({ numPages: nextNumPages }: PDFDocumentProxy): void => {
setNumPages(nextNumPages);
};
+
+ // Nothing to show yet, rather than the failure state `Document` renders for an empty file
+ if (src == null) {
+ return null;
+ }
+
return (
();
useEffect(() => {
if (bodyPath) {
setSrc(convertFileSrc(bodyPath));
} else if (data) {
- const blob = new Blob([new Uint8Array(data)], { type: "video/mp4" });
+ // As in AudioViewer: a media element trusts the declared type instead of sniffing
+ const blob = new Blob([new Uint8Array(data)], { type: mimeType ?? "video/mp4" });
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 ;
diff --git a/apps/yaak-client/lib/contextMenu.ts b/apps/yaak-client/lib/contextMenu.ts
new file mode 100644
index 00000000..c465fa3d
--- /dev/null
+++ b/apps/yaak-client/lib/contextMenu.ts
@@ -0,0 +1,30 @@
+import { atom } from "jotai";
+import type { DropdownItem } from "../components/core/Dropdown";
+import { jotaiStore } from "./jotai";
+
+/**
+ * A menu opened from somewhere that can't render React.
+ *
+ * Most menus in the app are a `Dropdown` wrapped around their own trigger. This is for the
+ * cases where the trigger isn't a React component at all — a CodeMirror widget builds its DOM
+ * synchronously, so it can only hand over a position and a list of items. Same shape as
+ * {@link showDialog}.
+ */
+export interface ContextMenuInstance {
+ id: string;
+ /** Where to open, in viewport coordinates */
+ triggerPosition: { x: number; y: number };
+ /** The trigger's box, when it has one, so placement can align to its edges */
+ triggerRect?: Pick;
+ items: DropdownItem[];
+}
+
+export const contextMenusAtom = atom([]);
+
+export function showContextMenu({ id, ...props }: ContextMenuInstance) {
+ jotaiStore.set(contextMenusAtom, (m) => [...m.filter((c) => c.id !== id), { id, ...props }]);
+}
+
+export function hideContextMenu(id: string) {
+ jotaiStore.set(contextMenusAtom, (m) => m.filter((c) => c.id !== id));
+}
diff --git a/apps/yaak-client/lib/largeValue.ts b/apps/yaak-client/lib/largeValue.ts
new file mode 100644
index 00000000..1bd663fe
--- /dev/null
+++ b/apps/yaak-client/lib/largeValue.ts
@@ -0,0 +1,233 @@
+import { save } from "@tauri-apps/plugin-dialog";
+import { Icon } from "@yaakapp-internal/ui";
+import mime from "mime";
+import { createElement } from "react";
+import type { DropdownItem } from "../components/core/Dropdown";
+import type { SniffedValue } from "../components/core/Editor/sniffValue";
+import { isEncodedRun } from "../components/core/Editor/sniffValue";
+import { copyToClipboard } from "./copy";
+import { fireAndForget } from "./fireAndForget";
+import { invokeCmd } from "./tauri";
+import { showToast } from "./toast";
+
+/**
+ * How the value is written, which is the thing worth knowing about it — that it is base64
+ * explains why it reads as gibberish far better than its length does.
+ *
+ * A value we couldn't identify may still be plainly encoded, so it is checked here too: an
+ * unrecognised format and an unrecognised encoding are different things to be told.
+ *
+ * `head` need only be the first {@link SNIFF_HEAD_CHARS} characters of the value.
+ */
+export function encodingLabel(head: string, sniffed: SniffedValue | null, chars: number): string {
+ const size = `${chars.toLocaleString()} chars`;
+ if (sniffed?.encoding === "percent") {
+ return `Percent-encoded · ${size}`;
+ }
+ const encoded = sniffed?.encoding === "base64" || isEncodedRun(head, sniffed?.offset ?? 0);
+ return encoded ? `Base64 · ${size}` : size;
+}
+
+interface ActionOptions {
+ /** The whole value. A function, so opening a menu never copies megabytes to build it. */
+ value: () => string;
+ sniffed: SniffedValue | null;
+ /** What to copy. The tag copies only what it hides; the viewer copies what it shows. */
+ copyText: () => string;
+ /** Left out inside the viewer itself, where there is nothing further to open */
+ onView?: () => void;
+}
+
+/**
+ * The things you can do with a collapsed value, as menu items.
+ *
+ * Shared so the tag in the editor and the dialog it opens offer the same list, rather than
+ * drifting apart.
+ */
+export function largeValueActions({
+ value,
+ sniffed,
+ copyText,
+ onView,
+}: ActionOptions): DropdownItem[] {
+ const items: DropdownItem[] = [];
+
+ if (onView != null) {
+ items.push({
+ label: sniffed == null ? "View" : `View ${sniffed.label}`,
+ leftSlot: createElement(Icon, { icon: "eye" }),
+ onSelect: onView,
+ });
+ }
+
+ items.push({
+ label: sniffed?.mime.startsWith("image/") ? "Copy Image" : "Copy",
+ leftSlot: createElement(Icon, { icon: "copy" }),
+ onSelect: () => copyValue(value(), sniffed, copyText()),
+ });
+
+ items.push({
+ label: "Save to File",
+ leftSlot: createElement(Icon, { icon: "download" }),
+ onSelect: () =>
+ fireAndForget(saveValue(value(), sniffed, sniffed?.label.toLowerCase() ?? "value")),
+ });
+
+ return items;
+}
+
+/** The payload, with the `data:` header taken off. */
+function payloadOf(text: string, sniffed: SniffedValue): string {
+ return text.slice(sniffed.offset);
+}
+
+/**
+ * A payload every strict base64 decoder will accept.
+ *
+ * The url-safe alphabet stands for the same bytes, and a ragged final group is dropped rather
+ * than padded — it can only ever be worth a single byte, and both `atob` and the Rust decoder
+ * behind the save command reject one outright.
+ */
+function normalizeBase64(payload: string): string {
+ const clean = payload.replace(/\s+/g, "").replaceAll("-", "+").replaceAll("_", "/");
+ return clean.slice(0, clean.length - (clean.length % 4));
+}
+
+/**
+ * The bytes a collapsed value stands for.
+ *
+ * `atob` rather than `Uint8Array.fromBase64`, which is too new to rely on across the webviews we
+ * ship on. Only ever called for something the user asked to see, never during layout.
+ */
+export function decodeValue(text: string, sniffed: SniffedValue): Uint8Array {
+ const payload = payloadOf(text, sniffed);
+ if (sniffed.encoding === "percent") {
+ return new TextEncoder().encode(decodeURIComponent(payload));
+ }
+
+ const binary = atob(normalizeBase64(payload));
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) {
+ bytes[i] = binary.charCodeAt(i);
+ }
+ return bytes;
+}
+
+/**
+ * Whether the clipboard can take this as a picture rather than as text.
+ *
+ * SVG is left out on purpose: it is markup, so it pastes usefully as text, and rasterising it
+ * would throw away the thing that makes it worth having.
+ */
+function isCopyableImage(sniffed: SniffedValue | null): sniffed is SniffedValue {
+ return (
+ sniffed != null && sniffed.mime.startsWith("image/") && !sniffed.mime.startsWith("image/svg")
+ );
+}
+
+/**
+ * The value as a PNG.
+ *
+ * Not a preference: PNG is the only image format the Clipboard API requires a browser to accept
+ * on write — `text/plain`, `text/html` and `image/png` are the spec's mandatory data types — and
+ * engines reject `image/jpeg` outright. So anything else is rasterised through a canvas, which
+ * costs a decode and an encode but means a JPEG or a WEBP pastes just as readily as a PNG.
+ *
+ * `ClipboardItem.supports()` exists to ask whether a format could be written as it stands, and
+ * skipping the conversion would be worth real time on a large photo. It was tried: WebKit says
+ * no to `image/jpeg`, so the check only ever chose PNG and was removed again.
+ *
+ * The re-encode is lossless, so nothing is degraded, but a photograph lands on the clipboard far
+ * larger than its JPEG. That matters less than it looks: the system pasteboard holds images
+ * uncompressed regardless of what we hand it.
+ */
+async function toPngBlob(text: string, sniffed: SniffedValue): Promise {
+ const blob = new Blob([decodeValue(text, sniffed)], { type: sniffed.mime });
+ if (sniffed.mime === "image/png") {
+ return blob;
+ }
+
+ const bitmap = await createImageBitmap(blob);
+ const canvas = document.createElement("canvas");
+ canvas.width = bitmap.width;
+ canvas.height = bitmap.height;
+ const ctx = canvas.getContext("2d");
+ if (ctx == null) {
+ throw new Error("Could not get a canvas to convert the image");
+ }
+ ctx.drawImage(bitmap, 0, 0);
+ bitmap.close();
+
+ return await new Promise((resolve, reject) => {
+ canvas.toBlob(
+ (png) => (png == null ? reject(new Error("Could not encode the image")) : resolve(png)),
+ "image/png",
+ );
+ });
+}
+
+/**
+ * Copies the value: the picture itself when it is one, and the text it stands for otherwise.
+ *
+ * Not awaited, and deliberately so. The webview only allows a clipboard write that starts inside
+ * the click that asked for it, and decoding a few megabytes takes longer than that lasts — so
+ * the item is handed the still-pending promise rather than a finished blob.
+ */
+export function copyValue(text: string, sniffed: SniffedValue | null, hidden: string) {
+ if (!isCopyableImage(sniffed) || typeof ClipboardItem === "undefined") {
+ copyToClipboard(hidden);
+ return;
+ }
+
+ const png = toPngBlob(text, sniffed);
+ navigator.clipboard
+ .write([new ClipboardItem({ "image/png": png })])
+ .then(() =>
+ showToast({
+ id: "copied",
+ color: "success",
+ icon: "copy",
+ // Says PNG because that is what lands on the clipboard, whatever the value held
+ message: "Copied as PNG",
+ }),
+ )
+ .catch((err: unknown) => {
+ // Anything from a webview that won't take an image to a file we couldn't decode. The
+ // encoded text is always there to fall back on.
+ console.error("Failed to copy image, copying text instead", err);
+ copyToClipboard(hidden);
+ });
+}
+
+/** Base64 of a byte array, in chunks so a few megabytes don't blow the argument limit. */
+function toBase64(bytes: Uint8Array): string {
+ let binary = "";
+ for (let i = 0; i < bytes.length; i += 0x8000) {
+ binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
+ }
+ return btoa(binary);
+}
+
+/**
+ * Writes a collapsed value to a file the user picks.
+ *
+ * A value that is already base64 goes straight to the backend as-is — decoding it here only to
+ * encode it again would walk megabytes twice for nothing.
+ */
+export async function saveValue(text: string, sniffed: SniffedValue | null, name: string) {
+ const ext = sniffed == null ? "txt" : (mime.getExtension(sniffed.mime) ?? "bin");
+ const filepath = await save({ defaultPath: `${name}.${ext}`, title: "Save Value" });
+ if (filepath == null) {
+ return; // Cancelled
+ }
+
+ const data =
+ sniffed == null
+ ? toBase64(new TextEncoder().encode(text))
+ : sniffed.encoding === "base64"
+ ? normalizeBase64(payloadOf(text, sniffed))
+ : toBase64(decodeValue(text, sniffed));
+
+ await invokeCmd("cmd_save_base64_to_binary", { filepath, data });
+ showToast({ message: `Saved to ${filepath}` });
+}
diff --git a/apps/yaak-client/lib/tauri.ts b/apps/yaak-client/lib/tauri.ts
index df9ad069..3b08265e 100644
--- a/apps/yaak-client/lib/tauri.ts
+++ b/apps/yaak-client/lib/tauri.ts
@@ -45,6 +45,7 @@ type TauriCmd =
| "cmd_plugin_init_errors"
| "cmd_reload_plugins"
| "cmd_render_template"
+ | "cmd_save_base64_to_binary"
| "cmd_save_response"
| "cmd_secure_template"
| "cmd_send_ephemeral_request"
diff --git a/apps/yaak-client/routes/__root.tsx b/apps/yaak-client/routes/__root.tsx
index 1d01cf42..30630ef9 100644
--- a/apps/yaak-client/routes/__root.tsx
+++ b/apps/yaak-client/routes/__root.tsx
@@ -12,6 +12,9 @@ import { queryClient } from "../lib/queryClient";
const Toasts = lazy(() => import("../components/Toasts").then((m) => ({ default: m.Toasts })));
const Dialogs = lazy(() => import("../components/Dialogs").then((m) => ({ default: m.Dialogs })));
+const ContextMenus = lazy(() =>
+ import("../components/ContextMenus").then((m) => ({ default: m.ContextMenus })),
+);
export const Route = createRootRoute({
component: RouteComponent,
@@ -29,6 +32,7 @@ function RouteComponent() {
+
diff --git a/crates-tauri/yaak-app-client/Cargo.toml b/crates-tauri/yaak-app-client/Cargo.toml
index ee20039f..485dc520 100644
--- a/crates-tauri/yaak-app-client/Cargo.toml
+++ b/crates-tauri/yaak-app-client/Cargo.toml
@@ -28,6 +28,7 @@ openssl-sys = { version = "0.9.105", features = ["vendored"] } # For Ubuntu inst
rlimit = "0.11" # Raise the launchd 256 open-file soft limit at startup
[dependencies]
+base64 = "0.22.1" # For writing values the editor only ever held encoded
charset = "0.1.5"
chrono = { workspace = true, features = ["serde"] }
cookie = "0.18.1"
diff --git a/crates-tauri/yaak-app-client/src/lib.rs b/crates-tauri/yaak-app-client/src/lib.rs
index 696ecc2a..fc9f4301 100644
--- a/crates-tauri/yaak-app-client/src/lib.rs
+++ b/crates-tauri/yaak-app-client/src/lib.rs
@@ -1434,6 +1434,30 @@ async fn cmd_export_data(
})?)
}
+/// Decodes base64 and writes the bytes to a file the user picked.
+///
+/// The webview can't do this itself: its `fs` permissions are read-only and scoped to the app
+/// data directory, and widening them so it could write anywhere would be a poor trade in an app
+/// whose whole job is rendering responses from servers it doesn't control.
+///
+/// Base64 in rather than bytes for two reasons. A `Vec` crosses the IPC boundary as a JSON
+/// array of numbers, several times the size of the thing being saved. And the callers that need
+/// this — values the editor collapsed — are holding base64 already, so passing it through
+/// untouched means the save never decodes megabytes on the main thread.
+#[tauri::command]
+async fn cmd_save_base64_to_binary(
+ _app_handle: AppHandle,
+ filepath: &str,
+ data: &str,
+) -> YaakResult<()> {
+ use base64::Engine;
+ let bytes = base64::engine::general_purpose::STANDARD
+ .decode(data)
+ .map_err(|e| GenericError(format!("Data is not valid base64: {e}")))?;
+ fs::write(filepath, bytes).map_err(|e| GenericError(e.to_string()))?;
+ Ok(())
+}
+
#[tauri::command]
async fn cmd_save_response(
app_handle: AppHandle,
@@ -1863,6 +1887,7 @@ pub fn run() {
cmd_reload_plugins,
cmd_render_template,
cmd_restart,
+ cmd_save_base64_to_binary,
cmd_save_response,
cmd_send_ephemeral_request,
cmd_send_http_request,