@finos/legend-application 10.2.11 → 10.2.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/lib/application/LegendApplication.d.ts.map +1 -1
  2. package/lib/application/LegendApplication.js +1 -2
  3. package/lib/application/LegendApplication.js.map +1 -1
  4. package/lib/components/ActionAlert.d.ts +1 -0
  5. package/lib/components/ActionAlert.d.ts.map +1 -1
  6. package/lib/components/BlockingAlert.d.ts +1 -0
  7. package/lib/components/BlockingAlert.d.ts.map +1 -1
  8. package/lib/components/LegendApplicationComponentFrameworkProvider.d.ts +6 -0
  9. package/lib/components/LegendApplicationComponentFrameworkProvider.d.ts.map +1 -1
  10. package/lib/components/LegendApplicationComponentFrameworkProvider.js +21 -13
  11. package/lib/components/LegendApplicationComponentFrameworkProvider.js.map +1 -1
  12. package/lib/components/NotificationManager.d.ts +1 -0
  13. package/lib/components/NotificationManager.d.ts.map +1 -1
  14. package/lib/components/VirtualAssistant.d.ts +1 -0
  15. package/lib/components/VirtualAssistant.d.ts.map +1 -1
  16. package/lib/components/shared/TabManager.d.ts +1 -0
  17. package/lib/components/shared/TabManager.d.ts.map +1 -1
  18. package/lib/components/shared/TextSearchAdvancedConfigMenu.d.ts +1 -0
  19. package/lib/components/shared/TextSearchAdvancedConfigMenu.d.ts.map +1 -1
  20. package/lib/const.d.ts +1 -1
  21. package/lib/const.d.ts.map +1 -1
  22. package/lib/const.js +1 -1
  23. package/lib/const.js.map +1 -1
  24. package/lib/index.css +2 -2
  25. package/lib/index.css.map +1 -1
  26. package/lib/index.d.ts +1 -0
  27. package/lib/index.d.ts.map +1 -1
  28. package/lib/index.js +1 -0
  29. package/lib/index.js.map +1 -1
  30. package/lib/stores/ApplicationEvent.d.ts +1 -0
  31. package/lib/stores/ApplicationEvent.d.ts.map +1 -1
  32. package/lib/stores/ApplicationEvent.js +1 -0
  33. package/lib/stores/ApplicationEvent.js.map +1 -1
  34. package/lib/stores/ApplicationStore.d.ts +2 -0
  35. package/lib/stores/ApplicationStore.d.ts.map +1 -1
  36. package/lib/stores/ApplicationStore.js +3 -0
  37. package/lib/stores/ApplicationStore.js.map +1 -1
  38. package/lib/stores/CommandCenter.d.ts +1 -0
  39. package/lib/stores/CommandCenter.d.ts.map +1 -1
  40. package/lib/stores/CommandCenter.js.map +1 -1
  41. package/lib/stores/KeyboardShortcutsService.d.ts +4 -10
  42. package/lib/stores/KeyboardShortcutsService.d.ts.map +1 -1
  43. package/lib/stores/KeyboardShortcutsService.js +36 -33
  44. package/lib/stores/KeyboardShortcutsService.js.map +1 -1
  45. package/lib/stores/LegendApplicationDocumentation.d.ts +1 -0
  46. package/lib/stores/LegendApplicationDocumentation.d.ts.map +1 -1
  47. package/lib/stores/LegendApplicationDocumentation.js +1 -0
  48. package/lib/stores/LegendApplicationDocumentation.js.map +1 -1
  49. package/lib/stores/PureLanguageSupport.d.ts.map +1 -1
  50. package/lib/stores/PureLanguageSupport.js +14 -1
  51. package/lib/stores/PureLanguageSupport.js.map +1 -1
  52. package/lib/stores/TerminalService.d.ts +23 -0
  53. package/lib/stores/TerminalService.d.ts.map +1 -0
  54. package/lib/stores/TerminalService.js +25 -0
  55. package/lib/stores/TerminalService.js.map +1 -0
  56. package/lib/stores/terminal/Terminal.d.ts +155 -0
  57. package/lib/stores/terminal/Terminal.d.ts.map +1 -0
  58. package/lib/stores/terminal/Terminal.js +171 -0
  59. package/lib/stores/terminal/Terminal.js.map +1 -0
  60. package/lib/stores/terminal/XTerm.d.ts +91 -0
  61. package/lib/stores/terminal/XTerm.d.ts.map +1 -0
  62. package/lib/stores/terminal/XTerm.js +693 -0
  63. package/lib/stores/terminal/XTerm.js.map +1 -0
  64. package/package.json +19 -13
  65. package/src/application/LegendApplication.tsx +5 -2
  66. package/src/components/LegendApplicationComponentFrameworkProvider.tsx +24 -18
  67. package/src/const.ts +1 -1
  68. package/src/index.ts +1 -0
  69. package/src/stores/ApplicationEvent.ts +1 -0
  70. package/src/stores/ApplicationStore.ts +4 -1
  71. package/src/stores/CommandCenter.ts +1 -0
  72. package/src/stores/KeyboardShortcutsService.ts +43 -48
  73. package/src/stores/LegendApplicationDocumentation.ts +1 -0
  74. package/src/stores/PureLanguageSupport.ts +15 -1
  75. package/src/stores/TerminalService.ts +30 -0
  76. package/src/stores/terminal/Terminal.ts +259 -0
  77. package/src/stores/terminal/XTerm.ts +880 -0
  78. package/tsconfig.json +3 -0
@@ -0,0 +1,880 @@
1
+ /**
2
+ * Copyright (c) 2020-present, Goldman Sachs
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ import type { GenericLegendApplicationStore } from '../ApplicationStore.js';
18
+ import {
19
+ Terminal as XTermTerminal,
20
+ type ITheme as XTermTheme,
21
+ type IDisposable as XTermDisposable,
22
+ } from 'xterm';
23
+ import { WebLinksAddon as XTermWebLinksAddon } from 'xterm-addon-web-links';
24
+ import { FitAddon as XTermFitAddon } from 'xterm-addon-fit';
25
+ import {
26
+ type ISearchDecorationOptions as XTermSearchDecorationOptions,
27
+ SearchAddon as XTermSearchAddon,
28
+ } from 'xterm-addon-search';
29
+ import { Unicode11Addon as XTermUnicode11Addon } from 'xterm-addon-unicode11';
30
+ import { WebglAddon as XTermWebglAddon } from 'xterm-addon-webgl';
31
+ import { MONOSPACED_FONT_FAMILY, TAB_SIZE } from '../../const.js';
32
+ import {
33
+ Terminal,
34
+ DISPLAY_ANSI_ESCAPE,
35
+ ANSI_moveCursor,
36
+ type TerminalWriteOption,
37
+ type TerminalSetupConfiguration,
38
+ type TerminalCommandConfiguration,
39
+ } from './Terminal.js';
40
+ import {
41
+ ActionState,
42
+ guaranteeNonNullable,
43
+ IllegalStateError,
44
+ isMatchingKeyCombination,
45
+ LogEvent,
46
+ prettyCONSTName,
47
+ uniqBy,
48
+ } from '@finos/legend-shared';
49
+ import { APPLICATION_EVENT } from '../ApplicationEvent.js';
50
+ import { forceDispatchKeyboardEvent } from '../../components/LegendApplicationComponentFrameworkProvider.js';
51
+
52
+ const LEGEND_XTERM_THEME: XTermTheme = {
53
+ foreground: '#cccccc',
54
+ background: '#1e1e1e',
55
+
56
+ cursor: '#cccccc',
57
+ /** The accent color of the cursor (fg color for a block cursor) */
58
+ // cursorAccent?: string;
59
+ /** The selection background color when the terminal does not have focus (can be transparent) */
60
+ // selectionInactiveBackground?: string;
61
+ selectionBackground: '#264f78', // blue
62
+
63
+ black: '#000000',
64
+ red: '#cd3131',
65
+ green: '#0dbc79',
66
+ yellow: '#e5e510',
67
+ blue: '#2472c8',
68
+ magenta: '#bc3fbc',
69
+ cyan: '#11a8cd',
70
+ white: '#e5e5e5',
71
+
72
+ brightBlack: '#666666',
73
+ brightRed: '#f14c4c',
74
+ brightGreen: '#23d18b',
75
+ brightYellow: '#f5f543',
76
+ brightBlue: '#3b8eea',
77
+ brightMagenta: '#d670d6',
78
+ brightCyan: '#29b8db',
79
+ brightWhite: '#e5e5e5',
80
+ };
81
+
82
+ const LEGEND_XTERM_SEARCH_THEME: XTermSearchDecorationOptions = {
83
+ matchOverviewRuler: '#d186167e',
84
+ activeMatchColorOverviewRuler: '#A0A0A0CC',
85
+ matchBackground: '#62331c',
86
+ activeMatchBackground: '#515C6A',
87
+ };
88
+
89
+ // robot acsii art
90
+ // See https://asciiartist.com/ascii-art-micro-robot/
91
+ const getHelpCommandContent = (
92
+ commandRegistry: Map<string, TerminalCommandConfiguration>,
93
+ ): string => `
94
+ ${
95
+ DISPLAY_ANSI_ESCAPE.BRIGHT_BLACK
96
+ }+-------------------------------------------------------+${
97
+ DISPLAY_ANSI_ESCAPE.RESET
98
+ }
99
+ ${DISPLAY_ANSI_ESCAPE.BRIGHT_BLACK}|${DISPLAY_ANSI_ESCAPE.RESET} ${
100
+ DISPLAY_ANSI_ESCAPE.BRIGHT_GREEN
101
+ }[@@]${
102
+ DISPLAY_ANSI_ESCAPE.RESET
103
+ } "Hi! Welcome to the HELP menu of Pure IDE" ${
104
+ DISPLAY_ANSI_ESCAPE.BRIGHT_BLACK
105
+ }|${DISPLAY_ANSI_ESCAPE.RESET}
106
+ ${DISPLAY_ANSI_ESCAPE.BRIGHT_BLACK}|${DISPLAY_ANSI_ESCAPE.RESET} ${
107
+ DISPLAY_ANSI_ESCAPE.BRIGHT_GREEN
108
+ }/|__|\\${
109
+ DISPLAY_ANSI_ESCAPE.RESET
110
+ } ${
111
+ DISPLAY_ANSI_ESCAPE.BRIGHT_BLACK
112
+ }|${DISPLAY_ANSI_ESCAPE.RESET}
113
+ ${DISPLAY_ANSI_ESCAPE.BRIGHT_BLACK}+--${DISPLAY_ANSI_ESCAPE.RESET} ${
114
+ DISPLAY_ANSI_ESCAPE.BRIGHT_GREEN
115
+ }d b${DISPLAY_ANSI_ESCAPE.RESET} ${
116
+ DISPLAY_ANSI_ESCAPE.BRIGHT_BLACK
117
+ }-----------------------------------------------+${DISPLAY_ANSI_ESCAPE.RESET}
118
+
119
+ Following is the list of supported commands:
120
+
121
+ ${uniqBy(Array.from(commandRegistry.values()), (config) => config.command)
122
+ .map(
123
+ (config) =>
124
+ `${DISPLAY_ANSI_ESCAPE.BRIGHT_GREEN}${config.command.padEnd(30)}${
125
+ DISPLAY_ANSI_ESCAPE.RESET
126
+ }${config.description}${
127
+ config.aliases?.length
128
+ ? `\n${''.padEnd(30)}Aliases: ${config.aliases.join(', ')}`
129
+ : ''
130
+ }\n${''.padEnd(30)}Usage: ${DISPLAY_ANSI_ESCAPE.DIM}${config.usage}${
131
+ DISPLAY_ANSI_ESCAPE.RESET
132
+ }`,
133
+ )
134
+ .join('\n')}`;
135
+
136
+ const getCommonANSIEscapeSequencesForStyling = (): string =>
137
+ `
138
+ Common ANSI Escape Sequences for Styling:
139
+
140
+ ${Object.entries(DISPLAY_ANSI_ESCAPE)
141
+ .map(
142
+ ([key, value]) =>
143
+ `${value}${prettyCONSTName(key).padEnd(20)}${
144
+ DISPLAY_ANSI_ESCAPE.RESET
145
+ // NOTE: since these are recommended ANSI escape sequences which can be used
146
+ // by users in strings input in Pure IDE, they have to be Unicode escape, if we send
147
+ // the original hexadecimal escape as part of the string, some string escape handling
148
+ // in Pure seems to escape the leading slash of the ANSI escape sequence \x1B; however
149
+ // this is not the case of the escape sequence for Unicode, \u001b hence our logic here
150
+ } ${value.replace('\x1b', '\\u001b')}`,
151
+ )
152
+ .join('\n')}`;
153
+
154
+ const DEFAULT_USER = 'purist';
155
+ const DEFAULT_COMMAND_HEADER = `
156
+ ${DISPLAY_ANSI_ESCAPE.BOLD}${DISPLAY_ANSI_ESCAPE.BRIGHT_BLUE}$${DEFAULT_USER}${DISPLAY_ANSI_ESCAPE.RESET}
157
+ ${DISPLAY_ANSI_ESCAPE.BOLD}${DISPLAY_ANSI_ESCAPE.MAGENTA}\u276f${DISPLAY_ANSI_ESCAPE.RESET} `;
158
+ const COMMAND_START = '\u276f ';
159
+
160
+ export class XTerm extends Terminal {
161
+ private readonly instance: XTermTerminal;
162
+ private readonly resizer: XTermFitAddon;
163
+ private readonly renderer: XTermWebglAddon;
164
+ private readonly searcher: XTermSearchAddon;
165
+ private webLinkProvider?: XTermWebLinksAddon;
166
+
167
+ private _TEMPORARY__onKeyListener?: XTermDisposable;
168
+ private _TEMPORARY__onDataListener?: XTermDisposable;
169
+
170
+ private command = '';
171
+ private commandHistory: string[] = [];
172
+ private currentCommandSearchString = '';
173
+ private commandHistoryNavigationIdx: number | undefined = undefined;
174
+ private isRunningCommand = false;
175
+
176
+ private readonly setupState = ActionState.create();
177
+
178
+ constructor(applicationStore: GenericLegendApplicationStore) {
179
+ super(applicationStore);
180
+
181
+ this.instance = new XTermTerminal({
182
+ allowProposedApi: true,
183
+
184
+ fontSize: 12,
185
+ letterSpacing: 2,
186
+ fontWeight: 400,
187
+ fontWeightBold: 700,
188
+ fontFamily: `"${MONOSPACED_FONT_FAMILY}", Menlo, Consolas, monospace`,
189
+ tabStopWidth: TAB_SIZE,
190
+ theme: LEGEND_XTERM_THEME,
191
+ overviewRulerWidth: 14, // 14px
192
+ scrollback: 10000, // buffer a substantial content length
193
+ convertEol: true, // treat \n as new line
194
+
195
+ // this is needed so we can control the cursor programmatically using escape sequences
196
+ scrollOnUserInput: false,
197
+ });
198
+
199
+ this.resizer = new XTermFitAddon();
200
+ this.searcher = new XTermSearchAddon();
201
+ this.renderer = new XTermWebglAddon();
202
+ }
203
+
204
+ setup(configuration?: TerminalSetupConfiguration | undefined): void {
205
+ if (this.setupState.hasCompleted) {
206
+ throw new IllegalStateError(`Terminal is already set up`);
207
+ }
208
+ this.setupState.complete();
209
+
210
+ // Handling context loss: The browser may drop WebGL contexts for various reasons like OOM or after the system has been suspended.
211
+ // An easy, but suboptimal way, to handle this is by disposing of WebglAddon when the `webglcontextlost` event fires
212
+ // NOTE: we don't really have a resilient way to fallback right now, hopefully, the fallback is to render in DOM
213
+ this.renderer.onContextLoss(() => {
214
+ this.renderer.dispose();
215
+ });
216
+ this.instance.loadAddon(this.resizer);
217
+ this.instance.loadAddon(this.searcher);
218
+ this.instance.loadAddon(this.renderer);
219
+
220
+ this.instance.loadAddon(new XTermUnicode11Addon());
221
+ this.instance.unicode.activeVersion = '11';
222
+
223
+ // NOTE: since we render the terminal using webgl/canvas, event is not bubbled
224
+ // naturally through the DOM tree, we have to manually force this
225
+ this.instance.attachCustomKeyEventHandler(
226
+ (event: KeyboardEvent): boolean => {
227
+ // NOTE: this is a cheap way to handle hotkey, but this is really the only
228
+ // hotkey we want to support at local scope of the terminal
229
+ // also, since here we have prevent default and stop propagation, we have to do
230
+ // this here instead at in `onKey` handler
231
+ if (
232
+ isMatchingKeyCombination(event, 'Control+KeyF') ||
233
+ isMatchingKeyCombination(event, 'Meta+KeyF')
234
+ ) {
235
+ // prevent default so as to not trigger browser platform search command
236
+ event.preventDefault();
237
+ event.stopPropagation();
238
+ this.searchConfig.focus();
239
+ return false;
240
+ }
241
+ return true; // return true to indicate the event should still be handled by xterm
242
+ },
243
+ );
244
+
245
+ this.webLinkProvider = configuration?.webLinkProvider
246
+ ? new XTermWebLinksAddon(configuration.webLinkProvider.handler, {
247
+ urlRegex: configuration.webLinkProvider.regex,
248
+ })
249
+ : new XTermWebLinksAddon();
250
+ this.instance.loadAddon(this.webLinkProvider);
251
+
252
+ (configuration?.commands ?? []).forEach((commandConfig) => {
253
+ [commandConfig.command, ...(commandConfig.aliases ?? [])].forEach(
254
+ (command) => {
255
+ if (!this.commandRegistry.has(command)) {
256
+ this.commandRegistry.set(command, commandConfig);
257
+ } else {
258
+ this.applicationStore.log.warn(
259
+ LogEvent.create(
260
+ APPLICATION_EVENT.APPLICATION_TERMINAL_COMMAND_CONFIGURATION_CHECK_FAILURE,
261
+ ),
262
+ `Found multiple duplicated terminal commands '${command}'`,
263
+ );
264
+ }
265
+ },
266
+ );
267
+ });
268
+
269
+ this.searcher.onDidChangeResults((result) => {
270
+ if (result) {
271
+ this.setSearchResultCount(result.resultCount);
272
+ this.setSearchCurrentResultIndex(result.resultIndex);
273
+ } else {
274
+ this.setSearchResultCount(undefined);
275
+ this.setSearchCurrentResultIndex(undefined);
276
+ }
277
+ });
278
+
279
+ // NOTE: `xterm` expects to be attached to a proper terminal program which handles
280
+ // input, since we can't do that yet, we implement a fairly basic input handling flow
281
+ // See https://github.com/xtermjs/xterm.js/issues/617#issuecomment-288849502
282
+ this._TEMPORARY__onKeyListener = this.instance.onKey(
283
+ ({ key, domEvent }) => {
284
+ // take care of command history navigation
285
+ if (domEvent.code === 'ArrowUp') {
286
+ this.setCommandFromHistory(
287
+ this.commandHistoryNavigationIdx !== undefined
288
+ ? this.commandHistoryNavigationIdx + 1
289
+ : 0,
290
+ );
291
+ return;
292
+ // reset current command in place
293
+ } else if (domEvent.code === 'ArrowDown') {
294
+ if (this.commandHistoryNavigationIdx !== undefined) {
295
+ this.setCommandFromHistory(
296
+ this.commandHistoryNavigationIdx === 0
297
+ ? undefined
298
+ : this.commandHistoryNavigationIdx - 1,
299
+ );
300
+ }
301
+ return;
302
+ } else {
303
+ // reset navigation history the moment any other key is pressed
304
+ this.commandHistoryNavigationIdx = undefined;
305
+ }
306
+
307
+ if (domEvent.code === 'Enter') {
308
+ // run command
309
+ if (this.command.trim()) {
310
+ const text = this.command;
311
+ const [command, ...args] = text.replaceAll(/\s+/g, ' ').split(' ');
312
+ this.addCommandToHistory(this.command);
313
+ if (!command) {
314
+ return;
315
+ }
316
+ const matchingCommand = this.commandRegistry.get(command);
317
+ if (!matchingCommand) {
318
+ this.fail(`command not found: ${command}`);
319
+ return;
320
+ }
321
+ if (this.isRunningCommand) {
322
+ return;
323
+ }
324
+ this.isRunningCommand = true;
325
+ matchingCommand
326
+ .handler(
327
+ args.map((arg) => arg.trim()),
328
+ command,
329
+ text,
330
+ )
331
+ .finally(() => {
332
+ this.isRunningCommand = false;
333
+ if (!this.isFlushed) {
334
+ this.abort();
335
+ }
336
+ });
337
+ }
338
+ } else if (
339
+ isMatchingKeyCombination(domEvent, 'Control+KeyC') ||
340
+ isMatchingKeyCombination(domEvent, 'Control+KeyD')
341
+ ) {
342
+ // abort command
343
+ this.abort();
344
+ } else if (domEvent.code === 'Backspace') {
345
+ // Alt: jump word only, Ctrl: jump to end
346
+ // this would apply for Delete, ArrowLeft, ArrowRight
347
+ this.deleteFromCommand(
348
+ domEvent.altKey || domEvent.ctrlKey
349
+ ? this.computeCursorJumpMovement(true)
350
+ : -1,
351
+ );
352
+ } else if (domEvent.code === 'Delete') {
353
+ this.deleteFromCommand(
354
+ domEvent.altKey || domEvent.ctrlKey
355
+ ? this.computeCursorJumpMovement(false)
356
+ : 1,
357
+ );
358
+ } else if (domEvent.code === 'ArrowLeft') {
359
+ const movement = this.computeCursorMovement(
360
+ domEvent.altKey || domEvent.ctrlKey
361
+ ? this.computeCursorJumpMovement(true)
362
+ : -1,
363
+ );
364
+ // console.log('left', movement);
365
+ this.instance.scrollLines(movement.scroll);
366
+ this.instance.write(movement.seq);
367
+ } else if (domEvent.code === 'ArrowRight') {
368
+ const movement = this.computeCursorMovement(
369
+ domEvent.altKey || domEvent.ctrlKey
370
+ ? this.computeCursorJumpMovement(false)
371
+ : 1,
372
+ );
373
+ // console.log('right', movement);
374
+ this.instance.scrollLines(movement.scroll);
375
+ this.instance.write(movement.seq);
376
+ } else if (
377
+ // use key here so we absolute do not allow any characters other than these
378
+ // being added to the input command
379
+ key.match(/^[A-Za-z0-9!@#$%^&*()\-_=+"':;,.<>/?[\]{}|\\~` ]$/)
380
+ ) {
381
+ // commonly supported keys
382
+ this.writeToCommand(key);
383
+ } else {
384
+ // for the rest, allow the keyboard event to be bubbled to
385
+ // application keyboard shortcuts handler
386
+ forceDispatchKeyboardEvent(domEvent);
387
+ }
388
+ },
389
+ );
390
+ // this is needed to support copy-pasting
391
+ this._TEMPORARY__onDataListener = this.instance.onData((val) => {
392
+ // only support pasting (not meant for 1 character though) and special functions starting with special
393
+ // ANSI escape sequence
394
+ if (val.length > 1 && !val.startsWith('\x1b')) {
395
+ this.writeToCommand(
396
+ val
397
+ // remove all unsupported characters, including newline
398
+ .replaceAll(/[^A-Za-z0-9!@#$%^&*()\-_=+"':;,.<>/?[\]{}|\\~` ]/g, '')
399
+ .trimEnd(),
400
+ );
401
+ }
402
+ });
403
+ }
404
+
405
+ // NOTE: this is fairly HACKY way to detect command
406
+ // we don't really have a better solution at the moment,
407
+ // but we should come with more systematic way of persisting the start line of command
408
+ // the challenge with this is due to text-reflow
409
+ //
410
+ // there is also a quriky known issue with text-reflow and the line with cursor
411
+ // See https://github.com/xtermjs/xterm.js/issues/1941#issuecomment-463660633
412
+ private getCommandRange(): {
413
+ // NOTE: all of these are absolute index in the buffer, not relative to the viewport
414
+ startY: number;
415
+ startX: number;
416
+ endY: number;
417
+ endX: number;
418
+ cursorIdx: number;
419
+ } {
420
+ const buffer = this.instance.buffer.active;
421
+ const cols = this.instance.cols;
422
+ const commandText = `${COMMAND_START}${this.command}`;
423
+ const commandFirstLine = `${COMMAND_START}${this.command.substring(
424
+ 0,
425
+ cols - COMMAND_START.length,
426
+ )}${
427
+ this.command.length < cols - COMMAND_START.length
428
+ ? ' '.repeat(cols - this.command.length - COMMAND_START.length)
429
+ : ''
430
+ }`;
431
+
432
+ let startY = 0;
433
+ let cursorIdx = 0;
434
+
435
+ for (let i = buffer.baseY + buffer.cursorY; i > -1; --i) {
436
+ const line = guaranteeNonNullable(buffer.getLine(i));
437
+ const lineText = line.translateToString();
438
+ if (lineText === commandFirstLine) {
439
+ startY = i;
440
+ cursorIdx +=
441
+ (i === buffer.baseY + buffer.cursorY ? buffer.cursorX : cols) -
442
+ COMMAND_START.length;
443
+ break;
444
+ } else {
445
+ cursorIdx +=
446
+ i === buffer.baseY + buffer.cursorY ? buffer.cursorX : cols;
447
+ }
448
+ }
449
+
450
+ // start line == -1 is the rare case where the command is too long and exceeds the buffer length
451
+ // leading to incomplete command being captured
452
+ return {
453
+ startY,
454
+ startX: COMMAND_START.length,
455
+ endY: startY + (commandText.length - (commandText.length % cols)) / cols,
456
+ endX: commandText.length % cols,
457
+ cursorIdx,
458
+ };
459
+ }
460
+
461
+ private computeCursorJumpMovement(back: boolean): number {
462
+ const range = this.getCommandRange();
463
+
464
+ let distance: number | undefined = undefined;
465
+ let foundWord = false;
466
+
467
+ // scan for the boundary of the closest word to the cursor position
468
+ if (back) {
469
+ for (let i = range.cursorIdx - 1; i > -1; --i) {
470
+ const char = this.command.charAt(i);
471
+ if (char.match(/\w/)) {
472
+ if (!foundWord) {
473
+ foundWord = true;
474
+ }
475
+ } else {
476
+ if (foundWord) {
477
+ distance = range.cursorIdx - i - 1;
478
+ break;
479
+ }
480
+ }
481
+ }
482
+ } else {
483
+ for (let i = range.cursorIdx + 1; i < this.command.length; ++i) {
484
+ const char = this.command.charAt(i);
485
+ if (char.match(/\w/)) {
486
+ if (!foundWord) {
487
+ foundWord = true;
488
+ }
489
+ } else {
490
+ if (foundWord) {
491
+ distance = i - range.cursorIdx - 1;
492
+ break;
493
+ }
494
+ }
495
+ }
496
+ }
497
+
498
+ if (distance === undefined) {
499
+ distance = back ? range.cursorIdx : this.command.length - range.cursorIdx;
500
+ }
501
+
502
+ return back ? -distance : distance;
503
+ }
504
+
505
+ /**
506
+ * Generate the ANSI escape sequence for new cursor position
507
+ * after being moved by the the number of cells.
508
+ *
509
+ * @param val a number (negative means cursor move leftwards)
510
+ * @param limit whether to limit the movement of the cursor by the command range
511
+ * @returns cursor movement information including the ANSI escape sequence for new cursor position and scroll distance
512
+ */
513
+ private computeCursorMovement(
514
+ val: number,
515
+ limit = true,
516
+ ): { seq: string; scroll: number } {
517
+ const buffer = this.instance.buffer.active;
518
+ const cols = this.instance.cols;
519
+ const rows = this.instance.rows;
520
+ const range = this.getCommandRange();
521
+
522
+ const maxDistance = limit
523
+ ? val < 0
524
+ ? range.cursorIdx
525
+ : this.command.length - range.cursorIdx
526
+ : val;
527
+ const distance = Math.min(Math.abs(val), maxDistance);
528
+
529
+ let newCursorX = buffer.cursorX;
530
+ let newCursorY = buffer.cursorY;
531
+ let abs_cursorY = buffer.baseY + buffer.cursorY;
532
+
533
+ if (val < 0) {
534
+ // move leftwards
535
+ newCursorX = (cols + ((buffer.cursorX - distance) % cols)) % cols;
536
+ newCursorY =
537
+ buffer.cursorY -
538
+ (distance > buffer.cursorX ? Math.ceil(distance / cols) : 0);
539
+ abs_cursorY = newCursorY + buffer.baseY;
540
+ newCursorY = Math.max(newCursorY, -1);
541
+ } else if (val > 0) {
542
+ // move rightwards
543
+ newCursorX = (buffer.cursorX + distance) % cols;
544
+ newCursorY =
545
+ buffer.cursorY +
546
+ (buffer.cursorX + distance >= cols
547
+ ? Math.floor((buffer.cursorX + distance) / cols)
548
+ : 0);
549
+ abs_cursorY = newCursorY + buffer.baseY;
550
+ newCursorY = Math.min(newCursorY, rows - 1);
551
+ }
552
+
553
+ const scroll =
554
+ abs_cursorY > buffer.viewportY + rows
555
+ ? abs_cursorY - (buffer.viewportY + rows)
556
+ : abs_cursorY < buffer.viewportY
557
+ ? abs_cursorY - buffer.viewportY
558
+ : 0;
559
+
560
+ return {
561
+ // NOTE: currently, there is a design limitation with programmatically set the cursor using escape sequence
562
+ // by design, the scrollback (everything above the viewport/ybase) is readonly, and most terminals work like this.
563
+ // So for very long command that causes an overflow, one cannot set the cursor position pass the `baseY`
564
+ // this will affect both navigation and delete/backspace behavior
565
+ // See https://github.com/xtermjs/xterm.js/issues/4405
566
+ seq: ANSI_moveCursor(newCursorY + 1, newCursorX + 1),
567
+ scroll,
568
+ };
569
+ }
570
+
571
+ /**
572
+ * Write value to command with awareness of the current cursor position
573
+ */
574
+ private writeToCommand(val: string): void {
575
+ const range = this.getCommandRange();
576
+ const left = this.command.slice(0, range.cursorIdx);
577
+ const right = this.command.slice(range.cursorIdx);
578
+ const movement = this.computeCursorMovement(val.length, false);
579
+
580
+ this.instance.scrollLines(movement.scroll);
581
+ this.instance.write(
582
+ val +
583
+ right +
584
+ // update the cursor
585
+ movement.seq,
586
+ );
587
+ this.setCommand(left + val + right);
588
+ }
589
+
590
+ /**
591
+ * Remove number of characters from command with awareness of the current cursor position
592
+ * NOTE: negative number means backward deleting (i.e. backspace)
593
+ */
594
+ private deleteFromCommand(val: number): void {
595
+ // console.log(val);
596
+
597
+ const range = this.getCommandRange();
598
+
599
+ const maxDistance =
600
+ val < 0 ? range.cursorIdx : this.command.length - range.cursorIdx;
601
+ const distance = Math.min(Math.abs(val), maxDistance);
602
+
603
+ let left;
604
+ let right;
605
+ let cursorMovement;
606
+ if (val === 0) {
607
+ return;
608
+ } else if (val < 0) {
609
+ // remove leftwards
610
+ left = this.command.slice(0, range.cursorIdx - distance);
611
+ right = this.command.slice(range.cursorIdx, this.command.length);
612
+ cursorMovement = -distance;
613
+ } else {
614
+ // remove rightwards
615
+ left = this.command.slice(0, range.cursorIdx);
616
+ right = this.command.slice(
617
+ range.cursorIdx + distance,
618
+ this.command.length,
619
+ );
620
+ cursorMovement = 0;
621
+ }
622
+
623
+ const movement = this.computeCursorMovement(cursorMovement);
624
+
625
+ this.instance.scrollLines(movement.scroll);
626
+ this.instance.write(
627
+ // reset cursor to start of command, basically here, we're rewriting the entire command
628
+ ANSI_moveCursor(range.startY + 1, range.startX + 1) +
629
+ left +
630
+ right +
631
+ // fill space to erase cells rendered from previous command
632
+ ' '.repeat(distance) +
633
+ // move the cursor as well
634
+ movement.seq,
635
+ );
636
+ this.setCommand(left + right);
637
+ }
638
+
639
+ get isSetup(): boolean {
640
+ return this.setupState.hasCompleted;
641
+ }
642
+
643
+ isFocused(): boolean {
644
+ return document.activeElement === this.instance.textarea;
645
+ }
646
+
647
+ mount(container: HTMLElement): void {
648
+ if (!this.setupState.hasCompleted) {
649
+ throw new IllegalStateError(`XTerm terminal has not been set up yet`);
650
+ }
651
+
652
+ this.instance.open(container);
653
+ }
654
+
655
+ dispose(): void {
656
+ this.searcher.dispose();
657
+ this.resizer.dispose();
658
+ this.renderer.dispose();
659
+ this.webLinkProvider?.dispose();
660
+ this._TEMPORARY__onKeyListener?.dispose();
661
+ this._TEMPORARY__onDataListener?.dispose();
662
+ this.instance.dispose();
663
+ }
664
+
665
+ autoResize(): void {
666
+ this.resizer.fit();
667
+ }
668
+
669
+ focus(): void {
670
+ this.instance.focus();
671
+ }
672
+
673
+ private addCommandToHistory(val: string): void {
674
+ // if this is the same as previous command, do not push it to the history stack
675
+ if (val === this.commandHistory.at(0)) {
676
+ return;
677
+ }
678
+ // history command is essentially a stack, so we only insert at the beginning
679
+ this.commandHistory.unshift(val);
680
+ }
681
+
682
+ /**
683
+ * This methods help update the current command to a command in history
684
+ * stack, it does the necessary resetting and helps properly update
685
+ * the history navigation index
686
+ */
687
+ private setCommandFromHistory(idx: number | undefined): void {
688
+ const val =
689
+ idx === undefined
690
+ ? this.currentCommandSearchString
691
+ : // NOTE: only consider commands starting with the original command
692
+ // also note that empty string naturaly match all history command
693
+ this.commandHistory
694
+ .filter((command) =>
695
+ command.startsWith(this.currentCommandSearchString),
696
+ )
697
+ .at(idx);
698
+ if (val !== undefined) {
699
+ let range = this.getCommandRange();
700
+ this.instance.write(
701
+ // reset cursor to start of command and rewrite the entire command
702
+ ANSI_moveCursor(range.startY + 1, range.startX + 1) +
703
+ val.padEnd(this.command.length),
704
+ );
705
+ this.command = val;
706
+ range = this.getCommandRange();
707
+ this.instance.write(
708
+ // reset cursor to command end
709
+ ANSI_moveCursor(range.endY + 1, range.endX + 1),
710
+ );
711
+ this.commandHistoryNavigationIdx = idx;
712
+ }
713
+ }
714
+
715
+ private setCommand(val: string): void {
716
+ this.command = val;
717
+ this.currentCommandSearchString = val;
718
+ this.commandHistoryNavigationIdx = undefined;
719
+ }
720
+
721
+ private newCommand(): void {
722
+ this.instance.write(DEFAULT_COMMAND_HEADER);
723
+ this.setCommand('');
724
+ }
725
+
726
+ private newSystemCommand(command: string): void {
727
+ // if another command is already running, we don't need to print the command header anymore
728
+ // the potential pitfall here is that we could have another process prints to the
729
+ // terminal while the command is being run. Nothing much we can do here for now.
730
+ if (!this.isRunningCommand) {
731
+ if (this.command) {
732
+ this.abort();
733
+ this.newCommand();
734
+ }
735
+ this.instance.write(
736
+ `${DISPLAY_ANSI_ESCAPE.DIM}(system: ${command})\n${DISPLAY_ANSI_ESCAPE.RESET}`,
737
+ );
738
+ }
739
+ }
740
+
741
+ /**
742
+ * Flush the terminal screen completely
743
+ *
744
+ * Probably due to write buffer batching, calling `reset` or `clear` on xterm terminal immediately after
745
+ * write commands will not work. To solve this, we can either promisify the `reset` call or write the ANSI
746
+ * reset sequence \x1bc
747
+ */
748
+ private flushScreen(): void {
749
+ this.instance.write('\x1bc');
750
+ this.instance.reset();
751
+ }
752
+
753
+ private get isFlushed(): boolean {
754
+ const buffer = this.instance.buffer.active;
755
+ let isLastLineEmpty = true;
756
+
757
+ for (let i = buffer.baseY + buffer.cursorY; i > -1; --i) {
758
+ const line = guaranteeNonNullable(buffer.getLine(i));
759
+ const lineText = line.translateToString();
760
+
761
+ // skip empty lines
762
+ if (!lineText.trim()) {
763
+ continue;
764
+ } else {
765
+ isLastLineEmpty = lineText !== COMMAND_START;
766
+ break;
767
+ }
768
+ }
769
+
770
+ return this.command === '' && isLastLineEmpty;
771
+ }
772
+
773
+ clear(): void {
774
+ this.flushScreen();
775
+ this.instance.scrollToTop();
776
+ this.newCommand();
777
+ }
778
+
779
+ private resetANSIStyling(): void {
780
+ this.instance.write(DISPLAY_ANSI_ESCAPE.RESET);
781
+ }
782
+
783
+ override showHelp(): void {
784
+ this.resetANSIStyling();
785
+ this.instance.scrollToBottom();
786
+ if (!this.isFlushed && !this.isRunningCommand) {
787
+ this.abort();
788
+ }
789
+ this.instance.write(getHelpCommandContent(this.commandRegistry));
790
+ this.abort();
791
+ }
792
+
793
+ override showCommonANSIEscapeSequences(): void {
794
+ this.resetANSIStyling();
795
+ this.instance.scrollToBottom();
796
+ if (!this.isFlushed && !this.isRunningCommand) {
797
+ this.abort();
798
+ }
799
+ this.instance.write(getCommonANSIEscapeSequencesForStyling());
800
+ this.abort();
801
+ }
802
+
803
+ abort(): void {
804
+ this.resetANSIStyling();
805
+ this.instance.write('\n');
806
+ this.newCommand();
807
+ this.instance.scrollToBottom();
808
+ this.isRunningCommand = false;
809
+ }
810
+
811
+ fail(error: string, opts?: TerminalWriteOption): void {
812
+ if (opts?.systemCommand) {
813
+ this.newSystemCommand(opts.systemCommand);
814
+ }
815
+
816
+ this.instance.write(
817
+ `\n${DISPLAY_ANSI_ESCAPE.RED}${error}${DISPLAY_ANSI_ESCAPE.RED}`,
818
+ );
819
+ this.abort();
820
+ }
821
+
822
+ output(val: string, opts?: TerminalWriteOption): void {
823
+ this.resetANSIStyling();
824
+
825
+ if ((!opts?.clear || this.preserveLog) && opts?.systemCommand) {
826
+ this.newSystemCommand(opts.systemCommand);
827
+ }
828
+
829
+ if (!this.preserveLog && opts?.clear) {
830
+ this.flushScreen();
831
+ } else if (this.preserveLog || this.isRunningCommand) {
832
+ this.instance.write('\n');
833
+ }
834
+
835
+ this.instance.write(val);
836
+
837
+ this.resetANSIStyling();
838
+ this.instance.write('\n');
839
+ this.instance.scrollToBottom();
840
+ this.newCommand();
841
+ }
842
+
843
+ search(val: string): void {
844
+ this.searcher.findNext(val, {
845
+ decorations: LEGEND_XTERM_SEARCH_THEME,
846
+ regex: this.searchConfig.useRegex,
847
+ wholeWord: this.searchConfig.matchWholeWord,
848
+ caseSensitive: this.searchConfig.matchCaseSensitive,
849
+ // do incremental search so that the expansion will be expanded the selection if it
850
+ // still matches the term the user typed.
851
+ incremental: true,
852
+ });
853
+ }
854
+
855
+ clearSearch(): void {
856
+ this.searcher.clearDecorations();
857
+ this.instance.clearSelection();
858
+ this.setSearchText('');
859
+ this.setSearchResultCount(undefined);
860
+ this.setSearchCurrentResultIndex(undefined);
861
+ }
862
+
863
+ findPrevious(): void {
864
+ this.searcher.findPrevious(this.searchConfig.searchText, {
865
+ decorations: LEGEND_XTERM_SEARCH_THEME,
866
+ regex: this.searchConfig.useRegex,
867
+ wholeWord: this.searchConfig.matchWholeWord,
868
+ caseSensitive: this.searchConfig.matchCaseSensitive,
869
+ });
870
+ }
871
+
872
+ findNext(): void {
873
+ this.searcher.findNext(this.searchConfig.searchText, {
874
+ decorations: LEGEND_XTERM_SEARCH_THEME,
875
+ regex: this.searchConfig.useRegex,
876
+ wholeWord: this.searchConfig.matchWholeWord,
877
+ caseSensitive: this.searchConfig.matchCaseSensitive,
878
+ });
879
+ }
880
+ }