LCOV - code coverage report
Current view: top level - dashql-app/src/shell - browser_shell.ts Coverage Total Hit
Test: DashQL d805a4b Lines: 93.9 % 361 339
Test Date: 2026-11-09 20:22:13

            Line data    Source code
       1              : import type { IDisposable, Terminal } from '@xterm/xterm';
       2              : import type { FitAddon } from '@xterm/addon-fit';
       3              : import type { WebglAddon } from '@xterm/addon-webgl';
       4              : 
       5              : import { DashQLShell, DashQLShellPromptAction, DashQLShellPromptInput } from './api.js';
       6              : import { stringifyError } from '../platform/logger/logger.js';
       7              : import { TerminalCompletionOverlay } from './completion_overlay.js';
       8              : import { VT100 } from './vt100.js';
       9              : 
      10            1 : const QUERY_SPINNER_INTERVAL_MS = 80;
      11              : 
      12            1 : export class TerminalQueryProgress {
      13            1 :     private active = false;
      14            1 :     private timer: ReturnType<typeof setInterval> | null = null;
      15              : 
      16            1 :     constructor(
      17            1 :         private readonly shell: DashQLShell,
      18            1 :         private readonly write: (data: string) => void,
      19            1 :         private readonly reducedMotion = false,
      20            1 :     ) { }
      21              : 
      22            1 :     update(message: string): void {
      23            1 :         const output = this.shell.renderTerminalQueryProgress(message);
      24            1 :         if (output.data.length === 0) return;
      25            1 :         this.active = true;
      26            1 :         this.write(output.data);
      27            1 :         if (this.timer == null && !this.reducedMotion) {
      28            1 :             this.timer = setInterval(() => {
      29            1 :                 this.write(this.shell.renderTerminalQueryProgress('', true).data);
      30            1 :             }, QUERY_SPINNER_INTERVAL_MS);
      31              :         }
      32            1 :     }
      33              : 
      34            1 :     stop(): void {
      35            1 :         if (this.timer != null) {
      36            1 :             clearInterval(this.timer);
      37            1 :             this.timer = null;
      38            1 :         }
      39            1 :     }
      40              : 
      41            1 :     clear(): void {
      42            1 :         this.stop();
      43            1 :         if (!this.active) return;
      44            1 :         this.active = false;
      45            1 :         this.write(this.shell.clearTerminalQueryProgress().data);
      46            1 :     }
      47            1 : }
      48              : 
      49              : export interface BrowserShellOptions {
      50              :     container: HTMLElement;
      51              :     shell: DashQLShell;
      52              :     greeter?: readonly string[];
      53              :     prompt?: string;
      54              :     onExit?: () => void;
      55              :     onQueryResult?: (queryId: number) => void;
      56              :     inputAriaLabel?: string;
      57              :     /// May the terminal take the focus once it opened? A page that went to the background meanwhile must not.
      58              :     mayFocus?: () => boolean;
      59              : }
      60              : 
      61              : export interface BrowserShellController {
      62              :     focus(): void;
      63              :     /// Continue with another shell, by default under the same prompt
      64              :     replaceShell(shell: DashQLShell, prompt?: string): void;
      65              :     writeStatus(message: string): void;
      66              :     /// Offer prompts one after another, each submit fills in the next one
      67              :     queuePrompts(prompts: readonly string[]): void;
      68              :     dispose(): void;
      69              : }
      70              : 
      71            1 : export function formatTerminalGreeter(lines: readonly string[]): string {
      72            1 :     if (lines.length === 0) return '';
      73            1 :     return VT100.ENABLE_AUTO_WRAP + VT100.BOLD + lines[0] + VT100.RESET_ATTRIBUTES + VT100.NEW_LINE +
      74            1 :         lines.slice(1).join(VT100.NEW_LINE) + VT100.NEW_LINE + VT100.NEW_LINE;
      75            1 : }
      76              : 
      77            1 : export function sanitizeTerminalText(data: string): string {
      78            1 :     const escape = data.indexOf(VT100.ESCAPE);
      79            1 :     if (escape >= 0) data = data.substring(0, escape);
      80            1 :     if (data.length === 1 && (data.charCodeAt(0) < 0x20 || data.charCodeAt(0) === 0x7f)) return '';
      81            1 :     return data
      82            1 :         .replace(/\r\n?/g, '\n')
      83            1 :         .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '');
      84            1 : }
      85              : 
      86            1 : export function isTerminalCancelData(data: string): boolean {
      87            1 :     return data === '\x03';
      88            1 : }
      89              : 
      90            1 : export function terminalPromptInputForKey(
      91            1 :     key: string,
      92            1 :     controlModifier = false,
      93            1 :     metaModifier = false,
      94            1 :     commandModifier = false,
      95            1 :     code = '',
      96            1 : ): DashQLShellPromptInput | null {
      97            1 :     const modifierKey = code.startsWith('Key') ? code.substring(3).toLowerCase() : key.toLowerCase();
      98            1 :     if (metaModifier) {
      99            1 :         switch (modifierKey) {
     100            1 :             case 'backspace':
     101            1 :                 return DashQLShellPromptInput.BACKWARD_KILL_WORD;
     102            1 :             case 'b':
     103            1 :                 return DashQLShellPromptInput.WORD_LEFT;
     104            1 :             case 'd':
     105            1 :                 return DashQLShellPromptInput.KILL_WORD;
     106            1 :             case 'f':
     107            1 :                 return DashQLShellPromptInput.WORD_RIGHT;
     108            1 :         }
     109              :     }
     110            1 :     if (commandModifier) {
     111            1 :         switch (modifierKey) {
     112            1 :             case 'a':
     113            1 :                 return DashQLShellPromptInput.START;
     114            1 :             case 'e':
     115            1 :                 return DashQLShellPromptInput.END;
     116            1 :         }
     117              :     }
     118            1 :     if (controlModifier) {
     119            1 :         switch (modifierKey) {
     120            1 :             case 'a':
     121            1 :                 return DashQLShellPromptInput.LINE_START;
     122            1 :             case 'b':
     123            1 :                 return DashQLShellPromptInput.LEFT;
     124            1 :             case 'd':
     125            1 :                 return DashQLShellPromptInput.EOF;
     126            1 :             case 'e':
     127            1 :                 return DashQLShellPromptInput.LINE_END;
     128            1 :             case 'f':
     129            1 :                 return DashQLShellPromptInput.RIGHT;
     130            1 :             case 'g':
     131            1 :                 return DashQLShellPromptInput.ABORT;
     132            1 :             case 'h':
     133            1 :                 return DashQLShellPromptInput.BACKSPACE;
     134            1 :             case 'i':
     135            1 :                 return DashQLShellPromptInput.TAB;
     136            1 :             case 'j':
     137            1 :                 return DashQLShellPromptInput.ENTER;
     138            1 :             case 'k':
     139            1 :                 return DashQLShellPromptInput.KILL_LINE;
     140            1 :             case 'l':
     141            1 :                 return DashQLShellPromptInput.CLEAR_SCREEN;
     142            1 :             case 'm':
     143            1 :                 return DashQLShellPromptInput.ENTER;
     144            1 :             case 'n':
     145            1 :                 return DashQLShellPromptInput.HISTORY_NEXT;
     146            1 :             case 'p':
     147            1 :                 return DashQLShellPromptInput.HISTORY_PREVIOUS;
     148            1 :             case 't':
     149            1 :                 return DashQLShellPromptInput.TRANSPOSE;
     150            1 :             case 'u':
     151            1 :                 return DashQLShellPromptInput.BACKWARD_KILL_LINE;
     152            1 :             case 'w':
     153            1 :                 return DashQLShellPromptInput.BACKWARD_KILL_WORD;
     154            1 :             case 'y':
     155            1 :                 return DashQLShellPromptInput.YANK;
     156            1 :         }
     157              :     }
     158            1 :     switch (key) {
     159            1 :         case 'Enter':
     160            1 :             return DashQLShellPromptInput.ENTER;
     161            1 :         case 'Tab':
     162            1 :             return DashQLShellPromptInput.TAB;
     163            0 :         case 'Backspace':
     164            0 :             return DashQLShellPromptInput.BACKSPACE;
     165            0 :         case 'Delete':
     166            0 :             return DashQLShellPromptInput.DELETE;
     167            0 :         case 'ArrowLeft':
     168            0 :             return DashQLShellPromptInput.LEFT;
     169            0 :         case 'ArrowRight':
     170            0 :             return DashQLShellPromptInput.RIGHT;
     171            1 :         case 'ArrowUp':
     172            1 :             return DashQLShellPromptInput.UP;
     173            1 :         case 'ArrowDown':
     174            1 :             return DashQLShellPromptInput.DOWN;
     175            1 :         case 'Home':
     176            1 :             return DashQLShellPromptInput.START;
     177            1 :         case 'End':
     178            1 :             return DashQLShellPromptInput.END;
     179            1 :         case 'Escape':
     180            1 :             return DashQLShellPromptInput.ESCAPE;
     181            1 :         default:
     182            1 :             return null;
     183            1 :     }
     184            1 : }
     185              : 
     186            1 : export async function loadWebglRenderer(terminal: Terminal): Promise<boolean> {
     187            1 :     try {
     188            1 :         const { WebglAddon } = await import('@xterm/addon-webgl');
     189            1 :         const webglAddon: WebglAddon = new WebglAddon();
     190            1 :         terminal.loadAddon(webglAddon);
     191            1 :         webglAddon.onContextLoss(() => webglAddon.dispose());
     192            1 :         return true;
     193            1 :     } catch {
     194              :         // xterm's DOM renderer remains active when WebGL is unavailable.
     195            1 :         return false;
     196            1 :     }
     197            1 : }
     198              : 
     199              : /// The color of the shell prompt as 0xRRGGBB, from the `--shell_prompt_color` theme token of an element.
     200              : /// Null if the theme leaves it unset, the prompt is then bold in the default foreground color.
     201            1 : export function resolveShellPromptColor(element: HTMLElement): number | null {
     202            1 :     const token = getComputedStyle(element).getPropertyValue('--shell_prompt_color').trim();
     203            1 :     if (token === '' || !CSS.supports('color', token)) return null;
     204              :     // Let the browser resolve the color to rgb()
     205            1 :     const probe = document.createElement('span');
     206            1 :     probe.style.color = token;
     207            1 :     element.appendChild(probe);
     208            1 :     const color = getComputedStyle(probe).color;
     209            1 :     probe.remove();
     210            1 :     const match = /^rgba?\((\d+),\s*(\d+),\s*(\d+)/.exec(color);
     211            1 :     return match == null ? null : (Number(match[1]) << 16) | (Number(match[2]) << 8) | Number(match[3]);
     212            1 : }
     213              : 
     214            1 : export async function embedDashQLShell(options: BrowserShellOptions): Promise<BrowserShellController> {
     215            1 :     const [{ Terminal }, { FitAddon }] = await Promise.all([
     216            1 :         import('@xterm/xterm'),
     217            1 :         import('@xterm/addon-fit'),
     218            1 :         import('@xterm/xterm/css/xterm.css'),
     219            1 :     ]) as [{ Terminal: typeof import('@xterm/xterm').Terminal }, { FitAddon: typeof import('@xterm/addon-fit').FitAddon }, unknown];
     220              : 
     221            1 :     let shell = options.shell;
     222            1 :     let activeQuery: AbortController | null = null;
     223            1 :     let activeProgress: TerminalQueryProgress | null = null;
     224            1 :     let disposed = false;
     225            1 :     let pendingPrompts: string[] = [];
     226            1 :     let prompt = options.prompt ?? 'dashql> ';
     227            1 :     const promptColor = resolveShellPromptColor(options.container);
     228            1 :     const terminal: Terminal = new Terminal({
     229            1 :         allowProposedApi: false,
     230            1 :         allowTransparency: true,
     231            1 :         convertEol: true,
     232            1 :         cursorBlink: true,
     233            1 :         cursorStyle: 'block',
     234              :         // The mono font of the app, a theme token that the app flavor may override
     235            1 :         fontFamily: getComputedStyle(options.container).getPropertyValue('--font_family_mono').trim() || 'monospace',
     236            1 :         fontSize: 13,
     237            1 :         screenReaderMode: true,
     238            1 :         scrollback: 10000,
     239            1 :         theme: {
     240            1 :             background: '#00000000',
     241            1 :             foreground: '#e6edf3',
     242            1 :             cursor: '#ffffff',
     243            1 :             selectionBackground: '#264f78',
     244            1 :         },
     245            1 :     });
     246            1 :     const fitAddon: FitAddon = new FitAddon();
     247            1 :     terminal.loadAddon(fitAddon);
     248            1 :     terminal.open(options.container);
     249            1 :     await loadWebglRenderer(terminal);
     250            1 :     const helper = options.container.querySelector<HTMLTextAreaElement>('.xterm-helper-textarea');
     251            1 :     helper?.setAttribute('aria-label', options.inputAriaLabel ?? 'DashQL shell input');
     252              : 
     253            1 :     const completionOverlay = new TerminalCompletionOverlay(
     254            1 :         options.container,
     255            1 :         terminal,
     256            0 :         () => consume(DashQLShellPromptInput.LEFT),
     257            0 :         () => consume(DashQLShellPromptInput.RIGHT),
     258            1 :     );
     259            1 :     const syncCompletion = () => {
     260            1 :         if (!disposed) completionOverlay.update(shell);
     261            1 :     };
     262              :     // The cursor that the completion list follows is only current once the terminal processed the output
     263            1 :     const writeOutput = (data: string) => {
     264            1 :         if (data.length === 0) syncCompletion();
     265            1 :         else terminal.write(data, syncCompletion);
     266            1 :     };
     267              :     // A blur ends the completion, like in the editor
     268            1 :     const abortCompletionOnBlur = () => {
     269            1 :         if (completionOverlay.isOpen && !disposed) consume(DashQLShellPromptInput.ABORT);
     270            1 :     };
     271            1 :     helper?.addEventListener('blur', abortCompletionOnBlur);
     272            1 :     const renderSubscription: IDisposable = terminal.onRender(() => completionOverlay.place());
     273              : 
     274            1 :     const syncSize = () => {
     275            1 :         if (disposed || options.container.clientWidth === 0 || options.container.clientHeight === 0) return;
     276            1 :         fitAddon.fit();
     277            1 :         const columns = Math.max(1, terminal.cols);
     278            1 :         shell.resize(columns);
     279            1 :         completionOverlay.place();
     280            1 :     };
     281            1 :     const resizeObserver = new ResizeObserver(syncSize);
     282            1 :     resizeObserver.observe(options.container);
     283              : 
     284            1 :     const submit = async () => {
     285            1 :         if (activeQuery != null) return;
     286            1 :         const abort = new AbortController();
     287            1 :         const executingShell = shell;
     288            1 :         const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
     289            1 :         const progress = new TerminalQueryProgress(executingShell, data => terminal.write(data), reducedMotion);
     290            1 :         const queryResult: { queryId: number | null } = { queryId: null };
     291            1 :         activeQuery = abort;
     292            1 :         activeProgress = progress;
     293            1 :         try {
     294            1 :             const output = await executingShell.submitPrompt(
     295            1 :                 abort.signal,
     296            1 :                 message => {
     297            1 :                     if (!disposed && shell === executingShell && activeQuery === abort) progress.update(message);
     298            1 :                 },
     299            1 :                 queryId => {
     300            1 :                     if (!disposed && shell === executingShell && activeQuery === abort) {
     301            1 :                         queryResult.queryId = queryId;
     302              :                     }
     303            1 :                 },
     304            1 :             );
     305            1 :             if (!disposed && shell === executingShell) {
     306            1 :                 progress.stop();
     307            1 :                 terminal.write(executingShell.finishTerminalQuery(output).data);
     308            1 :                 if (queryResult.queryId != null) options.onQueryResult?.(queryResult.queryId);
     309            1 :             }
     310            1 :         } catch (error) {
     311            0 :             if (!disposed && shell === executingShell) {
     312            0 :                 progress.stop();
     313            0 :                 terminal.write(executingShell.finishTerminalQuery(stringifyError(error), true).data);
     314            0 :             }
     315            1 :         } finally {
     316            1 :             progress.stop();
     317            1 :             if (activeProgress === progress) activeProgress = null;
     318            1 :             if (activeQuery === abort) activeQuery = null;
     319            1 :             if (!disposed && shell === executingShell) fillNextPrompt();
     320            1 :         }
     321            1 :     };
     322            1 :     const consume = (key: DashQLShellPromptInput, text = '') => {
     323            1 :         if (key === DashQLShellPromptInput.CANCEL) pendingPrompts = [];
     324            1 :         const output = shell.consumeTerminalInput(key, text);
     325            1 :         writeOutput(output.data);
     326            1 :         if (output.action === DashQLShellPromptAction.SUBMIT) void submit();
     327            1 :         else if (output.action === DashQLShellPromptAction.EXIT) options.onExit?.();
     328            1 :     };
     329              :     // Inserting text never submits, the user runs every queued prompt with Enter
     330            1 :     const fillNextPrompt = () => {
     331            1 :         if (activeQuery != null) return;
     332            1 :         const next = pendingPrompts.shift();
     333            1 :         if (next != null) consume(DashQLShellPromptInput.TEXT, next);
     334            1 :     };
     335              : 
     336            1 :     terminal.attachCustomKeyEventHandler(event => {
     337            1 :         if (event.type !== 'keydown') return true;
     338            1 :         if (event.ctrlKey && !event.altKey && !event.metaKey && event.key.toLowerCase() === 'c') {
     339            1 :             if (activeQuery != null) activeQuery.abort();
     340            1 :             else consume(DashQLShellPromptInput.CANCEL);
     341            1 :             event.preventDefault();
     342            1 :             event.stopPropagation();
     343            1 :             return false;
     344            1 :         }
     345            1 :         let key = terminalPromptInputForKey(
     346            1 :             event.key,
     347            1 :             event.ctrlKey && !event.altKey && !event.metaKey,
     348            1 :             event.altKey && !event.ctrlKey && !event.metaKey,
     349            1 :             event.metaKey && !event.altKey && !event.ctrlKey,
     350            1 :             event.code,
     351            1 :         );
     352            1 :         if (event.key === 'Enter') {
     353            1 :             if (event.ctrlKey || event.metaKey) key = DashQLShellPromptInput.FORCE_SUBMIT;
     354            1 :             else if (event.shiftKey) {
     355            0 :                 if (activeQuery == null) consume(DashQLShellPromptInput.TEXT, '\n');
     356            0 :                 event.preventDefault();
     357            0 :                 event.stopPropagation();
     358            0 :                 return false;
     359            0 :             }
     360            1 :         }
     361            1 :         if (key == null) return true;
     362            1 :         if (activeQuery == null) consume(key);
     363            1 :         event.preventDefault();
     364            1 :         event.stopPropagation();
     365            1 :         return false;
     366            1 :     });
     367              : 
     368            1 :     const dataSubscription: IDisposable = terminal.onData(data => {
     369            1 :         if (disposed || activeQuery != null) return;
     370            1 :         if (isTerminalCancelData(data)) {
     371            0 :             consume(DashQLShellPromptInput.CANCEL);
     372            0 :             return;
     373            0 :         }
     374            1 :         const text = sanitizeTerminalText(data);
     375            1 :         if (text.length > 0) consume(DashQLShellPromptInput.TEXT, text);
     376            1 :     });
     377              : 
     378            1 :     shell.setTerminalPromptColor(promptColor);
     379            1 :     terminal.write(options.greeter == null
     380            1 :         ? shell.openTerminal(prompt).data
     381            1 :         : formatTerminalGreeter(options.greeter) + shell.openTerminal(prompt).data);
     382            1 :     requestAnimationFrame(() => {
     383            1 :         syncSize();
     384            1 :         if (!disposed && (options.mayFocus?.() ?? true)) terminal.focus();
     385            1 :     });
     386              : 
     387            1 :     return {
     388            1 :         focus: () => terminal.focus(),
     389            1 :         replaceShell(nextShell, nextPrompt = prompt) {
     390            1 :             const history = shell.exportHistory();
     391            1 :             pendingPrompts = [];
     392            1 :             activeProgress?.clear();
     393            1 :             activeProgress = null;
     394            1 :             activeQuery?.abort();
     395            1 :             shell = nextShell;
     396            1 :             prompt = nextPrompt;
     397            1 :             shell.importHistory(history);
     398            1 :             shell.setTerminalPromptColor(promptColor);
     399            1 :             writeOutput(shell.openTerminal(prompt).data);
     400            1 :             syncSize();
     401            1 :         },
     402            1 :         writeStatus(message) {
     403            1 :             writeOutput(shell.renderTerminalStatus(message).data);
     404            1 :         },
     405            1 :         queuePrompts(prompts) {
     406            1 :             if (disposed) return;
     407            1 :             pendingPrompts = [...prompts];
     408            1 :             fillNextPrompt();
     409            1 :         },
     410            1 :         dispose() {
     411            1 :             if (disposed) return;
     412            1 :             pendingPrompts = [];
     413            1 :             activeProgress?.clear();
     414            1 :             activeProgress = null;
     415            1 :             disposed = true;
     416            1 :             activeQuery?.abort();
     417            1 :             helper?.removeEventListener('blur', abortCompletionOnBlur);
     418            1 :             renderSubscription.dispose();
     419            1 :             completionOverlay.destroy();
     420            1 :             dataSubscription.dispose();
     421            1 :             resizeObserver.disconnect();
     422            1 :             terminal.dispose();
     423            1 :         },
     424            1 :     };
     425            1 : }
        

Generated by: LCOV version 2.0-1