diff --git a/src/vs/base/common/strings.ts b/src/vs/base/common/strings.ts index 7e354e3bce5..194faf1134a 100644 --- a/src/vs/base/common/strings.ts +++ b/src/vs/base/common/strings.ts @@ -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; diff --git a/src/vs/platform/terminal/common/capabilities/capabilities.ts b/src/vs/platform/terminal/common/capabilities/capabilities.ts index 1040c7dbe05..ca4751849df 100644 --- a/src/vs/platform/terminal/common/capabilities/capabilities.ts +++ b/src/vs/platform/terminal/common/capabilities/capabilities.ts @@ -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; diff --git a/src/vs/platform/terminal/common/capabilities/commandDetectionCapability.ts b/src/vs/platform/terminal/common/capabilities/commandDetectionCapability.ts index cece45b95d0..b95e4d4e729 100644 --- a/src/vs/platform/terminal/common/capabilities/commandDetectionCapability.ts +++ b/src/vs/platform/terminal/common/capabilities/commandDetectionCapability.ts @@ -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, diff --git a/src/vs/workbench/contrib/terminalContrib/stickyScroll/browser/terminalStickyScrollOverlay.ts b/src/vs/workbench/contrib/terminalContrib/stickyScroll/browser/terminalStickyScrollOverlay.ts index 843730790bd..614469db200 100644 --- a/src/vs/workbench/contrib/terminalContrib/stickyScroll/browser/terminalStickyScrollOverlay.ts +++ b/src/vs/workbench/contrib/terminalContrib/stickyScroll/browser/terminalStickyScrollOverlay.ts @@ -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 diff --git a/test/smoke/src/areas/terminal/terminal-stickyScroll.test.ts b/test/smoke/src/areas/terminal/terminal-stickyScroll.test.ts index 5e247ab9608..622bc060ef4 100644 --- a/test/smoke/src/areas/terminal/terminal-stickyScroll.test.ts +++ b/test/smoke/src/areas/terminal/terminal-stickyScroll.test.ts @@ -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 { + 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 { - 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}`); }