diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 5776040b..af842f10 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 c5438eeb..0659c471 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 58073fd4..45d07b43 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 ecb9c3e5..25575f99 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 92579add..08ceeaa3 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,