From e10eb989bd0f32c97d2ec9e96a9a2472d67d1fe7 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Mon, 10 Aug 2026 22:15:24 -0400 Subject: [PATCH 1/7] feat(ui): add optional current-line lens --- .changeset/soft-line-lens.md | 5 + docs/keybindings.md | 1 + src/core/cli.test.ts | 2 + src/core/cli.ts | 3 + src/core/config.test.ts | 26 ++ src/core/config.ts | 12 + src/core/loaders.ts | 1 + src/core/types.ts | 3 + src/ui/App.tsx | 13 + src/ui/AppHost.cursor-line.test.tsx | 56 ++++ src/ui/components/panes/DiffPane.tsx | 278 +++++++++++------- src/ui/components/panes/DiffSection.tsx | 5 + src/ui/components/panes/SplitLineLens.test.ts | 73 +++++ src/ui/components/panes/SplitLineLens.tsx | 119 ++++++++ src/ui/diff/PierreDiffView.tsx | 8 +- src/ui/lib/appCommands.test.ts | 3 + src/ui/lib/appCommands.ts | 13 + src/ui/lib/appMenus.test.ts | 21 ++ src/ui/lib/appMenus.ts | 7 + test/pty/cursor-line.test.ts | 27 ++ .../docs/docs/configure/layout-and-display.md | 3 + .../src/content/docs/docs/reference/cli.md | 2 + .../src/content/docs/docs/reference/config.md | 10 + 23 files changed, 587 insertions(+), 104 deletions(-) create mode 100644 .changeset/soft-line-lens.md create mode 100644 src/ui/components/panes/SplitLineLens.test.ts create mode 100644 src/ui/components/panes/SplitLineLens.tsx diff --git a/.changeset/soft-line-lens.md b/.changeset/soft-line-lens.md new file mode 100644 index 000000000..631e337fc --- /dev/null +++ b/.changeset/soft-line-lens.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": minor +--- + +Add an optional split-view lens that pins the current line's old and new versions for easier comparison. diff --git a/docs/keybindings.md b/docs/keybindings.md index 04f7b80dc..061e30544 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -82,6 +82,7 @@ The built-in commands and the keys they ship with: | `hunk.view.toggleAgentNotes` | Toggle agent notes | `a` | | `hunk.view.toggleCopyDecorations` | Toggle copy decorations | _(none)_ | | `hunk.view.toggleHunkHeaders` | Toggle hunk headers | `m` | +| `hunk.view.toggleLineLens` | Toggle current-line lens | _(none)_ | | `hunk.view.toggleLineNumbers` | Toggle line numbers | `l` | | `hunk.view.toggleLineWrap` | Toggle line wrapping | `w` | | `hunk.view.toggleMenuBar` | Toggle menu bar | `M` | diff --git a/src/core/cli.test.ts b/src/core/cli.test.ts index 99240dd73..e0e31e74d 100644 --- a/src/core/cli.test.ts +++ b/src/core/cli.test.ts @@ -122,6 +122,7 @@ describe("parseCli", () => { "--agent-context", "notes.json", "--no-line-numbers", + "--line-lens", "-x4", "--wrap", "--no-hunk-headers", @@ -142,6 +143,7 @@ describe("parseCli", () => { watch: true, experimental: true, lineNumbers: false, + lineLens: true, tabWidth: 4, wrapLines: true, hunkHeaders: false, diff --git a/src/core/cli.ts b/src/core/cli.ts index 24347c8ef..01abb942e 100644 --- a/src/core/cli.ts +++ b/src/core/cli.ts @@ -70,6 +70,8 @@ export const COMMON_REVIEW_OPTIONS = [ description: "current-line marker: row, number, off", parse: "cursorLine", }, + { flag: "--line-lens", description: "show old/new current-line lens in split view" }, + { flag: "--no-line-lens", description: "hide the current-line lens" }, { flag: "--theme ", description: "named theme override" }, AUXILIARY_AGENT_OPTIONS.agentContext, { flag: "--pager", description: "use pager-style chrome" }, @@ -271,6 +273,7 @@ function buildCommonOptions( return { mode: options.mode, cursorLine: options.cursorLine, + lineLens: resolveBooleanFlag(argv, "--line-lens", "--no-line-lens"), theme: options.theme, agentContext: options.agentContext, pager: options.pager ? true : undefined, diff --git a/src/core/config.test.ts b/src/core/config.test.ts index 2dc1f6a2d..c4084b41e 100644 --- a/src/core/config.test.ts +++ b/src/core/config.test.ts @@ -80,6 +80,7 @@ describe("config persistence", () => { showAgentNotes: true, copyDecorations: true, cursorLine: "row", + showLineLens: true, }, { env: { HOME: home } }, ); @@ -97,6 +98,7 @@ describe("config persistence", () => { "agent_notes = true", "copy_decorations = true", 'cursor_line = "row"', + "line_lens = true", "", "[custom_theme]", 'label = "Keep me"', @@ -136,6 +138,7 @@ describe("config persistence", () => { showAgentNotes: true, copyDecorations: false, cursorLine: "row", + showLineLens: false, } as const; expect(diffPersistedViewPreferences(initial, { ...initial })).toEqual([]); @@ -243,6 +246,27 @@ describe("config resolution", () => { expect(fromFlag.input.options.cursorLine).toBe("off"); }); + test("reads the line lens from config and lets CLI flags outrank it", () => { + const home = createTempDir("hunk-config-home-"); + const repo = createTempDir("hunk-config-repo-"); + createRepo(repo); + + mkdirSync(join(home, ".config", "hunk"), { recursive: true }); + writeFileSync(join(home, ".config", "hunk", "config.toml"), "line_lens = true"); + + const fromConfig = resolveConfiguredCliInput(createPatchPagerInput(), { + cwd: repo, + env: { HOME: home }, + }); + expect(fromConfig.input.options.lineLens).toBe(true); + + const fromFlag = resolveConfiguredCliInput(createPatchPagerInput({ lineLens: false }), { + cwd: repo, + env: { HOME: home }, + }); + expect(fromFlag.input.options.lineLens).toBe(false); + }); + test("falls back to the built-in current-line style when config names an unknown one", () => { const home = createTempDir("hunk-config-home-"); const repo = createTempDir("hunk-config-repo-"); @@ -1002,6 +1026,7 @@ describe("config resolution", () => { "hunk_headers = false", "agent_notes = true", "copy_decorations = false", + "line_lens = true", ].join("\n"), ); @@ -1030,6 +1055,7 @@ describe("config resolution", () => { expect(bootstrap.initialShowHunkHeaders).toBe(false); expect(bootstrap.initialShowAgentNotes).toBe(true); expect(bootstrap.initialCopyDecorations).toBe(false); + expect(bootstrap.initialShowLineLens).toBe(true); }); test("loadAppBootstrap carries the configured custom theme into the UI bootstrap", async () => { diff --git a/src/core/config.ts b/src/core/config.ts index 2f09f31ee..6b2f2afbf 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -44,6 +44,7 @@ const DEFAULT_VIEW_PREFERENCES: PersistedViewPreferences = { showAgentNotes: false, copyDecorations: false, cursorLine: "row", + showLineLens: false, }; const VIEW_PREFERENCES_PROMPT_CONFIG_KEY = "prompt_save_view_preferences"; @@ -60,6 +61,7 @@ const PERSISTED_VIEW_PREFERENCE_KEYS: Array<{ { configKey: "agent_notes", value: (preferences) => preferences.showAgentNotes }, { configKey: "copy_decorations", value: (preferences) => preferences.copyDecorations }, { configKey: "cursor_line", value: (preferences) => preferences.cursorLine }, + { configKey: "line_lens", value: (preferences) => preferences.showLineLens }, ]; interface ConfigResolutionOptions { @@ -228,6 +230,15 @@ export const CONFIG_REFERENCE_OPTIONS: readonly ConfigReferenceOption[] = [ description: "Mark the current line as a full-row highlight or on its line number. `off` restores plain `j`/`k` scrolling.", }, + { + key: "line_lens", + property: "lineLens", + type: "boolean", + accepted: "`true` or `false`", + runtimeDefault: DEFAULT_VIEW_PREFERENCES.showLineLens, + description: + "Pin the current split row's old and new versions at the bottom of the review pane.", + }, { key: "vcs", property: "vcs", @@ -873,6 +884,7 @@ function mergeOptions(base: CommonOptions, overrides: CommonOptions): CommonOpti ...base, mode: overrides.mode ?? base.mode, cursorLine: overrides.cursorLine ?? base.cursorLine, + lineLens: overrides.lineLens ?? base.lineLens, vcs: overrides.vcs ?? base.vcs, theme: overrides.theme ?? base.theme, agentContext: overrides.agentContext ?? base.agentContext, diff --git a/src/core/loaders.ts b/src/core/loaders.ts index e630939bc..3e1ee9faa 100644 --- a/src/core/loaders.ts +++ b/src/core/loaders.ts @@ -514,5 +514,6 @@ export async function loadAppBootstrap( initialShowAgentNotes: input.options.agentNotes ?? false, initialCopyDecorations: input.options.copyDecorations ?? false, initialCursorLine: input.options.cursorLine ?? "row", + initialShowLineLens: input.options.lineLens ?? false, }; } diff --git a/src/core/types.ts b/src/core/types.ts index 8ebcd95de..98d547924 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -88,6 +88,7 @@ export interface Changeset { export interface CommonOptions { mode?: LayoutMode; cursorLine?: CursorLine; + lineLens?: boolean; vcs?: VcsMode; theme?: string; agentContext?: string; @@ -150,6 +151,7 @@ export interface PersistedViewPreferences { showAgentNotes: boolean; copyDecorations: boolean; cursorLine: CursorLine; + showLineLens: boolean; } export interface HelpCommandInput { @@ -394,6 +396,7 @@ export interface AppBootstrap { initialShowAgentNotes?: boolean; initialCopyDecorations?: boolean; initialCursorLine?: CursorLine; + initialShowLineLens?: boolean; startupNotices?: readonly StartupNotice[]; viewPreferencesConfigPath?: string; /** The user's `[keybindings]` table, resolved against command defaults in App. */ diff --git a/src/ui/App.tsx b/src/ui/App.tsx index e375416d0..d507fd8c4 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -246,6 +246,7 @@ export function App({ const [copyDecorations, setCopyDecorations] = useState(bootstrap.initialCopyDecorations ?? false); const [codeHorizontalOffset, setCodeHorizontalOffset] = useState(0); const [cursorLine, setCursorLine] = useState(bootstrap.initialCursorLine ?? "row"); + const [showLineLens, setShowLineLens] = useState(bootstrap.initialShowLineLens ?? false); const [lineCursorAlignmentRequest, setLineCursorAlignmentRequest] = useState<{ id: number; alignment: CurrentLineAlignment; @@ -322,6 +323,7 @@ export function App({ showAgentNotes, copyDecorations, cursorLine, + showLineLens, }), [ copyDecorations, @@ -329,6 +331,7 @@ export function App({ layoutMode, showAgentNotes, showHunkHeaders, + showLineLens, showLineNumbers, showMenuBar, themeId, @@ -1199,6 +1202,11 @@ export function App({ setShowLineNumbers((current) => !current); }; + /** Toggle the old-above-new lens pinned beneath split diffs. */ + const toggleLineLens = () => { + setShowLineLens((current) => !current); + }; + /** Toggle whether mouse selection copies review decorations or only file content. */ const toggleCopyDecorations = () => { setCopyDecorations((current) => !current); @@ -1750,6 +1758,8 @@ export function App({ canAlignCurrentLine: cursorLine !== "off" && review.lineCursor !== null, canApplyFilePresentationToAllMatching: selectedFileViewBulkTarget !== null, canRefreshCurrentInput, + canToggleLineLens: + showLineLens || (!pagerMode && resolvedLayout === "split" && cursorLine !== "off"), alignCurrentLine, applyFilePresentationToAllMatching, focusFilter, @@ -1773,6 +1783,7 @@ export function App({ toggleGapForSelectedHunk: review.toggleSelectedHunkGap, toggleHelp, toggleHunkHeaders, + toggleLineLens, toggleLineNumbers, toggleLineWrap, toggleMenuBar, @@ -1811,6 +1822,7 @@ export function App({ showAgentNotes, showHelp, showHunkHeaders, + showLineLens, showLineNumbers, showMenuBar, wrapLines, @@ -2081,6 +2093,7 @@ export function App({ showAgentNotes={showAgentNotes} showLineNumbers={showLineNumbers} showHunkHeaders={showHunkHeaders} + showLineLens={showLineLens} sourceStatusByFileId={review.sourceStatusByFileId} tabWidth={tabWidth} wrapLines={wrapLines} diff --git a/src/ui/AppHost.cursor-line.test.tsx b/src/ui/AppHost.cursor-line.test.tsx index d5c7d8ef5..f2eaf5825 100644 --- a/src/ui/AppHost.cursor-line.test.tsx +++ b/src/ui/AppHost.cursor-line.test.tsx @@ -47,6 +47,7 @@ function createCursorLineBootstrap( initialMode, }), initialCursorLine: cursorLine, + initialShowLineLens: true, }; } @@ -123,6 +124,7 @@ async function renderWrappedCursorLineApp(cursorLine: CursorLine) { initialWrapLines: true, }), initialCursorLine: cursorLine, + initialShowLineLens: true, }; const setup = await testRender(, { width: 120, @@ -230,6 +232,60 @@ describe("current line highlight", () => { } }); + test("pins the split row's old and new versions below the viewport", async () => { + const setup = await renderCursorLineApp("row", "split"); + + try { + await act(async () => { + await setup.mockInput.typeText("j"); + }); + await flush(setup); + + const lines = setup.captureCharFrame().split("\n"); + const lensIndex = lines.findIndex((line) => line.includes("Current line")); + expect(lensIndex).toBeGreaterThanOrEqual(0); + expect(lines[lensIndex + 1]).toContain("beta = 2;"); + expect(lines[lensIndex + 2]).toContain("beta = 22222;"); + } finally { + await act(async () => { + setup.renderer.destroy(); + }); + } + }); + + test("hides the split-line lens without a current line or split layout", async () => { + const stack = await renderCursorLineApp("row", "stack"); + const off = await renderCursorLineApp("off", "split"); + + try { + expect(stack.captureCharFrame()).not.toContain("Current line"); + expect(off.captureCharFrame()).not.toContain("Current line"); + } finally { + await act(async () => { + stack.renderer.destroy(); + off.renderer.destroy(); + }); + } + }); + + test("hides the lens when it would consume the whole review viewport", async () => { + const setup = await testRender( + , + { width: 80, height: 8 }, + ); + + try { + await flush(setup); + const frame = setup.captureCharFrame(); + expect(frame).not.toContain("Current line"); + expect(frame).toContain("alpha = 1"); + } finally { + await act(async () => { + setup.renderer.destroy(); + }); + } + }); + test("marks a wrapped CJK row through the chunk renderer", async () => { const marked = await renderWrappedCursorLineApp("row"); const plain = await renderWrappedCursorLineApp("off"); diff --git a/src/ui/components/panes/DiffPane.tsx b/src/ui/components/panes/DiffPane.tsx index d7fe42e95..b14468997 100644 --- a/src/ui/components/panes/DiffPane.tsx +++ b/src/ui/components/panes/DiffPane.tsx @@ -53,6 +53,7 @@ import { measureDiffSectionGeometry, type DiffSectionGeometry, } from "../../diff/diffSectionGeometry"; +import type { DiffSectionRowPlan } from "../../diff/diffSectionRowPlan"; import { createReviewMouseWheelScrollAcceleration } from "../../lib/scrollAcceleration"; import { buildFileSectionLayouts, @@ -74,6 +75,7 @@ import type { AppTheme } from "../../themes"; import { DiffSection } from "./DiffSection"; import type { FileViewRowFailure } from "../../fileViews/types"; import { DiffFileHeaderRow } from "./DiffFileHeaderRow"; +import { SplitLineLens } from "./SplitLineLens"; import { VerticalScrollbar, type VerticalScrollbarHandle } from "../scrollbar/VerticalScrollbar"; import type { VisibleBodyBounds } from "../../diff/rowWindowing"; import type { ResolvedFileViewLayout } from "../../fileViews/useFileViews"; @@ -225,6 +227,7 @@ export function DiffPane({ screenTop = 0, showTopChrome, showAgentNotes, + showLineLens = false, showLineNumbers, showHunkHeaders, sourceStatusByFileId = EMPTY_SOURCE_STATUS_BY_FILE_ID, @@ -282,6 +285,7 @@ export function DiffPane({ screenTop?: number; showTopChrome?: boolean; showAgentNotes: boolean; + showLineLens?: boolean; showLineNumbers: boolean; showHunkHeaders: boolean; sourceStatusByFileId?: Record; @@ -321,6 +325,10 @@ export function DiffPane({ () => createReviewMouseWheelScrollAcceleration(), [], ); + const [lineLensRowPlan, setLineLensRowPlan] = useState<{ + fileId: string; + rowPlan: DiffSectionRowPlan; + } | null>(null); const [addNoteHoverClearSignal, setAddNoteHoverClearSignal] = useState(0); const [addNoteHoverClearFileId, setAddNoteHoverClearFileId] = useState(null); const hoveredFileIdRef = useRef(null); @@ -953,6 +961,49 @@ export function DiffPane({ [cursorLine, renderedLineCursor], ); + // The lens is fixed outside the scroll stream, so its height changes only the live viewport — + // never section geometry, windowing coordinates, or review navigation targets. + const splitLineLensFile = useMemo(() => { + if ( + !showLineLens || + layout !== "split" || + cursorLine === "off" || + !renderedLineCursor || + pagerMode || + renderer.height - screenTop < 8 || + fileViewRenderPlans.has(renderedLineCursor.fileId) + ) { + return undefined; + } + + const sectionIndex = fileSectionIndexById.get(renderedLineCursor.fileId); + return sectionIndex === undefined ? undefined : files[sectionIndex]; + }, [ + cursorLine, + fileSectionIndexById, + fileViewRenderPlans, + files, + layout, + pagerMode, + renderedLineCursor, + renderer.height, + screenTop, + showLineLens, + ]); + + const lineLensRowPlanCallback = useMemo(() => { + if (!splitLineLensFile) { + return undefined; + } + + const fileId = splitLineLensFile.id; + return (rowPlan: DiffSectionRowPlan) => { + setLineLensRowPlan((current) => + current?.fileId === fileId && current.rowPlan === rowPlan ? current : { fileId, rowPlan }, + ); + }; + }, [splitLineLensFile]); + const copySelectedRowKeysByFile = useMemo( () => buildCopySelectedRowKeys({ @@ -2130,113 +2181,134 @@ export function DiffPane({ /> ) : null} - - - + + - {fileRenderItems.map((item) => { - if (item.kind === "spacer") { + + {fileRenderItems.map((item) => { + if (item.kind === "spacer") { + return ( + + ); + } + + const { sectionIndex: index } = item; + const file = files[index]; + if (!file) { + return null; + } + return ( - 0} + showLineNumbers={showLineNumbers} + showHunkHeaders={showHunkHeaders} + sourceStatus={sourceStatusByFileId[file.id]} + tabWidth={tabWidth} + wrapLines={wrapLines} + theme={theme} + hoverActive={hoveredFileId === null || hoveredFileId === file.id} + hoverClearSignal={ + addNoteHoverClearFileId === file.id ? addNoteHoverClearSignal : 0 + } + viewWidth={diffContentWidth} + visibleAgentNotes={ + visibleAgentNotesByFile.get(file.id) ?? EMPTY_VISIBLE_AGENT_NOTES + } + visibleBodyBounds={visibleBodyBoundsByFile.get(file.id)} + onHover={() => setHoveredFileForRowActions(file.id)} + onMouseScroll={clearAddNoteHoverForScroll} + onFileViewRowFailure={onFileViewRowFailure} + onActiveAddNoteAffordanceChange={ + onActiveAddNoteAffordanceChange + ? activeAddNoteAffordanceCallback(file.id) + : undefined + } + onStartUserNoteAtHunk={ + reserveAddNoteColumn ? startUserNoteAtHunkCallback(file.id) : undefined + } + onRowPlanChange={ + file.id === splitLineLensFile?.id ? lineLensRowPlanCallback : undefined + } + onSelect={selectFileCallback(file.id)} + onToggleGap={(gapKey) => onToggleGap(file.id, gapKey)} /> ); - } - - const { sectionIndex: index } = item; - const file = files[index]; - if (!file) { - return null; - } - - return ( - 0} - showLineNumbers={showLineNumbers} - showHunkHeaders={showHunkHeaders} - sourceStatus={sourceStatusByFileId[file.id]} - tabWidth={tabWidth} - wrapLines={wrapLines} - theme={theme} - hoverActive={hoveredFileId === null || hoveredFileId === file.id} - hoverClearSignal={ - addNoteHoverClearFileId === file.id ? addNoteHoverClearSignal : 0 - } - viewWidth={diffContentWidth} - visibleAgentNotes={ - visibleAgentNotesByFile.get(file.id) ?? EMPTY_VISIBLE_AGENT_NOTES - } - visibleBodyBounds={visibleBodyBoundsByFile.get(file.id)} - onHover={() => setHoveredFileForRowActions(file.id)} - onMouseScroll={clearAddNoteHoverForScroll} - onFileViewRowFailure={onFileViewRowFailure} - onActiveAddNoteAffordanceChange={ - onActiveAddNoteAffordanceChange - ? activeAddNoteAffordanceCallback(file.id) - : undefined - } - onStartUserNoteAtHunk={ - reserveAddNoteColumn ? startUserNoteAtHunkCallback(file.id) : undefined - } - onSelect={selectFileCallback(file.id)} - onToggleGap={(gapKey) => onToggleGap(file.id, gapKey)} - /> - ); - })} - - - + })} + + + + + {splitLineLensFile && + renderedLineCursor && + lineLensRowPlan?.fileId === splitLineLensFile.id ? ( + + ) : null} ) : ( diff --git a/src/ui/components/panes/DiffSection.tsx b/src/ui/components/panes/DiffSection.tsx index 87ae6c123..0d96c5196 100644 --- a/src/ui/components/panes/DiffSection.tsx +++ b/src/ui/components/panes/DiffSection.tsx @@ -5,6 +5,7 @@ import { PierreDiffView, type ActiveAddNoteAffordance } from "../../diff/PierreD import type { CursorHighlight } from "../../diff/renderRows"; import type { VisibleBodyBounds } from "../../diff/rowWindowing"; import type { DiffSectionGeometry } from "../../diff/diffSectionGeometry"; +import type { DiffSectionRowPlan } from "../../diff/diffSectionRowPlan"; import type { VisibleAgentNote } from "../../lib/agentAnnotations"; import type { CopySelectedRowRange } from "./copySelection"; import { diffSectionId } from "../../lib/ids"; @@ -48,6 +49,7 @@ interface DiffSectionProps { onFileViewRowFailure?: (failure: FileViewRowFailure) => void; onActiveAddNoteAffordanceChange?: (affordance: ActiveAddNoteAffordance | null) => void; onStartUserNoteAtHunk?: (hunkIndex: number, target?: UserNoteLineTarget) => void; + onRowPlanChange?: (rowPlan: DiffSectionRowPlan) => void; onSelect: () => void; onToggleGap: (gapKey: string) => void; } @@ -86,6 +88,7 @@ function DiffSectionComponent({ onFileViewRowFailure, onActiveAddNoteAffordanceChange, onStartUserNoteAtHunk, + onRowPlanChange, onSelect, onToggleGap, }: DiffSectionProps) { @@ -170,6 +173,7 @@ function DiffSectionComponent({ onHover={onHover} onActiveAddNoteAffordanceChange={onActiveAddNoteAffordanceChange} onStartUserNoteAtHunk={onStartUserNoteAtHunk} + onRowPlanChange={onRowPlanChange} onToggleGap={onToggleGap} selectedHunkIndex={selectedHunkIndex} sectionGeometry={sectionGeometry} @@ -216,6 +220,7 @@ export const DiffSection = memo(DiffSectionComponent, (previous, next) => { previous.onFileViewRowFailure === next.onFileViewRowFailure && previous.onActiveAddNoteAffordanceChange === next.onActiveAddNoteAffordanceChange && previous.onStartUserNoteAtHunk === next.onStartUserNoteAtHunk && + previous.onRowPlanChange === next.onRowPlanChange && previous.theme === next.theme && previous.visibleAgentNotes === next.visibleAgentNotes && previous.visibleBodyBounds === next.visibleBodyBounds && diff --git a/src/ui/components/panes/SplitLineLens.test.ts b/src/ui/components/panes/SplitLineLens.test.ts new file mode 100644 index 000000000..e0992726b --- /dev/null +++ b/src/ui/components/panes/SplitLineLens.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from "bun:test"; +import type { DiffRow } from "../../diff/pierre"; +import { buildSplitLineLensRows, indexSplitRowsByStableKey } from "./SplitLineLens"; + +type SplitLineRow = Extract; + +/** Build one paired replacement row for lens adaptation tests. */ +function createSplitLineRow(): SplitLineRow { + return { + type: "split-line", + key: "row-1", + fileId: "sample", + hunkIndex: 0, + left: { + kind: "deletion", + sign: "-", + lineNumber: 4, + spans: [{ text: "const value = 1;", fg: "#ffffff" }], + }, + right: { + kind: "addition", + sign: "+", + lineNumber: 6, + spans: [{ text: "const value = 2;", bg: "#123456" }], + }, + }; +} + +describe("split-line lens rows", () => { + test("places the old version above the new version without losing highlighted spans", () => { + const [oldRow, newRow] = buildSplitLineLensRows(createSplitLineRow()); + + expect(oldRow.cell).toEqual({ + kind: "deletion", + sign: "-", + oldLineNumber: 4, + spans: [{ text: "const value = 1;", fg: "#ffffff" }], + }); + expect(newRow.cell).toEqual({ + kind: "addition", + sign: "+", + newLineNumber: 6, + spans: [{ text: "const value = 2;", bg: "#123456" }], + }); + }); + + test("indexes both sides of a split row for constant-time cursor movement", () => { + const row = createSplitLineRow(); + const indexed = indexSplitRowsByStableKey([ + { + kind: "diff-row", + key: row.key, + stableKey: "line:0:old:4", + stableAliasKeys: ["line:0:new:6"], + fileId: row.fileId, + hunkIndex: row.hunkIndex, + row, + }, + ]); + + expect(indexed.get("line:0:old:4")).toBe(row); + expect(indexed.get("line:0:new:6")).toBe(row); + }); + + test("keeps an absent side as an explicit blank lens row", () => { + const row = createSplitLineRow(); + row.left = { kind: "empty", sign: " ", spans: [] }; + + const [oldRow] = buildSplitLineLensRows(row); + + expect(oldRow.cell).toEqual({ kind: "context", sign: " ", spans: [] }); + }); +}); diff --git a/src/ui/components/panes/SplitLineLens.tsx b/src/ui/components/panes/SplitLineLens.tsx new file mode 100644 index 000000000..b90a2d72e --- /dev/null +++ b/src/ui/components/panes/SplitLineLens.tsx @@ -0,0 +1,119 @@ +import { useMemo } from "react"; +import type { DiffRow, SplitLineCell, StackLineCell } from "../../diff/pierre"; +import { DiffRowView, fitText } from "../../diff/renderRows"; +import { measureTextWidth } from "../../lib/text"; +import type { DiffSectionRowPlan } from "../../diff/diffSectionRowPlan"; +import type { PlannedReviewRow } from "../../diff/reviewRenderPlan"; +import type { LineCursor } from "../../lib/lineCursors"; +import type { AppTheme } from "../../themes"; + +type SplitLineRow = Extract; +type StackLineRow = Extract; + +/** Adapt one split cell into the full-width stack cell used by the line lens. */ +function lensStackCell(cell: SplitLineCell, side: "old" | "new"): StackLineCell { + return { + kind: cell.kind === "empty" ? "context" : cell.kind, + sign: cell.kind === "empty" ? " " : cell.sign, + ...(side === "old" ? { oldLineNumber: cell.lineNumber } : { newLineNumber: cell.lineNumber }), + ...(cell.moveKind ? { moveKind: cell.moveKind } : {}), + spans: cell.spans, + }; +} + +/** Convert a side-by-side row into fixed old-above-new lens rows. */ +export function buildSplitLineLensRows(row: SplitLineRow): [StackLineRow, StackLineRow] { + return [ + { + type: "stack-line", + key: `${row.key}:lens:old`, + fileId: row.fileId, + hunkIndex: row.hunkIndex, + cell: lensStackCell(row.left, "old"), + }, + { + type: "stack-line", + key: `${row.key}:lens:new`, + fileId: row.fileId, + hunkIndex: row.hunkIndex, + cell: lensStackCell(row.right, "new"), + }, + ]; +} + +/** Index split rows by every stable cursor anchor they expose. */ +export function indexSplitRowsByStableKey(plannedRows: readonly PlannedReviewRow[]) { + const splitRowsByStableKey = new Map(); + + for (const plannedRow of plannedRows) { + if (plannedRow.kind !== "diff-row" || plannedRow.row.type !== "split-line") { + continue; + } + + splitRowsByStableKey.set(plannedRow.stableKey, plannedRow.row); + for (const stableKey of plannedRow.stableAliasKeys ?? []) { + splitRowsByStableKey.set(stableKey, plannedRow.row); + } + } + + return splitRowsByStableKey; +} + +/** Pin the current split row's old and new versions below the review viewport. */ +export function SplitLineLens({ + codeHorizontalOffset = 0, + cursor, + rowPlan, + showLineNumbers, + theme, + width, +}: { + codeHorizontalOffset?: number; + cursor: LineCursor; + rowPlan: DiffSectionRowPlan; + showLineNumbers: boolean; + theme: AppTheme; + width: number; +}) { + const splitRowsByStableKey = useMemo( + () => indexSplitRowsByStableKey(rowPlan.plannedRows), + [rowPlan.plannedRows], + ); + const splitRow = splitRowsByStableKey.get(cursor.stableKey) ?? null; + const lensRows = useMemo(() => (splitRow ? buildSplitLineLensRows(splitRow) : null), [splitRow]); + + if (!lensRows) { + return null; + } + + const label = fitText("─ Current line · old above, new below ", width); + const rule = label + "─".repeat(Math.max(0, width - measureTextWidth(label))); + return ( + + {rule} + {lensRows.map((row) => ( + + ))} + + ); +} diff --git a/src/ui/diff/PierreDiffView.tsx b/src/ui/diff/PierreDiffView.tsx index 8cf47cc9e..5ceb86d71 100644 --- a/src/ui/diff/PierreDiffView.tsx +++ b/src/ui/diff/PierreDiffView.tsx @@ -11,7 +11,7 @@ import type { AppTheme } from "../themes"; import { type FileSourceStatus } from "./expandCollapsedRows"; import { spansForHighlightedSourceLine, type DiffRow } from "./pierre"; import { plannedReviewRowVisible } from "./plannedReviewRows"; -import { buildDiffSectionRowPlan } from "./diffSectionRowPlan"; +import { buildDiffSectionRowPlan, type DiffSectionRowPlan } from "./diffSectionRowPlan"; import { resolveVisiblePlannedRowWindow, type VisibleBodyBounds } from "./rowWindowing"; import { diffMessage, @@ -76,6 +76,7 @@ export function PierreDiffView({ onHover, onActiveAddNoteAffordanceChange, onStartUserNoteAtHunk, + onRowPlanChange, onToggleGap, showLineNumbers = true, showHunkHeaders = true, @@ -104,6 +105,7 @@ export function PierreDiffView({ onHover?: () => void; onActiveAddNoteAffordanceChange?: (affordance: ActiveAddNoteAffordance | null) => void; onStartUserNoteAtHunk?: (hunkIndex: number, target?: UserNoteLineTarget) => void; + onRowPlanChange?: (rowPlan: DiffSectionRowPlan) => void; onToggleGap?: (gapKey: string) => void; showLineNumbers?: boolean; showHunkHeaders?: boolean; @@ -241,6 +243,10 @@ export function PierreDiffView({ visibleAgentNotes, ], ); + useEffect(() => { + onRowPlanChange?.(sectionRowPlan); + }, [onRowPlanChange, sectionRowPlan]); + const plannedRows = sectionRowPlan.plannedRows; const lineNumberDigits = sectionRowPlan.lineNumberDigits; const fileHasSourceFetcher = Boolean(file?.sourceFetcher); diff --git a/src/ui/lib/appCommands.test.ts b/src/ui/lib/appCommands.test.ts index 430450ae2..c22d82f06 100644 --- a/src/ui/lib/appCommands.test.ts +++ b/src/ui/lib/appCommands.test.ts @@ -41,6 +41,7 @@ function createTestCommands(resolvedKeys?: ResolvedCommandKeys) { canAlignCurrentLine: true, canApplyFilePresentationToAllMatching: false, canRefreshCurrentInput: true, + canToggleLineLens: true, alignCurrentLine: record("alignCurrentLine"), applyFilePresentationToAllMatching: record("applyFilePresentationToAllMatching"), focusFilter: record("focusFilter"), @@ -64,6 +65,7 @@ function createTestCommands(resolvedKeys?: ResolvedCommandKeys) { toggleGapForSelectedHunk: record("toggleGapForSelectedHunk"), toggleHelp: record("toggleHelp"), toggleHunkHeaders: record("toggleHunkHeaders"), + toggleLineLens: record("toggleLineLens"), toggleLineNumbers: record("toggleLineNumbers"), toggleLineWrap: record("toggleLineWrap"), toggleMenuBar: record("toggleMenuBar"), @@ -245,6 +247,7 @@ describe("builtinCommandKeyDefaults", () => { "hunk.view.cursorLineOff", "hunk.view.cursorLineRow", "hunk.view.toggleCopyDecorations", + "hunk.view.toggleLineLens", ]); }); }); diff --git a/src/ui/lib/appCommands.ts b/src/ui/lib/appCommands.ts index 6db4b513a..ab9d99383 100644 --- a/src/ui/lib/appCommands.ts +++ b/src/ui/lib/appCommands.ts @@ -85,6 +85,7 @@ export interface BuildAppCommandsOptions { canAlignCurrentLine: boolean; canApplyFilePresentationToAllMatching: boolean; canRefreshCurrentInput: boolean; + canToggleLineLens: boolean; alignCurrentLine: (alignment: "top" | "center" | "bottom") => void; applyFilePresentationToAllMatching: () => void; focusFilter: () => void; @@ -109,6 +110,7 @@ export interface BuildAppCommandsOptions { toggleGapForSelectedHunk: () => void; toggleHelp: () => void; toggleHunkHeaders: () => void; + toggleLineLens: () => void; toggleLineNumbers: () => void; toggleLineWrap: () => void; toggleMenuBar: () => void; @@ -164,6 +166,7 @@ const PUBLIC_EXTENSION_COMMAND_IDS = new Set([ "hunk.view.openThemeSelector", "hunk.view.toggleAgentNotes", "hunk.view.toggleLineNumbers", + "hunk.view.toggleLineLens", "hunk.view.toggleLineWrap", "hunk.view.toggleMenuBar", "hunk.view.toggleHunkHeaders", @@ -310,6 +313,14 @@ function builtinCommandSpecs(options: BuildAppCommandsOptions): BuiltinCommandSp isEnabled: () => options.canAlignCurrentLine, run: () => options.alignCurrentLine("bottom"), }, + { + id: "hunk.view.toggleLineLens", + title: "Toggle current-line lens", + defaultKeys: [], + isEnabled: () => options.canToggleLineLens, + run: () => options.toggleLineLens(), + closesMenu: true, + }, { id: "hunk.view.cursorLineRow", title: "Highlight the current row", @@ -532,6 +543,7 @@ const NOOP_COMMAND_OPTIONS: BuildAppCommandsOptions = (() => { canAlignCurrentLine: false, canApplyFilePresentationToAllMatching: false, canRefreshCurrentInput: true, + canToggleLineLens: false, alignCurrentLine: noop, applyFilePresentationToAllMatching: noop, focusFilter: noop, @@ -554,6 +566,7 @@ const NOOP_COMMAND_OPTIONS: BuildAppCommandsOptions = (() => { toggleGapForSelectedHunk: noop, toggleHelp: noop, toggleHunkHeaders: noop, + toggleLineLens: noop, toggleLineNumbers: noop, toggleLineWrap: noop, toggleMenuBar: noop, diff --git a/src/ui/lib/appMenus.test.ts b/src/ui/lib/appMenus.test.ts index 77e402439..91ffff1fc 100644 --- a/src/ui/lib/appMenus.test.ts +++ b/src/ui/lib/appMenus.test.ts @@ -22,6 +22,7 @@ const MENU_STATE: Omit = showAgentNotes: true, showHelp: false, showHunkHeaders: false, + showLineLens: false, showLineNumbers: true, showMenuBar: true, wrapLines: true, @@ -40,6 +41,7 @@ function createTestCommands(overrides: Partial = {}) { canAlignCurrentLine: true, canApplyFilePresentationToAllMatching: false, canRefreshCurrentInput: true, + canToggleLineLens: true, alignCurrentLine: record("alignCurrentLine"), applyFilePresentationToAllMatching: record("applyFilePresentationToAllMatching"), focusFilter: noop, @@ -62,6 +64,7 @@ function createTestCommands(overrides: Partial = {}) { toggleGapForSelectedHunk: noop, toggleHelp: noop, toggleHunkHeaders: noop, + toggleLineLens: noop, toggleLineNumbers: noop, toggleLineWrap: noop, toggleMenuBar: noop, @@ -112,6 +115,24 @@ describe("buildAppMenus", () => { expect(checkedFor("row")).toEqual(["Current line: full row"]); expect(checkedFor("number")).toEqual(["Current line: line number"]); expect(checkedFor("off")).toEqual(["Current line: off"]); + + expect( + entry( + buildAppMenus({ + commands, + ...MENU_STATE, + layoutMode: "split", + showLineLens: true, + }), + "view", + "Current-line lens", + ).checked, + ).toBe(true); + + const disabledCommands = createTestCommands({ canToggleLineLens: false }).commands; + expect( + items(buildAppMenus({ commands: disabledCommands, ...MENU_STATE }).view), + ).not.toContainEqual(expect.objectContaining({ label: "Current-line lens" })); }); test("labels, hints, and checked state come from the commands and app state", () => { diff --git a/src/ui/lib/appMenus.ts b/src/ui/lib/appMenus.ts index fbba7fcb7..7dd151927 100644 --- a/src/ui/lib/appMenus.ts +++ b/src/ui/lib/appMenus.ts @@ -45,6 +45,7 @@ export interface BuildAppMenusOptions { showAgentNotes: boolean; showHelp: boolean; showHunkHeaders: boolean; + showLineLens: boolean; showLineNumbers: boolean; showMenuBar: boolean; wrapLines: boolean; @@ -135,6 +136,7 @@ export function buildAppMenus({ showAgentNotes, showHelp, showHunkHeaders, + showLineLens, showLineNumbers, showMenuBar, wrapLines, @@ -175,6 +177,11 @@ export function buildAppMenus({ label: "Copy decorations", checked: copyDecorations, }, + { + commandId: "hunk.view.toggleLineLens", + label: "Current-line lens", + checked: showLineLens, + }, { commandId: "hunk.view.cursorLineRow", label: "Current line: full row", diff --git a/test/pty/cursor-line.test.ts b/test/pty/cursor-line.test.ts index d5220f3f7..f7d8b622d 100644 --- a/test/pty/cursor-line.test.ts +++ b/test/pty/cursor-line.test.ts @@ -44,6 +44,33 @@ describe("PTY current line", () => { } }); + test("split mode pins the current row as old above new and stack mode hides it", async () => { + const fixture = harness.createLongWrapFilePair(); + const session = await harness.launchHunk({ + args: ["diff", fixture.before, fixture.after, "--mode", "split", "--line-lens"], + cols: 140, + rows: 18, + }); + + try { + const split = await session.waitForText(/Current line · old above, new below/, { + timeout: 15_000, + }); + const splitLines = split.split("\n"); + const lensIndex = lineIndexOf(split, "Current line"); + expect(splitLines[lensIndex + 1]).toContain("export const message = 'short';"); + expect(splitLines[lensIndex + 2]).toContain("this is a very long wrapped line"); + + await session.press("2"); + await harness.waitForSnapshot(session, (text) => !text.includes("Current line"), 5_000); + + await session.press("1"); + await session.waitForText(/Current line · old above, new below/, { timeout: 5_000 }); + } finally { + session.close(); + } + }); + test("a held step key advances one line per press", async () => { const fixture = harness.createPinnedHeaderRepoFixture(); const session = await harness.launchHunk({ diff --git a/website/src/content/docs/docs/configure/layout-and-display.md b/website/src/content/docs/docs/configure/layout-and-display.md index b71636b55..ec5161662 100644 --- a/website/src/content/docs/docs/configure/layout-and-display.md +++ b/website/src/content/docs/docs/configure/layout-and-display.md @@ -41,8 +41,11 @@ agent_notes = false copy_decorations = false transparent_background = false cursor_line = "row" +line_lens = false ``` `transparent_background` lets the terminal paint Hunk surfaces; turn it off when exact theme surfaces matter more than matching terminal transparency. `cursor_line` chooses how the line you are on is marked: `row` highlights the whole row, `number` marks only its line number, and `off` removes the marker and returns `k` / `j` to scrolling the view one row at a time. Switch it mid-review from the View menu, or set `--cursor-line