Merge pull request #197835 from microsoft/tyriar/197735

Implement terminal multi-line prompt stick scroll
This commit is contained in:
Daniel Imms
2023-11-09 09:49:22 -08:00
committed by GitHub
5 changed files with 94 additions and 27 deletions
+17
View File
@@ -167,6 +167,23 @@ export function stripWildcards(pattern: string): string {
return pattern.replace(/\*/g, '');
}
/**
* Finds the index of the nth occurrence of a character within a string.
* @param text The text to search.
* @param char The character to search for.
* @param n The number of chars to find
*/
export function findNthOccurrenceIndex(text: string, char: string, n: number): number {
let index = -1;
for (let i = 0; i < n; i++) {
index = text.indexOf(char, index + 1);
if (index === -1) {
break;
}
}
return index;
}
export interface RegExpOptions {
matchCase?: boolean;
wholeWord?: boolean;
@@ -252,6 +252,7 @@ interface IBaseTerminalCommand {
export interface ITerminalCommand extends IBaseTerminalCommand {
// Optional non-serializable
promptStartMarker?: IMarker;
marker?: IXtermMarker;
endMarker?: IXtermMarker;
executedMarker?: IXtermMarker;
@@ -266,6 +267,7 @@ export interface ITerminalCommand extends IBaseTerminalCommand {
export interface ISerializedTerminalCommand extends IBaseTerminalCommand {
// Optional non-serializable converted for serialization
startLine: number | undefined;
promptStartLine: number | undefined;
startX: number | undefined;
endLine: number | undefined;
executedLine: number | undefined;
@@ -611,6 +611,7 @@ export class CommandDetectionCapability extends Disposable implements ICommandDe
const newCommand: ITerminalCommand = {
command: this._handleCommandStartOptions?.ignoreCommandLine ? '' : (command || ''),
isTrusted: !!this._currentCommand.isTrusted,
promptStartMarker: this._currentCommand.promptStartMarker,
marker: this._currentCommand.commandStartMarker,
endMarker,
executedMarker,
@@ -683,6 +684,7 @@ export class CommandDetectionCapability extends Disposable implements ICommandDe
serialize(): ISerializedCommandDetectionCapability {
const commands: ISerializedTerminalCommand[] = this.commands.map(e => {
return {
promptStartLine: e.promptStartMarker?.line,
startLine: e.marker?.line,
startX: undefined,
endLine: e.endMarker?.line,
@@ -699,6 +701,7 @@ export class CommandDetectionCapability extends Disposable implements ICommandDe
});
if (this._currentCommand.commandStartMarker) {
commands.push({
promptStartLine: this._currentCommand.promptStartMarker?.line,
startLine: this._currentCommand.commandStartMarker.line,
startX: this._currentCommand.commandStartX,
endLine: undefined,
@@ -729,10 +732,14 @@ export class CommandDetectionCapability extends Disposable implements ICommandDe
if (!marker) {
continue;
}
const promptStartMarker = e.promptStartLine !== undefined ? this._terminal.registerMarker(e.promptStartLine - (buffer.baseY + buffer.cursorY)) : undefined;
// Partial command
if (!e.endLine) {
this._currentCommand.commandStartMarker = marker;
this._currentCommand.commandStartX = e.startX;
if (promptStartMarker) {
this._currentCommand.promptStartMarker = promptStartMarker;
}
this._cwd = e.cwd;
this._onCommandStarted.fire({ marker } as ITerminalCommand);
continue;
@@ -743,6 +750,7 @@ export class CommandDetectionCapability extends Disposable implements ICommandDe
const newCommand: ITerminalCommand = {
command: this.__isCommandStorageDisabled ? '' : e.command,
isTrusted: e.isTrusted,
promptStartMarker,
marker,
endMarker,
executedMarker,
@@ -11,6 +11,7 @@ import { CancelablePromise, createCancelablePromise } from 'vs/base/common/async
import { memoize, throttle } from 'vs/base/common/decorators';
import { Event } from 'vs/base/common/event';
import { Disposable, MutableDisposable, combinedDisposable, toDisposable } from 'vs/base/common/lifecycle';
import { findNthOccurrenceIndex } from 'vs/base/common/strings';
import 'vs/css!./media/stickyScroll';
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
import { ICommandDetectionCapability } from 'vs/platform/terminal/common/capabilities/capabilities';
@@ -162,19 +163,33 @@ export class TerminalStickyScrollOverlay extends Disposable {
return;
}
// TODO: Support multi-line prompts
// TODO: Support multi-line commands
// Determine prompt length
let promptRowCount = 1;
let promptStartLine = marker.line;
if (command.promptStartMarker) {
promptStartLine = Math.min(command.promptStartMarker?.line ?? marker.line, marker.line);
// Trim any leading whitespace-only lines to retain vertical space
while (promptStartLine < marker.line && (this._xterm.raw.buffer.active.getLine(promptStartLine)?.translateToString(true) ?? '').length === 0) {
promptStartLine++;
}
promptRowCount = marker.line - promptStartLine + 1;
}
// Clear attrs, reset cursor position, clear right
// TODO: Serializing all content up to the required line is inefficient; support providing single line/range serialize addon
const s = this._serializeAddon.serialize({
scrollback: this._xterm.raw.buffer.active.baseY - marker.line
scrollback: this._xterm.raw.buffer.active.baseY - promptStartLine
});
// Write content if it differs
const content = s ? s.substring(0, s.indexOf('\r')) : undefined;
const content = s ? s.substring(0, findNthOccurrenceIndex(s, '\r', promptRowCount)) : undefined;
if (content && this._currentContent !== content) {
this._stickyScrollOverlay.write('\x1b[0m\x1b[H\x1b[K');
if (this._stickyScrollOverlay.rows !== promptRowCount) {
this._stickyScrollOverlay.resize(this._stickyScrollOverlay.cols, promptRowCount);
}
this._stickyScrollOverlay.write('\x1b[0m\x1b[H\x1b[2K');
this._stickyScrollOverlay.write(content);
this._currentContent = content;
// Debug log to show the command
@@ -25,41 +25,66 @@ export function setup() {
await settingsEditor.clearUserSettings();
});
it('should show sticky scroll when appropriate', async () => {
// A polling approach is used to avoid test flakiness. While it's not ideal that this
// occurs, the main purpose of the tests is to verify sticky scroll shows and updates,
// not edge case race conditions on terminal start up
async function checkCommandAndOutput(
command: string,
exitCode: number,
prompt: string = 'Prompt> ',
expectedLineCount: number = 1
): Promise<void> {
const data = generateCommandAndOutput(prompt, command, exitCode);
await terminal.runCommandWithValue(TerminalCommandIdWithValue.WriteDataToTerminal, data);
// Verify line count
await app.code.waitForElements('.terminal-sticky-scroll .xterm-rows > *', true, e => e.length === expectedLineCount);
// Verify content
const element = await app.code.getElement('.terminal-sticky-scroll .xterm-rows');
if (
element &&
// New lines don't come through in textContent
element.textContent.indexOf(`${prompt.replace(/\\r\\n/g, '')}${command}`) >= 0
) {
return;
}
throw new Error(`Failed for command ${command}, exitcode ${exitCode}, text content ${element?.textContent}`);
}
beforeEach(async () => {
// Create the simplest system profile to get as little process interaction as possible
await terminal.createEmptyTerminal();
});
function generateCommandAndOutput(command: string, exitCode: number): string {
return [
`${vsc('A')}Prompt> ${vsc('B')}${command}`,
`\\r\\n${vsc('C')}`,
`\\r\\ndata`.repeat(50),
`\\r\\n${vsc(`D;${exitCode}`)}`,
].join('');
}
// A polling approach is used to avoid test flakiness. While it's not ideal that this
// occurs, the main purpose of the tests is to verify sticky scroll shows and updates,
// not edge case race conditions on terminal start up
async function checkCommandAndOutput(command: string, exitCode: number): Promise<void> {
const data = generateCommandAndOutput(command, exitCode);
await terminal.runCommandWithValue(TerminalCommandIdWithValue.WriteDataToTerminal, data);
const element = await app.code.getElement('.terminal-sticky-scroll .xterm-rows');
if (element && element.textContent.indexOf(`Prompt> ${command}`) >= 0) {
return;
}
throw new Error(`Failed for command ${command}, exitcode ${exitCode}, text content ${element?.textContent}`);
}
it('should show sticky scroll when appropriate', async () => {
// Write prompt, fill viewport, finish command, print new prompt, verify sticky scroll
await checkCommandAndOutput('sticky scroll 1', 0);
// And again with a failed command
await checkCommandAndOutput('sticky scroll 2', 1);
});
it('should support multi-line prompt', async () => {
// Standard multi-line prompt
await checkCommandAndOutput('sticky scroll 1', 0, "Multi-line\\r\\nPrompt> ", 2);
// New line before prompt
await checkCommandAndOutput('sticky scroll 2', 0, "\\r\\nMulti-line Prompt> ", 1);
// New line before multi-line prompt
await checkCommandAndOutput('sticky scroll 3', 0, "\\r\\nMulti-line\\r\\nPrompt> ", 2);
});
});
}
function generateCommandAndOutput(prompt: string, command: string, exitCode: number): string {
return [
`${vsc('A')}${prompt}${vsc('B')}${command}`,
`\\r\\n${vsc('C')}`,
`\\r\\ndata`.repeat(50),
`\\r\\n${vsc(`D;${exitCode}`)}`,
].join('');
}
function vsc(data: string) {
return setTextParams(`633;${data}`);
}