diff --git a/src/commandTarget.ts b/src/commandTarget.ts index d5ac622..247b72c 100644 --- a/src/commandTarget.ts +++ b/src/commandTarget.ts @@ -25,7 +25,10 @@ import * as vscode from "vscode"; import { getComparisonHintFromUris, + getGitUriRef, GitComparisonHint, + isRevisionRef, + normalizeFsPath, } from "./gitDiffResolver"; export interface CommandResourceLike { @@ -53,6 +56,17 @@ export interface CommandTarget { modifiedUri?: vscode.Uri; } +/** + * A comparison between two concrete sides, at least one of which names a + * specific git revision. Both sides are kept verbatim - including their refs - + * because the revision is exactly what the working tree and index comparison + * modes cannot represent. + */ +export interface ComparisonUriPair { + readonly originalUri: vscode.Uri; + readonly modifiedUri: vscode.Uri; +} + const gitRefPathPattern = /^[a-zA-Z][a-zA-Z0-9+.-]*:/; const indexMarkers = ["index", "staged"]; const workingTreeMarkers = [ @@ -252,6 +266,70 @@ export function getFileUriFromCommandArg(arg: unknown): vscode.Uri | undefined { return getCommandTarget(arg)?.targetUri; } +/** + * Detects a comparison that names a specific git revision on at least one side, + * such as the commit-to-commit diff opened from the Source Control Graph. + * + * Comparisons that only involve HEAD, the index, or the working tree are left + * alone so that {@link getCommandTarget}'s existing hints keep handling them. + * + * @param originalUri - The left side of the comparison, if known. + * @param modifiedUri - The right side of the comparison, if known. + * @returns Both sides when a revision is involved, otherwise undefined. + */ +export function getRevisionComparison( + originalUri: vscode.Uri | undefined, + modifiedUri: vscode.Uri | undefined, +): ComparisonUriPair | undefined { + if (!originalUri || !modifiedUri) { + return undefined; + } + + const namesRevision = + isRevisionRef(getGitUriRef(originalUri)) || + isRevisionRef(getGitUriRef(modifiedUri)); + + return namesRevision ? { originalUri, modifiedUri } : undefined; +} + +/** + * Reports whether two URIs refer to the same underlying file, ignoring whether + * each one addresses the working tree copy or a git revision of it. + * + * @param candidate - The URI to test. + * @param targetUri - The file the command is acting on. + * @returns True when both address the same path. + */ +export function refersToSameFile( + candidate: vscode.Uri, + targetUri: vscode.Uri, +): boolean { + return ( + normalizeFsPath(toFileBackedUri(candidate).fsPath) === + normalizeFsPath(toFileBackedUri(targetUri).fsPath) + ); +} + +/** + * Reads both sides of the diff editor that currently has focus. + * + * The editor title bar passes the command only the active resource, so when the + * diff was opened from a commit the counterpart revision has to come from the + * tab itself. + * + * @returns Both sides of the active diff tab, or undefined when the active tab + * is not a text diff editor. + */ +export function getActiveDiffTabUriPair(): ComparisonUriPair | undefined { + const input = vscode.window.tabGroups.activeTabGroup?.activeTab?.input; + + if (input instanceof vscode.TabInputTextDiff) { + return { originalUri: input.original, modifiedUri: input.modified }; + } + + return undefined; +} + export const __test__ = { extractComparisonUris, inferComparisonHint, diff --git a/src/extension.ts b/src/extension.ts index 5ec0289..dabb3cb 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -25,14 +25,20 @@ import * as vscode from "vscode"; import { MarkdownDiffProvider } from "./markdownDiff"; import { + describeComparisonSide, GitRepository, resolveSingleFileComparison, tryGetGitApi, } from "./gitDiffResolver"; import { resolveBlameInfo } from "./gitBlameResolver"; import { + CommandTarget, + ComparisonUriPair, + getActiveDiffTabUriPair, getCommandTarget, getFileUriFromCommandArg, + getRevisionComparison, + refersToSameFile, toFileBackedUri, } from "./commandTarget"; import * as path from "path"; @@ -1331,6 +1337,20 @@ export function activate(context: vscode.ExtensionContext) { } // --------------------------- + // A comparison between two revisions - the commit diff opened from the + // Source Control Graph, for instance - carries its refs in the URIs. The + // working tree and index modes below cannot express those, so render the + // two revisions directly. + const revisionComparison = resolveRevisionComparison( + commandTarget, + targetUri, + ); + + if (revisionComparison) { + await showRevisionDiff(revisionComparison, context); + return; + } + const initialComparison = await resolveSingleFileComparison( targetUri, comparisonHint, @@ -1452,6 +1472,75 @@ export function activate(context: vscode.ExtensionContext) { ); } +/** + * Finds a comparison that names a specific git revision for this invocation. + * + * The SCM views pass both sides as command arguments. The editor title bar + * passes only the active resource, so a diff opened from a commit is recovered + * from the focused tab instead - but only when that tab really shows the file + * the command is acting on. + * + * @param commandTarget - The resolved command argument, if there was one. + * @param targetUri - The file the command is acting on. + * @returns The revision comparison to render, or undefined when the existing + * working tree and index modes should handle the invocation. + */ +function resolveRevisionComparison( + commandTarget: CommandTarget | undefined, + targetUri: vscode.Uri, +): ComparisonUriPair | undefined { + const fromArgument = getRevisionComparison( + commandTarget?.originalUri, + commandTarget?.modifiedUri, + ); + + if (fromArgument) { + return fromArgument; + } + + const activePair = getActiveDiffTabUriPair(); + + if (!activePair || !refersToSameFile(activePair.modifiedUri, targetUri)) { + return undefined; + } + + return getRevisionComparison(activePair.originalUri, activePair.modifiedUri); +} + +/** + * Shows the rendered diff between two git revisions of the same document. + * + * @param comparison - The two sides to render. + * @param context - The extension context. + */ +async function showRevisionDiff( + comparison: ComparisonUriPair, + context: vscode.ExtensionContext, +) { + const { originalUri, modifiedUri } = comparison; + const leftLabel = describeComparisonSide(originalUri); + const rightLabel = describeComparisonSide(modifiedUri); + const fileUri = toFileBackedUri(modifiedUri); + + await createAndBindDiffPanel( + `${l10n.t("Markdown Diff")}: ${path.basename(fileUri.fsPath)} (${leftLabel} ↔ ${rightLabel})`, + context, + async () => ({ + originalUri, + modifiedUri, + leftLabel, + rightLabel, + // Committed blobs never change, so only a working tree side is watched. + watchUris: [originalUri, modifiedUri].filter( + (uri) => uri.scheme === "file", + ), + originalImageBaseUri: originalUri, + modifiedImageBaseUri: modifiedUri, + fallbackSourceUri: fileUri, + }), + ); +} + /** * Shows a diff between two specific Markdown files in a webview panel. * diff --git a/src/gitDiffResolver.ts b/src/gitDiffResolver.ts index 0907957..5c33cc6 100644 --- a/src/gitDiffResolver.ts +++ b/src/gitDiffResolver.ts @@ -92,7 +92,23 @@ const HEAD_LABEL = "HEAD"; const INDEX_LABEL = "Staged"; const WORKING_TREE_LABEL = "Working Tree"; -function normalizeFsPath(fsPath: string): string { +/** The ref the Git extension uses for the staged copy of a file. */ +const INDEX_REF = ""; +/** The ref the Git extension uses for the working tree copy of a file. */ +const WORKING_TREE_REF = "~"; + +const FULL_COMMIT_SHA_PATTERN = /^[0-9a-f]{40}$/i; +const SHORT_COMMIT_SHA_LENGTH = 7; + +/** + * Normalizes a file system path for comparison, so that separator style and - + * on Windows - letter case cannot make two references to one file look + * different. + * + * @param fsPath - The path to normalize. + * @returns The comparable form of the path. + */ +export function normalizeFsPath(fsPath: string): string { const normalized = fsPath.replace(/\\/g, "/"); return process.platform === "win32" ? normalized.toLowerCase() : normalized; } @@ -340,6 +356,56 @@ export function getGitUriRef(uri: vscode.Uri | undefined): string | undefined { } } +/** + * Reports whether a git ref names a specific revision - a commit, tag, or + * branch - rather than one of the three well-known refs that the working tree + * and index comparison modes already express. + * + * @param ref - A ref read from a git URI, or undefined for a non-git URI. + * @returns True when the ref names a revision those modes cannot reach. + */ +export function isRevisionRef(ref: string | undefined): boolean { + return ( + ref !== undefined && + ref !== INDEX_REF && + ref !== WORKING_TREE_REF && + ref !== HEAD_LABEL + ); +} + +/** + * Shortens a full commit SHA so that it reads well in a diff panel label. + * + * @param ref - The ref to display. + * @returns The 7-character prefix for a full SHA, otherwise the ref unchanged. + */ +export function shortenRef(ref: string): string { + return FULL_COMMIT_SHA_PATTERN.test(ref) + ? ref.slice(0, SHORT_COMMIT_SHA_LENGTH) + : ref; +} + +/** + * Builds the panel label for one side of a comparison. + * + * @param uri - The URI backing that side of the comparison. + * @returns The ref for a revision side, otherwise the well-known label for + * HEAD, the index, or the working tree. + */ +export function describeComparisonSide(uri: vscode.Uri): string { + const ref = getGitUriRef(uri); + + if (ref === undefined || ref === WORKING_TREE_REF) { + return WORKING_TREE_LABEL; + } + + if (ref === INDEX_REF) { + return INDEX_LABEL; + } + + return shortenRef(ref); +} + export function getComparisonHintFromUris( originalUri?: vscode.Uri, modifiedUri?: vscode.Uri, @@ -347,11 +413,11 @@ export function getComparisonHintFromUris( const originalRef = getGitUriRef(originalUri); const modifiedRef = getGitUriRef(modifiedUri); - if (modifiedRef === "") { + if (modifiedRef === INDEX_REF) { return "index"; } - if (originalRef === "~" || originalRef === "") { + if (originalRef === WORKING_TREE_REF || originalRef === INDEX_REF) { return "workingTree"; } diff --git a/src/test/suite/commandTarget.test.ts b/src/test/suite/commandTarget.test.ts index 8aa2ddb..6f41c7a 100644 --- a/src/test/suite/commandTarget.test.ts +++ b/src/test/suite/commandTarget.test.ts @@ -23,8 +23,17 @@ */ import * as assert from "assert"; +import * as fs from "fs/promises"; +import * as os from "os"; +import * as path from "path"; import * as vscode from "vscode"; -import { __test__, getCommandTarget } from "../../commandTarget"; +import { + __test__, + getActiveDiffTabUriPair, + getCommandTarget, + getRevisionComparison, + refersToSameFile, +} from "../../commandTarget"; describe("Command Target Parsing", () => { const fileUri = vscode.Uri.file("/repo/docs/example.md"); @@ -138,3 +147,168 @@ describe("Command Target Parsing", () => { assert.strictEqual(normalized.toString(), fileUri.toString()); }); }); + +describe("Revision Comparison Detection", () => { + const fileUri = vscode.Uri.file("/repo/docs/example.md"); + const parentSha = "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678"; + const commitSha = "c02e3e4a1b2c3d4e5f60718293a4b5c6d7e8f901"; + + const gitUri = (ref: string) => + fileUri.with({ + scheme: "git", + query: JSON.stringify({ path: fileUri.fsPath, ref }), + }); + + it("should detect a commit-to-commit comparison", () => { + const comparison = getRevisionComparison( + gitUri(parentSha), + gitUri(commitSha), + ); + + assert.ok(comparison, "Expected a revision comparison"); + assert.strictEqual( + comparison?.originalUri.toString(), + gitUri(parentSha).toString(), + ); + assert.strictEqual( + comparison?.modifiedUri.toString(), + gitUri(commitSha).toString(), + ); + }); + + it("should detect a comparison between a commit and the working tree", () => { + const comparison = getRevisionComparison(gitUri(commitSha), fileUri); + + assert.ok(comparison, "Expected a revision comparison"); + assert.strictEqual(comparison?.modifiedUri.toString(), fileUri.toString()); + }); + + it("should ignore HEAD-to-working-tree comparisons", () => { + assert.strictEqual( + getRevisionComparison(gitUri("HEAD"), fileUri), + undefined, + ); + }); + + it("should ignore staged comparisons", () => { + assert.strictEqual( + getRevisionComparison(gitUri("HEAD"), gitUri("")), + undefined, + ); + }); + + it("should ignore working-tree comparisons", () => { + assert.strictEqual(getRevisionComparison(gitUri("~"), fileUri), undefined); + }); + + it("should ignore comparisons between plain files", () => { + const otherUri = vscode.Uri.file("/repo/docs/other.md"); + assert.strictEqual(getRevisionComparison(fileUri, otherUri), undefined); + }); + + it("should ignore comparisons with a missing side", () => { + assert.strictEqual( + getRevisionComparison(gitUri(commitSha), undefined), + undefined, + ); + assert.strictEqual( + getRevisionComparison(undefined, gitUri(commitSha)), + undefined, + ); + }); +}); + +describe("Active Diff Tab Detection", () => { + const createdDirs: string[] = []; + + async function createMarkdownFile( + dir: string, + name: string, + contents: string, + ): Promise { + const filePath = path.join(dir, name); + await fs.writeFile(filePath, contents, "utf8"); + return vscode.Uri.file(filePath); + } + + async function createTempDir(): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "rmd-diff-tab-")); + createdDirs.push(dir); + return dir; + } + + afterEach(async () => { + await vscode.commands.executeCommand("workbench.action.closeAllEditors"); + + // Windows can still hold a handle on a just-closed editor, so removing the + // scratch directory is best effort - it lives under the OS temp directory. + await Promise.all( + createdDirs.splice(0).map(async (dir) => { + try { + await fs.rm(dir, { recursive: true, force: true }); + } catch { + // Leave the directory for the OS to reclaim. + } + }), + ); + }); + + it("should read both sides of the active diff editor", async () => { + const dir = await createTempDir(); + const originalUri = await createMarkdownFile(dir, "left.md", "# One\n"); + const modifiedUri = await createMarkdownFile(dir, "right.md", "# Two\n"); + + await vscode.commands.executeCommand( + "vscode.diff", + originalUri, + modifiedUri, + "Rich Markdown Diff Test", + ); + + const pair = getActiveDiffTabUriPair(); + + assert.ok(pair, "Expected the active diff tab to be detected"); + assert.strictEqual(pair?.originalUri.toString(), originalUri.toString()); + assert.strictEqual(pair?.modifiedUri.toString(), modifiedUri.toString()); + }); + + it("should ignore a plain text editor", async () => { + const dir = await createTempDir(); + const fileUri = await createMarkdownFile(dir, "single.md", "# Only\n"); + + const document = await vscode.workspace.openTextDocument(fileUri); + await vscode.window.showTextDocument(document); + + assert.strictEqual(getActiveDiffTabUriPair(), undefined); + }); +}); + +describe("Same File Detection", () => { + const fileUri = vscode.Uri.file("/repo/docs/example.md"); + const commitSha = "c02e3e4a1b2c3d4e5f60718293a4b5c6d7e8f901"; + + const gitUri = (uri: vscode.Uri, ref: string) => + uri.with({ + scheme: "git", + query: JSON.stringify({ path: uri.fsPath, ref }), + }); + + it("should match a git revision URI against its working tree file", () => { + assert.strictEqual(refersToSameFile(gitUri(fileUri, commitSha), fileUri), true); + }); + + it("should match two revisions of the same file", () => { + assert.strictEqual( + refersToSameFile(gitUri(fileUri, commitSha), gitUri(fileUri, "HEAD")), + true, + ); + }); + + it("should not match different files", () => { + const otherUri = vscode.Uri.file("/repo/docs/other.md"); + assert.strictEqual( + refersToSameFile(gitUri(otherUri, commitSha), fileUri), + false, + ); + }); +}); diff --git a/src/test/suite/gitDiffResolver.test.ts b/src/test/suite/gitDiffResolver.test.ts index 93ee819..6f91ab1 100644 --- a/src/test/suite/gitDiffResolver.test.ts +++ b/src/test/suite/gitDiffResolver.test.ts @@ -25,12 +25,15 @@ import * as assert from "assert"; import * as vscode from "vscode"; import { + describeComparisonSide, getComparisonHintFromUris, getGitUriRef, GitApi, GitChange, GitRepository, + isRevisionRef, resolveSingleFileComparison, + shortenRef, } from "../../gitDiffResolver"; class FakeRepository implements GitRepository { @@ -227,3 +230,75 @@ describe("Git Diff Resolver", () => { assert.strictEqual(comparison.modifiedUri?.toString(), fileUri.toString()); }); }); + +describe("Revision Refs", () => { + const commitSha = "c02e3e4a1b2c3d4e5f60718293a4b5c6d7e8f901"; + + it("should treat a commit SHA as a revision", () => { + assert.strictEqual(isRevisionRef(commitSha), true); + }); + + it("should treat branch and tag names as revisions", () => { + assert.strictEqual(isRevisionRef("main"), true); + assert.strictEqual(isRevisionRef("v1.4.0"), true); + assert.strictEqual(isRevisionRef("HEAD~3"), true); + }); + + it("should not treat the index ref as a revision", () => { + assert.strictEqual(isRevisionRef(""), false); + }); + + it("should not treat the working tree ref as a revision", () => { + assert.strictEqual(isRevisionRef("~"), false); + }); + + it("should not treat HEAD as a revision", () => { + assert.strictEqual(isRevisionRef("HEAD"), false); + }); + + it("should not treat a missing ref as a revision", () => { + assert.strictEqual(isRevisionRef(undefined), false); + }); + + it("should shorten a full commit SHA for display", () => { + assert.strictEqual(shortenRef(commitSha), "c02e3e4"); + }); + + it("should leave branch names and short refs unshortened", () => { + assert.strictEqual(shortenRef("main"), "main"); + assert.strictEqual(shortenRef("v1.4.0"), "v1.4.0"); + assert.strictEqual(shortenRef("c02e3e4"), "c02e3e4"); + }); +}); + +describe("Comparison Side Labels", () => { + const fileUri = vscode.Uri.file("/repo/docs/example.md"); + const commitSha = "c02e3e4a1b2c3d4e5f60718293a4b5c6d7e8f901"; + + const gitUri = (ref: string) => + fileUri.with({ + scheme: "git", + query: JSON.stringify({ path: fileUri.fsPath, ref }), + }); + + it("should label a commit side with its shortened SHA", () => { + assert.strictEqual(describeComparisonSide(gitUri(commitSha)), "c02e3e4"); + }); + + it("should label a branch side with its name", () => { + assert.strictEqual(describeComparisonSide(gitUri("main")), "main"); + }); + + it("should label the HEAD side", () => { + assert.strictEqual(describeComparisonSide(gitUri("HEAD")), "HEAD"); + }); + + it("should label the index side as staged", () => { + assert.strictEqual(describeComparisonSide(gitUri("")), "Staged"); + }); + + it("should label the working tree side", () => { + assert.strictEqual(describeComparisonSide(gitUri("~")), "Working Tree"); + assert.strictEqual(describeComparisonSide(fileUri), "Working Tree"); + }); +});