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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/renderer/src/components/ChangesTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { useWindowedRows } from '../lib/windowing';
const SUMMARY_LIMIT = 72;

const FILE_ROW_ESTIMATES = { row: 28 };
const fileRowKind = (): string => 'row';

export function ChangesTab(): React.JSX.Element {
const status = useAppStore((s) => s.status);
Expand All @@ -34,7 +35,9 @@ export function ChangesTab(): React.JSX.Element {
const allState: boolean | 'indeterminate' = files.length === 0 ? false : includedCount === files.length && partialCount === 0 ? true : includedCount === 0 ? false : 'indeterminate';

const [listEl, setListEl] = useState<HTMLElement | null>(null);
const win = useWindowedRows(listEl, { count: visible.length, kindOf: () => 'row', estimates: FILE_ROW_ESTIMATES, resetKey: status });
const repoPath = useAppStore((s) => s.currentRepo?.path);
const fileKey = useCallback((i: number) => visible[i].path, [visible]);
const win = useWindowedRows(listEl, { count: visible.length, kindOf: fileRowKind, keyOf: fileKey, estimates: FILE_ROW_ESTIMATES, resetKey: repoPath });

const contextMenu = useCallback((e: React.MouseEvent, file: WorkingFile) => {
const s = store.get();
Expand Down
4 changes: 3 additions & 1 deletion src/renderer/src/components/HistoryTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { Avatar, Badge, Button, Callout, Checkbox, FilterInput, Icon, RelativeTi
import { useWindowedRows } from '../lib/windowing';

const COMMIT_ROW_ESTIMATES = { row: 60 };
const commitRowKind = (): string => 'row';

const SIGNATURE_BADGES: Partial<Record<SignatureStatus, { icon: IconName; className: string; label: (signer: string | null) => string }>> = {
good: { icon: 'check-circle', className: 'sig-good', label: (signer) => `Good signature${signer ? ` from ${signer}` : ''}` },
Expand Down Expand Up @@ -79,7 +80,8 @@ export function HistoryTab(): React.JSX.Element {
const [historySearchFocused, setHistorySearchFocused] = useState(false);
const [filterCheatSheetOpen, setFilterCheatSheetOpen] = useState(true);
const [filterCheatSheetDismissed, setFilterCheatSheetDismissed] = useState(false);
const win = useWindowedRows(listEl, { count: history.commits.length, kindOf: () => 'row', estimates: COMMIT_ROW_ESTIMATES, resetKey: history.path ?? '' });
const commitKey = useCallback((i: number) => history.commits[i].sha, [history.commits]);
const win = useWindowedRows(listEl, { count: history.commits.length, kindOf: commitRowKind, keyOf: commitKey, estimates: COMMIT_ROW_ESTIMATES, resetKey: history.path ?? '' });
const filterActive = historyFilterActive(history);
const reorderDisabled = historyReorderDisabled(history);

Expand Down
6 changes: 4 additions & 2 deletions src/renderer/src/components/diff/DiffPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,8 @@ export const DiffPane = memo(function DiffPane({ path, oldPath, status, mode, em
renderBlameCard={renderBlameCard}
highlightTerm={mode === 'commit' ? diffState.highlightTerm : null}
onExplainRange={explainEligibleMode && aiExplainEnabled ? (hunkIndex, startLine, endLine) => actions.explainSelectedLines(path, hunkIndex, startLine, endLine) : undefined}
revealLine={diffState.revealLine}
onRevealed={actions.clearDiffReveal}
/>
) : null}
</div>
Expand All @@ -314,10 +316,10 @@ export const DiffPane = memo(function DiffPane({ path, oldPath, status, mode, em
);
});

function DiffBody({ diff, path, viewMode, wrap, syntax, intraline, selectable, selectedLines, onSelectionChange, annotations, activeAnnotationId, onAnnotationClick, renderAnnotationCard, blame, activeBlameId, onBlameBlockClick, renderBlameCard, highlightTerm, onExplainRange }: { diff: FileDiff; path: string; mode: string; viewMode: 'unified' | 'split'; wrap: boolean; syntax: boolean; intraline: boolean; selectable: boolean; selectedLines: string[] | null; onSelectionChange: (s: Set<string>, total: number) => void; annotations?: LineAnnotation[]; activeAnnotationId?: string | null; onAnnotationClick?: (id: string) => void; renderAnnotationCard?: (ids: string[]) => React.ReactNode; blame?: BlameHunk[] | null; activeBlameId?: string | null; onBlameBlockClick?: (id: string) => void; renderBlameCard?: (hunk: BlameHunk) => React.ReactNode; highlightTerm?: { text: string; regex: boolean } | null; onExplainRange?: (hunkIndex: number, startLine: number, endLine: number) => void }): React.JSX.Element {
function DiffBody({ diff, path, viewMode, wrap, syntax, intraline, selectable, selectedLines, onSelectionChange, annotations, activeAnnotationId, onAnnotationClick, renderAnnotationCard, blame, activeBlameId, onBlameBlockClick, renderBlameCard, highlightTerm, onExplainRange, revealLine, onRevealed }: { diff: FileDiff; path: string; mode: string; viewMode: 'unified' | 'split'; wrap: boolean; syntax: boolean; intraline: boolean; selectable: boolean; selectedLines: string[] | null; onSelectionChange: (s: Set<string>, total: number) => void; annotations?: LineAnnotation[]; activeAnnotationId?: string | null; onAnnotationClick?: (id: string) => void; renderAnnotationCard?: (ids: string[]) => React.ReactNode; blame?: BlameHunk[] | null; activeBlameId?: string | null; onBlameBlockClick?: (id: string) => void; renderBlameCard?: (hunk: BlameHunk) => React.ReactNode; highlightTerm?: { text: string; regex: boolean } | null; onExplainRange?: (hunkIndex: number, startLine: number, endLine: number) => void; revealLine: number | null; onRevealed: () => void }): React.JSX.Element {
switch (diff.kind) {
case 'text':
return <TextDiff diff={diff} mode={viewMode} wrap={wrap} syntax={syntax} intraline={intraline} selectable={selectable} selectedLines={selectedLines} onSelectionChange={onSelectionChange} annotations={annotations} activeAnnotationId={activeAnnotationId} onAnnotationClick={onAnnotationClick} renderAnnotationCard={renderAnnotationCard} blame={blame} activeBlameId={activeBlameId} onBlameBlockClick={onBlameBlockClick} renderBlameCard={renderBlameCard} highlightTerm={highlightTerm} onExplainRange={onExplainRange} />;
return <TextDiff diff={diff} mode={viewMode} wrap={wrap} syntax={syntax} intraline={intraline} selectable={selectable} selectedLines={selectedLines} onSelectionChange={onSelectionChange} annotations={annotations} activeAnnotationId={activeAnnotationId} onAnnotationClick={onAnnotationClick} renderAnnotationCard={renderAnnotationCard} blame={blame} activeBlameId={activeBlameId} onBlameBlockClick={onBlameBlockClick} renderBlameCard={renderBlameCard} highlightTerm={highlightTerm} onExplainRange={onExplainRange} revealLine={revealLine} onRevealed={onRevealed} />;
case 'conflict':
return <ConflictDiff diff={diff} path={path} syntax={syntax} />;
case 'image':
Expand Down
46 changes: 32 additions & 14 deletions src/renderer/src/components/diff/TextDiff.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ export interface TextDiffProps {
highlightTerm?: { text: string; regex: boolean } | null;
/** When set, right-clicking a line (or a text selection spanning several lines of one hunk) offers "Explain selected lines"/"Explain this line", reporting the new-side line range. */
onExplainRange?: (hunkIndex: number, startLine: number, endLine: number) => void;
/** New-side line to scroll to and briefly flash (an Explain reference); `onRevealed` is called once it has been handled. */
revealLine?: number | null;
onRevealed?: () => void;
}

const EXPAND_STEP = 20;
Expand Down Expand Up @@ -238,7 +241,7 @@ function rowNewNo(r: Row): number | null {

const ROW_ESTIMATES = { hunk: 28, extra: 20, line: 20, pair: 20, card: 140, expander: 28 };

export function TextDiff({ diff, mode, wrap, syntax, intraline, selectable, selectedLines, onSelectionChange, annotations, activeAnnotationId, onAnnotationClick, renderAnnotationCard, blame, activeBlameId, onBlameBlockClick, renderBlameCard, highlightTerm, onExplainRange }: TextDiffProps): React.JSX.Element {
export function TextDiff({ diff, mode, wrap, syntax, intraline, selectable, selectedLines, onSelectionChange, annotations, activeAnnotationId, onAnnotationClick, renderAnnotationCard, blame, activeBlameId, onBlameBlockClick, renderBlameCard, highlightTerm, onExplainRange, revealLine, onRevealed }: TextDiffProps): React.JSX.Element {
const [expansions, setExpansions] = useState<Record<string, number>>({});
useEffect(() => setExpansions({}), [diff]);
const tableRef = useRef<HTMLTableElement>(null);
Expand Down Expand Up @@ -648,8 +651,10 @@ export function TextDiff({ diff, mode, wrap, syntax, intraline, selectable, sele
}, [views, split, pairsOf, showAnnotationCard, activeLine, annotationsByLine, showBlameCard, blameActiveLine]);

const kindOf = useCallback((i: number) => rows[i].kind, [rows]);
const keys = useMemo(() => rows.map(rowKey), [rows]);
const keyOf = useCallback((i: number) => keys[i], [keys]);
const resetKey = useMemo(() => ({}), [diff.hunks, wrap, split, !!blame]);
const win = useWindowedRows(container, { count: rows.length, kindOf, estimates: ROW_ESTIMATES, resetKey });
const win = useWindowedRows(container, { count: rows.length, kindOf, keyOf, estimates: ROW_ESTIMATES, resetKey });

// Keeps the unified nowrap table from re-sizing as long lines scroll in and out of the window.
const longestLine = useMemo(() => {
Expand All @@ -658,7 +663,9 @@ export function TextDiff({ diff, mode, wrap, syntax, intraline, selectable, sele
return n;
}, [diff.hunks]);

// Reads rows through a ref so the scroll-to effects below fire only when their target changes, not when a hunk expands or a card opens.
// Reads rows through a ref so the scroll-to effects below fire only when their target changes (or the scroll
// container first becomes available on mount), not when a hunk expands, a card opens or the same file reloads.
// Layout effects, so the jump happens before the first paint of the new rows.
const rowsRef = useRef(rows);
rowsRef.current = rows;
const scrollToRow = useCallback(
Expand All @@ -668,18 +675,29 @@ export function TextDiff({ diff, mode, wrap, syntax, intraline, selectable, sele
},
[win.scrollTo],
);
useEffect(() => {
useLayoutEffect(() => {
if (activeLine !== null) scrollToRow((r) => rowNewNo(r) === activeLine, 'center');
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeLine, diff]);
useEffect(() => {
}, [activeLine, scrollToRow]);
useLayoutEffect(() => {
if (highlightKey) scrollToRow((r) => (r.kind === 'line' && r.key === highlightKey) || (r.kind === 'pair' && (r.p.left?.key === highlightKey || r.p.right?.key === highlightKey)), 'center');
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [highlightKey, diff]);
useEffect(() => {
}, [highlightKey, scrollToRow]);
useLayoutEffect(() => {
if (blameActiveLine !== null) scrollToRow((r) => rowNewNo(r) === blameActiveLine, 'nearest');
}, [blameActiveLine, scrollToRow]);
const [flashLine, setFlashLine] = useState<number | null>(null);
useLayoutEffect(() => {
if (revealLine == null || !container) return;
scrollToRow((r) => rowNewNo(r) === revealLine, 'center');
setFlashLine(revealLine);
onRevealed?.();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [blameActiveLine]);
}, [revealLine, container, scrollToRow]);
useEffect(() => {
if (flashLine === null) return;
const t = window.setTimeout(() => setFlashLine(null), 1200);
return () => window.clearTimeout(t);
}, [flashLine]);
const flash = (newNo: number | null) => (newNo !== null && newNo === flashLine ? 'flash-highlight' : '');

const renderRow = (r: Row, i: number): React.ReactNode => {
const ref = win.rowRef(i);
Expand All @@ -690,7 +708,7 @@ export function TextDiff({ diff, mode, wrap, syntax, intraline, selectable, sele
const x = r.x;
const html = (highlighted('new', x.newNo) ?? escapeHtml(x.text)) || ' ';
return (
<tr ref={ref} key={rowKey(r)} className="context extra">
<tr ref={ref} key={rowKey(r)} className={`context extra ${flash(x.newNo)}`}>
{blameCell(x.newNo)}
{selectable ? <td className="sel" /> : null}
<td className="num">{x.oldNo}</td>
Expand All @@ -715,7 +733,7 @@ export function TextDiff({ diff, mode, wrap, syntax, intraline, selectable, sele
const { line, key } = r;
const isChange = line.type !== 'context';
return (
<tr ref={ref} key={key} className={`${line.type} ${isChange && selected.has(key) ? 'selected-line' : ''} ${line.newLineNumber !== null && annotationsByLine.has(line.newLineNumber) ? 'annotated' : ''} ${key === highlightKey ? 'highlight-match' : ''}`} data-new-line={line.newLineNumber ?? undefined} data-key={key}>
<tr ref={ref} key={key} className={`${line.type} ${isChange && selected.has(key) ? 'selected-line' : ''} ${line.newLineNumber !== null && annotationsByLine.has(line.newLineNumber) ? 'annotated' : ''} ${key === highlightKey ? 'highlight-match' : ''} ${flash(line.newLineNumber)}`} data-new-line={line.newLineNumber ?? undefined} data-key={key}>
{blameCell(line.newLineNumber)}
{selCell(isChange ? { line, key } : null)}
<td className="num">{line.oldLineNumber ?? ''}</td>
Expand All @@ -739,7 +757,7 @@ export function TextDiff({ diff, mode, wrap, syntax, intraline, selectable, sele
const rightNo = p.right?.line.newLineNumber ?? null;
const pairMatches = (!!p.left && p.left.key === highlightKey) || (!!p.right && p.right.key === highlightKey);
return (
<tr ref={ref} key={rowKey(r)} className={`${rowClass} ${(p.left && selected.has(p.left.key) && p.left.line.type !== 'context') || (p.right && selected.has(p.right.key) && p.right.line.type !== 'context') ? 'selected-line' : ''} ${rightNo !== null && annotationsByLine.has(rightNo) ? 'annotated' : ''} ${pairMatches ? 'highlight-match' : ''}`} data-new-line={rightNo ?? undefined} data-key={p.left?.key ?? p.right?.key ?? undefined}>
<tr ref={ref} key={rowKey(r)} className={`${rowClass} ${(p.left && selected.has(p.left.key) && p.left.line.type !== 'context') || (p.right && selected.has(p.right.key) && p.right.line.type !== 'context') ? 'selected-line' : ''} ${rightNo !== null && annotationsByLine.has(rightNo) ? 'annotated' : ''} ${pairMatches ? 'highlight-match' : ''} ${flash(rightNo)}`} data-new-line={rightNo ?? undefined} data-key={p.left?.key ?? p.right?.key ?? undefined}>
{blameCell(rightNo)}
{selCell(p.left)}
{p.left ? (
Expand Down
Loading
Loading