diff --git a/src/core/config.test.ts b/src/core/config.test.ts index f1e28080..582d5360 100644 --- a/src/core/config.test.ts +++ b/src/core/config.test.ts @@ -58,6 +58,7 @@ describe("config resolution", () => { [ 'theme = "graphite"', "line_numbers = false", + "color_moved = true", "", "[patch]", 'mode = "split"', @@ -87,6 +88,7 @@ describe("config resolution", () => { wrapLines: true, hunkHeaders: false, agentNotes: true, + colorMoved: true, }); }); diff --git a/src/core/config.ts b/src/core/config.ts index 21f12fd3..13cbfdac 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -63,6 +63,7 @@ function readConfigPreferences(source: Record): CommonOptions { wrapLines: normalizeBoolean(source.wrap_lines), hunkHeaders: normalizeBoolean(source.hunk_headers), agentNotes: normalizeBoolean(source.agent_notes), + colorMoved: normalizeBoolean(source.color_moved), }; } @@ -81,6 +82,7 @@ function mergeOptions(base: CommonOptions, overrides: CommonOptions): CommonOpti wrapLines: overrides.wrapLines ?? base.wrapLines, hunkHeaders: overrides.hunkHeaders ?? base.hunkHeaders, agentNotes: overrides.agentNotes ?? base.agentNotes, + colorMoved: overrides.colorMoved ?? base.colorMoved, }; } @@ -194,6 +196,7 @@ export function resolveConfiguredCliInput( wrapLines: resolvedOptions.wrapLines ?? DEFAULT_VIEW_PREFERENCES.wrapLines, hunkHeaders: resolvedOptions.hunkHeaders ?? DEFAULT_VIEW_PREFERENCES.showHunkHeaders, agentNotes: resolvedOptions.agentNotes ?? DEFAULT_VIEW_PREFERENCES.showAgentNotes, + colorMoved: resolvedOptions.colorMoved, }; return { diff --git a/src/core/git.test.ts b/src/core/git.test.ts index 45713747..33e6356d 100644 --- a/src/core/git.test.ts +++ b/src/core/git.test.ts @@ -1,7 +1,26 @@ import { describe, expect, test } from "bun:test"; -import { buildGitStashShowArgs, runGitText } from "./git"; +import { buildGitDiffArgs, buildGitStashShowArgs, runGitText } from "./git"; describe("git command helpers", () => { + test("enables deterministic color-moved output for patch parsing", () => { + const args = buildGitDiffArgs( + { + kind: "vcs", + staged: false, + options: { mode: "auto" }, + }, + [], + { mode: "zebra", whitespaceMode: "allow-indentation-change" }, + ); + + expect(args).toContain("--color=always"); + expect(args).toContain("--color-moved=zebra"); + expect(args).toContain("--color-moved-ws=allow-indentation-change"); + expect(args).not.toContain("--no-color"); + expect(args).toContain("color.diff.oldMoved=magenta bold"); + expect(args).toContain("color.diff.newMoved=cyan bold"); + }); + test("disables external diff tools for stash patches", () => { const args = buildGitStashShowArgs({ kind: "stash-show", diff --git a/src/core/git.ts b/src/core/git.ts index f2f6243a..32d67ece 100644 --- a/src/core/git.ts +++ b/src/core/git.ts @@ -21,6 +21,11 @@ interface RunGitCommandOptions extends RunGitTextOptions { acceptedExitCodes?: number[]; } +export interface GitColorMovedOptions { + mode: string; + whitespaceMode?: string; +} + /** Append Git pathspec arguments only when the caller requested them. */ export function appendGitPathspecs(args: string[], pathspecs?: string[]) { if (!pathspecs || pathspecs.length === 0) { @@ -44,13 +49,58 @@ const DIFF_PREFIX_NORMALIZATION_ARGS = [ "diff.dstPrefix=b/", ]; +const GIT_MOVED_LINE_COLOR_CONFIG = [ + "-c", + "color.diff.oldMoved=magenta bold", + "-c", + "color.diff.oldMovedAlternative=magenta bold", + "-c", + "color.diff.oldMovedDimmed=magenta dim", + "-c", + "color.diff.oldMovedAlternativeDimmed=magenta dim", + "-c", + "color.diff.newMoved=cyan bold", + "-c", + "color.diff.newMovedAlternative=cyan bold", + "-c", + "color.diff.newMovedDimmed=cyan dim", + "-c", + "color.diff.newMovedAlternativeDimmed=cyan dim", +]; + function withNormalizedDiffPrefixes(args: string[]) { return [...DIFF_PREFIX_NORMALIZATION_ARGS, ...args]; } +/** Return Git color flags for patch commands, enabling ANSI only when Hunk needs move classes. */ +function gitPatchColorArgs(colorMoved: GitColorMovedOptions | null) { + if (!colorMoved) { + return ["--no-color"]; + } + + return [ + "--color=always", + `--color-moved=${colorMoved.mode}`, + ...(colorMoved.whitespaceMode ? [`--color-moved-ws=${colorMoved.whitespaceMode}`] : []), + ]; +} + +/** Add deterministic moved-line colors so the parser can classify Git's ANSI output reliably. */ +function withGitMovedLineColorConfig(args: string[], colorMoved: GitColorMovedOptions | null) { + if (!colorMoved) { + return args; + } + + return [...GIT_MOVED_LINE_COLOR_CONFIG, ...args]; +} + /** Build the exact `git diff` arguments used for the shared working-tree and range review path. */ -export function buildGitDiffArgs(input: VcsCommandInput, excludedPathspecs: string[] = []) { - const args = ["diff", "--no-ext-diff", "--find-renames", "--no-color"]; +export function buildGitDiffArgs( + input: VcsCommandInput, + excludedPathspecs: string[] = [], + colorMoved: GitColorMovedOptions | null = null, +) { + const args = ["diff", "--no-ext-diff", "--find-renames", ...gitPatchColorArgs(colorMoved)]; if (input.staged) { args.push("--staged"); @@ -70,7 +120,7 @@ export function buildGitDiffArgs(input: VcsCommandInput, excludedPathspecs: stri appendGitPathspecs(args, input.pathspecs); } - return withNormalizedDiffPrefixes(args); + return withNormalizedDiffPrefixes(withGitMovedLineColorConfig(args, colorMoved)); } /** Build the cheap tracked-file stats query used to skip huge file diffs before patch output. */ @@ -113,26 +163,45 @@ function buildGitNewFileDiffArgs(filePath: string) { } /** Build the exact `git show` arguments used for commit review. */ -export function buildGitShowArgs(input: ShowCommandInput) { - const args = ["show", "--format=", "--no-ext-diff", "--find-renames", "--no-color"]; +export function buildGitShowArgs( + input: ShowCommandInput, + colorMoved: GitColorMovedOptions | null = null, +) { + const args = [ + "show", + "--format=", + "--no-ext-diff", + "--find-renames", + ...gitPatchColorArgs(colorMoved), + ]; if (input.ref) { args.push(input.ref); } appendGitPathspecs(args, input.pathspecs); - return withNormalizedDiffPrefixes(args); + return withNormalizedDiffPrefixes(withGitMovedLineColorConfig(args, colorMoved)); } /** Build the exact `git stash show -p` arguments used for stash review. */ -export function buildGitStashShowArgs(input: StashShowCommandInput) { - const args = ["stash", "show", "-p", "--no-ext-diff", "--find-renames", "--no-color"]; +export function buildGitStashShowArgs( + input: StashShowCommandInput, + colorMoved: GitColorMovedOptions | null = null, +) { + const args = [ + "stash", + "show", + "-p", + "--no-ext-diff", + "--find-renames", + ...gitPatchColorArgs(colorMoved), + ]; if (input.ref) { args.push(input.ref); } - return withNormalizedDiffPrefixes(args); + return withNormalizedDiffPrefixes(withGitMovedLineColorConfig(args, colorMoved)); } export function formatGitCommandLabel(input: GitBackedInput) { @@ -323,6 +392,72 @@ export function runGitText(options: RunGitTextOptions) { return runGitCommand(options).stdout; } +const GIT_BOOLEAN_TRUE_VALUES = new Set(["true", "yes", "on", "1", "always"]); +const GIT_BOOLEAN_FALSE_VALUES = new Set(["false", "no", "off", "0", "never"]); + +/** Read an optional Git config value without treating an unset key as an error. */ +function readOptionalGitConfig( + input: GitBackedInput, + key: string, + options: Omit = {}, +) { + const result = runGitCommand({ + input, + args: ["config", "--get", key], + ...options, + acceptedExitCodes: [0, 1], + }); + + if (result.exitCode !== 0) { + return undefined; + } + + return result.stdout.trim() || undefined; +} + +/** Normalize Git's diff.colorMoved config into the mode Hunk should request from Git. */ +function normalizeGitColorMovedMode(value: string | undefined) { + if (!value) { + return undefined; + } + + const normalized = value.toLowerCase(); + if (GIT_BOOLEAN_FALSE_VALUES.has(normalized) || normalized === "no") { + return null; + } + + if (GIT_BOOLEAN_TRUE_VALUES.has(normalized)) { + return "zebra"; + } + + return value; +} + +/** Resolve whether Hunk should ask Git to color moved lines for this patch command. */ +export function resolveGitColorMovedOptions( + input: GitBackedInput, + options: Omit = {}, +): GitColorMovedOptions | null { + const gitMode = normalizeGitColorMovedMode( + readOptionalGitConfig(input, "diff.colorMoved", options), + ); + + if (gitMode === null) { + return null; + } + + const mode = gitMode ?? (input.options.colorMoved ? "zebra" : undefined); + if (!mode) { + return null; + } + + const whitespaceMode = readOptionalGitConfig(input, "diff.colorMovedWS", options); + return { + mode, + whitespaceMode, + }; +} + /** * Return whether one `hunk diff` input still compares against the live working tree. * diff --git a/src/core/loaders.test.ts b/src/core/loaders.test.ts index a458e63a..e393c122 100644 --- a/src/core/loaders.test.ts +++ b/src/core/loaders.test.ts @@ -630,6 +630,62 @@ describe("loadAppBootstrap", () => { ]); }); + test("tags moved lines from git diff.colorMoved output", async () => { + const dir = createTempRepo("hunk-git-color-moved-"); + + writeFileSync( + join(dir, "example.txt"), + [ + "start anchor", + "relocated block first line has many chars", + "relocated block second line has many chars", + "relocated block third line has many chars", + "middle unchanged one has many chars", + "middle unchanged two has many chars", + "end anchor", + "", + ].join("\n"), + ); + git(dir, "add", "example.txt"); + git(dir, "commit", "-m", "initial"); + git(dir, "config", "--local", "diff.colorMoved", "zebra"); + + writeFileSync( + join(dir, "example.txt"), + [ + "start anchor", + "middle unchanged one has many chars", + "middle unchanged two has many chars", + "relocated block first line has many chars", + "relocated block second line has many chars", + "relocated block third line has many chars", + "end anchor", + "", + ].join("\n"), + ); + + const bootstrap = await loadFromRepo(dir, { + kind: "vcs", + staged: false, + options: { mode: "auto" }, + }); + const file = bootstrap.changeset.files[0]; + + expect(file?.path).toBe("example.txt"); + expect(file?.lineMoveKinds?.additionLines.some(Boolean)).toBe(true); + expect(file?.lineMoveKinds?.deletionLines.some(Boolean)).toBe(true); + + const movedAdditions = file?.metadata.additionLines.filter( + (_line, index) => file.lineMoveKinds?.additionLines[index] === "moved", + ); + const movedDeletions = file?.metadata.deletionLines.filter( + (_line, index) => file.lineMoveKinds?.deletionLines[index] === "moved", + ); + + expect(movedAdditions).toContain("middle unchanged one has many chars\n"); + expect(movedDeletions).toContain("middle unchanged one has many chars\n"); + }); + test("reports a friendly error when git review runs outside a repository", async () => { const dir = mkdtempSync(join(tmpdir(), "hunk-nonrepo-")); tempDirs.push(dir); diff --git a/src/core/loaders.ts b/src/core/loaders.ts index c53e6880..6e799a59 100644 --- a/src/core/loaders.ts +++ b/src/core/loaders.ts @@ -18,6 +18,7 @@ import { buildGitShowArgs, buildGitStashShowArgs, listGitUntrackedFiles, + resolveGitColorMovedOptions, resolveGitRepoRoot, runGitText, runGitUntrackedFileDiffText, @@ -35,6 +36,8 @@ import type { Changeset, CliInput, DiffFile, + DiffLineMoveKind, + DiffLineMoveKinds, DiffToolCommandInput, FileCommandInput, VcsCommandInput, @@ -70,6 +73,115 @@ function stripTerminalControl(text: string) { .replace(/\x1b[@-_]/g, ""); } +/** Return SGR parameter strings that Git emitted before one diff line marker. */ +function leadingSgrParameters(rawLine: string, expectedSign: "+" | "-") { + const parameters: string[] = []; + let index = 0; + + while (index < rawLine.length) { + if (rawLine[index] === "\x1b") { + const csi = rawLine.slice(index).match(/^\x1b\[([0-?]*)([ -/]*)([@-~])/); + if (csi) { + if (csi[3] === "m") { + parameters.push(csi[1] ?? ""); + } + index += csi[0].length; + continue; + } + } + + return rawLine[index] === expectedSign ? parameters : []; + } + + return []; +} + +/** Return whether one SGR parameter list contains the Git color Hunk reserves for moved lines. */ +function sgrContainsColor(parameters: string[], colorCode: "35" | "36") { + return parameters.some((parameter) => parameter.split(";").includes(colorCode)); +} + +/** Classify one ANSI-colored Git diff line as moved when it carries Hunk's reserved color. */ +function movedLineKindFromAnsi( + rawLine: string, + side: "addition" | "deletion", +): DiffLineMoveKind | undefined { + const colorCode = side === "addition" ? "36" : "35"; + const sign = side === "addition" ? "+" : "-"; + return sgrContainsColor(leadingSgrParameters(rawLine, sign), colorCode) ? "moved" : undefined; +} + +/** Capture Git's color-moved ANSI classes before the normal patch parser strips colors. */ +function collectLineMoveKinds(patchText: string): DiffLineMoveKinds[] { + const files: DiffLineMoveKinds[] = []; + let current: DiffLineMoveKinds | null = null; + let inHunk = false; + let additionLineIndex = 0; + let deletionLineIndex = 0; + + const createFileMoveKinds = () => { + const moveKinds: DiffLineMoveKinds = { additionLines: [], deletionLines: [] }; + files.push(moveKinds); + inHunk = false; + additionLineIndex = 0; + deletionLineIndex = 0; + return moveKinds; + }; + + for (const rawLine of patchText.replaceAll("\r\n", "\n").split("\n")) { + const plainLine = stripTerminalControl(rawLine); + + if (plainLine.startsWith("diff --git ")) { + current = createFileMoveKinds(); + continue; + } + + if (!current && (plainLine.startsWith("--- ") || plainLine.startsWith("@@ "))) { + current = createFileMoveKinds(); + } + + const activeMoveKinds = current; + if (!activeMoveKinds) { + continue; + } + + if (plainLine.startsWith("@@ ")) { + inHunk = true; + continue; + } + + if (!inHunk) { + continue; + } + + if (plainLine.startsWith("+") && !plainLine.startsWith("+++")) { + activeMoveKinds.additionLines[additionLineIndex] = movedLineKindFromAnsi(rawLine, "addition"); + additionLineIndex += 1; + continue; + } + + if (plainLine.startsWith("-") && !plainLine.startsWith("---")) { + activeMoveKinds.deletionLines[deletionLineIndex] = movedLineKindFromAnsi(rawLine, "deletion"); + deletionLineIndex += 1; + continue; + } + + if (plainLine.startsWith(" ")) { + additionLineIndex += 1; + deletionLineIndex += 1; + } + } + + return files; +} + +/** Return whether a parsed moved-line map has at least one classified line. */ +function hasLineMoveKinds(moveKinds: DiffLineMoveKinds | undefined) { + return Boolean( + moveKinds && (moveKinds.additionLines.some(Boolean) || moveKinds.deletionLines.some(Boolean)), + ); +} + /** * Strip `git log -p` / `git show -p` commit metadata so the surviving text * is a plain patch stream that `@pierre/diffs` can parse without spamming @@ -214,6 +326,7 @@ interface BuildDiffFileOptions { isTooLarge?: boolean; stats?: DiffFile["stats"]; statsTruncated?: boolean; + lineMoveKinds?: DiffLineMoveKinds; } /** Build the normalized per-file model used by the UI regardless of input mode. */ @@ -230,6 +343,7 @@ function buildDiffFile( isTooLarge, stats, statsTruncated, + lineMoveKinds, }: BuildDiffFileOptions = {}, ): DiffFile { const normalizedMetadata = normalizeDiffMetadataPaths(metadata); @@ -244,6 +358,7 @@ function buildDiffFile( language: getFiletypeFromFileName(path) ?? undefined, stats: stats ?? countDiffStats(normalizedMetadata), metadata: normalizedMetadata, + lineMoveKinds, agent: findAgentFileContext(agentContext, path, resolvedPreviousPath), isUntracked, isBinary: isBinary ?? patchLooksBinary(patch), @@ -818,8 +933,10 @@ function normalizePatchChangeset( sourceLabel: string, agentContext: AgentContext | null, ): Changeset { + const rawPatchText = patchText.replaceAll("\r\n", "\n"); + const lineMoveKinds = collectLineMoveKinds(rawPatchText); const normalizedPatchText = normalizeGitPatchPrefixes( - stripGitLogMetadata(stripTerminalControl(patchText.replaceAll("\r\n", "\n"))), + stripGitLogMetadata(stripTerminalControl(rawPatchText)), ); let parsedPatches: ReturnType; @@ -856,6 +973,9 @@ function normalizePatchChangeset( index, sourceLabel, agentContext, + { + lineMoveKinds: hasLineMoveKinds(lineMoveKinds[index]) ? lineMoveKinds[index] : undefined, + }, ), ), }; @@ -982,12 +1102,14 @@ async function loadGitChangeset( const largeTrackedFiles = parseGitNumstat( runGitText({ input, args: buildGitDiffNumstatArgs(input), cwd }), ).filter((file) => shouldSkipLargeTrackedDiff(file, repoRoot)); + const colorMoved = resolveGitColorMovedOptions(input, { cwd }); const trackedChangeset = normalizePatchChangeset( runGitText({ input, args: buildGitDiffArgs( input, largeTrackedFiles.map((file) => file.path), + colorMoved, ), cwd, }), @@ -1063,9 +1185,10 @@ async function loadShowChangeset( ) { const repoRoot = resolveGitRepoRoot(input, { cwd }); const repoName = basename(repoRoot); + const colorMoved = resolveGitColorMovedOptions(input, { cwd }); return normalizePatchChangeset( - runGitText({ input, args: buildGitShowArgs(input), cwd }), + runGitText({ input, args: buildGitShowArgs(input, colorMoved), cwd }), input.ref ? `${repoName} show ${input.ref}` : `${repoName} show HEAD`, repoRoot, agentContext, @@ -1104,9 +1227,10 @@ async function loadStashShowChangeset( const repoRoot = resolveGitRepoRoot(input, { cwd }); const repoName = basename(repoRoot); + const colorMoved = resolveGitColorMovedOptions(input, { cwd }); return normalizePatchChangeset( - runGitText({ input, args: buildGitStashShowArgs(input), cwd }), + runGitText({ input, args: buildGitStashShowArgs(input, colorMoved), cwd }), input.ref ? `${repoName} stash ${input.ref}` : `${repoName} stash`, repoRoot, agentContext, diff --git a/src/core/types.ts b/src/core/types.ts index b79fa395..f8e36081 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -39,6 +39,7 @@ export interface DiffFile { deletions: number; }; metadata: FileDiffMetadata; + lineMoveKinds?: DiffLineMoveKinds; agent: AgentFileContext | null; isUntracked?: boolean; isBinary?: boolean; @@ -46,6 +47,13 @@ export interface DiffFile { statsTruncated?: boolean; } +export type DiffLineMoveKind = "moved"; + +export interface DiffLineMoveKinds { + additionLines: Array; + deletionLines: Array; +} + export interface Changeset { id: string; sourceLabel: string; @@ -67,6 +75,7 @@ export interface CommonOptions { wrapLines?: boolean; hunkHeaders?: boolean; agentNotes?: boolean; + colorMoved?: boolean; } export interface PersistedViewPreferences { diff --git a/src/ui/diff/pierre.test.ts b/src/ui/diff/pierre.test.ts index cb64908f..ac78d8bd 100644 --- a/src/ui/diff/pierre.test.ts +++ b/src/ui/diff/pierre.test.ts @@ -3,6 +3,7 @@ import { parseDiffFromFile } from "@pierre/diffs"; import type { DiffFile } from "../../core/types"; import { buildSplitRows, buildStackRows, loadHighlightedDiff, type DiffRow } from "./pierre"; import { resolveTheme } from "../themes"; +import { stackCellPalette } from "./rowStyle"; function createDiffFile(): DiffFile { const metadata = parseDiffFromFile( @@ -164,6 +165,42 @@ describe("Pierre diff rows", () => { expect(additionRow.cell.newLineNumber).toBe(1); }); + test("carries moved-line tags into row palettes", () => { + const file = createDiffFile(); + file.lineMoveKinds = { + deletionLines: ["moved"], + additionLines: ["moved"], + }; + const theme = resolveTheme("graphite", null); + const rows = buildStackRows(file, null, theme); + const movedDeletion = rows.find( + (row) => row.type === "stack-line" && row.cell.kind === "deletion", + ); + const movedAddition = rows.find( + (row) => row.type === "stack-line" && row.cell.kind === "addition", + ); + + expect(movedDeletion).toBeDefined(); + expect(movedAddition).toBeDefined(); + + if (!movedDeletion || movedDeletion.type !== "stack-line") { + throw new Error("Expected a moved deletion row"); + } + + if (!movedAddition || movedAddition.type !== "stack-line") { + throw new Error("Expected a moved addition row"); + } + + expect(movedDeletion.cell.moveKind).toBe("moved"); + expect(movedAddition.cell.moveKind).toBe("moved"); + expect( + stackCellPalette(movedDeletion.cell.kind, theme, movedDeletion.cell.moveKind).contentBg, + ).toBe(theme.movedRemovedBg); + expect( + stackCellPalette(movedAddition.cell.kind, theme, movedAddition.cell.moveKind).contentBg, + ).toBe(theme.movedAddedBg); + }); + test("does not produce newline characters in spans for highlighted empty lines", async () => { const file = createEmptyLineDiffFile(); const theme = resolveTheme("midnight", null); diff --git a/src/ui/diff/pierre.ts b/src/ui/diff/pierre.ts index 7d99b3d2..9cbaf027 100644 --- a/src/ui/diff/pierre.ts +++ b/src/ui/diff/pierre.ts @@ -6,7 +6,7 @@ import { type FileDiffMetadata, } from "@pierre/diffs"; import { formatHunkHeader } from "../../core/hunkHeader"; -import type { DiffFile } from "../../core/types"; +import type { DiffFile, DiffLineMoveKind } from "../../core/types"; import { blendHex, hexColorDistance } from "../lib/color"; import type { AppTheme } from "../themes"; import { expandDiffTabs } from "./codeColumns"; @@ -77,6 +77,7 @@ export interface SplitLineCell { kind: "context" | "addition" | "deletion" | "empty"; sign: string; lineNumber?: number; + moveKind?: DiffLineMoveKind; spans: RenderSpan[]; } @@ -85,6 +86,7 @@ export interface StackLineCell { sign: string; oldLineNumber?: number; newLineNumber?: number; + moveKind?: DiffLineMoveKind; spans: RenderSpan[]; } @@ -336,6 +338,7 @@ function makeSplitCell( rawLine: string | undefined, highlightedLine: HastNode | undefined, theme: AppTheme, + moveKind?: DiffLineMoveKind, ) { if (kind === "empty") { return { @@ -365,6 +368,7 @@ function makeSplitCell( kind, sign: kind === "addition" ? "+" : kind === "deletion" ? "-" : " ", lineNumber, + moveKind, spans, } satisfies SplitLineCell; } @@ -377,6 +381,7 @@ function makeStackCell( rawLine: string | undefined, highlightedLine: HastNode | undefined, theme: AppTheme, + moveKind?: DiffLineMoveKind, ) { // Same lazy-fallback strategy as split cells: only normalize the raw source line when we really // need the plain-text fallback, not when highlighted spans are already ready to reuse. @@ -398,6 +403,7 @@ function makeStackCell( sign: kind === "addition" ? "+" : kind === "deletion" ? "-" : " ", oldLineNumber, newLineNumber, + moveKind, spans, } satisfies StackLineCell; } @@ -628,6 +634,7 @@ export function buildSplitRows( file.metadata.deletionLines[deletionLineIndex + offset], deletionLines[deletionLineIndex + offset], theme, + file.lineMoveKinds?.deletionLines[deletionLineIndex + offset], ) : makeSplitCell("empty", undefined, undefined, undefined, theme), right: hasAddition @@ -637,6 +644,7 @@ export function buildSplitRows( file.metadata.additionLines[additionLineIndex + offset], additionLines[additionLineIndex + offset], theme, + file.lineMoveKinds?.additionLines[additionLineIndex + offset], ) : makeSplitCell("empty", undefined, undefined, undefined, theme), }); @@ -736,6 +744,7 @@ export function buildStackRows( file.metadata.deletionLines[deletionLineIndex + offset], deletionLines[deletionLineIndex + offset], theme, + file.lineMoveKinds?.deletionLines[deletionLineIndex + offset], ), }); } @@ -753,6 +762,7 @@ export function buildStackRows( file.metadata.additionLines[additionLineIndex + offset], additionLines[additionLineIndex + offset], theme, + file.lineMoveKinds?.additionLines[additionLineIndex + offset], ), }); } diff --git a/src/ui/diff/renderRows.tsx b/src/ui/diff/renderRows.tsx index 3576e074..b4cc6e04 100644 --- a/src/ui/diff/renderRows.tsx +++ b/src/ui/diff/renderRows.tsx @@ -205,7 +205,7 @@ function buildWrappedSplitCell( prefixWidth: number, theme: AppTheme, ) { - const palette = splitCellPalette(cell.kind, theme); + const palette = splitCellPalette(cell.kind, theme, cell.moveKind); const { gutterWidth, contentWidth } = resolveSplitCellGeometry( width, lineNumberDigits, @@ -238,7 +238,7 @@ function buildWrappedStackCell( prefixWidth: number, theme: AppTheme, ) { - const palette = stackCellPalette(cell.kind, theme); + const palette = stackCellPalette(cell.kind, theme, cell.moveKind); const { gutterWidth, contentWidth } = resolveStackCellGeometry( width, lineNumberDigits, @@ -275,7 +275,7 @@ function renderSplitCell( bg: string; }, ) { - const palette = splitCellPalette(cell.kind, theme); + const palette = splitCellPalette(cell.kind, theme, cell.moveKind); const prefixWidth = prefix?.text.length ?? 0; const { gutterWidth, contentWidth } = resolveSplitCellGeometry( width, @@ -326,7 +326,7 @@ function renderStackCell( bg: string; }, ) { - const palette = stackCellPalette(cell.kind, theme); + const palette = stackCellPalette(cell.kind, theme, cell.moveKind); const prefixWidth = prefix?.text.length ?? 0; const { gutterWidth, contentWidth } = resolveStackCellGeometry( width, diff --git a/src/ui/diff/rowStyle.ts b/src/ui/diff/rowStyle.ts index 74f81bad..e05088f5 100644 --- a/src/ui/diff/rowStyle.ts +++ b/src/ui/diff/rowStyle.ts @@ -55,11 +55,15 @@ export function splitRightRailColor( } /** Pick split-view colors from the semantic diff cell kind. */ -export function splitCellPalette(kind: SplitLineCell["kind"], theme: AppTheme) { +export function splitCellPalette( + kind: SplitLineCell["kind"], + theme: AppTheme, + moveKind?: SplitLineCell["moveKind"], +) { if (kind === "addition") { return { - gutterBg: theme.addedBg, - contentBg: theme.addedBg, + gutterBg: moveKind ? theme.movedAddedBg : theme.addedBg, + contentBg: moveKind ? theme.movedAddedBg : theme.addedBg, signColor: theme.addedSignColor, numberColor: theme.addedSignColor, }; @@ -67,8 +71,8 @@ export function splitCellPalette(kind: SplitLineCell["kind"], theme: AppTheme) { if (kind === "deletion") { return { - gutterBg: theme.removedBg, - contentBg: theme.removedBg, + gutterBg: moveKind ? theme.movedRemovedBg : theme.removedBg, + contentBg: moveKind ? theme.movedRemovedBg : theme.removedBg, signColor: theme.removedSignColor, numberColor: theme.removedSignColor, }; @@ -92,11 +96,15 @@ export function splitCellPalette(kind: SplitLineCell["kind"], theme: AppTheme) { } /** Pick stack-view colors from the semantic diff cell kind. */ -export function stackCellPalette(kind: StackLineCell["kind"], theme: AppTheme) { +export function stackCellPalette( + kind: StackLineCell["kind"], + theme: AppTheme, + moveKind?: StackLineCell["moveKind"], +) { if (kind === "addition") { return { - gutterBg: theme.addedBg, - contentBg: theme.addedBg, + gutterBg: moveKind ? theme.movedAddedBg : theme.addedBg, + contentBg: moveKind ? theme.movedAddedBg : theme.addedBg, signColor: theme.addedSignColor, numberColor: theme.addedSignColor, }; @@ -104,8 +112,8 @@ export function stackCellPalette(kind: StackLineCell["kind"], theme: AppTheme) { if (kind === "deletion") { return { - gutterBg: theme.removedBg, - contentBg: theme.removedBg, + gutterBg: moveKind ? theme.movedRemovedBg : theme.removedBg, + contentBg: moveKind ? theme.movedRemovedBg : theme.removedBg, signColor: theme.removedSignColor, numberColor: theme.removedSignColor, }; diff --git a/src/ui/staticDiffPager.ts b/src/ui/staticDiffPager.ts index ca133e40..7b05eef7 100644 --- a/src/ui/staticDiffPager.ts +++ b/src/ui/staticDiffPager.ts @@ -94,7 +94,7 @@ function renderStaticRow( } const { cell } = row; - const palette = stackCellPalette(cell.kind, theme); + const palette = stackCellPalette(cell.kind, theme, cell.moveKind); return `${colorText(marker(), stackRailColor(cell.kind, theme, true), theme.panel)}${colorText( staticStackGutterText(cell, lineNumberWidth, options.lineNumbers !== false), palette.numberColor, diff --git a/src/ui/themes.ts b/src/ui/themes.ts index 8bf95c3d..6b958a58 100644 --- a/src/ui/themes.ts +++ b/src/ui/themes.ts @@ -14,6 +14,8 @@ export interface AppTheme { muted: string; addedBg: string; removedBg: string; + movedAddedBg: string; + movedRemovedBg: string; contextBg: string; addedContentBg: string; removedContentBg: string; @@ -103,6 +105,8 @@ export const THEMES: AppTheme[] = [ muted: "#9aa4af", addedBg: "#1f3025", removedBg: "#372526", + movedAddedBg: "#1d3140", + movedRemovedBg: "#34283d", contextBg: "#181c20", addedContentBg: "#24362a", removedContentBg: "#432b2d", @@ -152,6 +156,8 @@ export const THEMES: AppTheme[] = [ muted: "#8da5c7", addedBg: "#153526", removedBg: "#47262a", + movedAddedBg: "#123247", + movedRemovedBg: "#3a2748", contextBg: "#0f1b2d", addedContentBg: "#102a1f", removedContentBg: "#371b1e", @@ -201,6 +207,8 @@ export const THEMES: AppTheme[] = [ muted: "#786753", addedBg: "#dff0e1", removedBg: "#f6ddde", + movedAddedBg: "#dcebf4", + movedRemovedBg: "#eadff1", contextBg: "#faf6ee", addedContentBg: "#eaf8ec", removedContentBg: "#fbebeb", @@ -250,6 +258,8 @@ export const THEMES: AppTheme[] = [ muted: "#c7a18d", addedBg: "#183424", removedBg: "#4a1f1f", + movedAddedBg: "#17303a", + movedRemovedBg: "#3c273b", contextBg: "#24140e", addedContentBg: "#21432c", removedContentBg: "#5a2727",