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
+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();
}