From 40f20331c8a2916e76e0d2f8bb198c8285571837 Mon Sep 17 00:00:00 2001 From: Tom Tresansky Date: Wed, 7 Oct 2026 18:22:22 -0400 Subject: [PATCH] Open a per-file diff tab from a commit's file list The Commits panel's commit popover listed changed files but the only thing you could do with one was copy its path. Now each row carries an open-diff button, and right-clicking offers both "Open diff in tab" and "Copy path". The tab renders one file's slice of the commit (^ vs ) in Monaco side-by-side, read-only. No new IPC: getCommitFileDiffSides already existed for ReviewDiffPane, and DiffView's router just needed to stop treating commitHash as "whole commit, ignore filePath". Threaded as an optional callback, so the terminal's SHA popover and the mobile panel keep copy-on-click. --- src/renderer/App.tsx | 5 +- .../components/BranchCommitsPanel.tsx | 16 ++- .../components/CollapsedRightPanel.tsx | 14 +- src/renderer/components/CommitInfoModal.tsx | 124 ++++++++++++++---- src/renderer/components/DiffView.tsx | 51 +++++-- src/renderer/components/RightColumn.tsx | 10 +- src/renderer/hooks/useTabHandlers.ts | 27 ++++ 7 files changed, 206 insertions(+), 41 deletions(-) diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 5776040b3..af842f102 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -724,7 +724,8 @@ const setQuestStep = useCallback((next: QuestStep) => { handleSplitPane, handleSendToAgent, handleOpenFile, - handleOpenDiff + handleOpenDiff, + handleOpenCommitFileDiff } = useTabHandlers({ panes, activePaneId, @@ -1788,6 +1789,7 @@ const setQuestStep = useCallback((next: QuestStep) => { onMerged={refreshMergedStatus} onRemoveWorktree={handleDeleteWorktree} onOpenDiff={handleOpenDiff} + onOpenCommitFileDiff={handleOpenCommitFileDiff} onOpenFile={handleOpenFile} onSendToAgent={handleSendToAgent} onOpenPR={(url) => { @@ -1814,6 +1816,7 @@ const setQuestStep = useCallback((next: QuestStep) => { setCommandPaletteMode('files') setShowCommandPalette(true) }} + onOpenCommitFileDiff={handleOpenCommitFileDiff} /> )} void } -export function BranchCommitsPanel({ worktreePath }: BranchCommitsPanelProps): JSX.Element | null { +export function BranchCommitsPanel({ + worktreePath, + onOpenCommitFileDiff +}: BranchCommitsPanelProps): JSX.Element | null { const backend = useBackend() const fetcher = useCallback((path: string) => backend.getBranchCommits(path), [backend]) // Commit-info popover (reuses the terminal's CommitInfoModal). Tracks the @@ -149,6 +154,15 @@ export function BranchCommitsPanel({ worktreePath }: BranchCommitsPanelProps): J hasPrev: popover.index > 0, hasNext: popover.index < commits.length - 1 }} + onOpenFileDiff={ + onOpenCommitFileDiff + ? (filePath) => { + const c = commits[popover.index] + onOpenCommitFileDiff(c.hash, c.shortHash, filePath) + setPopover(null) + } + : undefined + } onClose={() => setPopover(null)} /> )} diff --git a/src/renderer/components/CollapsedRightPanel.tsx b/src/renderer/components/CollapsedRightPanel.tsx index c5438eeb3..0659c471e 100644 --- a/src/renderer/components/CollapsedRightPanel.tsx +++ b/src/renderer/components/CollapsedRightPanel.tsx @@ -46,6 +46,8 @@ interface CollapsedRightPanelProps { /** Opens the fuzzy file-open palette (same overlay as the Cmd+P * fileQuickOpen hotkey). */ onFileQuickOpen: () => void + /** Opens one file's changes within a commit as a diff tab. */ + onOpenCommitFileDiff?: (commitHash: string, shortHash: string, filePath: string) => void } const WIDTH = 48 @@ -70,7 +72,8 @@ export function CollapsedRightPanel({ onExpand, onOpenPR, onReview, - onFileQuickOpen + onFileQuickOpen, + onOpenCommitFileDiff }: CollapsedRightPanelProps): JSX.Element { const backend = useBackend() const activeBackend = useActiveBackend() @@ -514,6 +517,15 @@ export function CollapsedRightPanel({ hasPrev: commitPopover.index > 0, hasNext: commitPopover.index < commitsCount - 1 }} + onOpenFileDiff={ + onOpenCommitFileDiff + ? (filePath) => { + const c = commitsData[commitPopover.index] + onOpenCommitFileDiff(c.hash, c.shortHash, filePath) + setCommitPopover(null) + } + : undefined + } onClose={() => setCommitPopover(null)} /> )} diff --git a/src/renderer/components/CommitInfoModal.tsx b/src/renderer/components/CommitInfoModal.tsx index 58073fd4c..45d07b436 100644 --- a/src/renderer/components/CommitInfoModal.tsx +++ b/src/renderer/components/CommitInfoModal.tsx @@ -1,6 +1,16 @@ import { useEffect, useRef, useState } from 'react' -import { GitCommitHorizontal, Copy, Check, X, Loader2, ChevronUp, ChevronDown } from 'lucide-react' +import { + GitCommitHorizontal, + Copy, + Check, + X, + Loader2, + ChevronUp, + ChevronDown, + FileDiff +} from 'lucide-react' import { getBackend } from '../backend' +import { ContextMenu, type ContextMenuItem } from './ContextMenu' import type { ChangedFile, CommitMeta } from '../types' /** Optional prev/next navigation through a surrounding list of commits. @@ -24,6 +34,10 @@ interface CommitInfoModalProps { placement?: 'cursor' | 'right-edge' /** When present, renders up/down controls (and binds ↑/↓) to walk the list. */ nav?: CommitNav + /** When present, each file row gains an open-diff button and a right-click + * "Open diff" item that opens this commit's changes to that file in a tab. + * Omitted by the terminal popovers, which have no tab context. */ + onOpenFileDiff?: (filePath: string) => void onClose: () => void } @@ -46,31 +60,29 @@ const STATUS_COLOR: Record = { untracked: 'text-dim' } -function FileRow({ file }: { file: ChangedFile }): JSX.Element { +function FileRow({ + file, + copied, + onCopyPath, + onOpenDiff, + onContextMenu +}: { + file: ChangedFile + copied: boolean + onCopyPath: () => void + onOpenDiff?: () => void + onContextMenu: (e: React.MouseEvent) => void +}): JSX.Element { const lastSlash = file.path.lastIndexOf('/') const dir = lastSlash >= 0 ? file.path.slice(0, lastSlash + 1) : '' const name = lastSlash >= 0 ? file.path.slice(lastSlash + 1) : file.path - const [copied, setCopied] = useState(false) - const timer = useRef(null) - - useEffect(() => { - return () => { - if (timer.current !== null) window.clearTimeout(timer.current) - } - }, []) - - const copyPath = (): void => { - void navigator.clipboard?.writeText(file.path).then(() => { - setCopied(true) - if (timer.current !== null) window.clearTimeout(timer.current) - timer.current = window.setTimeout(() => setCopied(false), 1200) - }) - } return ( - + )} {copied ? ( ) : ( @@ -92,7 +117,7 @@ function FileRow({ file }: { file: ChangedFile }): JSX.Element { {file.deletions ? −{file.deletions} : null} )} - + ) } @@ -102,12 +127,30 @@ export function CommitInfoModal({ anchor, placement = 'cursor', nav, + onOpenFileDiff, onClose }: CommitInfoModalProps): JSX.Element { const [meta, setMeta] = useState(null) const [files, setFiles] = useState([]) const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading') const [copied, setCopied] = useState(false) + const [copiedPath, setCopiedPath] = useState(null) + const [fileMenu, setFileMenu] = useState<{ x: number; y: number; path: string } | null>(null) + const pathTimer = useRef(null) + + useEffect(() => { + return () => { + if (pathTimer.current !== null) window.clearTimeout(pathTimer.current) + } + }, []) + + const copyPath = (path: string): void => { + void navigator.clipboard?.writeText(path).then(() => { + setCopiedPath(path) + if (pathTimer.current !== null) window.clearTimeout(pathTimer.current) + pathTimer.current = window.setTimeout(() => setCopiedPath(null), 1200) + }) + } useEffect(() => { let cancelled = false @@ -142,7 +185,9 @@ export function CommitInfoModal({ // Consume it: the terminal still has focus, so without stopping // propagation the Escape would also be sent to the agent/PTY. e.stopPropagation() - onClose() + // An open file menu is the innermost layer — dismiss that first. + if (fileMenu) setFileMenu(null) + else onClose() } else if (nav && e.key === 'ArrowUp' && nav.hasPrev) { e.preventDefault() e.stopPropagation() @@ -158,7 +203,7 @@ export function CommitInfoModal({ // the terminal is focused. Capturing runs us first. window.addEventListener('keydown', onKey, true) return () => window.removeEventListener('keydown', onKey, true) - }, [onClose, nav]) + }, [onClose, nav, fileMenu]) const copySha = (): void => { const full = meta?.hash ?? sha @@ -168,6 +213,15 @@ export function CommitInfoModal({ }) } + const fileMenuItems = (path: string): ContextMenuItem[] => { + const items: ContextMenuItem[] = [] + if (onOpenFileDiff) { + items.push({ label: 'Open diff in tab', onClick: () => onOpenFileDiff(path) }) + } + items.push({ label: 'Copy path', onClick: () => copyPath(path) }) + return items + } + const shortSha = meta?.shortHash ?? sha.slice(0, 10) // Clamp the popover into the viewport. 'right-edge' butts its right side @@ -268,12 +322,32 @@ export function CommitInfoModal({ {files.length} {files.length === 1 ? 'file' : 'files'} changed {files.map((file) => ( - + copyPath(file.path)} + onOpenDiff={onOpenFileDiff ? () => onOpenFileDiff(file.path) : undefined} + onContextMenu={(e) => { + e.preventDefault() + setFileMenu({ x: e.clientX, y: e.clientY, path: file.path }) + }} + /> ))} )} + {/* Sibling of the popover, not a child: the popover clips its own + overflow, and the menu needs to escape those bounds. */} + {fileMenu && ( + setFileMenu(null)} + /> + )} ) } diff --git a/src/renderer/components/DiffView.tsx b/src/renderer/components/DiffView.tsx index ecb9c3e57..25575f999 100644 --- a/src/renderer/components/DiffView.tsx +++ b/src/renderer/components/DiffView.tsx @@ -18,6 +18,9 @@ interface DiffViewProps { } export function DiffView(props: DiffViewProps): JSX.Element { + // commitHash + filePath = one file's changes within that commit (Monaco, + // side-by-side). commitHash alone = the whole commit as raw diff text. + if (props.commitHash && props.filePath) return if (props.commitHash) return if (props.filePath) return return ( @@ -32,6 +35,7 @@ function FileDiffView({ filePath, staged, branchDiff, + commitHash, onSendToAgent }: DiffViewProps): JSX.Element { const backend = useBackend() @@ -50,8 +54,8 @@ function FileDiffView({ // Only unstaged working diffs have a modified side that IS the working // tree — the only place edits can meaningfully land. Everything else - // (staged / branch) is read-only. - const editable = !staged && !branchDiff + // (staged / branch / a historical commit) is read-only. + const editable = !staged && !branchDiff && !commitHash const dirty = editable && modifiedValue !== savedValue useEffect(() => { @@ -63,8 +67,15 @@ function FileDiffView({ setSaveError(null) setWordWrap(false) if (!filePath) return - backend - .getFileDiffSides(worktreePath, filePath, staged ?? false, branchDiff ? 'branch' : 'working') + const request = commitHash + ? backend.getCommitFileDiffSides(worktreePath, commitHash, filePath) + : backend.getFileDiffSides( + worktreePath, + filePath, + staged ?? false, + branchDiff ? 'branch' : 'working' + ) + request .then((r) => { if (cancelled) return setSides(r) @@ -75,7 +86,7 @@ function FileDiffView({ return () => { cancelled = true } - }, [worktreePath, filePath, staged, branchDiff]) + }, [worktreePath, filePath, staged, branchDiff, commitHash]) const save = useCallback(async () => { if (!filePath || !editable) return @@ -125,6 +136,17 @@ function FileDiffView({ ) } + // Both sides missing means the path isn't in the commit at all — most + // likely a rename we resolved to the wrong side. Say so rather than + // falling through to the generic "No changes". + if (commitHash && !sides.originalExists && !sides.modifiedExists) { + return ( +
+ Not found in commit {commitHash.slice(0, 10)}. +
+ ) + } + if (!dirty && sides.original === sides.modified) { return (
@@ -133,11 +155,13 @@ function FileDiffView({ ) } - const readOnlyBanner = branchDiff - ? 'Viewing branch diff (base…HEAD) — read-only.' - : staged - ? 'Viewing staged diff — read-only. Unstage the file to edit here.' - : null + const readOnlyBanner = commitHash + ? `Viewing this file as changed by commit ${commitHash.slice(0, 10)} — read-only.` + : branchDiff + ? 'Viewing branch diff (base…HEAD) — read-only.' + : staged + ? 'Viewing staged diff — read-only. Unstage the file to edit here.' + : null return (
@@ -155,8 +179,11 @@ function FileDiffView({ {saveError} )} - {staged && !branchDiff && staged} - {branchDiff && branch} + {commitHash && ( + {commitHash.slice(0, 10)} + )} + {!commitHash && staged && !branchDiff && staged} + {!commitHash && branchDiff && branch} {!sides.originalExists && new file} {!sides.modifiedExists && deleted} diff --git a/src/renderer/components/RightColumn.tsx b/src/renderer/components/RightColumn.tsx index 92579add1..08ceeaa30 100644 --- a/src/renderer/components/RightColumn.tsx +++ b/src/renderer/components/RightColumn.tsx @@ -37,6 +37,7 @@ interface RightColumnProps { onMerged: () => void onRemoveWorktree: (path: string) => void onOpenDiff: ChangedFilesPanelProps['onOpenDiff'] + onOpenCommitFileDiff: (commitHash: string, shortHash: string, filePath: string) => void onOpenFile: AllFilesPanelProps['onOpenFile'] onSendToAgent: (worktreePath: string, text: string) => void onOpenPR: (url: string) => void @@ -59,6 +60,7 @@ export function RightColumn({ onMerged, onRemoveWorktree, onOpenDiff, + onOpenCommitFileDiff, onOpenFile, onSendToAgent, onOpenPR, @@ -117,7 +119,13 @@ export function RightColumn({ case 'todos': return case 'commits': - return + return ( + + ) case 'changedFiles': return ( ^ … ), as opposed to + // handleOpenCommit's whole-commit text view. Distinct tab id prefix so + // both can be open for the same commit at once. + const handleOpenCommitFileDiff = useCallback( + (commitHash: string, shortHash: string, filePath: string) => { + if (!activeWorktreeId) return + const tabId = `diff-commit-${shortHash}-${filePath}` + const tree = panes[activeWorktreeId] + const existingLeaf = tree ? findLeafByTabId(tree, tabId) : null + if (existingLeaf) { + handleSelectTab(activeWorktreeId, existingLeaf.id, tabId) + return + } + const fileName = filePath.split('/').pop() || filePath + const tab: TerminalTab = { + id: tabId, + type: 'diff', + label: `${fileName} @ ${shortHash}`, + filePath, + commitHash + } + appendTabToPane(activeWorktreeId, tab) + }, + [activeWorktreeId, panes, handleSelectTab, appendTabToPane] + ) + const handleReorderTabs = useCallback( (worktreePath: string, paneId: string, fromId: string, toId: string) => { if (fromId === toId) return @@ -388,6 +414,7 @@ export function useTabHandlers({ handleSleepTab, handleOpenPR, handleOpenCommit, + handleOpenCommitFileDiff, handleReorderTabs, handleMoveTabToPane, handleSplitPane,