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 : }
|