diff --git a/apps/yaak-client/components/LargeValueDialog.tsx b/apps/yaak-client/components/LargeValueDialog.tsx new file mode 100644 index 00000000..1b3ecb2e --- /dev/null +++ b/apps/yaak-client/components/LargeValueDialog.tsx @@ -0,0 +1,71 @@ +import { HStack } from "@yaakapp-internal/ui"; +import { useState } from "react"; +import { showDialog } from "../lib/dialog"; +import { CopyButton } from "./CopyButton"; +import { IconButton } from "./core/IconButton"; + +/** + * How much of the value to render at once. Rendering the whole thing would hit exactly the + * layout cost this dialog exists to avoid, so it pages instead. + */ +const PAGE_CHARS = 20_000; + +interface Props { + value: string; +} + +export function LargeValueDialog({ value }: Props) { + const [page, setPage] = useState(0); + const pageCount = Math.max(1, Math.ceil(value.length / PAGE_CHARS)); + const start = page * PAGE_CHARS; + const slice = value.slice(start, start + PAGE_CHARS); + + return ( +
+ + + {value.length.toLocaleString()} characters + + {pageCount > 1 && ( + + setPage((p) => Math.max(0, p - 1))} + /> + + {page + 1} / {pageCount} + + = pageCount - 1} + onClick={() => setPage((p) => Math.min(pageCount - 1, p + 1))} + /> + + )} +
+ +
+
+
+
{slice}
+
+
+ ); +} + +LargeValueDialog.show = (value: string) => { + showDialog({ + id: "large-value", + title: "Large Value", + size: "lg", + className: "h-[calc(100vh-10rem)] max-h-200!", + render: () => , + }); +}; diff --git a/apps/yaak-client/components/core/Editor/Editor.css b/apps/yaak-client/components/core/Editor/Editor.css index 4a37a4cd..ecf6d37a 100644 --- a/apps/yaak-client/components/core/Editor/Editor.css +++ b/apps/yaak-client/components/core/Editor/Editor.css @@ -253,6 +253,12 @@ @apply cursor-default!; } +.cm-editor .cm-largeValue { + @apply px-2 mx-0.5 rounded border border-border-subtle bg-surface-highlight; + @apply text-text-subtle text-sm cursor-pointer align-middle; + @apply hover:text-text hover:border-border; +} + .cm-editor .cm-activeLineGutter { @apply bg-transparent text-text-subtle; } diff --git a/apps/yaak-client/components/core/Editor/Editor.tsx b/apps/yaak-client/components/core/Editor/Editor.tsx index 2dc1c203..acaf6a43 100644 --- a/apps/yaak-client/components/core/Editor/Editor.tsx +++ b/apps/yaak-client/components/core/Editor/Editor.tsx @@ -15,7 +15,6 @@ import { HStack } from "@yaakapp-internal/ui"; import classNames from "classnames"; import type { GraphQLSchema } from "graphql"; import { useAtomValue } from "jotai"; -import { md5 } from "js-md5"; import type { ReactNode, RefObject } from "react"; import { Children, @@ -33,6 +32,7 @@ import { useEnvironmentVariables } from "../../../hooks/useEnvironmentVariables" import { eventMatchesHotkey } from "../../../hooks/useHotKey"; import { useRequestEditor } from "../../../hooks/useRequestEditor"; import { useTemplateFunctionCompletionOptions } from "../../../hooks/useTemplateFunctions"; +import { docFingerprint } from "../../../lib/docFingerprint"; import { editEnvironment } from "../../../lib/editEnvironment"; import { tryFormatJson, tryFormatXml } from "../../../lib/formatters"; import { jotaiStore } from "../../../lib/jotai"; @@ -656,9 +656,9 @@ function saveCachedEditorState(stateKey: string | null, state: EditorState | nul if (!stateKey || state == null) return; const stateObj = state.toJSON(stateFields); - // Save state in sessionStorage by removing doc and saving the hash of it instead. + // Save state in sessionStorage by removing doc and saving a fingerprint of it instead. // This will be checked on restore and put back in if it matches. - stateObj.docHash = md5(stateObj.doc); + stateObj.docHash = docFingerprint(stateObj.doc); stateObj.doc = undefined; try { @@ -678,7 +678,7 @@ function getCachedEditorState(doc: string, stateKey: string | null) { const { docHash, ...state } = JSON.parse(stateStr); // Ensure the doc matches the one that was used to save the state - if (docHash !== md5(doc)) { + if (docHash !== docFingerprint(doc)) { return null; } diff --git a/apps/yaak-client/components/core/Editor/extensions.ts b/apps/yaak-client/components/core/Editor/extensions.ts index ec9d46ce..95904089 100644 --- a/apps/yaak-client/components/core/Editor/extensions.ts +++ b/apps/yaak-client/components/core/Editor/extensions.ts @@ -59,6 +59,7 @@ import { pluralizeCount } from "../../../lib/pluralize"; import { showGraphQLDocExplorerAtom } from "../../graphql/graphqlAtoms"; import type { EditorProps } from "./Editor"; import { jsonParseLinter } from "./json-lint"; +import { largeValues } from "./largeValues"; import { pairs } from "./pairs/extension"; import { searchMatchCount } from "./searchMatchCount"; import { text } from "./text/extension"; @@ -258,6 +259,8 @@ export const baseExtensions = [ export const readonlyExtensions = [ EditorState.readOnly.of(true), EditorView.contentAttributes.of({ tabindex: "-1" }), + // Read-only only, so we never hide part of a document someone is editing + largeValues, ]; export const multiLineExtensions = ({ hideGutter }: { hideGutter?: boolean }) => [ diff --git a/apps/yaak-client/components/core/Editor/largeValues.test.ts b/apps/yaak-client/components/core/Editor/largeValues.test.ts new file mode 100644 index 00000000..1866616e --- /dev/null +++ b/apps/yaak-client/components/core/Editor/largeValues.test.ts @@ -0,0 +1,174 @@ +import { EditorState } from "@codemirror/state"; +import { jsonc } from "@shopify/lang-jsonc"; +import { describe, expect, test, vi } from "vite-plus/test"; +import { + COLLAPSE_KEEP_CHARS, + COLLAPSE_TOKEN_CHARS, + largeValueField, + largeValues, + MAX_VISIBLE_LINE_CHARS, +} from "./largeValues"; + +vi.mock("../../LargeValueDialog", () => ({ LargeValueDialog: { show: () => {} } })); + +const BIG = "A".repeat(1_000_000); + +/** With a grammar, so tokens can be collapsed individually */ +const jsonState = (doc: string) => EditorState.create({ doc, extensions: [jsonc(), largeValues] }); + +/** Without a grammar, so only the column rule applies */ +const plainState = (doc: string) => EditorState.create({ doc, extensions: largeValues }); + +function collapsedRanges(state: EditorState) { + const ranges: { from: number; to: number }[] = []; + const iter = state.field(largeValueField).decorations.iter(); + while (iter.value != null) { + ranges.push({ from: iter.from, to: iter.to }); + iter.next(); + } + return ranges; +} + +/** How much of each line is still rendered */ +function visibleLineLengths(state: EditorState) { + const hidden = collapsedRanges(state); + const lengths: number[] = []; + for (let n = 1; n <= state.doc.lines; n++) { + const line = state.doc.line(n); + const covered = hidden + .filter((h) => h.from >= line.from && h.to <= line.to) + .reduce((sum, h) => sum + (h.to - h.from), 0); + lengths.push(line.length - covered); + } + return lengths; +} + +describe("collapsing", () => { + test("leaves an ordinary body alone", () => { + expect(collapsedRanges(jsonState('{"hello":"world"}'))).toEqual([]); + }); + + test("leaves a large body of short lines alone", () => { + const doc = Array.from({ length: 20_000 }, (_, i) => ` { "id": ${i} },`).join("\n"); + expect(doc.length).toBeGreaterThan(MAX_VISIBLE_LINE_CHARS); + expect(collapsedRanges(jsonState(doc))).toEqual([]); + }); + + test("leaves a line just under the column limit alone", () => { + expect(collapsedRanges(plainState("x".repeat(MAX_VISIBLE_LINE_CHARS)))).toEqual([]); + }); + + test("never renders more than the column limit per line", () => { + for (const state of [jsonState(`{"image":"${BIG}"}`), plainState(BIG)]) { + for (const length of visibleLineLengths(state)) { + expect(length).toBeLessThanOrEqual(MAX_VISIBLE_LINE_CHARS); + } + } + }); + + test("keeps the document text intact", () => { + const doc = `{"image":"${BIG}"}`; + expect(jsonState(doc).sliceDoc()).toBe(doc); + expect(jsonState(doc).doc.length).toBe(doc.length); + }); +}); + +describe("token collapsing, with a grammar", () => { + test("collapses the string itself, keeping the structure around it", () => { + const doc = `{"name":"a.png","image":"${BIG}","size":12}`; + const ranges = collapsedRanges(jsonState(doc)); + + expect(ranges).toHaveLength(1); + // The collapse covers the middle of the base64 string and nothing else + const stringStart = doc.indexOf(`"${BIG}"`); + expect(ranges[0]!.from).toBe(stringStart + COLLAPSE_KEEP_CHARS); + expect(ranges[0]!.to).toBe(stringStart + BIG.length + 2 - COLLAPSE_KEEP_CHARS); + + // Everything after the value is still rendered, unlike a plain column cut + expect(doc.slice(ranges[0]!.to)).toContain('"size":12}'); + }); + + test("keeps every key visible in a minified body with several large values", () => { + const chunk = "B".repeat(200_000); + const doc = `{${["a", "b", "c", "d", "e"].map((k) => `"${k}":"${chunk}"`).join(",")}}`; + const state = jsonState(doc); + const ranges = collapsedRanges(state); + + expect(ranges).toHaveLength(5); + for (const key of ["a", "b", "c", "d", "e"]) { + // No collapse swallows the key + const at = doc.indexOf(`"${key}":`); + expect(ranges.some((r) => r.from <= at && r.to > at)).toBe(false); + } + expect(visibleLineLengths(state)[0]).toBeLessThanOrEqual(MAX_VISIBLE_LINE_CHARS); + }); + + test("collapses a value on a pretty-printed line", () => { + const doc = `{\n "name": "a.png",\n "image": "${BIG}"\n}`; + const state = jsonState(doc); + + expect(collapsedRanges(state)).toHaveLength(1); + // Only the long line is touched + expect(visibleLineLengths(state)).toEqual([1, 18, expect.any(Number), 1]); + expect(state.doc.line(2).text).toBe(' "name": "a.png",'); + }); + + test("ignores tokens under the collapse threshold", () => { + // Under the threshold once the surrounding quotes are counted + const short = "C".repeat(COLLAPSE_TOKEN_CHARS - 10); + const doc = `{${Array.from({ length: 4 }, (_, i) => `"k${i}":"${short}"`).join(",")}}`; + expect(doc.length).toBeGreaterThan(MAX_VISIBLE_LINE_CHARS); + + // Nothing is big enough to collapse on its own, so the column rule takes over + const ranges = collapsedRanges(jsonState(doc)); + expect(ranges).toHaveLength(1); + expect(ranges[0]!.to).toBe(doc.length); + }); +}); + +describe("column collapsing, without a grammar", () => { + test("collapses everything past the limit", () => { + const ranges = collapsedRanges(plainState(BIG)); + expect(ranges).toHaveLength(1); + expect(ranges[0]!.from).toBe(MAX_VISIBLE_LINE_CHARS); + expect(ranges[0]!.to).toBe(BIG.length); + }); + + test("handles a long line of many short tokens", () => { + // A single-line CSV row: no token is long enough to collapse on its own + const row = Array.from({ length: 40_000 }, (_, i) => `value ${i}`).join(", "); + const ranges = collapsedRanges(plainState(row)); + expect(ranges).toHaveLength(1); + expect(ranges[0]!.from).toBe(MAX_VISIBLE_LINE_CHARS); + }); + + test("collapses each long line independently", () => { + const doc = `${BIG}\nshort\n${BIG}`; + const ranges = collapsedRanges(plainState(doc)); + expect(ranges).toHaveLength(2); + for (const length of visibleLineLengths(plainState(doc))) { + expect(length).toBeLessThanOrEqual(MAX_VISIBLE_LINE_CHARS); + } + }); + + test("never hides a line break", () => { + const doc = `${BIG}\nshort`; + const state = plainState(doc); + for (const { from, to } of collapsedRanges(state)) { + expect(state.sliceDoc(from, to)).not.toContain("\n"); + } + expect(state.doc.lines).toBe(2); + }); +}); + +describe("recomputing", () => { + test("updates when the document changes", () => { + const state = plainState('{"image":"short"}'); + expect(collapsedRanges(state)).toEqual([]); + + const next = state.update({ + changes: { from: 0, to: state.doc.length, insert: BIG }, + }).state; + expect(collapsedRanges(next)).toHaveLength(1); + }); +}); diff --git a/apps/yaak-client/components/core/Editor/largeValues.ts b/apps/yaak-client/components/core/Editor/largeValues.ts new file mode 100644 index 00000000..b9f9a795 --- /dev/null +++ b/apps/yaak-client/components/core/Editor/largeValues.ts @@ -0,0 +1,255 @@ +import { ensureSyntaxTree, syntaxTree } from "@codemirror/language"; +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 { LargeValueDialog } from "../../LargeValueDialog"; + +/** + * How much of a line may be rendered before the rest is collapsed. + * + * VS Code draws nothing past column 10,000 (`editor.stopRenderingLineAfter`) for the same + * reason. It can afford to be blunt about it because it doesn't soft wrap by default; we + * collapse to a placeholder that can be opened instead. + */ +export const MAX_VISIBLE_LINE_CHARS = 10_000; + +/** + * A token longer than this on an over-long line is collapsed on its own, ahead of the column + * cut, so the structure around it stays visible. Needs a grammar to find. + */ +export const COLLAPSE_TOKEN_CHARS = 5_000; + +/** How much of a collapsed range stays visible at each end */ +export const COLLAPSE_KEEP_CHARS = 100; + +/** + * Keeps over-long lines from reaching layout, which is what makes the editor stall on a + * base64 blob or a minified payload. + * + * 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: + * + * 1. Collapse individual tokens over {@link COLLAPSE_TOKEN_CHARS}. For a language with a + * grammar this is the whole base64 string, so everything around it stays readable. A + * minified body with several large values keeps all of its keys visible. + * 2. Collapse whatever is still past the column limit. This needs no grammar, so it covers + * plain text and any line that isn't one big token. + * + * Nothing leaves the document. Copy, filter and save all still see the full text; the hidden + * part is reachable through {@link LargeValueDialog}. + * + * Read-only editors only. Hiding part of a document someone is editing would mean editing + * text they can't see. + */ + +interface Collapse { + /** Bounds of the hidden part */ + hiddenFrom: number; + hiddenTo: number; + /** Bounds of the whole value, including any visible ends, for the dialog */ + valueFrom: number; + valueTo: number; +} + +class LargeValueWidget extends WidgetType { + constructor(private readonly collapse: Collapse) { + super(); + } + + eq(other: LargeValueWidget) { + return ( + other.collapse.hiddenFrom === this.collapse.hiddenFrom && + other.collapse.hiddenTo === this.collapse.hiddenTo + ); + } + + toDOM(view: EditorView) { + const { hiddenFrom, hiddenTo, valueFrom, valueTo } = this.collapse; + const el = document.createElement("span"); + el.className = "cm-largeValue"; + el.textContent = `⋯ ${(hiddenTo - hiddenFrom).toLocaleString()} characters hidden ⋯`; + el.title = "View full value"; + el.addEventListener("mousedown", (e) => { + // Keep the editor from putting a cursor behind the dialog + e.preventDefault(); + e.stopPropagation(); + LargeValueDialog.show(view.state.sliceDoc(valueFrom, valueTo)); + }); + return el; + } + + ignoreEvent() { + return false; + } +} + +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 + } + + 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; + } +} + +/** + * How long to spend parsing before falling back to the column rule. + * + * The initial parse is budgeted by time, so it stops partway through a document with several + * large values, and we'd only find the first one. Parsing the rest of a 1 MB body costs about + * 12 ms, against the 171 ms of layout it saves. + */ +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) { + return syntaxTree(state); + } + return ensureSyntaxTree(state, lastLine.to, PARSE_TIMEOUT_MS) ?? syntaxTree(state); +} + +/** Tokens on this line big enough to collapse on their own, in document order. */ +function findLargeTokens(tree: SyntaxTree, line: Line): Collapse[] { + const collapses: Collapse[] = []; + + 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; + // Only leaves, so we collapse the string itself rather than the object holding it + if (node.node.firstChild != null) return true; + + const valueFrom = Math.max(node.from, line.from); + const valueTo = Math.min(node.to, line.to); + const hiddenFrom = valueFrom + COLLAPSE_KEEP_CHARS; + const hiddenTo = valueTo - COLLAPSE_KEEP_CHARS; + if (hiddenTo > hiddenFrom) { + collapses.push({ hiddenFrom, hiddenTo, valueFrom, valueTo }); + } + return false; + }, + }); + + return collapses; +} + +/** + * Where the line runs past the column limit, counting only what is still visible after the + * token collapses, or -1 if it fits. + */ +function findColumnCut(line: Line, tokens: Collapse[]): number { + let visible = 0; + let pos = line.from; + + for (const token of [...tokens, null]) { + const segmentEnd = token == null ? line.to : token.hiddenFrom; + if (segmentEnd > pos) { + if (visible + (segmentEnd - pos) > MAX_VISIBLE_LINE_CHARS) { + return pos + (MAX_VISIBLE_LINE_CHARS - visible); + } + visible += segmentEnd - pos; + } + if (token != null) { + pos = token.hiddenTo; + } + } + + return -1; +} + +function collapsesForLine(tree: SyntaxTree, line: Line): Collapse[] { + const tokens = findLargeTokens(tree, line); + const cut = findColumnCut(line, tokens); + if (cut < 0) { + return tokens; + } + + // The cut always lands in a visible stretch, so it never splits a token collapse + const kept = tokens.filter((t) => t.hiddenTo <= cut); + kept.push({ hiddenFrom: cut, hiddenTo: line.to, valueFrom: cut, valueTo: line.to }); + return kept; +} + +function buildDecorations(state: EditorState, longLines: Line[]): DecorationSet { + if (longLines.length === 0) { + return Decoration.none; + } + + const tree = treeForLongLines(state, longLines); + const ranges: Range[] = []; + for (const line of longLines) { + for (const collapse of collapsesForLine(tree, line)) { + ranges.push( + Decoration.replace({ widget: new LargeValueWidget(collapse) }).range( + collapse.hiddenFrom, + collapse.hiddenTo, + ), + ); + } + } + return Decoration.set(ranges); +} + +interface LargeValueState { + longLines: Line[]; + decorations: DecorationSet; +} + +export const largeValueField = StateField.define({ + create(state) { + const longLines = findLongLines(state.doc.toString()); + return { longLines, decorations: buildDecorations(state, longLines) }; + }, + + update(value, tr) { + if (tr.docChanged) { + const longLines = findLongLines(tr.state.doc.toString()); + return { longLines, decorations: buildDecorations(tr.state, longLines) }; + } + // 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), + // Step the cursor over a placeholder instead of stranding it inside + EditorView.atomicRanges.of( + (view) => view.state.field(f, false)?.decorations ?? Decoration.none, + ), + ], +}); + +export const largeValues: Extension = [largeValueField]; diff --git a/apps/yaak-client/lib/docFingerprint.test.ts b/apps/yaak-client/lib/docFingerprint.test.ts new file mode 100644 index 00000000..0fab4878 --- /dev/null +++ b/apps/yaak-client/lib/docFingerprint.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from "vite-plus/test"; +import { docFingerprint } from "./docFingerprint"; + +describe("docFingerprint", () => { + test("is stable for the same text", () => { + expect(docFingerprint("hello")).toBe(docFingerprint("hello")); + }); + + test("differs on different short text", () => { + expect(docFingerprint("hello")).not.toBe(docFingerprint("world")); + }); + + test("differs on length alone", () => { + expect(docFingerprint("a".repeat(1_000_000))).not.toBe(docFingerprint("a".repeat(1_000_001))); + }); + + test("notices a change at the start of a large document", () => { + const doc = "a".repeat(1_000_000); + expect(docFingerprint(doc)).not.toBe(docFingerprint(`b${doc.slice(1)}`)); + }); + + test("notices a change at the end of a large document", () => { + const doc = "a".repeat(1_000_000); + expect(docFingerprint(doc)).not.toBe(docFingerprint(`${doc.slice(0, -1)}b`)); + }); + + test("notices a change in the middle of a large document", () => { + const doc = "a".repeat(1_000_000); + const middle = doc.length / 2; + const changed = `${doc.slice(0, middle)}b${doc.slice(middle + 1)}`; + expect(doc.length).toBe(changed.length); + expect(docFingerprint(doc)).not.toBe(docFingerprint(changed)); + }); + + test("hashes small documents in full, so any change is caught", () => { + const doc = "a".repeat(100); + for (let i = 0; i < doc.length; i++) { + const changed = `${doc.slice(0, i)}b${doc.slice(i + 1)}`; + expect(docFingerprint(doc)).not.toBe(docFingerprint(changed)); + } + }); +}); diff --git a/apps/yaak-client/lib/docFingerprint.ts b/apps/yaak-client/lib/docFingerprint.ts new file mode 100644 index 00000000..cb37ec20 --- /dev/null +++ b/apps/yaak-client/lib/docFingerprint.ts @@ -0,0 +1,31 @@ +import { md5 } from "js-md5"; + +/** How much of each end and the middle to hash */ +const SAMPLE_CHARS = 512; + +/** + * A cheap stand-in for hashing a whole document. + * + * The editor caches undo history, folds and selection in sessionStorage, keyed by a hash of + * the document so a stale entry is never restored onto different content. Hashing the whole + * document costs about 4 ms per megabyte, and it is paid on every editor update as well as on + * restore, which is a lot of work to protect a fold position. + * + * Sampling the ends and the middle alongside the exact length is enough: two different + * documents would have to agree on length and all three samples to collide, and the cost of a + * collision is a fold or cursor landing where it doesn't belong. Documents small enough to + * hash outright still are. + */ +export function docFingerprint(text: string): string { + if (text.length <= SAMPLE_CHARS * 3) { + return `${text.length}:${md5(text)}`; + } + + const middle = Math.floor((text.length - SAMPLE_CHARS) / 2); + return [ + text.length, + md5(text.slice(0, SAMPLE_CHARS)), + md5(text.slice(middle, middle + SAMPLE_CHARS)), + md5(text.slice(-SAMPLE_CHARS)), + ].join(":"); +}