Merge pull request #206282 from microsoft/tyriar/reveal_link

Open detected links: Reveal source in terminal and timeout to use all links
This commit is contained in:
Daniel Imms
2024-02-26 11:52:12 -08:00
committed by GitHub
5 changed files with 175 additions and 48 deletions
@@ -474,6 +474,11 @@
pointer-events: none;
}
.terminal-range-highlight {
outline: 1px solid var(--vscode-focusBorder);
pointer-events: none;
}
.terminal-command-guide {
left: 0;
border: 1.5px solid #ffffff;
@@ -23,7 +23,7 @@ import { ITerminalStatusList } from 'vs/workbench/contrib/terminal/browser/termi
import { XtermTerminal } from 'vs/workbench/contrib/terminal/browser/xterm/xtermTerminal';
import { IRegisterContributedProfileArgs, IRemoteTerminalAttachTarget, IStartExtensionTerminalRequest, ITerminalConfiguration, ITerminalFont, ITerminalProcessExtHostProxy, ITerminalProcessInfo } from 'vs/workbench/contrib/terminal/common/terminal';
import { ISimpleSelectedSuggestion } from 'vs/workbench/services/suggest/browser/simpleSuggestWidget';
import type { IMarker, ITheme, Terminal as RawXtermTerminal } from '@xterm/xterm';
import type { IMarker, ITheme, Terminal as RawXtermTerminal, IBufferRange } from '@xterm/xterm';
import { ScrollPosition } from 'vs/workbench/contrib/terminal/browser/xterm/markNavigationAddon';
import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey';
import { GroupIdentifier } from 'vs/workbench/common/editor';
@@ -113,13 +113,17 @@ export interface IMarkTracker {
selectToNextMark(): void;
selectToPreviousLine(): void;
selectToNextLine(): void;
clearMarker(): void;
clear(): void;
scrollToClosestMarker(startMarkerId: string, endMarkerId?: string, highlight?: boolean | undefined): void;
scrollToLine(line: number, position: ScrollPosition): void;
revealCommand(command: ITerminalCommand, position?: ScrollPosition): void;
revealRange(range: IBufferRange): void;
registerTemporaryDecoration(marker: IMarker, endMarker: IMarker | undefined, showOutline: boolean): void;
showCommandGuide(command: ITerminalCommand | undefined): void;
saveScrollState(): void;
restoreScrollState(): void;
}
export interface ITerminalGroup {
@@ -7,12 +7,14 @@ import { coalesce } from 'vs/base/common/arrays';
import { Disposable, DisposableStore, MutableDisposable, dispose } from 'vs/base/common/lifecycle';
import { IMarkTracker } from 'vs/workbench/contrib/terminal/browser/terminal';
import { ITerminalCapabilityStore, ITerminalCommand, TerminalCapability } from 'vs/platform/terminal/common/capabilities/capabilities';
import type { Terminal, IMarker, ITerminalAddon, IDecoration } from '@xterm/xterm';
import type { Terminal, IMarker, ITerminalAddon, IDecoration, IBufferRange } 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 { ICurrentPartialCommand } from 'vs/platform/terminal/common/capabilities/commandDetection/terminalCommand';
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
import { TerminalSettingId } from 'vs/platform/terminal/common/terminal';
enum Boundary {
Top,
@@ -24,6 +26,13 @@ export const enum ScrollPosition {
Middle
}
interface IScrollToMarkerOptions {
hideDecoration?: boolean;
/** Scroll even if the line is within the viewport */
forceScroll?: boolean;
bufferRange?: IBufferRange;
}
export class MarkNavigationAddon extends Disposable implements IMarkTracker, ITerminalAddon {
private _currentMarker: IMarker | Boundary = Boundary.Bottom;
private _selectionStart: IMarker | Boundary | null = null;
@@ -43,6 +52,7 @@ export class MarkNavigationAddon extends Disposable implements IMarkTracker, ITe
constructor(
private readonly _capabilities: ITerminalCapabilityStore,
@IConfigurationService private readonly _configurationService: IConfigurationService,
@IThemeService private readonly _themeService: IThemeService
) {
super();
@@ -91,7 +101,7 @@ export class MarkNavigationAddon extends Disposable implements IMarkTracker, ITe
return undefined;
}
clearMarker(): void {
clear(): void {
// Clear the current marker so successive focus/selection actions are performed from the
// bottom of the buffer
this._currentMarker = Boundary.Bottom;
@@ -219,16 +229,20 @@ export class MarkNavigationAddon extends Disposable implements IMarkTracker, ITe
}
}
private _scrollToMarker(start: IMarker | number, position: ScrollPosition, end?: IMarker | number, hideDecoration?: boolean): void {
private _scrollToMarker(start: IMarker | number, position: ScrollPosition, end?: IMarker | number, options?: IScrollToMarkerOptions): void {
if (!this._terminal) {
return;
}
if (!this._isMarkerInViewport(this._terminal, start)) {
if (!this._isMarkerInViewport(this._terminal, start) || options?.forceScroll) {
const line = this.getTargetScrollLine(toLineIndex(start), position);
this._terminal.scrollToLine(line);
}
if (!hideDecoration) {
this.registerTemporaryDecoration(start, end, true);
if (!options?.hideDecoration) {
if (options?.bufferRange) {
this._highlightBufferRange(options.bufferRange);
} else {
this.registerTemporaryDecoration(start, end, true);
}
}
}
@@ -260,6 +274,19 @@ export class MarkNavigationAddon extends Disposable implements IMarkTracker, ITe
);
}
revealRange(range: IBufferRange): void {
this._scrollToMarker(
range.start.y - 1,
ScrollPosition.Middle,
range.end.y - 1,
{
bufferRange: range,
// Ensure scroll shows the line when sticky scroll is enabled
forceScroll: !!this._configurationService.getValue(TerminalSettingId.StickyScrollEnabled)
}
);
}
showCommandGuide(command: ITerminalCommand | undefined): void {
if (!this._terminal) {
return;
@@ -314,6 +341,50 @@ export class MarkNavigationAddon extends Disposable implements IMarkTracker, ITe
}
}
private _scrollState: { viewportY: number } | undefined;
saveScrollState(): void {
this._scrollState = { viewportY: this._terminal?.buffer.active.viewportY ?? 0 };
}
restoreScrollState(): void {
if (this._scrollState && this._terminal) {
this._terminal.scrollToLine(this._scrollState.viewportY);
this._scrollState = undefined;
}
}
private _highlightBufferRange(range: IBufferRange): void {
if (!this._terminal) {
return;
}
this._resetNavigationDecorations();
const startLine = range.start.y;
const decorationCount = range.end.y - range.start.y + 1;
for (let i = 0; i < decorationCount; i++) {
const decoration = this._terminal.registerDecoration({
marker: this._createMarkerForOffset(startLine - 1, i),
x: range.start.x - 1,
width: (range.end.x - 1) - (range.start.x - 1) + 1,
overviewRulerOptions: undefined
});
if (decoration) {
this._navigationDecorations?.push(decoration);
let renderedElement: HTMLElement | undefined;
decoration.onRender(element => {
if (!renderedElement) {
renderedElement = element;
element.classList.add('terminal-range-highlight');
}
});
decoration.onDispose(() => { this._navigationDecorations = this._navigationDecorations?.filter(d => d !== decoration); });
}
}
}
registerTemporaryDecoration(marker: IMarker | number, endMarker: IMarker | number | undefined, showOutline: boolean): void {
if (!this._terminal) {
return;
@@ -373,7 +444,7 @@ export class MarkNavigationAddon extends Disposable implements IMarkTracker, ITe
}
getTargetScrollLine(line: number, position: ScrollPosition): number {
// Middle is treated at 1/4 of the viewport's size because context below is almost always
// Middle is treated as 1/4 of the viewport's size because context below is almost always
// more important than context above in the terminal.
if (this._terminal && position === ScrollPosition.Middle) {
return Math.max(line - Math.floor(this._terminal.rows / 4), 0);
@@ -397,7 +468,7 @@ export class MarkNavigationAddon extends Disposable implements IMarkTracker, ITe
return;
}
const endMarker = endMarkerId ? detectionCapability.getMark(endMarkerId) : startMarker;
this._scrollToMarker(startMarker, ScrollPosition.Top, endMarker, !highlight);
this._scrollToMarker(startMarker, ScrollPosition.Top, endMarker, { hideDecoration: !highlight });
}
selectToPreviousMark(): void {
@@ -83,7 +83,7 @@ class TerminalLinkContribution extends DisposableStore implements ITerminalContr
});
}
const links = await this._getLinks();
return await this._terminalLinkQuickpick.show(links);
return await this._terminalLinkQuickpick.show(this._instance, links);
}
private async _getLinks(): Promise<{ viewport: IDetectedLinks; all: Promise<IDetectedLinks> }> {
@@ -8,13 +8,13 @@ import { Emitter, Event } from 'vs/base/common/event';
import { localize } from 'vs/nls';
import { QuickPickItem, IQuickInputService, IQuickPickItem, QuickInputHideReason } from 'vs/platform/quickinput/common/quickInput';
import { IDetectedLinks } from 'vs/workbench/contrib/terminalContrib/links/browser/terminalLinkManager';
import { TerminalLinkQuickPickEvent } from 'vs/workbench/contrib/terminal/browser/terminal';
import { TerminalLinkQuickPickEvent, type IDetachedTerminalInstance, type ITerminalInstance } from 'vs/workbench/contrib/terminal/browser/terminal';
import type { ILink } from '@xterm/xterm';
import { DisposableStore } from 'vs/base/common/lifecycle';
import { IAccessibleViewService } from 'vs/workbench/contrib/accessibility/browser/accessibleView';
import { AccessibleViewProviderId } from 'vs/workbench/contrib/accessibility/browser/accessibilityConfiguration';
import type { TerminalLink } from 'vs/workbench/contrib/terminalContrib/links/browser/terminalLink';
import { Sequencer } from 'vs/base/common/async';
import { Sequencer, timeout } from 'vs/base/common/async';
import { EditorViewState } from 'vs/workbench/browser/quickaccess';
import { IEditorService } from 'vs/workbench/services/editor/common/editorService';
import { IHistoryService } from 'vs/workbench/services/history/common/history';
@@ -30,6 +30,8 @@ export class TerminalLinkQuickpick extends DisposableStore {
private readonly _editorSequencer = new Sequencer();
private readonly _editorViewState: EditorViewState;
private _instance: ITerminalInstance | IDetachedTerminalInstance | undefined;
private readonly _onDidRequestMoreLinks = this.add(new Emitter<void>());
readonly onDidRequestMoreLinks = this._onDidRequestMoreLinks.event;
@@ -45,12 +47,20 @@ export class TerminalLinkQuickpick extends DisposableStore {
this._editorViewState = new EditorViewState(_editorService);
}
async show(links: { viewport: IDetectedLinks; all: Promise<IDetectedLinks> }): Promise<void> {
async show(instance: ITerminalInstance | IDetachedTerminalInstance, links: { viewport: IDetectedLinks; all: Promise<IDetectedLinks> }): Promise<void> {
this._instance = instance;
// Allow all links a small amount of time to elapse to finish, if this is not done in this
// time they will be loaded upon the first filter.
const result = await Promise.race([links.all, timeout(500)]);
const usingAllLinks = typeof result === 'object';
const resolvedLinks = usingAllLinks ? result : links.viewport;
// Get raw link picks
const wordPicks = links.viewport.wordLinks ? await this._generatePicks(links.viewport.wordLinks) : undefined;
const filePicks = links.viewport.fileLinks ? await this._generatePicks(links.viewport.fileLinks) : undefined;
const folderPicks = links.viewport.folderLinks ? await this._generatePicks(links.viewport.folderLinks) : undefined;
const webPicks = links.viewport.webLinks ? await this._generatePicks(links.viewport.webLinks) : undefined;
const wordPicks = resolvedLinks.wordLinks ? await this._generatePicks(resolvedLinks.wordLinks) : undefined;
const filePicks = resolvedLinks.fileLinks ? await this._generatePicks(resolvedLinks.fileLinks) : undefined;
const folderPicks = resolvedLinks.folderLinks ? await this._generatePicks(resolvedLinks.folderLinks) : undefined;
const webPicks = resolvedLinks.webLinks ? await this._generatePicks(resolvedLinks.webLinks) : undefined;
const picks: LinkQuickPickItem[] = [];
if (webPicks) {
@@ -84,36 +94,38 @@ export class TerminalLinkQuickpick extends DisposableStore {
// ASAP with only the viewport entries.
let accepted = false;
const disposables = new DisposableStore();
disposables.add(Event.once(pick.onDidChangeValue)(async () => {
const allLinks = await links.all;
if (accepted) {
return;
}
const wordIgnoreLinks = [...(allLinks.fileLinks ?? []), ...(allLinks.folderLinks ?? []), ...(allLinks.webLinks ?? [])];
if (!usingAllLinks) {
disposables.add(Event.once(pick.onDidChangeValue)(async () => {
const allLinks = await links.all;
if (accepted) {
return;
}
const wordIgnoreLinks = [...(allLinks.fileLinks ?? []), ...(allLinks.folderLinks ?? []), ...(allLinks.webLinks ?? [])];
const wordPicks = allLinks.wordLinks ? await this._generatePicks(allLinks.wordLinks, wordIgnoreLinks) : undefined;
const filePicks = allLinks.fileLinks ? await this._generatePicks(allLinks.fileLinks) : undefined;
const folderPicks = allLinks.folderLinks ? await this._generatePicks(allLinks.folderLinks) : undefined;
const webPicks = allLinks.webLinks ? await this._generatePicks(allLinks.webLinks) : undefined;
const picks: LinkQuickPickItem[] = [];
if (webPicks) {
picks.push({ type: 'separator', label: localize('terminal.integrated.urlLinks', "Url") });
picks.push(...webPicks);
}
if (filePicks) {
picks.push({ type: 'separator', label: localize('terminal.integrated.localFileLinks', "File") });
picks.push(...filePicks);
}
if (folderPicks) {
picks.push({ type: 'separator', label: localize('terminal.integrated.localFolderLinks', "Folder") });
picks.push(...folderPicks);
}
if (wordPicks) {
picks.push({ type: 'separator', label: localize('terminal.integrated.searchLinks', "Workspace Search") });
picks.push(...wordPicks);
}
pick.items = picks;
}));
const wordPicks = allLinks.wordLinks ? await this._generatePicks(allLinks.wordLinks, wordIgnoreLinks) : undefined;
const filePicks = allLinks.fileLinks ? await this._generatePicks(allLinks.fileLinks) : undefined;
const folderPicks = allLinks.folderLinks ? await this._generatePicks(allLinks.folderLinks) : undefined;
const webPicks = allLinks.webLinks ? await this._generatePicks(allLinks.webLinks) : undefined;
const picks: LinkQuickPickItem[] = [];
if (webPicks) {
picks.push({ type: 'separator', label: localize('terminal.integrated.urlLinks', "Url") });
picks.push(...webPicks);
}
if (filePicks) {
picks.push({ type: 'separator', label: localize('terminal.integrated.localFileLinks', "File") });
picks.push(...filePicks);
}
if (folderPicks) {
picks.push({ type: 'separator', label: localize('terminal.integrated.localFolderLinks', "Folder") });
picks.push(...folderPicks);
}
if (wordPicks) {
picks.push({ type: 'separator', label: localize('terminal.integrated.searchLinks', "Workspace Search") });
picks.push(...wordPicks);
}
pick.items = picks;
}));
}
disposables.add(pick.onDidChangeActive(async () => {
const [item] = pick.activeItems;
@@ -122,6 +134,17 @@ export class TerminalLinkQuickpick extends DisposableStore {
return new Promise(r => {
disposables.add(pick.onDidHide(({ reason }) => {
// Restore terminal scroll state
if (this._terminalScrollStateSaved) {
const markTracker = this._instance?.xterm?.markTracker;
if (markTracker) {
markTracker.restoreScrollState();
markTracker.clear();
this._terminalScrollStateSaved = false;
}
}
// Restore view state upon cancellation if we changed it
// but only when the picker was closed via explicit user
// gesture and not e.g. when focus was lost because that
@@ -208,11 +231,18 @@ export class TerminalLinkQuickpick extends DisposableStore {
}
private _previewItem(item: ITerminalLinkQuickPickItem | IQuickPickItem) {
if (!item || !('link' in item) || !item.link || !('uri' in item.link) || !item.link.uri) {
if (!item || !('link' in item) || !item.link) {
return;
}
// Any link can be previewed in the termninal
const link = item.link;
this._previewItemInTerminal(link);
if (!('uri' in link) || !link.uri) {
return;
}
if (link.type !== TerminalBuiltinLinkType.LocalFile) {
return;
}
@@ -223,6 +253,10 @@ export class TerminalLinkQuickpick extends DisposableStore {
return;
}
this._previewItemInEditor(link);
}
private _previewItemInEditor(link: TerminalLink) {
const linkSuffix = link.parsedLink ? link.parsedLink.suffix : getLinkSuffix(link.text);
const selection = linkSuffix?.row === undefined ? undefined : {
startLineNumber: linkSuffix.row ?? 1,
@@ -245,6 +279,19 @@ export class TerminalLinkQuickpick extends DisposableStore {
}
});
}
private _terminalScrollStateSaved: boolean = false;
private _previewItemInTerminal(link: ILink) {
const xterm = this._instance?.xterm;
if (!xterm) {
return;
}
if (!this._terminalScrollStateSaved) {
xterm.markTracker.saveScrollState();
this._terminalScrollStateSaved = true;
}
xterm.markTracker.revealRange(link.range);
}
}
export interface ITerminalLinkQuickPickItem extends IQuickPickItem {