diff --git a/src/vs/workbench/contrib/terminalContrib/stickyScroll/browser/terminalStickyScrollOverlay.ts b/src/vs/workbench/contrib/terminalContrib/stickyScroll/browser/terminalStickyScrollOverlay.ts index 01ce5480db2bbb..3e2d08a9a251c2 100644 --- a/src/vs/workbench/contrib/terminalContrib/stickyScroll/browser/terminalStickyScrollOverlay.ts +++ b/src/vs/workbench/contrib/terminalContrib/stickyScroll/browser/terminalStickyScrollOverlay.ts @@ -279,7 +279,6 @@ export class TerminalStickyScrollOverlay extends Disposable { const buffer = xterm.buffer.active; const promptRowCount = command.getPromptRowCount(); const commandRowCount = command.getCommandRowCount(); - const stickyScrollLineStart = startMarker.line - (promptRowCount - 1); // Calculate the row offset, this is the number of rows that will be clipped from the top // of the sticky overlay because we do not want to show any content above the bounds of the @@ -288,11 +287,11 @@ export class TerminalStickyScrollOverlay extends Disposable { const isPartialCommand = !isFullTerminalCommand(command); const rowOffset = !isPartialCommand && command.endMarker ? Math.max(buffer.viewportY - command.endMarker.line + 1, 0) : 0; const maxLineCount = Math.min(this._rawMaxLineCount, Math.floor(xterm.rows * Constants.StickyScrollPercentageCap)); - const stickyScrollLineCount = Math.min(promptRowCount + commandRowCount - 1, maxLineCount) - rowOffset; - const isTruncated = stickyScrollLineCount < promptRowCount + commandRowCount - 1; + const { lineStart: stickyScrollLineStart, lineCount: stickyScrollLineCount, isTruncated } = getStickyScrollLayout(startMarker.line, promptRowCount, commandRowCount, maxLineCount, rowOffset); - // Hide sticky scroll if it's currently on a line that contains it - if (buffer.viewportY <= stickyScrollLineStart) { + // Hide sticky scroll if there is nothing to show or if it's currently on a line that + // contains it + if (stickyScrollLineCount <= 0 || buffer.viewportY <= stickyScrollLineStart) { this._setVisible(false); return; } @@ -554,3 +553,39 @@ function lineStartsWith(line: IBufferLine | undefined, text: string): boolean { } return true; } + +/** + * Determines what part of a command's prompt and command line to show in the sticky scroll overlay. + * + * The command line is prioritized over the prompt, only including the prompt when the prompt and + * the command line both fit within `maxLineCount`. This matters because the rows above the command + * line are not necessarily prompt rows, for example output printed in between the previous command + * finishing and the prompt being drawn (job control notifications, background processes, etc.) is + * considered part of the prompt. Including those rows regardless would push the command line out of + * the overlay, showing only output and hiding the command the output belongs to. + * + * @param commandStartLine The buffer line the command line starts on. + * @param promptRowCount The number of rows the prompt spans, including the command start row. + * @param commandRowCount The number of rows the command line spans, including the command start row. + * @param maxLineCount The maximum number of rows the overlay is allowed to show. + * @param rowOffset The number of rows to clip from the top of the overlay. A `lineCount` of 0 means + * the row offset clipped all rows and there is nothing to show. + */ +export function getStickyScrollLayout( + commandStartLine: number, + promptRowCount: number, + commandRowCount: number, + maxLineCount: number, + rowOffset: number +): { lineStart: number; lineCount: number; isTruncated: boolean } { + // The prompt and the command line share the command start row + const promptAndCommandRowCount = promptRowCount + commandRowCount - 1; + const includePrompt = promptAndCommandRowCount <= maxLineCount; + const contentRowCount = includePrompt ? promptAndCommandRowCount : commandRowCount; + const lineCount = Math.max(Math.min(contentRowCount, maxLineCount) - rowOffset, 0); + return { + lineStart: includePrompt ? commandStartLine - (promptRowCount - 1) : commandStartLine, + lineCount, + isTruncated: lineCount < contentRowCount + }; +} diff --git a/src/vs/workbench/contrib/terminalContrib/stickyScroll/test/browser/terminalStickyScrollOverlay.test.ts b/src/vs/workbench/contrib/terminalContrib/stickyScroll/test/browser/terminalStickyScrollOverlay.test.ts new file mode 100644 index 00000000000000..7ade430a010041 --- /dev/null +++ b/src/vs/workbench/contrib/terminalContrib/stickyScroll/test/browser/terminalStickyScrollOverlay.test.ts @@ -0,0 +1,84 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { Terminal } from '@xterm/xterm'; +import { deepStrictEqual } from 'assert'; +import { importAMDNodeModule } from '../../../../../../amdX.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { CommandDetectionCapability } from '../../../../../../platform/terminal/common/capabilities/commandDetectionCapability.js'; +import { TestXtermLogger } from '../../../../../../platform/terminal/test/common/terminalTestHelpers.js'; +import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; +import { writeP } from '../../../../terminal/browser/terminalTestHelpers.js'; +import { getStickyScrollLayout } from '../../browser/terminalStickyScrollOverlay.js'; + +suite('TerminalStickyScrollOverlay', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + suite('getStickyScrollLayout', () => { + test('should show the prompt and the command line when they fit', () => { + deepStrictEqual(getStickyScrollLayout(10, 2, 1, 5, 0), { lineStart: 9, lineCount: 2, isTruncated: false }); + }); + + test('should truncate the command line when it does not fit', () => { + deepStrictEqual(getStickyScrollLayout(10, 1, 8, 5, 0), { lineStart: 10, lineCount: 5, isTruncated: true }); + }); + + test('should drop the prompt when the prompt and the command line do not fit', () => { + deepStrictEqual(getStickyScrollLayout(10, 6, 1, 5, 0), { lineStart: 10, lineCount: 1, isTruncated: false }); + }); + + test('should prefer the command line rows over the prompt rows', () => { + deepStrictEqual(getStickyScrollLayout(10, 2, 5, 5, 0), { lineStart: 10, lineCount: 5, isTruncated: false }); + }); + + test('should clip rows from the top of the overlay for the row offset', () => { + deepStrictEqual(getStickyScrollLayout(10, 3, 1, 5, 1), { lineStart: 8, lineCount: 2, isTruncated: true }); + }); + + test('should show nothing when the row offset clips all rows', () => { + deepStrictEqual(getStickyScrollLayout(10, 6, 1, 5, 1), { lineStart: 10, lineCount: 0, isTruncated: true }); + }); + }); + + suite('command detection', () => { + let xterm: Terminal; + let capability: CommandDetectionCapability; + + setup(async () => { + const TerminalCtor = (await importAMDNodeModule('@xterm/xterm', 'lib/xterm.js')).Terminal; + xterm = store.add(new TerminalCtor({ allowProposedApi: true, cols: 80, rows: 30, logger: TestXtermLogger })); + const instantiationService = workbenchInstantiationService(undefined, store); + capability = store.add(instantiationService.createInstance(CommandDetectionCapability, xterm)); + }); + + test('should show the command line when output is printed after the previous command finished', async () => { + // A command finishes and more output is printed before the prompt is drawn. The prompt + // start is reported at the previous command's end, so that output is treated as part + // of the prompt of the following command. + capability.handlePromptStart(); + await writeP(xterm, '\r$ '); + capability.handleCommandStart(); + await writeP(xterm, 'npm install'); + capability.handleCommandExecuted(); + await writeP(xterm, '\r\nadded 100 packages\r\n'); + capability.handleCommandFinished(0); + await writeP(xterm, 'output printed after the command finished\r\n'.repeat(5)); + + capability.handlePromptStart(); + await writeP(xterm, '$ '); + capability.handleCommandStart(); + await writeP(xterm, './scripts/code.sh'); + capability.handleCommandExecuted(); + + const command = capability.currentCommand!; + const commandStartLine = command.commandStartMarker!.line; + const promptRowCount = command.getPromptRowCount(); + deepStrictEqual( + { promptRowCount, ...getStickyScrollLayout(commandStartLine, promptRowCount, command.getCommandRowCount(), 5, 0) }, + { promptRowCount: 6, lineStart: commandStartLine, lineCount: 1, isTruncated: false } + ); + }); + }); +});