Identify collapsed values and open them in a viewer (#533)

This commit is contained in:
Gregory Schier
2026-08-13 12:26:35 -07:00
committed by GitHub
parent 74d1b5d6ce
commit c596e25e5e
19 changed files with 1551 additions and 148 deletions
Generated
+1
View File
@@ -10982,6 +10982,7 @@ dependencies = [
name = "yaak-app-client"
version = "0.0.0"
dependencies = [
"base64 0.22.1",
"charset",
"chrono",
"cookie",
@@ -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 }) => (
<ErrorBoundary key={id} name={`ContextMenu ${id}`}>
<ContextMenu
items={items}
triggerPosition={triggerPosition}
triggerRect={triggerRect}
onClose={() => hideContextMenu(id)}
/>
</ErrorBoundary>
))}
</>
);
}
@@ -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 (
<PagedText text={text}>
{sniffed != null && (
<Banner color="info" className="mb-3">
{sniffed.label} content cannot be previewed, so it is shown as it appears in the
response.
</Banner>
)}
</PagedText>
);
}
if (decoded == null) {
return (
<HStack className="h-full" alignItems="center" justifyContent="center">
<LoadingIcon />
</HStack>
);
}
if ("error" in decoded) {
return (
<PagedText text={text}>
<Banner color="danger" className="mb-3">
Failed to decode this {sniffed.label} value: {decoded.error}
</Banner>
</PagedText>
);
}
const { bytes } = decoded;
switch (viewer) {
case "svg":
return <DecodedSvg bytes={bytes} />;
case "image":
return (
<div className="h-full overflow-auto flex items-center justify-center">
<ImageViewer data={toArrayBuffer(bytes)} mimeType={sniffed.mime} />
</div>
);
case "audio":
return <AudioViewer data={bytes} mimeType={sniffed.mime} />;
case "video":
return <VideoViewer data={bytes} mimeType={sniffed.mime} />;
case "pdf":
return (
<Suspense fallback={<LoadingIcon />}>
<PdfViewer data={bytes} />
</Suspense>
);
case "text":
return <DecodedText bytes={bytes} />;
}
}
/**
* 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 <SvgViewer text={text} />;
}
function DecodedText({ bytes }: { bytes: Uint8Array }) {
const text = useMemo(() => new TextDecoder().decode(bytes), [bytes]);
return <PagedText text={text} />;
}
/**
* 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 (
<Dropdown items={items}>
<Button size="xs" variant="border" forDropdown>
Actions
</Button>
</Dropdown>
);
}
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<Decoded | null>(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 (
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)_auto] gap-3">
<div>{children}</div>
<pre className="overflow-auto font-mono text-sm text-text-subtle select-text">{body}</pre>
<HStack space={2} alignItems="center" className="text-sm text-text-subtle">
<span>
{(to - from).toLocaleString()} of {text.length.toLocaleString()} characters
</span>
{pages > 1 && (
<HStack space={2} alignItems="center" className="ml-auto">
<Button
size="xs"
variant="border"
disabled={page === 0}
onClick={() => setPage((p) => p - 1)}
>
Previous
</Button>
<span>
Page {page + 1} of {pages.toLocaleString()}
</span>
<Button
size="xs"
variant="border"
disabled={page >= pages - 1}
onClick={() => setPage((p) => p + 1)}
>
Next
</Button>
</HStack>
)}
</HStack>
</div>
);
}
/** 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: (
<HStack space={3} alignItems="center">
<span>
{sniffed == null ? "Hidden Value" : sniffed.label} · {formatSize(text.length)}
</span>
<LargeValueActions text={text} sniffed={sniffed} />
</HStack>
),
render: () => <LargeValueDialog text={text} sniffed={sniffed} />,
});
}
+21 -8
View File
@@ -233,23 +233,33 @@ export const Dropdown = forwardRef<DropdownRef, DropdownProps>(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<DOMRect, "top" | "bottom" | "left" | "right">;
className?: string;
items: DropdownProps["items"];
onClose: () => void;
}
export const ContextMenu = forwardRef<DropdownRef, ContextMenuProps>(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<DropdownRef, ContextMenuProps>(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}
@@ -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 {
@@ -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);
});
});
@@ -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 =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" ' +
'stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" ' +
'aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>';
/**
* 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 =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" ' +
'stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" ' +
'aria-hidden="true"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/>' +
'<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>';
// 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<DOMRect, "top" | "bottom" | "left" | "right">,
) {
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": "<tag>"` 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<Decoration>[] = [];
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<Decoration>[] = [];
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<LargeValueState>({
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];
@@ -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("<svg/>")}`;
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);
});
});
@@ -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:[<mime>][;<param>…],` — 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<string, { mime: string; label: string }> = {
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<string, string> = {
"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();
}
@@ -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<string>();
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 <audio className="w-full" controls src={src} />;
@@ -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<string>();
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 (
<img
@@ -3,7 +3,7 @@ import "react-pdf/dist/Page/AnnotationLayer.css";
import { convertFileSrc } from "@tauri-apps/api/core";
import "./PdfViewer.css";
import type { PDFDocumentProxy } from "pdfjs-dist";
import { useEffect, useRef, useState } from "react";
import { useMemo, useRef, useState } from "react";
import { Document, Page } from "react-pdf";
import { useContainerSize } from "@yaakapp-internal/ui";
import { fireAndForget } from "../../lib/fireAndForget";
@@ -30,26 +30,32 @@ const options = {
export function PdfViewer({ bodyPath, data }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const [numPages, setNumPages] = useState<number>();
const [src, setSrc] = useState<string | { data: Uint8Array }>();
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 (
<div ref={containerRef} className="w-full h-full overflow-y-auto">
<Document
@@ -4,23 +4,25 @@ import { useEffect, useState } from "react";
interface Props {
bodyPath?: string;
data?: Uint8Array;
mimeType?: string;
}
export function VideoViewer({ bodyPath, data }: Props) {
export function VideoViewer({ bodyPath, data, mimeType }: Props) {
const [src, setSrc] = useState<string>();
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 <video className="w-full" controls src={src} />;
+30
View File
@@ -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<DOMRect, "top" | "bottom" | "left" | "right">;
items: DropdownItem[];
}
export const contextMenusAtom = atom<ContextMenuInstance[]>([]);
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));
}
+233
View File
@@ -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<ArrayBuffer> {
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<Blob> {
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}` });
}
+1
View File
@@ -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"
+4
View File
@@ -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() {
<Suspense>
<Toasts />
<Dialogs />
<ContextMenus />
</Suspense>
<Layout />
<GlobalHooks />
+1
View File
@@ -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"
+25
View File
@@ -1434,6 +1434,30 @@ async fn cmd_export_data<R: Runtime>(
})?)
}
/// 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<u8>` 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<R: Runtime>(
_app_handle: AppHandle<R>,
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<R: Runtime>(
app_handle: AppHandle<R>,
@@ -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,