diff --git a/.changeset/quiet-row-facade.md b/.changeset/quiet-row-facade.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/quiet-row-facade.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/tidy-mounted-rows.md b/.changeset/tidy-mounted-rows.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/tidy-mounted-rows.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/docs/browser-review-seam-audit.md b/docs/browser-review-seam-audit.md index ba63fdd05..c490c38c8 100644 --- a/docs/browser-review-seam-audit.md +++ b/docs/browser-review-seam-audit.md @@ -109,13 +109,13 @@ whether a client needs more than that is Phase 5's first question. _Repaid (Phase 1 PR 2, model side)_: `splitLineCount`/`unifiedLineCount` carried on `ReviewFileV1` by `core/review/document.ts`; the browser consumes them in Phase 5. - **A8. Empty-diff explanation — 3 variants with different precedence.** Terminal - `renderRows.tsx` `diffMessage` (rename-pure first), web `ReviewStream.tsx` (binary first), + `plannedRowText.ts` `diffMessage` (rename-pure first), web `ReviewStream.tsx` (binary first), `staticDiffPager.ts` (extra cases). Same file can explain itself differently per client. Fix: `reviewEmptyDiffReason(file)` in core. _Repaid (Phase 1 PR 2)_: `reviewEmptyDiffReason` in `core/review/document.ts` with one canonical precedence — what the change _is_ outranks how it is stored (`rename-only` -> `binary` -> `too-large` -> `new-file` -> `deleted-file` -> `no-hunks`), the review stream's existing order. - `renderRows.tsx` and `staticDiffPager.ts` keep their own wording and share the reason; fixture + `plannedRowText.ts` and `staticDiffPager.ts` keep their own wording and share the reason; fixture `binary-rename-with-no-rows`. The static pager's own order put storage first, so a renamed binary or oversized rename now reports as a rename there too. - **A9. STML tag vocabulary — parse shared, tag semantics forked.** Terminal diff --git a/docs/extension-architecture.md b/docs/extension-architecture.md index a70d2fcd4..571d89a16 100644 --- a/docs/extension-architecture.md +++ b/docs/extension-architecture.md @@ -128,10 +128,13 @@ clusters, with context and gap lines sharing one range list under both side keys — and the one span transform that repaints backgrounds without changing text. `src/ui/diff/rowStyle.ts` resolves tones against the actual line background with the word-diff minimum-contrast guarantee. -`src/ui/diff/renderRows.tsx` applies the transform per rendered cell, which -keeps highlights out of `buildDiffSectionRowPlan`, its caches, and every -geometry measurement: a highlight change is a repaint, never a re-plan. The -static pager never runs extension code, so highlights are interactive-only. +`src/ui/diff/CodeRowView.tsx` applies the transform through the cell painter, +which keeps highlights out of `buildDiffSectionRowPlan`, its caches, and every +geometry measurement: a highlight change is a repaint, never a re-plan. +`src/ui/diff/DiffRowView.tsx` remains only the memoized dispatch facade; raw-row +adaptation there supports the public OpenTUI and extension current-line surfaces, while +`src/ui/diff/cursorHighlight.ts` owns stable-key cursor matching. The static pager never +runs extension code, so highlights are interactive-only. Agent attention marks (`hunk session highlight add` / `clear`) join this same pipeline rather than growing a second one: `useTerminalReview.ts` validates diff --git a/src/opentui/HunkDiffBody.tsx b/src/opentui/HunkDiffBody.tsx index d500d6a88..90e38defa 100644 --- a/src/opentui/HunkDiffBody.tsx +++ b/src/opentui/HunkDiffBody.tsx @@ -3,7 +3,7 @@ import { DEFAULT_TAB_WIDTH } from "../core/run/tabWidth"; import { findMaxLineNumber } from "../ui/diff/codeColumns"; import { buildSplitRows, buildStackRows } from "../ui/diff/diffRows"; import { diffMessage, fitText } from "../ui/diff/plannedRowText"; -import { DiffRowView } from "../ui/diff/renderRows"; +import { DiffRowView } from "../ui/diff/DiffRowView"; import { useHighlightedDiff } from "../ui/diff/useHighlightedDiff"; import { resolveTheme } from "../ui/themes"; import { toInternalDiffFile } from "./model"; diff --git a/src/ui/components/panes/DiffPane.tsx b/src/ui/components/panes/DiffPane.tsx index 0b1391934..ab9e39e6f 100644 --- a/src/ui/components/panes/DiffPane.tsx +++ b/src/ui/components/panes/DiffPane.tsx @@ -27,7 +27,8 @@ import { } from "../../../core/review/state"; import type { FileSourceStatus } from "../../diff/expandCollapsedRows"; import type { ActiveAddNoteAffordance } from "../../diff/DiffSectionBody"; -import { isNestedRowMouseAction, type CursorHighlight } from "../../diff/renderRows"; +import type { CursorHighlight } from "../../diff/cursorHighlight"; +import { isNestedRowMouseAction } from "../../diff/rowMouseActions"; import type { DraftReviewNote } from "../../lib/reviewNoteMapping"; import { createVisibleAgentNote, diff --git a/src/ui/components/panes/DiffSection.tsx b/src/ui/components/panes/DiffSection.tsx index 3302365a5..8d96afaa7 100644 --- a/src/ui/components/panes/DiffSection.tsx +++ b/src/ui/components/panes/DiffSection.tsx @@ -4,7 +4,7 @@ import type { LayoutMode } from "../../../core/run/commandInputs"; import type { UserNoteLineTarget } from "../../../core/liveComments"; import type { FileSourceStatus } from "../../diff/expandCollapsedRows"; import { DiffSectionBody, type ActiveAddNoteAffordance } from "../../diff/DiffSectionBody"; -import type { CursorHighlight } from "../../diff/renderRows"; +import type { CursorHighlight } from "../../diff/cursorHighlight"; import type { VisibleBodyBounds } from "../../diff/rowWindowing"; import type { DiffSectionGeometry } from "../../diff/diffSectionGeometry"; import type { DiffSectionRowPlan } from "../../diff/diffSectionRowPlan"; diff --git a/src/ui/components/panes/FileView.tsx b/src/ui/components/panes/FileView.tsx index 39601e231..4548201a9 100644 --- a/src/ui/components/panes/FileView.tsx +++ b/src/ui/components/panes/FileView.tsx @@ -9,7 +9,7 @@ import type { } from "../../../extension-api/types"; import type { AppTheme } from "../../themes"; import type { DiffSectionGeometry } from "../../diff/diffSectionGeometry"; -import { plannedRowMatchesCursor, type CursorHighlight } from "../../diff/renderRows"; +import { plannedRowMatchesCursor, type CursorHighlight } from "../../diff/cursorHighlight"; import { cursorLineHighlightBg } from "../../diff/rowStyle"; import { resolveVisibleRowIndexWindow, type VisibleBodyBounds } from "../../diff/rowWindowing"; import { reviewRowId } from "../../lib/ids"; diff --git a/src/ui/components/ui-components.test.tsx b/src/ui/components/ui-components.test.tsx index 2c47cc137..54d694819 100644 --- a/src/ui/components/ui-components.test.tsx +++ b/src/ui/components/ui-components.test.tsx @@ -36,7 +36,7 @@ const { DiffFileHeaderRow } = await import("./panes/DiffFileHeaderRow"); const { DiffSectionBody } = await import("../diff/DiffSectionBody"); const { measurePlannedRenderedRowHeight, measureRenderedRowHeight } = await import("../diff/codeRowLayout"); -const { DiffRowView } = await import("../diff/renderRows"); +const { DiffRowView } = await import("../diff/DiffRowView"); function createTestDiffFile( id: string, diff --git a/src/ui/diff/CodeCellView.test.tsx b/src/ui/diff/CodeCellView.test.tsx index 7c570d6b9..49360ab32 100644 --- a/src/ui/diff/CodeCellView.test.tsx +++ b/src/ui/diff/CodeCellView.test.tsx @@ -11,7 +11,7 @@ import { import { legacyPlannedDiffRow, planCodeRowLayout } from "./codeRowLayout"; import type { DiffRow } from "./diffRows"; import { lineHighlightPaintKey, type LineHighlightPaintIndex } from "./lineHighlightPaint"; -import { DiffRowView } from "./renderRows"; +import { DiffRowView } from "./DiffRowView"; import { resolveTheme, withTransparentSurfaces } from "../themes"; /** Capture one code-row component and always release its OpenTUI renderer. */ diff --git a/src/ui/diff/CodeRowView.test.tsx b/src/ui/diff/CodeRowView.test.tsx new file mode 100644 index 000000000..8091c8a12 --- /dev/null +++ b/src/ui/diff/CodeRowView.test.tsx @@ -0,0 +1,71 @@ +import { expect, test } from "bun:test"; +import { testRender } from "@opentui/react/test-utils"; +import { act } from "react"; +import { capturedTestColorToHex } from "../../../test/helpers/test-color-helpers"; +import { resolveTheme } from "../themes"; +import { CodeRowView, type PlannedCodeReviewRow } from "./CodeRowView"; +import { cursorLineHighlightBg, selectionHighlightBg, stackCellPalette } from "./rowStyle"; + +/** Return the normalized background painted behind matching captured text. */ +function backgroundForText( + capture: ReturnType>["captureSpans"]>, + text: string, +) { + const span = capture.lines + .flatMap((line) => line.spans) + .find((candidate) => candidate.text.includes(text)); + return capturedTestColorToHex(span?.bg)?.toLowerCase(); +} + +test("CodeRowView gives copy selection precedence over cursor paint", async () => { + const theme = resolveTheme("github-dark-default", null); + const plannedRow: PlannedCodeReviewRow = { + kind: "diff-row", + key: "diff-row:precedence", + stableKey: "line:0:new:1", + fileId: "paint", + hunkIndex: 0, + row: { + type: "stack-line", + key: "precedence", + fileId: "paint", + hunkIndex: 0, + cell: { + kind: "addition", + sign: "+", + newLineNumber: 1, + spans: [{ text: "selected" }], + }, + }, + }; + const setup = await testRender( + , + { width: 20, height: 2 }, + ); + + try { + await act(async () => { + await setup.renderOnce(); + }); + const background = backgroundForText(setup.captureSpans(), "selected"); + const baseBackground = stackCellPalette("addition", theme).contentBg; + + expect(background).toBe(selectionHighlightBg(baseBackground, theme).toLowerCase()); + expect(background).not.toBe(cursorLineHighlightBg(baseBackground, theme).toLowerCase()); + } finally { + await act(async () => { + setup.renderer.destroy(); + }); + } +}); diff --git a/src/ui/diff/CodeRowView.tsx b/src/ui/diff/CodeRowView.tsx new file mode 100644 index 000000000..88c466349 --- /dev/null +++ b/src/ui/diff/CodeRowView.tsx @@ -0,0 +1,396 @@ +/** Mounts split and stack code rows from the canonical code-row layout and paint plans. */ +import type { UserNoteLineTarget } from "../../core/liveComments"; +import type { CopySelectedRowRange } from "../lib/diffSpatial"; +import type { AppTheme } from "../themes"; +import { CODE_ROW_ADD_NOTE_BADGE_TEXT, CODE_ROW_ADD_NOTE_BADGE_WIDTH } from "./codeRowAffordance"; +import { + planCodeRowLayout, + type CodeRowLayoutPlan, + type PlannedDiffReviewRow, +} from "./codeRowLayout"; +import { codeCellView, FULL_CODE_CELL_COL_RANGE, type CodeCellHighlight } from "./CodeCellView"; +import type { CursorHighlight } from "./cursorHighlight"; +import type { DiffRow } from "./diffRows"; +import type { LineHighlightPaintIndex } from "./lineHighlightPaint"; +import { + cursorLineHighlightBg, + diffRailMarker, + selectionHighlightBg, + splitLeftRailColor, + splitRightRailColor, + stackRailColor, +} from "./rowStyle"; +import { markNestedRowMouseAction } from "./rowMouseActions"; + +type CodeDiffRow = Extract; + +/** Planned review row carrying split or stack code cells. */ +export type PlannedCodeReviewRow = Omit & { + row: CodeDiffRow; +}; + +export interface CodeRowViewProps { + plannedRow: PlannedCodeReviewRow; + width: number; + lineNumberDigits: number; + showLineNumbers: boolean; + wrapLines: boolean; + codeHorizontalOffset: number; + theme: AppTheme; + selected: boolean; + copySelectedRowRange?: CopySelectedRowRange; + copySelectedSide?: "left" | "right"; + cursorHighlight?: CursorHighlight; + lineHighlights?: LineHighlightPaintIndex; + showAddNoteBadge?: boolean; + onHoverRow?: (rowKey: string) => void; + onStartUserNoteAtHunk?: (hunkIndex: number, target?: UserNoteLineTarget) => void; +} + +/** Choose whether copy selection or the cursor paints one half of a row. */ +function pickRowHighlight( + selection: CodeCellHighlight, + cursor: CodeCellHighlight | undefined, + hasSelection: boolean, + onCursor: boolean, +) { + if (hasSelection) return selection; + return onCursor ? cursor : undefined; +} + +/** Render the hover-only add-note target as a separate clickable hit area. */ +function renderAddNoteButton( + key: string, + theme: AppTheme, + hunkIndex: number, + target: UserNoteLineTarget | undefined, + onStartUserNoteAtHunk?: (hunkIndex: number, target?: UserNoteLineTarget) => void, +) { + return ( + { + markNestedRowMouseAction(event); + onStartUserNoteAtHunk?.(hunkIndex, target); + }} + > + + {CODE_ROW_ADD_NOTE_BADGE_TEXT} + + + ); +} + +/** Fill the reserved wrapped-row hover column so row backgrounds do not visibly shrink. */ +function renderAddNoteSpacer(key: string, width: number, bg: string) { + if (width <= 0) { + return null; + } + + return ( + + + + ); +} + +/** Mount one split or stack code row with selection, cursor, guide, and affordance paint. */ +export function CodeRowView({ + plannedRow, + width, + lineNumberDigits, + showLineNumbers, + wrapLines, + codeHorizontalOffset, + theme, + selected, + copySelectedRowRange, + copySelectedSide, + cursorHighlight, + lineHighlights, + showAddNoteBadge = false, + onHoverRow, + onStartUserNoteAtHunk, +}: CodeRowViewProps) { + // Extension marks repaint span backgrounds only; geometry inputs keep using the source row. + const row = codeCellView.applyLineHighlights( + plannedRow.row, + lineHighlights, + theme, + ) as CodeDiffRow; + const { anchorId } = plannedRow; + const hasCopySelection = copySelectedRowRange !== undefined; + const codeRowLayout = planCodeRowLayout(plannedRow, { + lineNumberDigits, + reserveAddNoteColumn: Boolean(onStartUserNoteAtHunk), + showAddNoteBadge, + showLineNumbers, + width, + wrapLines, + }) as CodeRowLayoutPlan; + + // For split rows, the user's drag is anchored to one column-half of the diff. Apply the + // selection-highlight blend only to that side so it is clear which file (A or B) the + // selection represents. + const hasLeftSelection = hasCopySelection && copySelectedSide !== "right"; + const hasRightSelection = hasCopySelection && copySelectedSide !== "left"; + + // A split context row shows the same source line on both halves, so marking one of them would + // read as half a row. Change rows keep the split, since the halves are different note targets. + const splitContextRow = + row.type === "split-line" && row.left.kind === "context" && row.right.kind === "context"; + const onCursorRow = cursorHighlight !== undefined; + const selectionHighlight: CodeCellHighlight = { + bg: (baseBg) => selectionHighlightBg(baseBg, theme), + colRange: copySelectedRowRange, + }; + const cursorRowHighlight: CodeCellHighlight | undefined = onCursorRow + ? { + bg: (baseBg) => cursorLineHighlightBg(baseBg, theme), + colRange: cursorHighlight.style === "row" ? FULL_CODE_CELL_COL_RANGE : undefined, + } + : undefined; + const leftHighlight = pickRowHighlight( + selectionHighlight, + cursorRowHighlight, + hasLeftSelection, + onCursorRow && (splitContextRow || cursorHighlight.side === "old"), + ); + const rightHighlight = pickRowHighlight( + selectionHighlight, + cursorRowHighlight, + hasRightSelection, + onCursorRow && (splitContextRow || cursorHighlight.side === "new"), + ); + const cellHighlight = pickRowHighlight( + selectionHighlight, + cursorRowHighlight, + hasCopySelection, + onCursorRow, + ); + + if (row.type === "split-line") { + // The planner and row type are derived from the same complete planned row. + const splitLayout = codeRowLayout as Extract; + const guideOnOldSide = splitLayout.noteGuideSide === "old"; + const guideOnNewSide = splitLayout.noteGuideSide === "new"; + const addNoteTarget: UserNoteLineTarget | undefined = + row.right.lineNumber !== undefined + ? { side: "new", line: row.right.lineNumber } + : row.left.lineNumber !== undefined + ? { side: "old", line: row.left.lineNumber } + : undefined; + + const addBadgeWidth = splitLayout.addNoteBadgeWidth; + const leftPrefix = { + text: guideOnOldSide ? "│" : diffRailMarker(), + fg: guideOnOldSide + ? theme.noteBorder + : splitLeftRailColor(row.left.kind, theme, selected || hasCopySelection), + bg: theme.panel, + }; + const rightPrefix = { + text: "▌", + fg: splitRightRailColor(row.right.kind, theme, selected || hasCopySelection), + bg: theme.panel, + }; + + if (!wrapLines) { + return ( + onHoverRow?.(row.key)} + > + + {codeCellView.renderNowrapSplit({ + row, + layout: splitLayout, + lineNumberDigits, + showLineNumbers, + theme, + horizontalOffset: codeHorizontalOffset, + leftPrefix, + rightPrefix, + leftHighlight, + rightHighlight, + guideOnNewSide, + })} + + {showAddNoteBadge + ? renderAddNoteButton( + `${row.key}:add-note`, + theme, + row.hunkIndex, + addNoteTarget, + onStartUserNoteAtHunk, + ) + : null} + + ); + } + + const wrapped = codeCellView.createWrappedSplit({ + row, + layout: splitLayout, + lineNumberDigits, + showLineNumbers, + theme, + leftPrefix, + rightPrefix, + leftHighlight, + rightHighlight, + guideOnNewSide, + }); + + return ( + + {Array.from({ length: wrapped.lineCount }, (_, index) => { + const showBadgeOnLine = showAddNoteBadge && index === 0; + const styledRow = wrapped.paintLine(index, showBadgeOnLine ? 0 : addBadgeWidth); + + if (!showBadgeOnLine) { + return ( + onHoverRow?.(row.key)} + /> + ); + } + return ( + onHoverRow?.(row.key)} + > + + + + {renderAddNoteButton( + `${row.key}:add-note:${index}`, + theme, + row.hunkIndex, + addNoteTarget, + onStartUserNoteAtHunk, + )} + + ); + })} + + ); + } + + // The planner and row type are derived from the same complete planned row. + const stackLayout = codeRowLayout as Extract; + const guideOnOldSide = stackLayout.noteGuideSide === "old"; + const guideOnNewSide = stackLayout.noteGuideSide === "new"; + const addNoteTarget: UserNoteLineTarget | undefined = + row.cell.newLineNumber !== undefined + ? { side: "new", line: row.cell.newLineNumber } + : row.cell.oldLineNumber !== undefined + ? { side: "old", line: row.cell.oldLineNumber } + : undefined; + const addBadgeWidth = stackLayout.addNoteBadgeWidth; + const prefix = { + text: guideOnOldSide ? "│" : diffRailMarker(), + fg: guideOnOldSide + ? theme.noteBorder + : stackRailColor(row.cell.kind, theme, selected || hasCopySelection), + bg: theme.panel, + }; + + if (!wrapLines) { + return ( + onHoverRow?.(row.key)} + > + + {codeCellView.renderNowrapStack({ + row, + layout: stackLayout, + lineNumberDigits, + showLineNumbers, + theme, + horizontalOffset: codeHorizontalOffset, + prefix, + highlight: cellHighlight, + guideOnNewSide, + })} + + {showAddNoteBadge + ? renderAddNoteButton( + `${row.key}:add-note`, + theme, + row.hunkIndex, + addNoteTarget, + onStartUserNoteAtHunk, + ) + : null} + + ); + } + + const wrapped = codeCellView.createWrappedStack({ + row, + layout: stackLayout, + lineNumberDigits, + showLineNumbers, + theme, + prefix, + highlight: cellHighlight, + guideOnNewSide, + }); + + return ( + + {Array.from({ length: wrapped.lineCount }, (_, index) => { + const showBadgeOnLine = showAddNoteBadge && index === 0; + const styledRow = wrapped.paintLine(index); + + return ( + onHoverRow?.(row.key)} + > + 0 ? Math.max(0, width - addBadgeWidth) : "100%", + height: 1, + }} + > + + + {showBadgeOnLine + ? renderAddNoteButton( + `${row.key}:add-note:${index}`, + theme, + row.hunkIndex, + addNoteTarget, + onStartUserNoteAtHunk, + ) + : renderAddNoteSpacer( + `${row.key}:add-note-spacer:${index}`, + addBadgeWidth, + wrapped.contentBackground, + )} + + ); + })} + + ); +} diff --git a/src/ui/diff/DiffMetaRowView.tsx b/src/ui/diff/DiffMetaRowView.tsx new file mode 100644 index 000000000..a7dbf27ea --- /dev/null +++ b/src/ui/diff/DiffMetaRowView.tsx @@ -0,0 +1,154 @@ +/** Renders collapsed gaps and hunk headers without introducing code-row geometry policy. */ +import type { UserNoteLineTarget } from "../../core/liveComments"; +import { reviewGapId } from "../../core/review/expansion"; +import type { AppTheme } from "../themes"; +import { CODE_ROW_ADD_NOTE_BADGE_TEXT } from "./codeRowAffordance"; +import type { PlannedDiffReviewRow } from "./codeRowLayout"; +import type { DiffRow } from "./diffRows"; +import { fitText } from "./plannedRowText"; +import { diffRailMarker, dimRailColor, neutralRailColor } from "./rowStyle"; +import { markNestedRowMouseAction } from "./rowMouseActions"; + +type DiffMetaRow = Extract; + +/** Planned review row carrying metadata rather than code cells. */ +export type PlannedDiffMetaReviewRow = Omit & { + row: DiffMetaRow; +}; + +export interface DiffMetaRowViewProps { + plannedRow: PlannedDiffMetaReviewRow; + width: number; + theme: AppTheme; + selected: boolean; + showHunkHeaders: boolean; + showAddNoteBadge?: boolean; + onHoverRow?: (rowKey: string) => void; + onStartUserNoteAtHunk?: (hunkIndex: number, target?: UserNoteLineTarget) => void; + onToggleGap?: (gapKey: string) => void; +} + +/** Build the rendered label text for one collapsed gap row. */ +function collapsedRowLabel(text: string, expandable: boolean) { + if (!expandable) { + return `··· ${text} ···`; + } + + // The leading chevron hints that the row is interactive on terminals that + // render Unicode glyphs. The label still reads naturally on plain VT100. + return `▾ ${text}`; +} + +/** Render one collapsed gap or hunk header with its nested row controls. */ +export function DiffMetaRowView({ + plannedRow, + width, + theme, + selected, + showHunkHeaders, + showAddNoteBadge = false, + onHoverRow, + onStartUserNoteAtHunk, + onToggleGap, +}: DiffMetaRowViewProps) { + const { anchorId, row } = plannedRow; + if (row.type === "hunk-header" && !showHunkHeaders) { + return null; + } + + const badges = [ + showAddNoteBadge + ? { + key: "user-note", + text: CODE_ROW_ADD_NOTE_BADGE_TEXT, + onClick: () => onStartUserNoteAtHunk?.(row.hunkIndex), + } + : null, + ].filter((badge): badge is { key: string; text: string; onClick: () => void } => Boolean(badge)); + const badgeWidth = badges.reduce((total, badge) => total + badge.text.length + 1, 0); + const collapsedExpandable = row.type === "collapsed" && Boolean(onToggleGap); + const labelText = + row.type === "collapsed" ? collapsedRowLabel(row.text, collapsedExpandable) : row.text; + const label = fitText(labelText, Math.max(0, width - 1 - badgeWidth)); + const handleCollapsedClick = + row.type === "collapsed" && onToggleGap + ? () => onToggleGap(reviewGapId(row.position, row.hunkIndex)) + : undefined; + + if (badges.length === 0) { + return ( + onHoverRow?.(row.key)} + onMouseOver={() => onHoverRow?.(row.key)} + onMouseUp={handleCollapsedClick} + > + + + {diffRailMarker()} + + + {label} + + + + ); + } + + return ( + onHoverRow?.(row.key)} + onMouseOver={() => onHoverRow?.(row.key)} + > + + + + {diffRailMarker()} + + + {label} + + + + {badges.map((badge) => ( + { + markNestedRowMouseAction(event); + badge.onClick(); + }} + > + {` ${badge.text}`} + + ))} + + ); +} diff --git a/src/ui/diff/DiffRowView.tsx b/src/ui/diff/DiffRowView.tsx new file mode 100644 index 000000000..1fb02a2bd --- /dev/null +++ b/src/ui/diff/DiffRowView.tsx @@ -0,0 +1,185 @@ +/** Adapts and dispatches diff rows to their focused mounted row views. */ +import { memo } from "react"; +import type { UserNoteLineTarget } from "../../core/liveComments"; +import type { CopySelectedRowRange } from "../lib/diffSpatial"; +import type { AppTheme } from "../themes"; +import { CodeRowView, type PlannedCodeReviewRow } from "./CodeRowView"; +import { legacyPlannedDiffRow, type PlannedDiffReviewRow } from "./codeRowLayout"; +import type { CursorHighlight } from "./cursorHighlight"; +import { DiffMetaRowView, type PlannedDiffMetaReviewRow } from "./DiffMetaRowView"; +import type { DiffRow } from "./diffRows"; +import type { LineHighlightPaintIndex } from "./lineHighlightPaint"; + +/** Dispatch one planned diff row to its focused metadata or code view. */ +function renderRow( + plannedRow: PlannedDiffReviewRow, + width: number, + lineNumberDigits: number, + showLineNumbers: boolean, + showHunkHeaders: boolean, + wrapLines: boolean, + codeHorizontalOffset: number, + theme: AppTheme, + selected: boolean, + copySelectedRowRange: CopySelectedRowRange | undefined, + copySelectedSide: "left" | "right" | undefined, + cursorHighlight: CursorHighlight | undefined, + lineHighlights: LineHighlightPaintIndex | undefined, + showAddNoteBadge = false, + onHoverRow?: (rowKey: string) => void, + onStartUserNoteAtHunk?: (hunkIndex: number, target?: UserNoteLineTarget) => void, + onToggleGap?: (gapKey: string) => void, +) { + if (plannedRow.row.type === "collapsed" || plannedRow.row.type === "hunk-header") { + return ( + + ); + } + + if (plannedRow.row.type === "split-line" || plannedRow.row.type === "stack-line") { + return ( + + ); + } + + return ( + + Unsupported row. + + ); +} + +/** Inputs accepted by the memoized diff-row facade. */ +export interface DiffRowViewProps { + /** Complete review-stream row; preferred when the caller owns the shared render plan. */ + plannedRow?: PlannedDiffReviewRow; + /** Raw row fallback for renderer-only surfaces outside the shared review stream. */ + row?: DiffRow; + width: number; + lineNumberDigits: number; + showLineNumbers: boolean; + showHunkHeaders: boolean; + wrapLines: boolean; + codeHorizontalOffset: number; + theme: AppTheme; + selected: boolean; + copySelectedRowRange?: CopySelectedRowRange; + copySelectedSide?: "left" | "right"; + cursorHighlight?: CursorHighlight; + /** Extension marks for this row's file, resolved to terminal columns. */ + lineHighlights?: LineHighlightPaintIndex; + anchorId?: string; + noteGuideSide?: "old" | "new"; + showAddNoteBadge?: boolean; + onHoverRow?: (rowKey: string) => void; + onStartUserNoteAtHunk?: (hunkIndex: number, target?: UserNoteLineTarget) => void; + onToggleGap?: (gapKey: string) => void; +} + +/** + * Render one diff row, memoized to avoid unnecessary rerenders. + * + * The comparator checks every handler by reference, so callers (DiffSectionBody) must pass + * identity-stable callbacks — e.g. one shared onHoverRow that receives the row key — or the memo + * silently degrades to re-rendering every visible row per parent render. + */ +export const DiffRowView = memo( + function DiffRowViewComponent({ + plannedRow, + row, + width, + lineNumberDigits, + showLineNumbers, + showHunkHeaders, + wrapLines, + codeHorizontalOffset, + theme, + selected, + copySelectedRowRange, + copySelectedSide, + cursorHighlight, + lineHighlights, + anchorId, + noteGuideSide, + showAddNoteBadge, + onHoverRow, + onStartUserNoteAtHunk, + onToggleGap, + }: DiffRowViewProps) { + const resolvedPlannedRow = + plannedRow ?? (row ? legacyPlannedDiffRow(row, anchorId, noteGuideSide) : undefined); + if (!resolvedPlannedRow) { + return null; + } + + return renderRow( + resolvedPlannedRow, + width, + lineNumberDigits, + showLineNumbers, + showHunkHeaders, + wrapLines, + codeHorizontalOffset, + theme, + selected, + copySelectedRowRange, + copySelectedSide, + cursorHighlight, + lineHighlights, + showAddNoteBadge, + onHoverRow, + onStartUserNoteAtHunk, + onToggleGap, + ); + }, + (previous, next) => { + return ( + previous.plannedRow === next.plannedRow && + previous.row === next.row && + previous.width === next.width && + previous.lineNumberDigits === next.lineNumberDigits && + previous.showLineNumbers === next.showLineNumbers && + previous.showHunkHeaders === next.showHunkHeaders && + previous.wrapLines === next.wrapLines && + previous.codeHorizontalOffset === next.codeHorizontalOffset && + previous.theme === next.theme && + previous.selected === next.selected && + previous.copySelectedRowRange === next.copySelectedRowRange && + previous.copySelectedSide === next.copySelectedSide && + previous.cursorHighlight === next.cursorHighlight && + previous.lineHighlights === next.lineHighlights && + previous.anchorId === next.anchorId && + previous.noteGuideSide === next.noteGuideSide && + previous.showAddNoteBadge === next.showAddNoteBadge && + previous.onHoverRow === next.onHoverRow && + previous.onStartUserNoteAtHunk === next.onStartUserNoteAtHunk && + previous.onToggleGap === next.onToggleGap + ); + }, +); diff --git a/src/ui/diff/DiffSectionBody.tsx b/src/ui/diff/DiffSectionBody.tsx index 7528068f0..2aec54724 100644 --- a/src/ui/diff/DiffSectionBody.tsx +++ b/src/ui/diff/DiffSectionBody.tsx @@ -24,7 +24,8 @@ import { plannedReviewRowVisible } from "./reviewRowGeometry"; import { buildDiffSectionRowPlan, type DiffSectionRowPlan } from "./diffSectionRowPlan"; import { resolveVisiblePlannedRowWindow, type VisibleBodyBounds } from "./rowWindowing"; import { diffMessage, fitText } from "./plannedRowText"; -import { DiffRowView, plannedRowMatchesCursor, type CursorHighlight } from "./renderRows"; +import { DiffRowView } from "./DiffRowView"; +import { plannedRowMatchesCursor, type CursorHighlight } from "./cursorHighlight"; import { useHighlightedDiff } from "./useHighlightedDiff"; import { useHighlightedSource } from "./useHighlightedSource"; diff --git a/src/ui/diff/cursorHighlight.test.ts b/src/ui/diff/cursorHighlight.test.ts new file mode 100644 index 000000000..41e0b590e --- /dev/null +++ b/src/ui/diff/cursorHighlight.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from "bun:test"; +import { plannedRowMatchesCursor, type CursorHighlight } from "./cursorHighlight"; + +const CURSOR: CursorHighlight = { + stableKey: "line:new:2", + style: "row", + side: "new", +}; + +describe("plannedRowMatchesCursor", () => { + test("matches canonical and alias stable keys", () => { + expect(plannedRowMatchesCursor({ stableKey: CURSOR.stableKey }, CURSOR)).toBe(true); + expect( + plannedRowMatchesCursor( + { stableKey: "line:old:2", stableAliasKeys: [CURSOR.stableKey] }, + CURSOR, + ), + ).toBe(true); + }); + + test("rejects absent and unrelated cursors", () => { + expect(plannedRowMatchesCursor({ stableKey: "line:new:3" }, CURSOR)).toBe(false); + expect(plannedRowMatchesCursor({ stableKey: CURSOR.stableKey }, undefined)).toBe(false); + }); +}); diff --git a/src/ui/diff/cursorHighlight.ts b/src/ui/diff/cursorHighlight.ts new file mode 100644 index 000000000..bf9fc44a8 --- /dev/null +++ b/src/ui/diff/cursorHighlight.ts @@ -0,0 +1,22 @@ +/** Defines cursor paint inputs and matches them to stable planned-row identities. */ +import type { CursorLine } from "../../core/run/commandInputs"; + +/** Cursor paint inputs shared by review planning and mounted code rows. */ +export interface CursorHighlight { + /** The render plan anchor of the row the cursor rests on, shared with reveal lookups. */ + stableKey: string; + style: Exclude; + /** Which half of a split row the cursor sits on, and where a note would anchor. */ + side: "old" | "new"; +} + +/** Report whether one planned row carries the anchor the cursor rests on. */ +export function plannedRowMatchesCursor( + row: { stableKey: string; stableAliasKeys?: readonly string[] }, + cursor: CursorHighlight | undefined, +) { + return ( + cursor !== undefined && + (row.stableKey === cursor.stableKey || row.stableAliasKeys?.includes(cursor.stableKey) === true) + ); +} diff --git a/src/ui/diff/renderRows.tsx b/src/ui/diff/renderRows.tsx deleted file mode 100644 index cd9ea5144..000000000 --- a/src/ui/diff/renderRows.tsx +++ /dev/null @@ -1,672 +0,0 @@ -import { memo, type ReactNode } from "react"; -import type { MouseEvent as TuiMouseEvent } from "@opentui/core"; -import type { UserNoteLineTarget } from "../../core/liveComments"; -import type { AppTheme } from "../themes"; -import { CODE_ROW_ADD_NOTE_BADGE_TEXT, CODE_ROW_ADD_NOTE_BADGE_WIDTH } from "./codeRowAffordance"; -import { - legacyPlannedDiffRow, - planCodeRowLayout, - type CodeRowLayoutPlan, - type PlannedDiffReviewRow, -} from "./codeRowLayout"; -import { reviewGapId } from "../../core/review/expansion"; -import type { DiffRow } from "./diffRows"; -import type { LineHighlightPaintIndex } from "./lineHighlightPaint"; -import { - diffRailMarker, - dimRailColor, - neutralRailColor, - cursorLineHighlightBg, - selectionHighlightBg, - splitLeftRailColor, - splitRightRailColor, - stackRailColor, -} from "./rowStyle"; -import { fitText } from "./plannedRowText"; -import { codeCellView, FULL_CODE_CELL_COL_RANGE, type CodeCellHighlight } from "./CodeCellView"; -import type { CopySelectedRowRange } from "../lib/diffSpatial"; -import type { CursorLine } from "../../core/run/commandInputs"; - -const marker = diffRailMarker; - -export interface CursorHighlight { - /** The render plan anchor of the row the cursor rests on, shared with reveal lookups. */ - stableKey: string; - style: Exclude; - /** Which half of a split row the cursor sits on, and where a note would anchor. */ - side: "old" | "new"; -} - -/** Report whether one planned row carries the anchor the cursor rests on. */ -export function plannedRowMatchesCursor( - row: { stableKey: string; stableAliasKeys?: readonly string[] }, - cursor: CursorHighlight | undefined, -) { - return ( - cursor !== undefined && - (row.stableKey === cursor.stableKey || row.stableAliasKeys?.includes(cursor.stableKey) === true) - ); -} - -/** Choose whether copy selection or the cursor paints one half of a row. */ -function pickRowHighlight( - selection: CodeCellHighlight, - cursor: CodeCellHighlight | undefined, - hasSelection: boolean, - onCursor: boolean, -) { - if (hasSelection) return selection; - return onCursor ? cursor : undefined; -} - -/** Build the rendered label text for one collapsed gap row. */ -function collapsedRowLabel(text: string, expandable: boolean) { - if (!expandable) { - return `··· ${text} ···`; - } - - // The leading chevron hints that the row is interactive on terminals that - // render Unicode glyphs. The label still reads naturally on plain VT100. - return `▾ ${text}`; -} - -/** Render collapsed and hunk-header rows, including the optional add-note target. */ -function renderHeaderRow( - row: Extract, - width: number, - theme: AppTheme, - selected: boolean, - anchorId?: string, - showAddNoteBadge = false, - onHoverRow?: (rowKey: string) => void, - onStartUserNoteAtHunk?: (hunkIndex: number, target?: UserNoteLineTarget) => void, - onToggleGap?: (gapKey: string) => void, -) { - const badges = [ - showAddNoteBadge - ? { - key: "user-note", - text: CODE_ROW_ADD_NOTE_BADGE_TEXT, - onClick: () => onStartUserNoteAtHunk?.(row.hunkIndex), - } - : null, - ].filter((badge): badge is { key: string; text: string; onClick: () => void } => Boolean(badge)); - const badgeWidth = badges.reduce((total, badge) => total + badge.text.length + 1, 0); - const collapsedExpandable = row.type === "collapsed" && Boolean(onToggleGap); - const labelText = - row.type === "collapsed" ? collapsedRowLabel(row.text, collapsedExpandable) : row.text; - const label = fitText(labelText, Math.max(0, width - 1 - badgeWidth)); - const handleCollapsedClick = - row.type === "collapsed" && onToggleGap - ? () => onToggleGap(reviewGapId(row.position, row.hunkIndex)) - : undefined; - - if (badges.length === 0) { - return ( - onHoverRow?.(row.key)} - onMouseOver={() => onHoverRow?.(row.key)} - onMouseUp={handleCollapsedClick} - > - - - {marker()} - - - {label} - - - - ); - } - - return ( - onHoverRow?.(row.key)} - onMouseOver={() => onHoverRow?.(row.key)} - > - - - - {marker()} - - - {label} - - - - {badges.map((badge) => ( - { - markNestedRowMouseAction(event); - badge.onClick(); - }} - > - {` ${badge.text}`} - - ))} - - ); -} - -const nestedRowMouseActions = new WeakSet(); - -/** Mark an event so the parent completes mouse cleanup without selecting the containing line. */ -export function markNestedRowMouseAction(event: TuiMouseEvent) { - nestedRowMouseActions.add(event); -} - -/** Return whether a nested control, rather than the diff line, owns this mouse event. */ -export function isNestedRowMouseAction(event: TuiMouseEvent) { - return nestedRowMouseActions.has(event); -} - -/** Render the hover-only add-note target as a separate clickable hit area. */ -function renderAddNoteButton( - key: string, - theme: AppTheme, - hunkIndex: number, - target: UserNoteLineTarget | undefined, - onStartUserNoteAtHunk?: (hunkIndex: number, target?: UserNoteLineTarget) => void, -) { - return ( - { - markNestedRowMouseAction(event); - onStartUserNoteAtHunk?.(hunkIndex, target); - }} - > - - {CODE_ROW_ADD_NOTE_BADGE_TEXT} - - - ); -} - -/** Fill the reserved wrapped-row hover column so row backgrounds do not visibly shrink. */ -function renderAddNoteSpacer(key: string, width: number, bg: string) { - if (width <= 0) { - return null; - } - - return ( - - - - ); -} - -/** Render one diff row. */ -function renderRow( - plannedRow: PlannedDiffReviewRow, - width: number, - lineNumberDigits: number, - showLineNumbers: boolean, - showHunkHeaders: boolean, - wrapLines: boolean, - codeHorizontalOffset: number, - theme: AppTheme, - selected: boolean, - copySelectedRowRange: CopySelectedRowRange | undefined, - copySelectedSide: "left" | "right" | undefined, - cursorHighlight: CursorHighlight | undefined, - lineHighlights: LineHighlightPaintIndex | undefined, - showAddNoteBadge = false, - onHoverRow?: (rowKey: string) => void, - onStartUserNoteAtHunk?: (hunkIndex: number, target?: UserNoteLineTarget) => void, - onToggleGap?: (gapKey: string) => void, -) { - // Extension marks repaint span backgrounds only; geometry inputs keep using the source row. - const row = codeCellView.applyLineHighlights(plannedRow.row, lineHighlights, theme); - const { anchorId } = plannedRow; - const hasCopySelection = !!copySelectedRowRange; - const reserveAddNoteColumn = Boolean(onStartUserNoteAtHunk); - const codeRowLayout = planCodeRowLayout(plannedRow, { - lineNumberDigits, - reserveAddNoteColumn, - showAddNoteBadge, - showLineNumbers, - width, - wrapLines, - }); - - // For split rows, the user's drag is anchored to one column-half of the diff. Apply the - // selection-highlight blend only to that side so it is clear which file (A or B) the - // selection represents. - const hasLeftSelection = hasCopySelection && copySelectedSide !== "right"; - const hasRightSelection = hasCopySelection && copySelectedSide !== "left"; - - // A split context row shows the same source line on both halves, so marking one of them would - // read as half a row. Change rows keep the split, since the halves are different note targets. - const splitContextRow = - row.type === "split-line" && row.left.kind === "context" && row.right.kind === "context"; - const onCursorRow = cursorHighlight !== undefined; - const selectionHighlight: CodeCellHighlight = { - bg: (baseBg) => selectionHighlightBg(baseBg, theme), - colRange: copySelectedRowRange, - }; - const cursorRowHighlight: CodeCellHighlight | undefined = onCursorRow - ? { - bg: (baseBg) => cursorLineHighlightBg(baseBg, theme), - colRange: cursorHighlight.style === "row" ? FULL_CODE_CELL_COL_RANGE : undefined, - } - : undefined; - const leftHighlight = pickRowHighlight( - selectionHighlight, - cursorRowHighlight, - hasLeftSelection, - onCursorRow && (splitContextRow || cursorHighlight.side === "old"), - ); - const rightHighlight = pickRowHighlight( - selectionHighlight, - cursorRowHighlight, - hasRightSelection, - onCursorRow && (splitContextRow || cursorHighlight.side === "new"), - ); - const cellHighlight = pickRowHighlight( - selectionHighlight, - cursorRowHighlight, - hasCopySelection, - onCursorRow, - ); - let baseRow: ReactNode; - - if (row.type === "collapsed") { - baseRow = renderHeaderRow( - row, - width, - theme, - selected || hasCopySelection, - anchorId, - showAddNoteBadge, - onHoverRow, - onStartUserNoteAtHunk, - onToggleGap, - ); - } else if (row.type === "hunk-header") { - baseRow = showHunkHeaders - ? renderHeaderRow( - row, - width, - theme, - selected || hasCopySelection, - anchorId, - showAddNoteBadge, - onHoverRow, - onStartUserNoteAtHunk, - ) - : null; - } else if (row.type === "split-line") { - // The planner and row type are derived from the same complete planned row. - const splitLayout = codeRowLayout as Extract; - const guideOnOldSide = splitLayout.noteGuideSide === "old"; - const guideOnNewSide = splitLayout.noteGuideSide === "new"; - const addNoteTarget: UserNoteLineTarget | undefined = - row.right.lineNumber !== undefined - ? { side: "new", line: row.right.lineNumber } - : row.left.lineNumber !== undefined - ? { side: "old", line: row.left.lineNumber } - : undefined; - - const addBadgeWidth = splitLayout.addNoteBadgeWidth; - const leftPrefix = { - text: guideOnOldSide ? "│" : marker(), - fg: guideOnOldSide - ? theme.noteBorder - : splitLeftRailColor(row.left.kind, theme, selected || hasCopySelection), - bg: theme.panel, - }; - const rightPrefix = { - text: "▌", - fg: splitRightRailColor(row.right.kind, theme, selected || hasCopySelection), - bg: theme.panel, - }; - - if (!wrapLines) { - baseRow = ( - onHoverRow?.(row.key)} - > - - {codeCellView.renderNowrapSplit({ - row, - layout: splitLayout, - lineNumberDigits, - showLineNumbers, - theme, - horizontalOffset: codeHorizontalOffset, - leftPrefix, - rightPrefix, - leftHighlight, - rightHighlight, - guideOnNewSide, - })} - - {showAddNoteBadge - ? renderAddNoteButton( - `${row.key}:add-note`, - theme, - row.hunkIndex, - addNoteTarget, - onStartUserNoteAtHunk, - ) - : null} - - ); - } else { - const wrapped = codeCellView.createWrappedSplit({ - row, - layout: splitLayout, - lineNumberDigits, - showLineNumbers, - theme, - leftPrefix, - rightPrefix, - leftHighlight, - rightHighlight, - guideOnNewSide, - }); - - baseRow = ( - - {Array.from({ length: wrapped.lineCount }, (_, index) => { - const showBadgeOnLine = showAddNoteBadge && index === 0; - const styledRow = wrapped.paintLine(index, showBadgeOnLine ? 0 : addBadgeWidth); - - if (!showBadgeOnLine) { - return ( - onHoverRow?.(row.key)} - /> - ); - } - return ( - onHoverRow?.(row.key)} - > - - - - {renderAddNoteButton( - `${row.key}:add-note:${index}`, - theme, - row.hunkIndex, - addNoteTarget, - onStartUserNoteAtHunk, - )} - - ); - })} - - ); - } - } else if (row.type === "stack-line") { - // The planner and row type are derived from the same complete planned row. - const stackLayout = codeRowLayout as Extract; - const guideOnOldSide = stackLayout.noteGuideSide === "old"; - const guideOnNewSide = stackLayout.noteGuideSide === "new"; - const addNoteTarget: UserNoteLineTarget | undefined = - row.cell.newLineNumber !== undefined - ? { side: "new", line: row.cell.newLineNumber } - : row.cell.oldLineNumber !== undefined - ? { side: "old", line: row.cell.oldLineNumber } - : undefined; - const addBadgeWidth = stackLayout.addNoteBadgeWidth; - const prefix = { - text: guideOnOldSide ? "│" : marker(), - fg: guideOnOldSide - ? theme.noteBorder - : stackRailColor(row.cell.kind, theme, selected || hasCopySelection), - bg: theme.panel, - }; - - if (!wrapLines) { - baseRow = ( - onHoverRow?.(row.key)} - > - - {codeCellView.renderNowrapStack({ - row, - layout: stackLayout, - lineNumberDigits, - showLineNumbers, - theme, - horizontalOffset: codeHorizontalOffset, - prefix, - highlight: cellHighlight, - guideOnNewSide, - })} - - {showAddNoteBadge - ? renderAddNoteButton( - `${row.key}:add-note`, - theme, - row.hunkIndex, - addNoteTarget, - onStartUserNoteAtHunk, - ) - : null} - - ); - } else { - const wrapped = codeCellView.createWrappedStack({ - row, - layout: stackLayout, - lineNumberDigits, - showLineNumbers, - theme, - prefix, - highlight: cellHighlight, - guideOnNewSide, - }); - - baseRow = ( - - {Array.from({ length: wrapped.lineCount }, (_, index) => { - const showBadgeOnLine = showAddNoteBadge && index === 0; - const styledRow = wrapped.paintLine(index); - - return ( - onHoverRow?.(row.key)} - > - 0 ? Math.max(0, width - addBadgeWidth) : "100%", - height: 1, - }} - > - - - {showBadgeOnLine - ? renderAddNoteButton( - `${row.key}:add-note:${index}`, - theme, - row.hunkIndex, - addNoteTarget, - onStartUserNoteAtHunk, - ) - : renderAddNoteSpacer( - `${row.key}:add-note-spacer:${index}`, - addBadgeWidth, - wrapped.contentBackground, - )} - - ); - })} - - ); - } - } else { - baseRow = ( - - Unsupported row. - - ); - } - - return baseRow; -} - -interface DiffRowViewProps { - /** Complete review-stream row; preferred when the caller owns the shared render plan. */ - plannedRow?: PlannedDiffReviewRow; - /** Raw row fallback for renderer-only surfaces outside the shared review stream. */ - row?: DiffRow; - width: number; - lineNumberDigits: number; - showLineNumbers: boolean; - showHunkHeaders: boolean; - wrapLines: boolean; - codeHorizontalOffset: number; - theme: AppTheme; - selected: boolean; - copySelectedRowRange?: CopySelectedRowRange; - copySelectedSide?: "left" | "right"; - cursorHighlight?: CursorHighlight; - /** Extension marks for this row's file, resolved to terminal columns. */ - lineHighlights?: LineHighlightPaintIndex; - anchorId?: string; - noteGuideSide?: "old" | "new"; - showAddNoteBadge?: boolean; - onHoverRow?: (rowKey: string) => void; - onStartUserNoteAtHunk?: (hunkIndex: number, target?: UserNoteLineTarget) => void; - onToggleGap?: (gapKey: string) => void; -} - -/** - * Render one diff row, memoized to avoid unnecessary rerenders. - * - * The comparator checks every handler by reference, so callers (DiffSectionBody) must pass - * identity-stable callbacks — e.g. one shared onHoverRow that receives the row key — or the memo - * silently degrades to re-rendering every visible row per parent render. - */ -export const DiffRowView = memo( - function DiffRowViewComponent({ - plannedRow, - row, - width, - lineNumberDigits, - showLineNumbers, - showHunkHeaders, - wrapLines, - codeHorizontalOffset, - theme, - selected, - copySelectedRowRange, - copySelectedSide, - cursorHighlight, - lineHighlights, - anchorId, - noteGuideSide, - showAddNoteBadge, - onHoverRow, - onStartUserNoteAtHunk, - onToggleGap, - }: DiffRowViewProps) { - const resolvedPlannedRow = - plannedRow ?? (row ? legacyPlannedDiffRow(row, anchorId, noteGuideSide) : undefined); - if (!resolvedPlannedRow) { - return null; - } - - return renderRow( - resolvedPlannedRow, - width, - lineNumberDigits, - showLineNumbers, - showHunkHeaders, - wrapLines, - codeHorizontalOffset, - theme, - selected, - copySelectedRowRange, - copySelectedSide, - cursorHighlight, - lineHighlights, - showAddNoteBadge, - onHoverRow, - onStartUserNoteAtHunk, - onToggleGap, - ); - }, - (previous, next) => { - return ( - previous.plannedRow === next.plannedRow && - previous.row === next.row && - previous.width === next.width && - previous.lineNumberDigits === next.lineNumberDigits && - previous.showLineNumbers === next.showLineNumbers && - previous.showHunkHeaders === next.showHunkHeaders && - previous.wrapLines === next.wrapLines && - previous.codeHorizontalOffset === next.codeHorizontalOffset && - previous.theme === next.theme && - previous.selected === next.selected && - previous.copySelectedRowRange === next.copySelectedRowRange && - previous.copySelectedSide === next.copySelectedSide && - previous.cursorHighlight === next.cursorHighlight && - previous.lineHighlights === next.lineHighlights && - previous.anchorId === next.anchorId && - previous.noteGuideSide === next.noteGuideSide && - previous.showAddNoteBadge === next.showAddNoteBadge && - previous.onHoverRow === next.onHoverRow && - previous.onStartUserNoteAtHunk === next.onStartUserNoteAtHunk && - previous.onToggleGap === next.onToggleGap - ); - }, -); diff --git a/src/ui/diff/rowMouseActions.ts b/src/ui/diff/rowMouseActions.ts new file mode 100644 index 000000000..983bf7118 --- /dev/null +++ b/src/ui/diff/rowMouseActions.ts @@ -0,0 +1,14 @@ +/** Tracks mouse events claimed by controls nested inside selectable diff rows. */ +import type { MouseEvent as TuiMouseEvent } from "@opentui/core"; + +const nestedRowMouseActions = new WeakSet(); + +/** Mark an event so the parent completes mouse cleanup without selecting the containing line. */ +export function markNestedRowMouseAction(event: TuiMouseEvent) { + nestedRowMouseActions.add(event); +} + +/** Return whether a nested control, rather than the diff line, owns this mouse event. */ +export function isNestedRowMouseAction(event: TuiMouseEvent) { + return nestedRowMouseActions.has(event); +} diff --git a/src/ui/lib/extensionCurrentLine.tsx b/src/ui/lib/extensionCurrentLine.tsx index c1bab0765..42bf4b10f 100644 --- a/src/ui/lib/extensionCurrentLine.tsx +++ b/src/ui/lib/extensionCurrentLine.tsx @@ -1,6 +1,6 @@ import type { ExtensionCurrentLinePaint } from "../../extension-api/types"; import type { DiffRow, SplitLineCell, StackLineCell } from "../diff/diffRows"; -import { DiffRowView } from "../diff/renderRows"; +import { DiffRowView } from "../diff/DiffRowView"; import type { DiffSectionRowPlan } from "../diff/diffSectionRowPlan"; import type { LineCursor } from "./lineCursors"; import type { AppTheme } from "../themes";