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