main ab6686c

terminal.js RawHistoryFolder

1(() => {
2  'use strict';
3
4  /** @typedef {{r: number, g: number, b: number}} RGB */
5  /** @typedef {{kind: 0}|{kind: 1, index: number}|{kind: 2, rgb: RGB}} Color */
6  /** @typedef {{bold?: boolean, italic?: boolean, inverse?: boolean, dim?: boolean, blink?: boolean, strikethrough?: boolean, underline?: boolean, underlineStyle?: number, foreground: Color, background: Color, underlineColor: Color}} TerminalStyle */
7  /** @typedef {{column: number, width: number, text: string, style: number}} CellUpdate */
8  /** @typedef {{row: number, cells: CellUpdate[]}} RowUpdate */
9  /** @typedef {{row: number, column: number, visible: boolean, style: number, styleSet: boolean}} Cursor */
10  /** @typedef {{schemaVersion: number, width: number, height: number, snapshot: boolean, rows: RowUpdate[], styles: TerminalStyle[], cursor: Cursor}} Update */
11  /** @typedef {{emit: boolean, preventDefault: boolean}} CaptureDecision */
12  /** @typedef {{schemaVersion?: number, type: string, [key: string]: unknown}} BrowserEvent */
13  /** @typedef {{maxCells?: number, maxRowsPerUpdate?: number, maxColumns?: number, maxStyles?: number, maxTextBytes?: number, maxUpdateBytes?: number, maxPasteBytes?: number}} BrowserLimits */
14  /** @typedef {{foreground: RGB, background: RGB, cursor: RGB, selection: RGB, selectionText: RGB, palette: RGB[]}} TerminalTheme */
15  /** @typedef {{label: string, decide?: (event: BrowserEvent) => CaptureDecision, send?: (event: BrowserEvent) => void, limits?: BrowserLimits}} MountOptions */
16  /** @typedef {{apply(update: Update): void, applyAll(updates: Update[]): void, focus(): void, setMouseCapture(enabled: boolean): void, setTheme(theme: TerminalTheme): void, destroy(): void}} Terminal */
17
18  const SCHEMA_VERSION = 2;
19  const DEFAULT_LIMITS = Object.freeze({
20    maxCells: 1_000_000,
21    maxRowsPerUpdate: 10_000,
22    maxColumns: 10_000,
23    maxStyles: 65_536,
24    maxTextBytes: 64 << 10,
25    maxUpdateBytes: 64 << 20,
26    maxPasteBytes: 1 << 20
27  });
28  const encoder = new TextEncoder();
29  const PRINTABLE_ASCII = /^[\x20-\x7e]*$/;
30
31  function fail(message) {
32    throw new TypeError(`VevTerminal: ${message}`);
33  }
34
35  function object(value, name) {
36    if (value === null || typeof value !== 'object' || Array.isArray(value)) fail(`${name} must be an object`);
37    return value;
38  }
39
40  function keys(value, allowed, name) {
41    for (const key of Object.keys(value)) {
42      if (!allowed.includes(key)) fail(`${name} contains unknown field ${key}`);
43    }
44  }
45
46  function integer(value, minimum, maximum, name) {
47    if (!Number.isSafeInteger(value) || value < minimum || value > maximum) fail(`${name} is out of range`);
48    return value;
49  }
50
51  function boolean(value, name, fallback = false) {
52    if (value === undefined) return fallback;
53    if (typeof value !== 'boolean') fail(`${name} must be boolean`);
54    return value;
55  }
56
57  /** @returns {{value: string, byteLength: number}} */
58  function boundedString(value, limit, name) {
59    if (typeof value !== 'string') fail(`${name} must be a string`);
60    const byteLength = PRINTABLE_ASCII.test(value) ? value.length : encoder.encode(value).byteLength;
61    if (byteLength > limit) fail(`${name} exceeds its byte limit`);
62    return { value, byteLength };
63  }
64
65  function limits(requested = {}) {
66    object(requested, 'limits');
67    keys(requested, Object.keys(DEFAULT_LIMITS), 'limits');
68    const result = {};
69    for (const [name, fallback] of Object.entries(DEFAULT_LIMITS)) {
70      result[name] = requested[name] === undefined ? fallback : integer(requested[name], 1, Number.MAX_SAFE_INTEGER, `limits.${name}`);
71    }
72    return Object.freeze(result);
73  }
74
75  function validateRGB(value, name) {
76    object(value, name);
77    keys(value, ['r', 'g', 'b'], name);
78    return {
79      r: integer(value.r, 0, 255, `${name}.r`),
80      g: integer(value.g, 0, 255, `${name}.g`),
81      b: integer(value.b, 0, 255, `${name}.b`)
82    };
83  }
84
85  function validateColor(value, name) {
86    object(value, name);
87    keys(value, ['kind', 'index', 'rgb'], name);
88    const kind = integer(value.kind, 0, 2, `${name}.kind`);
89    if (kind === 0) {
90      if (value.index !== undefined || value.rgb !== undefined) fail(`${name} default color has a payload`);
91      return { kind };
92    }
93    if (kind === 1) {
94      if (value.rgb !== undefined) fail(`${name} indexed color has an rgb payload`);
95      return { kind, index: integer(value.index, 0, 255, `${name}.index`) };
96    }
97    if (value.index !== undefined) fail(`${name} rgb color has an index payload`);
98    return { kind, rgb: validateRGB(value.rgb, `${name}.rgb`) };
99  }
100
101  function validateStyle(value, index) {
102    const name = `styles[${index}]`;
103    object(value, name);
104    keys(value, [
105      'bold', 'italic', 'inverse', 'dim', 'blink', 'strikethrough', 'underline',
106      'underlineStyle', 'foreground', 'background', 'underlineColor'
107    ], name);
108    return {
109      bold: boolean(value.bold, `${name}.bold`),
110      italic: boolean(value.italic, `${name}.italic`),
111      inverse: boolean(value.inverse, `${name}.inverse`),
112      dim: boolean(value.dim, `${name}.dim`),
113      blink: boolean(value.blink, `${name}.blink`),
114      strikethrough: boolean(value.strikethrough, `${name}.strikethrough`),
115      underline: boolean(value.underline, `${name}.underline`),
116      underlineStyle: value.underlineStyle === undefined ? 0 : integer(value.underlineStyle, 0, 5, `${name}.underlineStyle`),
117      foreground: validateColor(value.foreground, `${name}.foreground`),
118      background: validateColor(value.background, `${name}.background`),
119      underlineColor: validateColor(value.underlineColor, `${name}.underlineColor`)
120    };
121  }
122
123  function validateCursor(value, width, height) {
124    object(value, 'cursor');
125    keys(value, ['row', 'column', 'visible', 'style', 'styleSet'], 'cursor');
126    const styleSet = boolean(value.styleSet, 'cursor.styleSet');
127    const style = integer(value.style, 0, 6, 'cursor.style');
128    if (!styleSet && style !== 0) fail('cursor style must be zero when unset');
129    return {
130      row: integer(value.row, 0, height - 1, 'cursor.row'),
131      column: integer(value.column, 0, width - 1, 'cursor.column'),
132      visible: boolean(value.visible, 'cursor.visible'),
133      style,
134      styleSet
135    };
136  }
137
138  function validateRow(value, width, height, styleCount, configured, textBudget, seen) {
139    object(value, 'row');
140    keys(value, ['row', 'cells'], 'row');
141    const row = integer(value.row, 0, height - 1, 'row.row');
142    if (seen.has(row)) fail(`duplicate row ${row}`);
143    seen.add(row);
144    if (!Array.isArray(value.cells)) fail(`row ${row} cells must be an array`);
145    let column = 0;
146    let bytes = 0;
147    const cells = value.cells.map((candidate, index) => {
148      const name = `row ${row} cell ${index}`;
149      object(candidate, name);
150      keys(candidate, ['column', 'width', 'text', 'style'], name);
151      const current = integer(candidate.column, 0, width - 1, `${name}.column`);
152      const cellWidth = integer(candidate.width, 1, configured.maxColumns, `${name}.width`);
153      if (current !== column) fail(`row coverage is not contiguous at row ${row} column ${column}`);
154      if (current + cellWidth > width) fail(`row coverage exceeds width at row ${row}`);
155      const text = boundedString(candidate.text, configured.maxTextBytes, `${name}.text`);
156      // Entries wider than one wide character are text runs: printable ASCII,
157      // one character per column, so grid columns and font advance agree.
158      if (cellWidth > 2 && (text.value.length !== cellWidth || !PRINTABLE_ASCII.test(text.value))) fail(`${name} text run does not match its width`);
159      const cell = {
160        column: current,
161        width: cellWidth,
162        text: text.value,
163        style: integer(candidate.style, 0, styleCount - 1, `${name}.style`)
164      };
165      textBudget.value += text.byteLength;
166      if (textBudget.value > configured.maxUpdateBytes) fail('update text exceeds its aggregate byte limit');
167      bytes += text.byteLength;
168      column += cellWidth;
169      return cell;
170    });
171    if (column !== width) fail(`row coverage is ${column}, expected ${width}`);
172    return { row, cells, bytes };
173  }
174
175  /**
176   * Validate an entire update before any DOM mutation.
177   * @param {unknown} value
178   * @param {Readonly<Record<string, number>>} configured
179   * @returns {Update}
180   */
181  function validateUpdate(value, configured) {
182    object(value, 'update');
183    keys(value, ['schemaVersion', 'width', 'height', 'snapshot', 'rows', 'styles', 'cursor'], 'update');
184    if (value.schemaVersion !== SCHEMA_VERSION) fail(`unsupported update schema ${value.schemaVersion}`);
185    const width = integer(value.width, 1, configured.maxColumns, 'update.width');
186    const height = integer(value.height, 1, configured.maxRowsPerUpdate, 'update.height');
187    if (width > Math.floor(configured.maxCells / height)) fail('update exceeds the cell limit');
188    const snapshot = boolean(value.snapshot, 'update.snapshot');
189    if (!Array.isArray(value.styles) || value.styles.length > configured.maxStyles) fail('update styles exceed their limit');
190    const styles = value.styles.map(validateStyle);
191    if (!Array.isArray(value.rows) || value.rows.length > configured.maxRowsPerUpdate) fail('update rows exceed their limit');
192    if (value.rows.length > Math.floor(configured.maxCells / width)) fail('update represented cells exceed their limit');
193    if (value.rows.length > 0 && styles.length === 0) fail('an update with rows requires styles');
194    if (snapshot && value.rows.length !== height) fail('snapshot must contain every row');
195    const seen = new Set();
196    const textBudget = { value: 0 };
197    const rows = value.rows.map(row => validateRow(row, width, height, styles.length, configured, textBudget, seen));
198    if (snapshot) {
199      rows.sort((left, right) => left.row - right.row);
200      rows.forEach((row, index) => { if (row.row !== index) fail('snapshot rows are incomplete'); });
201    }
202    return { schemaVersion: SCHEMA_VERSION, width, height, snapshot, rows, styles, cursor: validateCursor(value.cursor, width, height) };
203  }
204
205  /**
206   * Merge validated updates into one equivalent update, or return null when
207   * the merged styles would exceed maxStyles (callers then apply in order).
208   * Every incremental update must match the geometry established by the
209   * mounted terminal or the latest snapshot, exactly as sequential apply
210   * requires, so a protocol error is reported before any DOM mutation.
211   * The merged update keeps the latest snapshot flag, geometry and cursor;
212   * rows are last-writer-wins and style ids are remapped.
213   */
214  function coalesce(updates, mounted, maxStyles) {
215    let geometry = mounted;
216    for (const update of updates) {
217      if (update.snapshot) geometry = { width: update.width, height: update.height };
218      else if (!geometry || update.width !== geometry.width || update.height !== geometry.height) fail('incremental update dimensions do not match the mounted snapshot');
219    }
220    if (updates.length === 1) return updates[0];
221    let start = 0;
222    for (let index = 0; index < updates.length; index += 1) if (updates[index].snapshot) start = index;
223    const tail = updates.slice(start);
224    const last = tail[tail.length - 1];
225    const rows = new Map();
226    for (const update of tail) for (const row of update.rows) rows.set(row.row, { row, styles: update.styles });
227    const styles = [];
228    const ids = new Map();
229    const remaps = new Map();
230    const merged = [];
231    for (const key of [...rows.keys()].sort((left, right) => left - right)) {
232      const { row, styles: source } = rows.get(key);
233      let remap = remaps.get(source);
234      if (!remap) {
235        remap = new Array(source.length);
236        remaps.set(source, remap);
237      }
238      const cells = row.cells.map(cell => {
239        let id = remap[cell.style];
240        if (id === undefined) {
241          const styleKey = JSON.stringify(source[cell.style]);
242          id = ids.get(styleKey);
243          if (id === undefined) {
244            id = styles.length;
245            ids.set(styleKey, id);
246            styles.push(source[cell.style]);
247          }
248          remap[cell.style] = id;
249        }
250        return id === cell.style ? cell : { ...cell, style: id };
251      });
252      merged.push({ row: row.row, bytes: row.bytes, cells });
253    }
254    if (styles.length > maxStyles) return null;
255    return { schemaVersion: SCHEMA_VERSION, width: last.width, height: last.height, snapshot: tail[0].snapshot, rows: merged, styles, cursor: last.cursor };
256  }
257
258  function xtermColor(index) {
259    if (index < 16) return `var(--vev-color-${index})`;
260    if (index < 232) {
261      const value = index - 16;
262      const levels = [0, 95, 135, 175, 215, 255];
263      const r = levels[Math.floor(value / 36)];
264      const g = levels[Math.floor(value / 6) % 6];
265      const b = levels[value % 6];
266      return `rgb(${r} ${g} ${b})`;
267    }
268    const gray = 8 + (index - 232) * 10;
269    return `rgb(${gray} ${gray} ${gray})`;
270  }
271
272  function colorValue(color, fallback) {
273    if (color.kind === 0) return fallback;
274    if (color.kind === 1) return xtermColor(color.index);
275    return `rgb(${color.rgb.r} ${color.rgb.g} ${color.rgb.b})`;
276  }
277
278  function addStyleClasses(node, style) {
279    if (style.bold) node.classList.add('vev-terminal__cell--bold');
280    if (style.italic) node.classList.add('vev-terminal__cell--italic');
281    if (style.dim) node.classList.add('vev-terminal__cell--dim');
282    if (style.blink) node.classList.add('vev-terminal__cell--blink');
283    if (style.strikethrough) node.classList.add('vev-terminal__cell--strike');
284    if (style.underline) {
285      node.classList.add('vev-terminal__cell--underline');
286      const suffix = ['single', 'single', 'double', 'wavy', 'dotted', 'dashed'][style.underlineStyle];
287      if (suffix !== 'single') node.classList.add(`vev-terminal__cell--underline-${suffix}`);
288    }
289  }
290
291  function buildRow(document, row, styles) {
292    const node = document.createElement('div');
293    node.className = 'vev-terminal__row';
294    node.dataset.row = String(row.row);
295    let text = '';
296    for (const cell of row.cells) {
297      const style = styles[cell.style];
298      const child = document.createElement('span');
299      child.className = 'vev-terminal__cell';
300      child.dataset.column = String(cell.column);
301      child.dataset.width = String(cell.width);
302      child.style.gridColumn = `${cell.column + 1} / span ${cell.width}`;
303      let foreground = colorValue(style.foreground, 'var(--vev-fg)');
304      let background = colorValue(style.background, 'var(--vev-bg)');
305      if (style.inverse) [foreground, background] = [background, foreground];
306      // Default colors already resolve through the stylesheet. Avoid three
307      // redundant custom properties per cell (and their style-resolution cost).
308      if (style.inverse || style.foreground.kind !== 0) child.style.setProperty('--vev-cell-fg', foreground);
309      if (style.inverse || style.background.kind !== 0) child.style.setProperty('--vev-cell-bg', background);
310      if (style.underlineColor.kind !== 0 || style.dim) child.style.setProperty('--vev-cell-underline', colorValue(style.underlineColor, foreground));
311      addStyleClasses(child, style);
312      child.textContent = cell.text;
313      // A plain space has no ink to clip. Avoid a compositor clip for every
314      // blank cell while retaining its grid box, selection and text content.
315      if (/^ +$/.test(cell.text) && !style.underline && !style.strikethrough) {
316        child.style.overflow = 'visible';
317      }
318      node.append(child);
319      text += cell.text;
320    }
321    return { node, text };
322  }
323
324  function validateTheme(theme) {
325    object(theme, 'theme');
326    keys(theme, ['foreground', 'background', 'cursor', 'selection', 'selectionText', 'palette'], 'theme');
327    const result = {
328      foreground: validateRGB(theme.foreground, 'theme.foreground'),
329      background: validateRGB(theme.background, 'theme.background'),
330      cursor: validateRGB(theme.cursor, 'theme.cursor'),
331      selection: validateRGB(theme.selection, 'theme.selection'),
332      selectionText: validateRGB(theme.selectionText, 'theme.selectionText')
333    };
334    if (!Array.isArray(theme.palette) || theme.palette.length !== 16) fail('theme.palette must contain 16 colors');
335    result.palette = theme.palette.map((color, index) => validateRGB(color, `theme.palette[${index}]`));
336    return result;
337  }
338
339  function rgbValue(color) { return `rgb(${color.r} ${color.g} ${color.b})`; }
340
341  /**
342   * Mount one interactive terminal adapter.
343   * @param {Element} root
344   * @param {MountOptions} options
345   * @returns {Terminal}
346   */
347  function mount(root, options = {}) {
348    if (!(root instanceof Element)) fail('mount root must be an Element');
349    if (root.classList.contains('vev-terminal')) fail('mount root already owns a terminal');
350    object(options, 'options');
351    keys(options, ['label', 'decide', 'send', 'limits'], 'options');
352    const label = boundedString(options.label, 1024, 'options.label').value.trim();
353    if (label === '') fail('options.label must not be empty');
354    if (options.decide !== undefined && typeof options.decide !== 'function') fail('options.decide must be a function');
355    if (options.send !== undefined && typeof options.send !== 'function') fail('options.send must be a function');
356    const configured = limits(options.limits);
357    let decide = options.decide || (() => ({ emit: true, preventDefault: false }));
358    let send = options.send || (() => {});
359    const document = root.ownerDocument;
360    const abort = new AbortController();
361    const signal = abort.signal;
362
363    const previousRole = root.hasAttribute('role') ? root.getAttribute('role') : null;
364    const previousLabel = root.hasAttribute('aria-label') ? root.getAttribute('aria-label') : null;
365    root.classList.add('vev-terminal');
366    root.setAttribute('role', 'group');
367    root.setAttribute('aria-label', label);
368
369    const input = document.createElement('textarea');
370    input.className = 'vev-terminal__input';
371    input.setAttribute('aria-label', `${label} input`);
372    input.setAttribute('autocomplete', 'off');
373    input.setAttribute('autocapitalize', 'off');
374    input.setAttribute('spellcheck', 'false');
375
376    const viewport = document.createElement('div');
377    viewport.className = 'vev-terminal__viewport';
378    viewport.setAttribute('aria-hidden', 'true');
379
380    const cursor = document.createElement('div');
381    cursor.className = 'vev-terminal__cursor';
382    cursor.hidden = true;
383    viewport.append(cursor);
384
385    const accessible = document.createElement('pre');
386    accessible.className = 'vev-terminal__accessible-output';
387    accessible.setAttribute('aria-live', 'off');
388    accessible.setAttribute('aria-label', `${label} output`);
389
390    // Grid tracks round the column width (Chromium: to 1/64 px) while text
391    // advances by the exact glyph width, so a long text run drifts out of its
392    // columns. The probe measures both and letter-spacing absorbs the gap.
393    const probe = document.createElement('span');
394    probe.className = 'vev-terminal__measure';
395    probe.setAttribute('aria-hidden', 'true');
396    probe.textContent = 'x'.repeat(64);
397
398    root.append(input, viewport, accessible, probe);
399
400    let destroyed = false;
401    let composing = false;
402    let editingKeyDown = null;
403    let mouseCapture = false;
404    let width = 0;
405    let height = 0;
406    let rowNodes = [];
407    let rowText = [];
408    let rowBytes = [];
409    let retainedBytes = 0;
410    let previousRows = [];
411    let previousStyles = '';
412    let resizeFrame = 0;
413    let pointerFrame = 0;
414    let pendingPointer = null;
415    let lastResize = '';
416    let letterSpacing = '';
417    const previousProperties = new Map();
418
419    function setOwnedProperty(name, value) {
420      if (!previousProperties.has(name)) {
421        previousProperties.set(name, {
422          value: root.style.getPropertyValue(name),
423          priority: root.style.getPropertyPriority(name)
424        });
425      }
426      root.style.setProperty(name, value);
427    }
428
429    function alive() {
430      if (destroyed) fail('instance is destroyed');
431    }
432
433    /** @returns {Readonly<BrowserEvent>|null} */
434    function capture(event, nativeEvent) {
435      const decision = decide(Object.freeze({ ...event }));
436      object(decision, 'capture decision');
437      keys(decision, ['emit', 'preventDefault'], 'capture decision');
438      const emit = boolean(decision.emit, 'capture decision.emit');
439      const preventDefault = boolean(decision.preventDefault, 'capture decision.preventDefault');
440      if (preventDefault && nativeEvent && nativeEvent.cancelable) nativeEvent.preventDefault();
441      return emit ? Object.freeze({ schemaVersion: SCHEMA_VERSION, ...event }) : null;
442    }
443
444    function dispatch(event, nativeEvent) {
445      const captured = capture(event, nativeEvent);
446      if (captured) send(captured);
447    }
448
449    function updateCursor(state) {
450      cursor.hidden = !state.visible;
451      cursor.className = 'vev-terminal__cursor';
452      if (!state.visible) return;
453      const style = state.styleSet ? state.style : 0;
454      const bar = style === 5 || style === 6;
455      const underline = style === 3 || style === 4;
456      if (bar) cursor.classList.add('vev-terminal__cursor--bar');
457      if (underline) cursor.classList.add('vev-terminal__cursor--underline');
458      if (style === 0 || style === 1 || style === 3 || style === 5) cursor.classList.add('vev-terminal__cell--blink');
459      cursor.style.left = `calc(${state.column} * var(--vev-cell-width))`;
460      cursor.style.top = `calc(${state.row} * var(--vev-cell-height))`;
461      cursor.style.width = bar ? '' : 'var(--vev-cell-width)';
462      cursor.style.height = underline ? '' : 'var(--vev-cell-height)';
463      if (underline) cursor.style.marginTop = 'calc(var(--vev-cell-height) * 0.88)';
464      else cursor.style.marginTop = '';
465    }
466
467    /** @param {Update} value */
468    function apply(value) {
469      alive();
470      applyValidated(validateUpdate(value, configured));
471    }
472
473    /**
474     * Validate a queue of updates and their geometry chain, then apply one
475     * merged update: a snapshot supersedes everything before it and later
476     * rows win. A consumer that falls behind rebuilds each changed row once
477     * instead of once per queued update. Invalid input throws before the DOM
478     * changes.
479     * @param {Update[]} values
480     */
481    function applyAll(values) {
482      alive();
483      if (!Array.isArray(values)) fail('updates must be an array');
484      if (values.length === 0) return;
485      const updates = values.map(value => validateUpdate(value, configured));
486      const mounted = rowNodes.length === 0 ? null : { width, height };
487      const merged = coalesce(updates, mounted, configured.maxStyles);
488      if (merged) applyValidated(merged);
489      else {
490        // Rare: the merged rows need more distinct styles than one update may
491        // carry. Each update is valid on its own and the geometry chain was
492        // checked above. Replay the retained-text budget too, then apply them
493        // in order, so a failure still leaves the DOM unchanged.
494        let bytes = rowBytes.slice();
495        let total = retainedBytes;
496        for (const update of updates) {
497          if (update.snapshot) {
498            bytes = [];
499            total = 0;
500          }
501          for (const row of update.rows) {
502            total += row.bytes - (bytes[row.row] || 0);
503            bytes[row.row] = row.bytes;
504          }
505          if (total > configured.maxUpdateBytes) fail('retained text exceeds its aggregate byte limit');
506        }
507        for (const update of updates) applyValidated(update);
508      }
509    }
510
511    function applyValidated(update) {
512      if (!update.snapshot && (update.width !== width || update.height !== height || rowNodes.length === 0)) fail('incremental update dimensions do not match the mounted snapshot');
513      if (!update.snapshot) {
514        for (let row = 0; row < height; row += 1) {
515          if (!rowNodes[row] || rowNodes[row].parentNode !== viewport) fail(`mounted row ${row} is unavailable`);
516        }
517      }
518      // maxUpdateBytes bounds the text retained by the mounted terminal, not
519      // only the current delta: reject the projected retained total before
520      // building or mutating the DOM.
521      let projected = retainedBytes;
522      for (const row of update.rows) {
523        projected -= rowBytes[row.row] || 0;
524        projected += row.bytes;
525      }
526      if (update.snapshot) projected = update.rows.reduce((total, row) => total + row.bytes, 0);
527      if (projected > configured.maxUpdateBytes) fail('retained text exceeds its aggregate byte limit');
528      const styleKey = JSON.stringify(update.styles);
529      const reusable = update.width === width && update.height === height && styleKey === previousStyles;
530      const built = update.rows.map(row => {
531        const old = previousRows[row.row];
532        const node = rowNodes[row.row];
533        // Text-only updates preserve geometry and styling. Blank cells skip
534        // clipping, so a change to or from blanks takes the rebuilding path.
535        const reuse = reusable && node?.parentNode === viewport && old &&
536          old.cells.length === row.cells.length && node.children.length === row.cells.length &&
537          row.cells.every((cell, index) => {
538            const before = old.cells[index];
539            return cell.column === before.column && cell.width === before.width && cell.style === before.style &&
540              !/^ +$/.test(cell.text) && !/^ +$/.test(before.text);
541          });
542        return reuse ? { row: row.row, node, bytes: row.bytes, text: row.cells.map(cell => cell.text).join(''), cells: row.cells } :
543          { row: row.row, bytes: row.bytes, ...buildRow(document, row, update.styles) };
544      });
545      for (const item of built) {
546        if (item.cells) {
547          item.cells.forEach((cell, index) => {
548            const child = item.node.children[index];
549            if (child.textContent !== cell.text) child.textContent = cell.text;
550          });
551        }
552      }
553
554      if (update.snapshot) {
555        const reuseAll = built.every(item => item.cells);
556        const fragment = document.createDocumentFragment();
557        const nextNodes = new Array(update.height);
558        const nextText = new Array(update.height);
559        const nextBytes = new Array(update.height).fill(0);
560        for (const item of built) {
561          nextNodes[item.row] = item.node;
562          nextText[item.row] = item.text;
563          nextBytes[item.row] = item.bytes;
564          if (!reuseAll) fragment.append(item.node);
565        }
566        if (!reuseAll) viewport.replaceChildren(fragment, cursor);
567        rowNodes = nextNodes;
568        rowText = nextText;
569        rowBytes = nextBytes;
570        retainedBytes = projected;
571      } else {
572        for (const item of built) {
573          if (!item.cells) rowNodes[item.row].replaceWith(item.node);
574          rowNodes[item.row] = item.node;
575          retainedBytes += item.bytes - (rowBytes[item.row] || 0);
576          rowBytes[item.row] = item.bytes;
577          rowText[item.row] = item.text;
578        }
579      }
580      if (update.snapshot || styleKey !== previousStyles) previousRows = new Array(update.height);
581      for (const row of update.rows) previousRows[row.row] = row;
582      previousStyles = styleKey;
583      width = update.width;
584      height = update.height;
585      setOwnedProperty('--vev-cols', String(width));
586      setOwnedProperty('--vev-rows', String(height));
587      const accessibleText = rowText.join('\n');
588      if (accessible.textContent !== accessibleText) accessible.textContent = accessibleText;
589      updateCursor(update.cursor);
590      scheduleResize();
591    }
592
593    function position(event) {
594      if (width === 0 || height === 0) return { row: 0, column: 0, x: 0, y: 0 };
595      const bounds = viewport.getBoundingClientRect();
596      if (bounds.width <= 0 || bounds.height <= 0) return { row: 0, column: 0, x: 0, y: 0 };
597      const clientX = Number.isFinite(event.clientX) ? event.clientX : bounds.left;
598      const clientY = Number.isFinite(event.clientY) ? event.clientY : bounds.top;
599      const x = Math.max(0, Math.min(bounds.width, clientX - bounds.left));
600      const y = Math.max(0, Math.min(bounds.height, clientY - bounds.top));
601      const column = Math.min(width - 1, Math.floor(x / (bounds.width / width)));
602      const row = Math.min(height - 1, Math.floor(y / (bounds.height / height)));
603      return { row, column, x, y };
604    }
605
606    function modifierFields(event) {
607      return { alt: event.altKey, ctrl: event.ctrlKey, meta: event.metaKey, shift: event.shiftKey };
608    }
609
610    function pointerPayload(event, action) {
611      const invalidButton = !Number.isInteger(event.button) || event.button < -1 || event.button > 4;
612      const invalidButtons = !Number.isInteger(event.buttons) || event.buttons < 0 || event.buttons > 31;
613      if (invalidButton || invalidButtons) return null;
614      return { type: 'pointer', action, button: event.button, buttons: event.buttons, ...position(event), ...modifierFields(event) };
615    }
616
617    function flushPointer() {
618      pointerFrame = 0;
619      const captured = pendingPointer;
620      pendingPointer = null;
621      if (captured) send(captured);
622    }
623
624    // A discrete pointer event must never be overtaken by an older deferred
625    // move: flush the pending move first so consumers observe event order.
626    function dispatchDiscretePointer(event, action, nativeEvent) {
627      const payload = pointerPayload(event, action);
628      if (!payload) return;
629      if (pendingPointer) flushPointer();
630      dispatch(payload, nativeEvent);
631    }
632
633    function measureAdvance() {
634      const column = probe.getBoundingClientRect().width;
635      const css = parseFloat(getComputedStyle(probe).width);
636      const range = document.createRange();
637      range.selectNodeContents(probe);
638      const advance = range.getBoundingClientRect().width / probe.textContent.length;
639      if (!(column > 0 && css > 0 && advance > 0)) return;
640      const spacing = `${((column - advance) * css / column).toFixed(4)}px`;
641      if (spacing !== letterSpacing) {
642        letterSpacing = spacing;
643        setOwnedProperty('--vev-letter-spacing', spacing);
644      }
645    }
646
647    function scheduleResize() {
648      if (resizeFrame) return;
649      resizeFrame = requestAnimationFrame(() => {
650        resizeFrame = 0;
651        measureAdvance();
652        if (width === 0 || height === 0) return;
653        const bounds = viewport.getBoundingClientRect();
654        const cellWidth = bounds.width / width;
655        const cellHeight = bounds.height / height;
656        if (bounds.width <= 0 || bounds.height <= 0 || cellWidth <= 0 || cellHeight <= 0) return;
657        const payload = {
658          type: 'resize', columns: width, rows: height,
659          pixelWidth: bounds.width, pixelHeight: bounds.height,
660          cellWidth, cellHeight, devicePixelRatio: window.devicePixelRatio
661        };
662        const identity = JSON.stringify(payload);
663        if (identity !== lastResize) {
664          lastResize = identity;
665          dispatch(payload);
666        }
667      });
668    }
669
670    input.addEventListener('beforeinput', event => {
671      if (composing || event.isComposing || event.inputType === 'insertCompositionText' || event.inputType === 'insertFromPaste') return;
672      const editingKey = { deleteContentBackward: 'Backspace', deleteContentForward: 'Delete', insertLineBreak: 'Enter', insertParagraph: 'Enter' }[event.inputType];
673      if (editingKey) {
674        if (editingKeyDown === editingKey) return;
675        dispatch({ type: 'key', key: editingKey, code: editingKey, repeat: false, location: 0, alt: false, ctrl: false, meta: false, shift: false }, event);
676        return;
677      }
678      if (!event.data) return;
679      if (encoder.encode(event.data).byteLength > configured.maxTextBytes) {
680        event.preventDefault();
681        return;
682      }
683      dispatch({ type: 'text', text: event.data }, event);
684    }, { signal });
685    input.addEventListener('input', event => {
686      if (!composing && !event.isComposing) input.value = '';
687    }, { signal });
688    input.addEventListener('compositionstart', () => { composing = true; }, { signal });
689    input.addEventListener('compositionend', event => {
690      composing = false;
691      input.value = '';
692      if (event.data && encoder.encode(event.data).byteLength <= configured.maxTextBytes) {
693        dispatch({ type: 'text', text: event.data }, event);
694      }
695    }, { signal });
696    input.addEventListener('keydown', event => {
697      if (composing || event.isComposing) return;
698      const key = event.key || 'Unidentified';
699      editingKeyDown = ['Backspace', 'Delete', 'Enter'].includes(key) ? key : null;
700      const code = event.code || 'Unidentified';
701      if (key === 'Unidentified' || key === 'Process') return;
702      const nonText = key.length !== 1 || event.ctrlKey || event.altKey || event.metaKey;
703      if (!nonText) return;
704      if (key.length > 128 || code.length > 128) {
705        event.preventDefault();
706        return;
707      }
708      dispatch({ type: 'key', key, code, repeat: event.repeat, location: event.location, ...modifierFields(event) }, event);
709    }, { signal });
710    input.addEventListener('keyup', () => { editingKeyDown = null; }, { signal });
711    input.addEventListener('blur', () => { editingKeyDown = null; }, { signal });
712    input.addEventListener('paste', event => {
713      const text = event.clipboardData?.getData('text/plain') || '';
714      if (encoder.encode(text).byteLength > configured.maxPasteBytes) {
715        event.preventDefault();
716        return;
717      }
718      dispatch({ type: 'paste', text }, event);
719    }, { signal });
720    input.addEventListener('focus', event => dispatch({ type: 'focus', focused: true }, event), { signal });
721    input.addEventListener('blur', event => dispatch({ type: 'focus', focused: false }, event), { signal });
722
723    viewport.addEventListener('pointerdown', event => {
724      if (mouseCapture && viewport.setPointerCapture) {
725        try {
726          if (event.isPrimary !== false) viewport.setPointerCapture(event.pointerId);
727        } catch {
728          // Pointer capture is best-effort; ordering still holds.
729        }
730      }
731      dispatchDiscretePointer(event, 'down', event);
732    }, { signal });
733    viewport.addEventListener('pointerup', event => {
734      dispatchDiscretePointer(event, 'up', event);
735    }, { signal });
736    viewport.addEventListener('pointercancel', event => {
737      dispatchDiscretePointer(event, 'cancel', event);
738    }, { signal });
739    viewport.addEventListener('pointermove', event => {
740      const payload = pointerPayload(event, 'move');
741      if (!payload) return;
742      const captured = capture(payload, event);
743      if (!captured || destroyed) return;
744      pendingPointer = captured;
745      if (!pointerFrame) pointerFrame = requestAnimationFrame(flushPointer);
746    }, { signal });
747    viewport.addEventListener('wheel', event => {
748      const invalidDelta = !Number.isFinite(event.deltaX) || !Number.isFinite(event.deltaY);
749      const invalidMode = !Number.isInteger(event.deltaMode) || event.deltaMode < 0 || event.deltaMode > 2;
750      if (invalidDelta || invalidMode) return;
751      const { row, column } = position(event);
752      dispatch({ type: 'wheel', deltaX: event.deltaX, deltaY: event.deltaY, deltaMode: event.deltaMode, row, column, ...modifierFields(event) }, event);
753    }, { signal, passive: false });
754
755    const observer = new ResizeObserver(scheduleResize);
756    observer.observe(root);
757    document.fonts?.addEventListener('loadingdone', scheduleResize, { signal });
758
759    return Object.freeze({
760      apply,
761      applyAll,
762      focus() { alive(); input.focus(); },
763      setMouseCapture(enabled) {
764        alive();
765        mouseCapture = Boolean(enabled);
766        root.classList.toggle('vev-terminal--mouse-capture', mouseCapture);
767      },
768      setTheme(theme) {
769        alive();
770        const value = validateTheme(theme);
771        setOwnedProperty('--vev-fg', rgbValue(value.foreground));
772        setOwnedProperty('--vev-bg', rgbValue(value.background));
773        setOwnedProperty('--vev-cursor', rgbValue(value.cursor));
774        setOwnedProperty('--vev-selection-bg', rgbValue(value.selection));
775        setOwnedProperty('--vev-selection-fg', rgbValue(value.selectionText));
776        value.palette.forEach((color, index) => setOwnedProperty(`--vev-color-${index}`, rgbValue(color)));
777      },
778      destroy() {
779        if (destroyed) return;
780        destroyed = true;
781        abort.abort();
782        observer.disconnect();
783        if (resizeFrame) cancelAnimationFrame(resizeFrame);
784        if (pointerFrame) cancelAnimationFrame(pointerFrame);
785        input.value = '';
786        accessible.textContent = '';
787        viewport.replaceChildren();
788        input.remove();
789        viewport.remove();
790        accessible.remove();
791        probe.remove();
792        rowNodes = [];
793        rowText = [];
794        rowBytes = [];
795        retainedBytes = 0;
796        previousRows = [];
797        previousStyles = '';
798        pendingPointer = null;
799        width = 0;
800        height = 0;
801        lastResize = '';
802        letterSpacing = '';
803        decide = () => ({ emit: false, preventDefault: false });
804        send = () => {};
805        for (const [name, previous] of previousProperties) {
806          if (previous.value === '') root.style.removeProperty(name);
807          else root.style.setProperty(name, previous.value, previous.priority);
808        }
809        previousProperties.clear();
810        root.classList.remove('vev-terminal', 'vev-terminal--mouse-capture');
811        if (previousRole === null) root.removeAttribute('role');
812        else root.setAttribute('role', previousRole);
813        if (previousLabel === null) root.removeAttribute('aria-label');
814        else root.setAttribute('aria-label', previousLabel);
815      }
816    });
817  }
818
819  globalThis.VevTerminal = Object.freeze({ schemaVersion: SCHEMA_VERSION, mount });
820})();

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 13

↑↓ moveenter openesc close