LCOV - code coverage report
Current view: top level - dashql-app/src/shell - browser_shell.ts Coverage Total Hit
Test: DashQL 915828b Lines: 93.6 % 346 324
Test Date: 2026-11-06 19:30:32

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

Generated by: LCOV version 2.0-1