Skip to content

Commit 9080657

Browse files
authored
Merge pull request microsoft#185896 from microsoft/tyriar/181837
Support for multi-line terminal links
2 parents b9ada3a + ecb3331 commit 9080657

File tree

8 files changed

+398
-12
lines changed

8 files changed

+398
-12
lines changed

src/vs/workbench/contrib/terminalContrib/links/browser/links.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { IDisposable } from 'vs/base/common/lifecycle';
1313
import { ITerminalExternalLinkProvider } from 'vs/workbench/contrib/terminal/browser/terminal';
1414
import { Event } from 'vs/base/common/event';
1515
import { ITerminalBackend } from 'vs/platform/terminal/common/terminal';
16+
import { ITextEditorSelection } from 'vs/platform/editor/common/editor';
1617

1718
export const ITerminalLinkProviderService = createDecorator<ITerminalLinkProviderService>('terminalLinkProviderService');
1819
export interface ITerminalLinkProviderService {
@@ -82,6 +83,16 @@ export interface ITerminalSimpleLink {
8283
*/
8384
uri?: URI;
8485

86+
/**
87+
* The location or selection range of the link.
88+
*/
89+
selection?: ITextEditorSelection;
90+
91+
/**
92+
* Whether to trim a trailing colon at the end of a path.
93+
*/
94+
disableTrimColon?: boolean;
95+
8596
/**
8697
* A hover label to override the default for the type.
8798
*/

src/vs/workbench/contrib/terminalContrib/links/browser/terminalLinkDetectorAdapter.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ export class TerminalLinkDetectorAdapter extends Disposable implements ILinkProv
102102

103103
private _createTerminalLink(l: ITerminalSimpleLink, activateCallback: XtermLinkMatcherHandler): TerminalLink {
104104
// Remove trailing colon if there is one so the link is more useful
105-
if (l.text.length > 0 && l.text.charAt(l.text.length - 1) === ':') {
105+
if (!l.disableTrimColon && l.text.length > 0 && l.text.charAt(l.text.length - 1) === ':') {
106106
l.text = l.text.slice(0, -1);
107107
l.bufferRange.end.x--;
108108
}

src/vs/workbench/contrib/terminalContrib/links/browser/terminalLinkManager.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import type { ILink, ILinkProvider, IViewportRange, Terminal } from 'xterm';
3131
import { convertBufferRangeToViewport } from 'vs/workbench/contrib/terminalContrib/links/browser/terminalLinkHelpers';
3232
import { RunOnceScheduler } from 'vs/base/common/async';
3333
import { ITerminalLogService } from 'vs/platform/terminal/common/terminal';
34+
import { TerminalMultiLineLinkDetector } from 'vs/workbench/contrib/terminalContrib/links/browser/terminalMultiLineLinkDetector';
3435

3536
export type XtermLinkMatcherHandler = (event: MouseEvent | undefined, link: string) => Promise<void>;
3637

@@ -73,6 +74,7 @@ export class TerminalLinkManager extends DisposableStore {
7374
// Setup link detectors in their order of priority
7475
this._setupLinkDetector(TerminalUriLinkDetector.id, this._instantiationService.createInstance(TerminalUriLinkDetector, this._xterm, this._processManager, this._linkResolver));
7576
if (enableFileLinks) {
77+
this._setupLinkDetector(TerminalMultiLineLinkDetector.id, this._instantiationService.createInstance(TerminalMultiLineLinkDetector, this._xterm, this._processManager, this._linkResolver));
7678
this._setupLinkDetector(TerminalLocalLinkDetector.id, this._instantiationService.createInstance(TerminalLocalLinkDetector, this._xterm, capabilities, this._processManager, this._linkResolver));
7779
}
7880
this._setupLinkDetector(TerminalWordLinkDetector.id, this._instantiationService.createInstance(TerminalWordLinkDetector, this._xterm));

src/vs/workbench/contrib/terminalContrib/links/browser/terminalLinkOpeners.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,15 @@ export class TerminalLocalFileLinkOpener implements ITerminalLinkOpener {
3636
throw new Error('Tried to open file link without a resolved URI');
3737
}
3838
const linkSuffix = link.parsedLink ? link.parsedLink.suffix : getLinkSuffix(link.text);
39-
const selection: ITextEditorSelection | undefined = linkSuffix?.row === undefined ? undefined : {
40-
startLineNumber: linkSuffix.row ?? 1,
41-
startColumn: linkSuffix.col ?? 1,
42-
endLineNumber: linkSuffix.rowEnd,
43-
endColumn: linkSuffix.colEnd
44-
};
39+
let selection: ITextEditorSelection | undefined = link.selection;
40+
if (!selection) {
41+
selection = linkSuffix?.row === undefined ? undefined : {
42+
startLineNumber: linkSuffix.row ?? 1,
43+
startColumn: linkSuffix.col ?? 1,
44+
endLineNumber: linkSuffix.rowEnd,
45+
endColumn: linkSuffix.colEnd
46+
};
47+
}
4548
await this._editorService.openEditor({
4649
resource: link.uri,
4750
options: { pinned: true, selection, revealIfOpened: true }

src/vs/workbench/contrib/terminalContrib/links/browser/terminalLocalLinkDetector.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,8 @@ export class TerminalLocalLinkDetector implements ITerminalLinkDetector {
9090

9191
const os = this._processManager.os || OS;
9292
const parsedLinks = detectLinks(text, os);
93-
this._logService.trace('terminalLocaLinkDetector#detect text', text);
94-
this._logService.trace('terminalLocaLinkDetector#detect parsedLinks', parsedLinks);
93+
this._logService.trace('terminalLocalLinkDetector#detect text', text);
94+
this._logService.trace('terminalLocalLinkDetector#detect parsedLinks', parsedLinks);
9595
for (const parsedLink of parsedLinks) {
9696

9797
// Don't try resolve any links of excessive length
@@ -153,7 +153,7 @@ export class TerminalLocalLinkDetector implements ITerminalLinkDetector {
153153
}
154154
}
155155
linkCandidates.push(...specialEndLinkCandidates);
156-
this._logService.trace('terminalLocaLinkDetector#detect linkCandidates', linkCandidates);
156+
this._logService.trace('terminalLocalLinkDetector#detect linkCandidates', linkCandidates);
157157

158158
// Validate the path and convert to the outgoing type
159159
const simpleLink = await this._validateAndGetLink(undefined, bufferRange, linkCandidates, trimRangeMap);
@@ -163,7 +163,7 @@ export class TerminalLocalLinkDetector implements ITerminalLinkDetector {
163163
parsedLink.prefix?.index ?? parsedLink.path.index,
164164
parsedLink.suffix ? parsedLink.suffix.suffix.index + parsedLink.suffix.suffix.text.length : parsedLink.path.index + parsedLink.path.text.length
165165
);
166-
this._logService.trace('terminalLocaLinkDetector#detect verified link', simpleLink);
166+
this._logService.trace('terminalLocalLinkDetector#detect verified link', simpleLink);
167167
links.push(simpleLink);
168168
}
169169

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import { URI } from 'vs/base/common/uri';
7+
import { IUriIdentityService } from 'vs/platform/uriIdentity/common/uriIdentity';
8+
import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace';
9+
import { ITerminalLinkDetector, ITerminalLinkResolver, ITerminalSimpleLink, TerminalBuiltinLinkType } from 'vs/workbench/contrib/terminalContrib/links/browser/links';
10+
import { convertLinkRangeToBuffer, getXtermLineContent } from 'vs/workbench/contrib/terminalContrib/links/browser/terminalLinkHelpers';
11+
import { IBufferLine, Terminal } from 'xterm';
12+
import { ITerminalProcessManager } from 'vs/workbench/contrib/terminal/common/terminal';
13+
import { ITerminalBackend, ITerminalLogService } from 'vs/platform/terminal/common/terminal';
14+
15+
const enum Constants {
16+
/**
17+
* The max line length to try extract word links from.
18+
*/
19+
MaxLineLength = 2000,
20+
21+
/**
22+
* The maximum length of a link to resolve against the file system. This limit is put in place
23+
* to avoid sending excessive data when remote connections are in place.
24+
*/
25+
MaxResolvedLinkLength = 1024,
26+
}
27+
28+
const candidateMatchers = [
29+
// Ripgrep:
30+
// 16:searchresult
31+
// 16: searchresult
32+
// Eslint:
33+
// 16:5 error ...
34+
/\s*(?<link>(?<line>\d+):(?<col>\d+)?)/
35+
];
36+
37+
export class TerminalMultiLineLinkDetector implements ITerminalLinkDetector {
38+
static id = 'multiline';
39+
40+
// This was chosen as a reasonable maximum line length given the tradeoff between performance
41+
// and how likely it is to encounter such a large line length. Some useful reference points:
42+
// - Window old max length: 260 ($MAX_PATH)
43+
// - Linux max length: 4096 ($PATH_MAX)
44+
readonly maxLinkLength = 500;
45+
46+
constructor(
47+
readonly xterm: Terminal,
48+
private readonly _processManager: Pick<ITerminalProcessManager, 'initialCwd' | 'os' | 'remoteAuthority' | 'userHome'> & { backend?: Pick<ITerminalBackend, 'getWslPath'> },
49+
private readonly _linkResolver: ITerminalLinkResolver,
50+
@ITerminalLogService private readonly _logService: ITerminalLogService,
51+
@IUriIdentityService private readonly _uriIdentityService: IUriIdentityService,
52+
@IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService
53+
) {
54+
}
55+
56+
async detect(lines: IBufferLine[], startLine: number, endLine: number): Promise<ITerminalSimpleLink[]> {
57+
const links: ITerminalSimpleLink[] = [];
58+
59+
// Get the text representation of the wrapped line
60+
const text = getXtermLineContent(this.xterm.buffer.active, startLine, endLine, this.xterm.cols);
61+
if (text === '' || text.length > Constants.MaxLineLength) {
62+
return [];
63+
}
64+
65+
this._logService.trace('terminalMultiLineLinkDetector#detect text', text);
66+
67+
// Match against the fallback matchers which are mainly designed to catch paths with spaces
68+
// that aren't possible using the regular mechanism.
69+
for (const matcher of candidateMatchers) {
70+
const match = text.match(matcher);
71+
const group = match?.groups;
72+
if (!group) {
73+
continue;
74+
}
75+
const link = group?.link;
76+
const line = group?.line;
77+
const col = group?.col;
78+
if (!link || line === undefined) {
79+
continue;
80+
}
81+
82+
// Don't try resolve any links of excessive length
83+
if (link.length > Constants.MaxResolvedLinkLength) {
84+
continue;
85+
}
86+
87+
this._logService.trace('terminalMultiLineLinkDetector#detect candidate', link);
88+
89+
// Scan up looking for the first line that could be a path
90+
let possiblePath: string | undefined;
91+
for (let index = startLine - 1; index >= 0; index--) {
92+
// Ignore lines that aren't at the beginning of a wrapped line
93+
if (this.xterm.buffer.active.getLine(index)!.isWrapped) {
94+
continue;
95+
}
96+
const text = getXtermLineContent(this.xterm.buffer.active, index, index, this.xterm.cols);
97+
if (!text.match(/^\s*\d/)) {
98+
possiblePath = text;
99+
break;
100+
}
101+
}
102+
if (!possiblePath) {
103+
continue;
104+
}
105+
106+
// Check if the first non-matching line is an absolute or relative link
107+
const linkStat = await this._linkResolver.resolveLink(this._processManager, possiblePath);
108+
if (linkStat) {
109+
let type: TerminalBuiltinLinkType;
110+
if (linkStat.isDirectory) {
111+
if (this._isDirectoryInsideWorkspace(linkStat.uri)) {
112+
type = TerminalBuiltinLinkType.LocalFolderInWorkspace;
113+
} else {
114+
type = TerminalBuiltinLinkType.LocalFolderOutsideWorkspace;
115+
}
116+
} else {
117+
type = TerminalBuiltinLinkType.LocalFile;
118+
}
119+
120+
// Convert the entire line's text string index into a wrapped buffer range
121+
const bufferRange = convertLinkRangeToBuffer(lines, this.xterm.cols, {
122+
startColumn: 1,
123+
startLineNumber: 1,
124+
endColumn: 1 + text.length,
125+
endLineNumber: 1
126+
}, startLine);
127+
128+
const simpleLink: ITerminalSimpleLink = {
129+
text: link,
130+
uri: linkStat.uri,
131+
selection: {
132+
startLineNumber: parseInt(line),
133+
startColumn: col ? parseInt(col) : 0
134+
},
135+
disableTrimColon: true,
136+
bufferRange: bufferRange,
137+
type
138+
};
139+
this._logService.trace('terminalMultiLineLinkDetector#detect verified link', simpleLink);
140+
links.push(simpleLink);
141+
142+
// Break on the first match
143+
break;
144+
}
145+
}
146+
147+
return links;
148+
}
149+
150+
private _isDirectoryInsideWorkspace(uri: URI) {
151+
const folders = this._workspaceContextService.getWorkspace().folders;
152+
for (let i = 0; i < folders.length; i++) {
153+
if (this._uriIdentityService.extUri.isEqualOrParent(uri, folders[i].uri)) {
154+
return true;
155+
}
156+
}
157+
return false;
158+
}
159+
}

src/vs/workbench/contrib/terminalContrib/links/test/browser/linkTestUtils.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,19 @@ export async function assertLinkHelper(
1818

1919
// Write the text and wait for the parser to finish
2020
await new Promise<void>(r => detector.xterm.write(text, r));
21+
const textSplit = text.split('\r\n');
22+
const lastLineIndex = textSplit.filter((e, i) => i !== textSplit.length - 1).reduce((p, c) => {
23+
return p + Math.max(Math.ceil(c.length / 80), 1);
24+
}, 0);
2125

2226
// Ensure all links are provided
2327
const lines: IBufferLine[] = [];
2428
for (let i = 0; i < detector.xterm.buffer.active.cursorY + 1; i++) {
2529
lines.push(detector.xterm.buffer.active.getLine(i)!);
2630
}
2731

28-
const actualLinks = (await detector.detect(lines, 0, detector.xterm.buffer.active.cursorY)).map(e => {
32+
// Detect links always on the last line with content
33+
const actualLinks = (await detector.detect(lines, lastLineIndex, detector.xterm.buffer.active.cursorY)).map(e => {
2934
return {
3035
link: e.uri?.toString() ?? e.text,
3136
type: expectedType,

0 commit comments

Comments
 (0)