From cd670a11d7f2b63fe08d9a8754a375c264b63961 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Sat, 16 May 2026 14:17:27 -0700 Subject: [PATCH] feat: surface git's color-moved highlights for moved lines moved code in a diff is highlighted distinctly from added/removed. Loads the relevant git config keys, threads a 'moved' lane through the diff renderer, and renders moved-from / moved-to lines with the configured palette. Fixes #261 --- src/core/config.test.ts | 2 + src/core/config.ts | 3 + src/core/diffFile.ts | 5 +- src/core/git.test.ts | 26 +++++- src/core/git.ts | 153 +++++++++++++++++++++++++++++++++--- src/core/loaders.test.ts | 56 +++++++++++++ src/core/loaders.ts | 117 ++++++++++++++++++++++++++- src/core/types.ts | 11 +++ src/core/vcs/git.ts | 23 +++++- src/ui/diff/pierre.test.ts | 37 +++++++++ src/ui/diff/pierre.ts | 12 ++- src/ui/diff/renderRows.tsx | 4 +- src/ui/diff/rowStyle.ts | 28 ++++--- src/ui/staticDiffPager.ts | 2 +- src/ui/themes.ts | 2 + src/ui/themes/catppuccin.ts | 2 + src/ui/themes/ember.ts | 2 + src/ui/themes/graphite.ts | 2 + src/ui/themes/midnight.ts | 2 + src/ui/themes/paper.ts | 2 + src/ui/themes/types.ts | 2 + 21 files changed, 464 insertions(+), 29 deletions(-) diff --git a/src/core/config.test.ts b/src/core/config.test.ts index a0085d48..10f53aa7 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 0c8c93ed..b220b5b2 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -233,6 +233,7 @@ function readConfigPreferences(source: Record): CommonOptions { hunkHeaders: normalizeBoolean(source.hunk_headers), agentNotes: normalizeBoolean(source.agent_notes), copyDecorations: normalizeBoolean(source.copy_decorations), + colorMoved: normalizeBoolean(source.color_moved), }; } @@ -252,6 +253,7 @@ function mergeOptions(base: CommonOptions, overrides: CommonOptions): CommonOpti hunkHeaders: overrides.hunkHeaders ?? base.hunkHeaders, agentNotes: overrides.agentNotes ?? base.agentNotes, copyDecorations: overrides.copyDecorations ?? base.copyDecorations, + colorMoved: overrides.colorMoved ?? base.colorMoved, }; } @@ -344,6 +346,7 @@ export function resolveConfiguredCliInput( hunkHeaders: resolvedOptions.hunkHeaders ?? DEFAULT_VIEW_PREFERENCES.showHunkHeaders, agentNotes: resolvedOptions.agentNotes ?? DEFAULT_VIEW_PREFERENCES.showAgentNotes, copyDecorations: resolvedOptions.copyDecorations ?? DEFAULT_VIEW_PREFERENCES.copyDecorations, + colorMoved: resolvedOptions.colorMoved, }; if (resolvedOptions.theme === "custom" && !resolvedCustomTheme) { diff --git a/src/core/diffFile.ts b/src/core/diffFile.ts index 7db986c5..a80103fb 100644 --- a/src/core/diffFile.ts +++ b/src/core/diffFile.ts @@ -3,7 +3,7 @@ import { findAgentFileContext } from "./agent"; import { patchLooksBinary } from "./binary"; import { normalizeDiffMetadataPaths, normalizeDiffPath } from "./diffPaths"; import type { FileSourceFetcher } from "./fileSource"; -import type { AgentContext, DiffFile } from "./types"; +import type { AgentContext, DiffFile, DiffLineMoveKinds } from "./types"; /** Count visible additions and deletions from parsed diff metadata. */ export function countDiffStats(metadata: FileDiffMetadata) { @@ -38,6 +38,7 @@ export 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. */ @@ -55,6 +56,7 @@ export function buildDiffFile( isTooLarge, stats, statsTruncated, + lineMoveKinds, }: BuildDiffFileOptions = {}, ): DiffFile { const normalizedMetadata = normalizeDiffMetadataPaths(metadata); @@ -77,6 +79,7 @@ export function buildDiffFile( language: getFiletypeFromFileName(path) ?? undefined, stats: stats ?? countDiffStats(normalizedMetadata), metadata: normalizedMetadata, + lineMoveKinds, agent: findAgentFileContext(agentContext, path, resolvedPreviousPath), isUntracked, isBinary: resolvedIsBinary, diff --git a/src/core/git.test.ts b/src/core/git.test.ts index 5b1c14bc..81c5d76f 100644 --- a/src/core/git.test.ts +++ b/src/core/git.test.ts @@ -2,7 +2,12 @@ import { afterEach, describe, expect, test } from "bun:test"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { buildGitStashShowArgs, resolveGitDiffEndpoints, runGitText } from "./git"; +import { + buildGitDiffArgs, + buildGitStashShowArgs, + resolveGitDiffEndpoints, + runGitText, +} from "./git"; import type { VcsCommandInput } from "./types"; const tempDirs: string[] = []; @@ -51,6 +56,25 @@ afterEach(() => { } }); 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 98e89056..b76df79e 100644 --- a/src/core/git.ts +++ b/src/core/git.ts @@ -22,6 +22,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) { @@ -45,13 +50,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"); @@ -71,7 +121,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. */ @@ -114,26 +164,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) { @@ -329,6 +398,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 6b111ce5..a2c08f94 100644 --- a/src/core/loaders.test.ts +++ b/src/core/loaders.test.ts @@ -653,6 +653,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 06fc3551..ba562fc9 100644 --- a/src/core/loaders.ts +++ b/src/core/loaders.ts @@ -18,7 +18,7 @@ import { import { createFileSourceFetcher, type FileSourceSpec } from "./fileSource"; import { normalizeUntrackedPatchHeaders, runGitUntrackedFileDiffText } from "./git"; import { splitPatchIntoFileChunks, findPatchChunk } from "./patch/chunks"; -import { normalizePatchText } from "./patch/normalize"; +import { normalizePatchText, stripTerminalControl } from "./patch/normalize"; import { createUnsupportedVcsOperationError, getVcsAdapter, operationFromInput } from "./vcs"; import type { AppBootstrap, @@ -27,6 +27,8 @@ import type { CliInput, CustomThemeConfig, DiffFile, + DiffLineMoveKind, + DiffLineMoveKinds, DiffToolCommandInput, FileCommandInput, VcsCommandInput, @@ -69,6 +71,113 @@ function createSourceFetcherBuilder( }; } +/** 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 one file has any captured moved-line classifications. */ +function hasLineMoveKinds(moveKinds: DiffLineMoveKinds | undefined) { + return Boolean(moveKinds?.additionLines.some(Boolean) || moveKinds?.deletionLines.some(Boolean)); +} + interface CountedLines { complete: boolean; lines: number; @@ -265,6 +374,7 @@ function normalizePatchChangeset( agentContext: AgentContext | null, perFileOptions?: Pick, ): Changeset { + const lineMoveKinds = collectLineMoveKinds(patchText); const normalizedPatchText = normalizePatchText(patchText); let parsedPatches: ReturnType; @@ -301,7 +411,10 @@ function normalizePatchChangeset( index, sourceLabel, agentContext, - perFileOptions, + { + ...perFileOptions, + lineMoveKinds: hasLineMoveKinds(lineMoveKinds[index]) ? lineMoveKinds[index] : undefined, + }, ), ), }; diff --git a/src/core/types.ts b/src/core/types.ts index 0924ff2e..10a3a8e4 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -52,6 +52,7 @@ export interface DiffFile { deletions: number; }; metadata: FileDiffMetadata; + lineMoveKinds?: DiffLineMoveKinds; agent: AgentFileContext | null; isUntracked?: boolean; isBinary?: boolean; @@ -62,6 +63,13 @@ export interface DiffFile { sourceFetcher?: FileSourceFetcher; } +export type DiffLineMoveKind = "moved"; + +export interface DiffLineMoveKinds { + additionLines: Array; + deletionLines: Array; +} + export interface Changeset { id: string; sourceLabel: string; @@ -84,6 +92,7 @@ export interface CommonOptions { hunkHeaders?: boolean; agentNotes?: boolean; copyDecorations?: boolean; + colorMoved?: boolean; } export interface CustomSyntaxColorsConfig { @@ -111,6 +120,8 @@ export interface CustomThemeConfig { muted?: string; addedBg?: string; removedBg?: string; + movedAddedBg?: string; + movedRemovedBg?: string; contextBg?: string; addedContentBg?: string; removedContentBg?: string; diff --git a/src/core/vcs/git.ts b/src/core/vcs/git.ts index 47f83c86..071c9fef 100644 --- a/src/core/vcs/git.ts +++ b/src/core/vcs/git.ts @@ -6,6 +6,7 @@ import { buildGitShowArgs, buildGitStashShowArgs, listGitUntrackedFiles, + resolveGitColorMovedOptions, resolveGitCommitRef, resolveGitDiffEndpoints, resolveGitRepoRoot, @@ -238,6 +239,7 @@ export const gitAdapter: VcsAdapter = { const largeTrackedFiles = parseGitNumstat( runGitText({ input, args: buildGitDiffNumstatArgs(input), cwd, gitExecutable }), ).filter((file) => shouldSkipLargeTrackedDiff(file, repoRoot)); + const colorMoved = resolveGitColorMovedOptions(input, { cwd, gitExecutable }); return { repoRoot, sourceLabel: repoRoot, @@ -247,6 +249,7 @@ export const gitAdapter: VcsAdapter = { args: buildGitDiffArgs( input, largeTrackedFiles.map((file) => file.path), + colorMoved, ), cwd, gitExecutable, @@ -271,7 +274,15 @@ export const gitAdapter: VcsAdapter = { repoRoot, sourceLabel: repoRoot, title: input.ref ? `${repoName} show ${input.ref}` : `${repoName} show HEAD`, - patchText: runGitText({ input, args: buildGitShowArgs(input), cwd, gitExecutable }), + patchText: runGitText({ + input, + args: buildGitShowArgs( + input, + resolveGitColorMovedOptions(input, { cwd, gitExecutable }), + ), + cwd, + gitExecutable, + }), sourceFetcherBuilder: buildGitReviewSourceFetcherBuilder( operation, repoRoot, @@ -288,7 +299,15 @@ export const gitAdapter: VcsAdapter = { repoRoot, sourceLabel: repoRoot, title: input.ref ? `${repoName} stash ${input.ref}` : `${repoName} stash`, - patchText: runGitText({ input, args: buildGitStashShowArgs(input), cwd, gitExecutable }), + patchText: runGitText({ + input, + args: buildGitStashShowArgs( + input, + resolveGitColorMovedOptions(input, { cwd, gitExecutable }), + ), + cwd, + gitExecutable, + }), sourceFetcherBuilder: buildGitReviewSourceFetcherBuilder( operation, repoRoot, diff --git a/src/ui/diff/pierre.test.ts b/src/ui/diff/pierre.test.ts index de665585..29d25b6d 100644 --- a/src/ui/diff/pierre.test.ts +++ b/src/ui/diff/pierre.test.ts @@ -11,6 +11,7 @@ import { } from "./pierre"; import { resolveSplitPaneWidths } from "./codeColumns"; import { renderCodeOnlyPlannedRowText, renderDecoratedPlannedRowText } from "./renderRows"; +import { stackCellPalette } from "./rowStyle"; import { buildReviewRenderPlan } from "./reviewRenderPlan"; import { measureTextWidth } from "../lib/text"; import { resolveTheme } from "../themes"; @@ -175,6 +176,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("renders planned split rows to copyable visible text", () => { const file = createDiffFile(); const theme = resolveTheme("midnight", null); diff --git a/src/ui/diff/pierre.ts b/src/ui/diff/pierre.ts index cffa8aa6..9720ad74 100644 --- a/src/ui/diff/pierre.ts +++ b/src/ui/diff/pierre.ts @@ -8,7 +8,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 { sanitizeTerminalLine } from "../../lib/terminalText"; import type { AppTheme } from "../themes"; @@ -84,6 +84,7 @@ export interface SplitLineCell { kind: "context" | "addition" | "deletion" | "empty"; sign: string; lineNumber?: number; + moveKind?: DiffLineMoveKind; spans: RenderSpan[]; } @@ -92,6 +93,7 @@ export interface StackLineCell { sign: string; oldLineNumber?: number; newLineNumber?: number; + moveKind?: DiffLineMoveKind; spans: RenderSpan[]; } @@ -366,6 +368,7 @@ function makeSplitCell( rawLine: string | undefined, highlightedLine: HastNode | undefined, theme: AppTheme, + moveKind?: DiffLineMoveKind, ) { if (kind === "empty") { return { @@ -395,6 +398,7 @@ function makeSplitCell( kind, sign: kind === "addition" ? "+" : kind === "deletion" ? "-" : " ", lineNumber, + moveKind, spans, } satisfies SplitLineCell; } @@ -407,6 +411,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. @@ -428,6 +433,7 @@ function makeStackCell( sign: kind === "addition" ? "+" : kind === "deletion" ? "-" : " ", oldLineNumber, newLineNumber, + moveKind, spans, } satisfies StackLineCell; } @@ -764,6 +770,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 @@ -773,6 +780,7 @@ export function buildSplitRows( file.metadata.additionLines[additionLineIndex + offset], additionLines[additionLineIndex + offset], theme, + file.lineMoveKinds?.additionLines[additionLineIndex + offset], ) : makeSplitCell("empty", undefined, undefined, undefined, theme), }); @@ -877,6 +885,7 @@ export function buildStackRows( file.metadata.deletionLines[deletionLineIndex + offset], deletionLines[deletionLineIndex + offset], theme, + file.lineMoveKinds?.deletionLines[deletionLineIndex + offset], ), }); } @@ -894,6 +903,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 7625461e..3805f83b 100644 --- a/src/ui/diff/renderRows.tsx +++ b/src/ui/diff/renderRows.tsx @@ -867,7 +867,7 @@ function renderSplitCell( selectionColRange?: CopySelectedRowRange, paneOffset = 0, ) { - const basePalette = splitCellPalette(cell.kind, theme); + const basePalette = splitCellPalette(cell.kind, theme, cell.moveKind); const palette = selected ? applySelectionPalette(basePalette, theme) : basePalette; const resolvedPrefix = selected && prefix ? applySelectionPrefix(prefix, theme) : prefix; const prefixWidth = resolvedPrefix?.text.length ?? 0; @@ -933,7 +933,7 @@ function renderStackCell( selected = false, selectionColRange?: CopySelectedRowRange, ) { - const basePalette = stackCellPalette(cell.kind, theme); + const basePalette = stackCellPalette(cell.kind, theme, cell.moveKind); const palette = selected ? applySelectionPalette(basePalette, theme) : basePalette; const resolvedPrefix = selected && prefix ? applySelectionPrefix(prefix, theme) : prefix; const prefixWidth = resolvedPrefix?.text.length ?? 0; diff --git a/src/ui/diff/rowStyle.ts b/src/ui/diff/rowStyle.ts index 7170295b..36166cae 100644 --- a/src/ui/diff/rowStyle.ts +++ b/src/ui/diff/rowStyle.ts @@ -67,11 +67,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, }; @@ -79,8 +83,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, }; @@ -104,11 +108,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, }; @@ -116,8 +124,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 2bfe4a33..9cec3ec2 100644 --- a/src/ui/staticDiffPager.ts +++ b/src/ui/staticDiffPager.ts @@ -96,7 +96,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 56bc57fa..bff0096d 100644 --- a/src/ui/themes.ts +++ b/src/ui/themes.ts @@ -47,6 +47,8 @@ function buildCustomTheme(customTheme: CustomThemeConfig) { muted: customTheme.muted ?? baseTheme.muted, addedBg: customTheme.addedBg ?? baseTheme.addedBg, removedBg: customTheme.removedBg ?? baseTheme.removedBg, + movedAddedBg: customTheme.movedAddedBg ?? baseTheme.movedAddedBg, + movedRemovedBg: customTheme.movedRemovedBg ?? baseTheme.movedRemovedBg, contextBg: customTheme.contextBg ?? baseTheme.contextBg, addedContentBg: customTheme.addedContentBg ?? baseTheme.addedContentBg, removedContentBg: customTheme.removedContentBg ?? baseTheme.removedContentBg, diff --git a/src/ui/themes/catppuccin.ts b/src/ui/themes/catppuccin.ts index 9fa0c910..1c9511cc 100644 --- a/src/ui/themes/catppuccin.ts +++ b/src/ui/themes/catppuccin.ts @@ -119,6 +119,8 @@ export function createCatppuccinTheme(flavor: CatppuccinFlavor) { muted: palette.subtext0, addedBg: blendHex(palette.green, contextBg, 0.15), removedBg: blendHex(palette.red, contextBg, 0.15), + movedAddedBg: blendHex(palette.sky, contextBg, 0.18), + movedRemovedBg: blendHex(palette.mauve, contextBg, 0.18), contextBg, addedContentBg: blendHex(palette.green, contextBg, 0.25), removedContentBg: blendHex(palette.red, contextBg, 0.25), diff --git a/src/ui/themes/ember.ts b/src/ui/themes/ember.ts index b4a8748f..adf185f5 100644 --- a/src/ui/themes/ember.ts +++ b/src/ui/themes/ember.ts @@ -17,6 +17,8 @@ export const EMBER_THEME: AppTheme = withLazySyntaxStyle( muted: "#c7a18d", addedBg: "#183424", removedBg: "#4a1f1f", + movedAddedBg: "#17303a", + movedRemovedBg: "#3c273b", contextBg: "#24140e", addedContentBg: "#21432c", removedContentBg: "#5a2727", diff --git a/src/ui/themes/graphite.ts b/src/ui/themes/graphite.ts index bcd7d73a..f20a177c 100644 --- a/src/ui/themes/graphite.ts +++ b/src/ui/themes/graphite.ts @@ -17,6 +17,8 @@ export const GRAPHITE_THEME: AppTheme = withLazySyntaxStyle( muted: "#9aa4af", addedBg: "#1f3025", removedBg: "#372526", + movedAddedBg: "#1d3140", + movedRemovedBg: "#34283d", contextBg: "#181c20", addedContentBg: "#24362a", removedContentBg: "#432b2d", diff --git a/src/ui/themes/midnight.ts b/src/ui/themes/midnight.ts index b8afbdbe..d933ae14 100644 --- a/src/ui/themes/midnight.ts +++ b/src/ui/themes/midnight.ts @@ -17,6 +17,8 @@ export const MIDNIGHT_THEME: AppTheme = withLazySyntaxStyle( muted: "#8da5c7", addedBg: "#153526", removedBg: "#47262a", + movedAddedBg: "#123247", + movedRemovedBg: "#3a2748", contextBg: "#0f1b2d", addedContentBg: "#102a1f", removedContentBg: "#371b1e", diff --git a/src/ui/themes/paper.ts b/src/ui/themes/paper.ts index 8fcdfa42..56c8cf80 100644 --- a/src/ui/themes/paper.ts +++ b/src/ui/themes/paper.ts @@ -17,6 +17,8 @@ export const PAPER_THEME: AppTheme = withLazySyntaxStyle( muted: "#786753", addedBg: "#dff0e1", removedBg: "#f6ddde", + movedAddedBg: "#dcebf4", + movedRemovedBg: "#eadff1", contextBg: "#faf6ee", addedContentBg: "#eaf8ec", removedContentBg: "#fbebeb", diff --git a/src/ui/themes/types.ts b/src/ui/themes/types.ts index 30b77152..9b677233 100644 --- a/src/ui/themes/types.ts +++ b/src/ui/themes/types.ts @@ -14,6 +14,8 @@ export interface AppTheme { muted: string; addedBg: string; removedBg: string; + movedAddedBg: string; + movedRemovedBg: string; contextBg: string; addedContentBg: string; removedContentBg: string;