diff --git a/src/vs/platform/terminal/common/capabilities/commandDetectionCapability.ts b/src/vs/platform/terminal/common/capabilities/commandDetectionCapability.ts index b95e4d4e729..6463fb9d997 100644 --- a/src/vs/platform/terminal/common/capabilities/commandDetectionCapability.ts +++ b/src/vs/platform/terminal/common/capabilities/commandDetectionCapability.ts @@ -886,6 +886,22 @@ export function getLinesForCommand(buffer: IBuffer, command: ITerminalCommand, c return lines; } +export function getPromptRowCount(command: ITerminalCommand, buffer: IBuffer): number { + if (!command.marker) { + return 1; + } + let promptRowCount = 1; + let promptStartLine = command.marker.line; + if (command.promptStartMarker) { + promptStartLine = Math.min(command.promptStartMarker?.line ?? command.marker.line, command.marker.line); + // Trim any leading whitespace-only lines to retain vertical space + while (promptStartLine < command.marker.line && (buffer.getLine(promptStartLine)?.translateToString(true) ?? '').length === 0) { + promptStartLine++; + } + promptRowCount = command.marker.line - promptStartLine + 1; + } + return promptRowCount; +} function getXtermLineContent(buffer: IBuffer, lineStart: number, lineEnd: number, cols: number): string { // Cap the maximum number of lines generated to prevent potential performance problems. This is diff --git a/src/vs/workbench/contrib/terminal/browser/media/terminal.css b/src/vs/workbench/contrib/terminal/browser/media/terminal.css index 5c30094e8b9..7c6db4be560 100644 --- a/src/vs/workbench/contrib/terminal/browser/media/terminal.css +++ b/src/vs/workbench/contrib/terminal/browser/media/terminal.css @@ -502,11 +502,19 @@ } .terminal-scroll-highlight-outline { - border: 1px solid #ffffff; + border-left: 1px solid #ffffff; + border-right: 1px solid #ffffff; pointer-events: none; } +.terminal-scroll-highlight-outline.top { + border-top: 1px solid #ffffff; +} +.terminal-scroll-highlight-outline.bottom { + border-bottom: 1px solid #ffffff; +} -.terminal-scroll-highlight { +.terminal-scroll-highlight, +.terminal-scroll-highlight.terminal-scroll-highlight-outline { border-color: var(--vscode-focusBorder); } .hc-black .xterm-find-result-decoration, diff --git a/src/vs/workbench/contrib/terminal/browser/terminal.ts b/src/vs/workbench/contrib/terminal/browser/terminal.ts index 31fe58e9eac..48c3cb8c340 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminal.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminal.ts @@ -110,6 +110,7 @@ export interface IMarkTracker { scrollToClosestMarker(startMarkerId: string, endMarkerId?: string, highlight?: boolean | undefined): void; scrollToLine(line: number, position: ScrollPosition): void; + revealCommand(command: ITerminalCommand, position?: ScrollPosition): void; registerTemporaryDecoration(marker: IMarker, endMarker?: IMarker): void; } diff --git a/src/vs/workbench/contrib/terminal/browser/xterm/markNavigationAddon.ts b/src/vs/workbench/contrib/terminal/browser/xterm/markNavigationAddon.ts index e03f4b4829b..32eacd9dc73 100644 --- a/src/vs/workbench/contrib/terminal/browser/xterm/markNavigationAddon.ts +++ b/src/vs/workbench/contrib/terminal/browser/xterm/markNavigationAddon.ts @@ -6,12 +6,13 @@ import { coalesce } from 'vs/base/common/arrays'; import { Disposable, dispose } from 'vs/base/common/lifecycle'; import { IMarkTracker } from 'vs/workbench/contrib/terminal/browser/terminal'; -import { ITerminalCapabilityStore, TerminalCapability } from 'vs/platform/terminal/common/capabilities/capabilities'; +import { ITerminalCapabilityStore, ITerminalCommand, TerminalCapability } from 'vs/platform/terminal/common/capabilities/capabilities'; import type { Terminal, IMarker, ITerminalAddon, IDecoration } from '@xterm/xterm'; import { timeout } from 'vs/base/common/async'; import { IThemeService } from 'vs/platform/theme/common/themeService'; import { TERMINAL_OVERVIEW_RULER_CURSOR_FOREGROUND_COLOR } from 'vs/workbench/contrib/terminal/common/terminalColorRegistry'; import { getWindow } from 'vs/base/browser/dom'; +import { getPromptRowCount } from 'vs/platform/terminal/common/capabilities/commandDetectionCapability'; enum Boundary { Top, @@ -67,6 +68,14 @@ export class MarkNavigationAddon extends Disposable implements IMarkTracker, ITe return markers; } + private _findCommand(marker: IMarker): ITerminalCommand | undefined { + const commandCapability = this._capabilities.get(TerminalCapability.CommandDetection); + if (commandCapability) { + return commandCapability.commands.find(e => e.marker?.line === marker.line); + } + return undefined; + } + clearMarker(): void { // Clear the current marker so successive focus/selection actions are performed from the // bottom of the buffer @@ -137,7 +146,7 @@ export class MarkNavigationAddon extends Disposable implements IMarkTracker, ITe } this._currentMarker = this._getMarkers(skipEmptyCommands)[markerIndex]; - this._scrollToMarker(this._currentMarker, scrollPosition); + this._scrollToCommand(this._currentMarker, scrollPosition); } scrollToNextMark(scrollPosition: ScrollPosition = ScrollPosition.Middle, retainSelection: boolean = false, skipEmptyCommands: boolean = true): void { @@ -183,43 +192,65 @@ export class MarkNavigationAddon extends Disposable implements IMarkTracker, ITe } this._currentMarker = this._getMarkers(skipEmptyCommands)[markerIndex]; - this._scrollToMarker(this._currentMarker, scrollPosition); + this._scrollToCommand(this._currentMarker, scrollPosition); } - private _scrollToMarker(marker: IMarker, position: ScrollPosition, endMarker?: IMarker, hideDecoration?: boolean): void { + private _scrollToCommand(marker: IMarker, position: ScrollPosition): void { + const command = this._findCommand(marker); + if (command) { + this.revealCommand(command, position); + } else { + this._scrollToMarker(marker, position); + } + } + + private _scrollToMarker(start: IMarker | number, position: ScrollPosition, end?: IMarker | number, hideDecoration?: boolean): void { if (!this._terminal) { return; } - if (!this._isMarkerInViewport(this._terminal, marker)) { - const line = this.getTargetScrollLine(marker.line, position); + if (!this._isMarkerInViewport(this._terminal, start)) { + const line = this.getTargetScrollLine(toLineIndex(start), position); this._terminal.scrollToLine(line); } if (!hideDecoration) { - this.registerTemporaryDecoration(marker, endMarker); + this.registerTemporaryDecoration(start, end); } } - private _createMarkerForOffset(marker: IMarker, offset: number): IMarker { - if (offset === 0) { + private _createMarkerForOffset(marker: IMarker | number, offset: number): IMarker { + if (offset === 0 && isMarker(marker)) { return marker; } else { - const offsetMarker = this._terminal?.registerMarker(-this._terminal.buffer.active.cursorY + marker.line - this._terminal.buffer.active.baseY + offset); + const offsetMarker = this._terminal?.registerMarker(-this._terminal.buffer.active.cursorY + toLineIndex(marker) - this._terminal.buffer.active.baseY + offset); if (offsetMarker) { return offsetMarker; } else { - throw new Error(`Could not register marker with offset ${marker.line}, ${offset}`); + throw new Error(`Could not register marker with offset ${toLineIndex(marker)}, ${offset}`); } } } - registerTemporaryDecoration(marker: IMarker, endMarker?: IMarker): void { + revealCommand(command: ITerminalCommand, position: ScrollPosition = ScrollPosition.Middle): void { + if (!this._terminal || !command.marker) { + return; + } + + const promptRowCount = getPromptRowCount(command, this._terminal.buffer.active); + this._scrollToMarker( + command.marker.line - (promptRowCount - 1), + position, + command.marker + ); + } + + registerTemporaryDecoration(marker: IMarker | number, endMarker?: IMarker | number): void { if (!this._terminal) { return; } this._resetNavigationDecorations(); const color = this._themeService.getColorTheme().getColor(TERMINAL_OVERVIEW_RULER_CURSOR_FOREGROUND_COLOR); - const startLine = marker.line; - const decorationCount = endMarker ? endMarker.line - startLine + 1 : 1; + const startLine = toLineIndex(marker); + const decorationCount = endMarker ? toLineIndex(endMarker) - startLine + 1 : 1; for (let i = 0; i < decorationCount; i++) { const decoration = this._terminal.registerDecoration({ @@ -236,14 +267,18 @@ export class MarkNavigationAddon extends Disposable implements IMarkTracker, ITe decoration.onRender(element => { if (!renderedElement) { renderedElement = element; - if (decorationCount > 1) { - element.classList.add('terminal-scroll-highlight'); - } else { - element.classList.add('terminal-scroll-highlight', 'terminal-scroll-highlight-outline'); + element.classList.add('terminal-scroll-highlight', 'terminal-scroll-highlight-outline'); + if (i === 0) { + element.classList.add('top'); } - if (this._terminal?.element) { - element.style.marginLeft = `-${getWindow(this._terminal.element).getComputedStyle(this._terminal.element).paddingLeft}`; + if (i === decorationCount - 1) { + element.classList.add('bottom'); } + } else { + element.classList.add('terminal-scroll-highlight', 'terminal-scroll-highlight-outline'); + } + if (this._terminal?.element) { + element.style.marginLeft = `-${getWindow(this._terminal.element).getComputedStyle(this._terminal.element).paddingLeft}`; } }); decoration.onDispose(() => { this._navigationDecorations = this._navigationDecorations?.filter(d => d !== decoration); }); @@ -270,9 +305,10 @@ export class MarkNavigationAddon extends Disposable implements IMarkTracker, ITe return line; } - private _isMarkerInViewport(terminal: Terminal, marker: IMarker) { + private _isMarkerInViewport(terminal: Terminal, marker: IMarker | number) { const viewportY = terminal.buffer.active.viewportY; - return marker.line >= viewportY && marker.line < viewportY + terminal.rows; + const line = toLineIndex(marker); + return line >= viewportY && line < viewportY + terminal.rows; } scrollToClosestMarker(startMarkerId: string, endMarkerId?: string, highlight?: boolean | undefined): void { @@ -474,3 +510,11 @@ export function selectLines(xterm: Terminal, start: IMarker | Boundary, end: IMa xterm.selectLines(startLine, endLine); } + +function isMarker(value: IMarker | number): value is IMarker { + return typeof value !== 'number'; +} + +function toLineIndex(line: IMarker | number): number { + return isMarker(line) ? line.line : line; +} diff --git a/src/vs/workbench/contrib/terminalContrib/stickyScroll/browser/terminalStickyScrollOverlay.ts b/src/vs/workbench/contrib/terminalContrib/stickyScroll/browser/terminalStickyScrollOverlay.ts index 614469db200..a11af947fbf 100644 --- a/src/vs/workbench/contrib/terminalContrib/stickyScroll/browser/terminalStickyScrollOverlay.ts +++ b/src/vs/workbench/contrib/terminalContrib/stickyScroll/browser/terminalStickyScrollOverlay.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import type { CanvasAddon as CanvasAddonType } from '@xterm/addon-canvas'; import type { SerializeAddon as SerializeAddonType } from '@xterm/addon-serialize'; -import type { IMarker, ITerminalOptions, Terminal as RawXtermTerminal, Terminal as XTermTerminal } from '@xterm/xterm'; +import type { ITerminalOptions, Terminal as RawXtermTerminal, Terminal as XTermTerminal } from '@xterm/xterm'; import { importAMDNodeModule } from 'vs/amdX'; import { $, addStandardDisposableListener } from 'vs/base/browser/dom'; import { CancelablePromise, createCancelablePromise } from 'vs/base/common/async'; @@ -14,10 +14,10 @@ import { Disposable, MutableDisposable, combinedDisposable, toDisposable } from 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'; +import { ICommandDetectionCapability, ITerminalCommand } from 'vs/platform/terminal/common/capabilities/capabilities'; +import { getPromptRowCount } from 'vs/platform/terminal/common/capabilities/commandDetectionCapability'; import { IThemeService } from 'vs/platform/theme/common/themeService'; import { IXtermColorProvider, IXtermTerminal } from 'vs/workbench/contrib/terminal/browser/terminal'; -import { ScrollPosition } from 'vs/workbench/contrib/terminal/browser/xterm/markNavigationAddon'; import { TERMINAL_CONFIG_SECTION } from 'vs/workbench/contrib/terminal/common/terminal'; import { terminalStickyScrollHoverBackground } from 'vs/workbench/contrib/terminalContrib/stickyScroll/browser/terminalStickyScrollColorRegistry'; @@ -39,7 +39,7 @@ export class TerminalStickyScrollOverlay extends Disposable { private _pendingCanvasAddon?: CancelablePromise; private _element?: HTMLElement; - private _currentStickyMarker?: IMarker; + private _currentStickyCommand?: ITerminalCommand; private _currentContent?: string; private _refreshListeners = this._register(new MutableDisposable()); @@ -142,7 +142,7 @@ export class TerminalStickyScrollOverlay extends Disposable { // The command from viewportY + 1 is used because this one will not be obscured by sticky // scroll. const command = this._commandDetection.getCommandForLine(this._xterm.raw.buffer.active.viewportY + 1); - this._currentStickyMarker = undefined; + this._currentStickyCommand = undefined; // Sticky scroll only works with non-partial commands if (!command || !('marker' in command)) { @@ -166,21 +166,12 @@ export class TerminalStickyScrollOverlay extends Disposable { // 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; - } + const promptRowCount = getPromptRowCount(command, this._xterm.raw.buffer.active); // 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 - promptStartLine + scrollback: this._xterm.raw.buffer.active.baseY - marker.line + (promptRowCount - 1) }); // Write content if it differs @@ -197,7 +188,7 @@ export class TerminalStickyScrollOverlay extends Disposable { } if (content && command.exitCode !== undefined) { - this._currentStickyMarker = marker; + this._currentStickyCommand = command; this._setVisible(true); } else { this._setVisible(false); @@ -228,9 +219,8 @@ export class TerminalStickyScrollOverlay extends Disposable { // Scroll to the command on click this._register(addStandardDisposableListener(hoverOverlay, 'click', () => { - if (this._xterm && this._currentStickyMarker) { - this._xterm.scrollToLine(this._currentStickyMarker.line, ScrollPosition.Middle); - this._xterm.markTracker.registerTemporaryDecoration(this._currentStickyMarker); + if (this._xterm && this._currentStickyCommand) { + this._xterm.markTracker.revealCommand(this._currentStickyCommand); } }));