Repository: microsoft/vscode (TypeScript)
Date: 2023-06-24 22:09:53+00:00
Task type: Feature

Issue:
Support linking from git diff output to the line requested
I use `git diff` in the terminal and currently it's a little awkward getting to the line I am looking at. Take this for example:

![image](https://github.com/microsoft/vscode/assets/2193314/2ae3c688-2a70-4bb1-8c6f-001e26f5aa71)

Say I want to get to the line `// if (mutator...`, currently I have to scroll up to the file name and click that:

![image](https://github.com/microsoft/vscode/assets/2193314/9bbea5a3-c8bd-471a-8447-8b88624a64f3)

Then either:

- ctrl+g, 69, enter, down a few times times
- ctrl+f, "if (mutator", escape

We could create a link directly to the line in question by scanning up and finding the `@@` line info and then up again to the file. I'm not totally sure the best way to present such a link. We could for example link the entire line, but that would prevent other detected links from being usable, maybe that's fine though?


Issue discussion:

Solution patch:
diff --git a/src/vs/workbench/contrib/terminalContrib/links/browser/terminalMultiLineLinkDetector.ts b/src/vs/workbench/contrib/terminalContrib/links/browser/terminalMultiLineLinkDetector.ts
--- a/src/vs/workbench/contrib/terminalContrib/links/browser/terminalMultiLineLinkDetector.ts
+++ b/src/vs/workbench/contrib/terminalContrib/links/browser/terminalMultiLineLinkDetector.ts
@@ -25,13 +25,22 @@ const enum Constants {
 	MaxResolvedLinkLength = 1024,
 }
 
-const candidateMatchers = [
+const lineNumberPrefixMatchers = [
 	// Ripgrep:
+	//   /some/file
 	//   16:searchresult
 	//   16:    searchresult
 	// Eslint:
-	//   16:5  error ...
-	/\s*(?<link>(?<line>\d+):(?<col>\d+)?)/
+	//   /some/file
+	//     16:5  error ...
+	/ *(?<link>(?<line>\d+):(?<col>\d+)?)/
+];
+
+const gitDiffMatchers = [
+	// --- a/some/file
+	// +++ b/some/file
+	// @@ -8,11 +8,11 @@ file content...
+	/^(?<link>@@ .+ \+(?<toFileLine>\d+),(?<toFileCount>\d+) @@)/
 ];
 
 export class TerminalMultiLineLinkDetector implements ITerminalLinkDetector {
@@ -66,7 +75,7 @@ export class TerminalMultiLineLinkDetector implements ITerminalLinkDetector {
 
 		// Match against the fallback matchers which are mainly designed to catch paths with spaces
 		// that aren't possible using the regular mechanism.
-		for (const matcher of candidateMatchers) {
+		for (const matcher of lineNumberPrefixMatchers) {
 			const match = text.match(matcher);
 			const group = match?.groups;
 			if (!group) {
@@ -130,7 +139,7 @@ export class TerminalMultiLineLinkDetector implements ITerminalLinkDetector {
 					uri: linkStat.uri,
 					selection: {
 						startLineNumber: parseInt(line),
-						startColumn: col ? parseInt(col) : 0
+						startColumn: col ? parseInt(col) : 1
 					},
 					disableTrimColon: true,
 					bufferRange: bufferRange,
@@ -144,6 +153,88 @@ export class TerminalMultiLineLinkDetector implements ITerminalLinkDetector {
 			}
 		}
 
+		if (links.length === 0) {
+			for (const matcher of gitDiffMatchers) {
+				const match = text.match(matcher);
+				const group = match?.groups;
+				if (!group) {
+					continue;
+				}
+				const link = group?.link;
+				const toFileLine = group?.toFileLine;
+				const toFileCount = group?.toFileCount;
+				if (!link || toFileLine === undefined) {
+					continue;
+				}
+
+				// Don't try resolve any links of excessive length
+				if (link.length > Constants.MaxResolvedLinkLength) {
+					continue;
+				}
+
+				this._logService.trace('terminalMultiLineLinkDetector#detect candidate', link);
+
+
+				// Scan up looking for the first line that could be a path
+				let possiblePath: string | undefined;
+				for (let index = startLine - 1; index >= 0; index--) {
+					// Ignore lines that aren't at the beginning of a wrapped line
+					if (this.xterm.buffer.active.getLine(index)!.isWrapped) {
+						continue;
+					}
+					const text = getXtermLineContent(this.xterm.buffer.active, index, index, this.xterm.cols);
+					const match = text.match(/\+\+\+ b\/(?<path>.+)/);
+					if (match) {
+						possiblePath = match.groups?.path;
+						break;
+					}
+				}
+				if (!possiblePath) {
+					continue;
+				}
+
+				// Check if the first non-matching line is an absolute or relative link
+				const linkStat = await this._linkResolver.resolveLink(this._processManager, possiblePath);
+				if (linkStat) {
+					let type: TerminalBuiltinLinkType;
+					if (linkStat.isDirectory) {
+						if (this._isDirectoryInsideWorkspace(linkStat.uri)) {
+							type = TerminalBuiltinLinkType.LocalFolderInWorkspace;
+						} else {
+							type = TerminalBuiltinLinkType.LocalFolderOutsideWorkspace;
+						}
+					} else {
+						type = TerminalBuiltinLinkType.LocalFile;
+					}
+
+					// Convert the link to the buffer range
+					const bufferRange = convertLinkRangeToBuffer(lines, this.xterm.cols, {
+						startColumn: 1,
+						startLineNumber: 1,
+						endColumn: 1 + link.length,
+						endLineNumber: 1
+					}, startLine);
+
+					const simpleLink: ITerminalSimpleLink = {
+						text: link,
+						uri: linkStat.uri,
+						selection: {
+							startLineNumber: parseInt(toFileLine),
+							startColumn: 1,
+							endLineNumber: parseInt(toFileLine) + parseInt(toFileCount)
+						},
+						bufferRange: bufferRange,
+						type
+					};
+					this._logService.trace('terminalMultiLineLinkDetector#detect verified link', simpleLink);
+					links.push(simpleLink);
+
+					// Break on the first match
+					break;
+				}
+			}
+		}
+
 		return links;
 	}
 


Test patch:
diff --git a/src/vs/workbench/contrib/terminalContrib/links/test/browser/terminalMultiLineLinkDetector.test.ts b/src/vs/workbench/contrib/terminalContrib/links/test/browser/terminalMultiLineLinkDetector.test.ts
--- a/src/vs/workbench/contrib/terminalContrib/links/test/browser/terminalMultiLineLinkDetector.test.ts
+++ b/src/vs/workbench/contrib/terminalContrib/links/test/browser/terminalMultiLineLinkDetector.test.ts
@@ -84,12 +84,18 @@ interface LinkFormatInfo {
 }
 
 const supportedLinkFormats: LinkFormatInfo[] = [
+	// 5: file content...                         [#181837]
+	//   5:3  error                               [#181837]
 	{ urlFormat: '{0}\r\n{1}:foo', line: '5' },
 	{ urlFormat: '{0}\r\n{1}: foo', line: '5' },
 	{ urlFormat: '{0}\r\n5:another link\r\n{1}:{2} foo', line: '5', column: '3' },
 	{ urlFormat: '{0}\r\n  {1}:{2} foo', line: '5', column: '3' },
 	{ urlFormat: '{0}\r\n  5:6  error  another one\r\n  {1}:{2}  error', line: '5', column: '3' },
 	{ urlFormat: `{0}\r\n  5:6  error  ${'a'.repeat(80)}\r\n  {1}:{2}  error`, line: '5', column: '3' },
+
+	// @@ ... <to-file-range> @@ content...       [#182878]   (tests check the entire line, so they don't include the line content at the end of the last @@)
+	{ urlFormat: '+++ b/{0}\r\n@@ -7,6 +{1},7 @@', line: '5' },
+	{ urlFormat: '+++ b/{0}\r\n@@ -1,1 +1,1 @@\r\nfoo\r\nbar\r\n@@ -7,6 +{1},7 @@', line: '5' },
 ];
 
 suite('Workbench - TerminalMultiLineLinkDetector', () => {