Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions src/commandTarget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@
import * as vscode from "vscode";
import {
getComparisonHintFromUris,
getGitUriRef,
GitComparisonHint,
isRevisionRef,
normalizeFsPath,
} from "./gitDiffResolver";

export interface CommandResourceLike {
Expand Down Expand Up @@ -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 = [
Expand Down Expand Up @@ -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,
Expand Down
89 changes: 89 additions & 0 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
*
Expand Down
72 changes: 69 additions & 3 deletions src/gitDiffResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -340,18 +356,68 @@ 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,
): GitComparisonHint {
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";
}

Expand Down
Loading