diff --git a/src/main/services/git.service.ts b/src/main/services/git.service.ts index 4634f80..4dcfaeb 100644 --- a/src/main/services/git.service.ts +++ b/src/main/services/git.service.ts @@ -311,7 +311,44 @@ export async function getWorkingDiff(repoPath: string): Promise { // Show both staged and unstaged changes const unstaged = await g.diff() const staged = await g.diff(['--cached']) - return [staged, unstaged].filter(Boolean).join('\n') + // Untracked files are not picked up by `git diff`; synthesize an addition + // diff for each so they render in the branch-compare view. + const status = await g.status() + const untrackedDiffs: string[] = [] + for (const filePath of status.not_added) { + const synth = await synthesizeAddedFileDiff(repoPath, filePath) + if (synth) untrackedDiffs.push(synth) + } + return [staged, unstaged, ...untrackedDiffs].filter(Boolean).join('\n') +} + +async function synthesizeAddedFileDiff(repoPath: string, filePath: string): Promise { + let buf: Buffer + try { + buf = await readFile(join(repoPath, filePath)) + } catch { + return null + } + // Skip binary files — produce a stub note instead of garbled text. + const isBinary = buf.includes(0) + const header = + `diff --git a/${filePath} b/${filePath}\n` + + `new file mode 100644\n` + + `index 0000000..0000000\n` + + `--- /dev/null\n` + + `+++ b/${filePath}\n` + if (isBinary) { + return header + `Binary files /dev/null and b/${filePath} differ\n` + } + const text = buf.toString('utf8') + if (text.length === 0) return header + `@@ -0,0 +0,0 @@\n` + const hadTrailingNewline = text.endsWith('\n') + const body = hadTrailingNewline ? text.slice(0, -1) : text + const lines = body.split('\n') + const hunk = `@@ -0,0 +1,${lines.length} @@\n` + const added = lines.map((l) => `+${l}`).join('\n') + const tail = hadTrailingNewline ? '\n' : '\n\\ No newline at end of file\n' + return header + hunk + added + tail } // ── Branch comparison (PR preview) ───────────────────────────────────────── diff --git a/src/renderer/components/git/ChangedFiles.tsx b/src/renderer/components/git/ChangedFiles.tsx index 3c68028..2c64c77 100644 --- a/src/renderer/components/git/ChangedFiles.tsx +++ b/src/renderer/components/git/ChangedFiles.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from 'react' +import React, { useCallback, useEffect, useMemo, useState } from 'react' import { useGitStore, WORKING_CHANGES_HASH } from '../../stores/gitStore' import { useToastStore } from '../../stores/toastStore' import { ListBox, ListItem } from '../ui/ListBox' @@ -29,10 +29,138 @@ export function ChangedFiles({ repoPath }: Props) { const isWorking = selectedCommitHash === WORKING_CHANGES_HASH + // Multi-select state — only meaningful for the working-changes pseudo-commit. + // `extra` holds additionally-selected file paths (active selection is + // selectedFilePath in the store). `anchor` is the pivot for shift-range. + const [extra, setExtra] = useState>(() => new Set()) + const [anchor, setAnchor] = useState(null) + + // Reset multi-select when leaving working changes or when file list changes + // such that selected paths no longer exist. + useEffect(() => { + if (!isWorking) { + if (extra.size > 0) setExtra(new Set()) + if (anchor !== null) setAnchor(null) + return + } + const present = new Set(changedFiles.map((f) => f.filePath)) + let changed = false + const next = new Set() + for (const p of extra) { + if (present.has(p)) next.add(p) + else changed = true + } + if (changed) setExtra(next) + if (anchor && !present.has(anchor)) setAnchor(null) + }, [isWorking, changedFiles, extra, anchor]) + + const selectedSet = useMemo(() => { + const s = new Set(extra) + if (selectedFilePath) s.add(selectedFilePath) + return s + }, [extra, selectedFilePath]) + + const handleClick = useCallback( + (e: React.MouseEvent, file: FileDiff) => { + if (!selectedCommitHash) return + if (!isWorking) { + // Single-select for non-working commits + selectFile(repoPath, selectedCommitHash, file.filePath) + return + } + const cmd = e.metaKey || e.ctrlKey + const shift = e.shiftKey + if (shift && (anchor || selectedFilePath)) { + const start = anchor ?? selectedFilePath! + const startIdx = changedFiles.findIndex((f) => f.filePath === start) + const endIdx = changedFiles.findIndex((f) => f.filePath === file.filePath) + if (startIdx === -1 || endIdx === -1) { + selectFile(repoPath, selectedCommitHash, file.filePath) + setExtra(new Set()) + setAnchor(file.filePath) + return + } + const [lo, hi] = startIdx < endIdx ? [startIdx, endIdx] : [endIdx, startIdx] + const next = new Set() + for (let i = lo; i <= hi; i++) next.add(changedFiles[i].filePath) + next.delete(file.filePath) // active path lives in selectedFilePath + setExtra(next) + selectFile(repoPath, selectedCommitHash, file.filePath) + return + } + if (cmd) { + // Toggle this file in the selection. + const isCurrentlySelected = selectedSet.has(file.filePath) + if (isCurrentlySelected) { + // Deselect — but keep at least one file as the active diff target. + if (file.filePath === selectedFilePath) { + // Promote one of `extra` to active (or clear if none). + const promoted = extra.values().next().value as string | undefined + if (promoted) { + const nextExtra = new Set(extra) + nextExtra.delete(promoted) + setExtra(nextExtra) + selectFile(repoPath, selectedCommitHash, promoted) + } else { + // Nothing else selected — leave the click as a no-op. + } + } else { + const nextExtra = new Set(extra) + nextExtra.delete(file.filePath) + setExtra(nextExtra) + } + } else { + // Add to selection. Make this the active diff target and push the + // previously-active path into `extra`. + const nextExtra = new Set(extra) + if (selectedFilePath && selectedFilePath !== file.filePath) nextExtra.add(selectedFilePath) + nextExtra.delete(file.filePath) + setExtra(nextExtra) + selectFile(repoPath, selectedCommitHash, file.filePath) + } + setAnchor(file.filePath) + return + } + // Plain click — single select + setExtra(new Set()) + setAnchor(file.filePath) + selectFile(repoPath, selectedCommitHash, file.filePath) + }, + [repoPath, isWorking, selectedCommitHash, selectedFilePath, anchor, changedFiles, extra, selectedSet, selectFile] + ) + const buildItems = useCallback( (file: FileDiff): ContextMenuItem[] => { const { addToast } = useToastStore.getState() const refresh = () => loadWorkingFiles(repoPath) + + // If the right-clicked file is part of the multi-selection, target all + // selected files; otherwise treat the click as a single-file action and + // make sure the click target becomes the active selection. + const targets = isWorking && selectedSet.has(file.filePath) + ? Array.from(selectedSet) + : [file.filePath] + + const isMulti = targets.length > 1 + const suffix = isMulti ? ` (${targets.length} files)` : '' + + const runAll = async ( + action: (path: string) => Promise, + successMsg?: (n: number) => string + ) => { + const errors: string[] = [] + for (const path of targets) { + try { + await action(path) + } catch (err) { + errors.push(`${path}: ${err instanceof Error ? err.message : String(err)}`) + } + } + await refresh() + if (errors.length > 0) addToast('error', errors.join('\n')) + else if (successMsg) addToast('success', successMsg(targets.length)) + } + const items: ContextMenuItem[] = [ { label: 'Open', @@ -44,66 +172,60 @@ export function ChangedFiles({ repoPath }: Props) { onClick: () => window.api.git.revealFile(`${repoPath}/${file.filePath}`), }, { - label: 'Copy path', - onClick: () => navigator.clipboard.writeText(file.filePath), + label: isMulti ? `Copy ${targets.length} paths` : 'Copy path', + onClick: () => navigator.clipboard.writeText(targets.join('\n')), separatorAfter: isWorking, }, ] if (isWorking) { items.push( { - label: 'Stage file', - onClick: async () => { - try { - await window.api.git.stageFile(repoPath, file.filePath) - await refresh() - } catch (err) { - addToast('error', err instanceof Error ? err.message : String(err)) - } - }, + label: `Stage file${suffix}`, + onClick: () => runAll((p) => window.api.git.stageFile(repoPath, p)), }, { - label: 'Unstage file', - onClick: async () => { - try { - await window.api.git.unstageFile(repoPath, file.filePath) - await refresh() - } catch (err) { - addToast('error', err instanceof Error ? err.message : String(err)) - } - }, + label: `Unstage file${suffix}`, + onClick: () => runAll((p) => window.api.git.unstageFile(repoPath, p)), }, { - label: 'Stash this file', - onClick: async () => { - try { - await window.api.git.stashFile(repoPath, file.filePath) - await refresh() - addToast('success', `Stashed ${file.filePath}`) - } catch (err) { - addToast('error', err instanceof Error ? err.message : String(err)) - } - }, + label: `Stash this file${suffix}`, + onClick: () => + runAll( + (p) => window.api.git.stashFile(repoPath, p), + (n) => (n === 1 ? `Stashed ${targets[0]}` : `Stashed ${n} files`) + ), separatorAfter: true, }, { - label: 'Discard changes', + label: `Discard changes${suffix}`, variant: 'danger', - onClick: async () => { - if (!confirm(`Discard changes to ${file.filePath}? This cannot be undone.`)) return - try { - await window.api.git.discardFile(repoPath, file.filePath) - await refresh() - } catch (err) { - addToast('error', err instanceof Error ? err.message : String(err)) - } + onClick: () => { + const msg = isMulti + ? `Discard changes to ${targets.length} files? This cannot be undone.` + : `Discard changes to ${targets[0]}? This cannot be undone.` + if (!confirm(msg)) return + runAll((p) => window.api.git.discardFile(repoPath, p)) }, } ) } return items }, - [repoPath, selectedCommitHash, isWorking, selectFile, loadWorkingFiles] + [repoPath, selectedCommitHash, isWorking, selectedSet, selectFile, loadWorkingFiles] + ) + + const handleContextMenu = useCallback( + (e: React.MouseEvent, file: FileDiff) => { + // If right-clicking a file outside the current selection, replace the + // selection with just that file before opening the menu. + if (isWorking && !selectedSet.has(file.filePath) && selectedCommitHash) { + setExtra(new Set()) + setAnchor(file.filePath) + selectFile(repoPath, selectedCommitHash, file.filePath) + } + onContextMenu(e, buildItems(file)) + }, + [isWorking, selectedSet, selectedCommitHash, repoPath, selectFile, onContextMenu, buildItems] ) if (!selectedCommitHash) { @@ -123,27 +245,33 @@ export function ChangedFiles({ repoPath }: Props) { selectFile(repoPath, selectedCommitHash, changedFiles[index].filePath) } > - {changedFiles.map((file) => ( - selectFile(repoPath, selectedCommitHash, file.filePath)} - onContextMenu={(e) => onContextMenu(e, buildItems(file))} - className="text-xs flex items-center gap-2" - style={{ padding: '6px 12px' }} - > - - {STATUS_LABELS[file.status] || '?'} - - {file.filePath} - {(file.insertions > 0 || file.deletions > 0) && ( - - {file.insertions > 0 && +{file.insertions}} - {file.deletions > 0 && -{file.deletions}} + {changedFiles.map((file) => { + const inSelection = selectedSet.has(file.filePath) + const isActive = file.filePath === selectedFilePath + return ( + handleClick(e, file)} + onContextMenu={(e) => handleContextMenu(e, file)} + className={`text-xs flex items-center gap-2 ${ + inSelection && !isActive ? 'bg-accent/5 text-text' : '' + }`} + style={{ padding: '6px 12px' }} + > + + {STATUS_LABELS[file.status] || '?'} - )} - - ))} + {file.filePath} + {(file.insertions > 0 || file.deletions > 0) && ( + + {file.insertions > 0 && +{file.insertions}} + {file.deletions > 0 && -{file.deletions}} + + )} + + ) + })} {menu} diff --git a/src/renderer/components/git/PRPreviewPanel.tsx b/src/renderer/components/git/PRPreviewPanel.tsx index c941b11..810f62e 100644 --- a/src/renderer/components/git/PRPreviewPanel.tsx +++ b/src/renderer/components/git/PRPreviewPanel.tsx @@ -18,11 +18,23 @@ export function PRPreviewPanel({ repoPath }: Props) { const { baseBranch, files, fullDiff, commits, workingFiles, selectedFilePath, selectedCommitHash, commitDiff, - viewMode, loading, + viewMode, loading, refreshing, selectFile, selectNextFile, selectPrevFile, - selectCommit, nextCommit, prevCommit, setViewMode, + selectCommit, nextCommit, prevCommit, setViewMode, refresh, } = usePRPreviewStore() + // Auto-refresh while the preview is mounted so committed/working changes + // pick up quickly as the user works. + useEffect(() => { + if (loading) return + const id = setInterval(() => { + if (!usePRPreviewStore.getState().refreshing) { + refresh(repoPath) + } + }, 4000) + return () => clearInterval(id) + }, [repoPath, refresh, loading]) + const hasWorkingChanges = workingFiles.length > 0 const filesCol = useResizable({ direction: 'horizontal', initialSize: 240, minSize: 160, maxSize: 400 }) @@ -102,11 +114,29 @@ export function PRPreviewPanel({ repoPath }: Props) { className="flex items-center justify-between bg-bg-tertiary border-b border-border" style={{ padding: '4px 12px' }} > - + {commits.length} commit{commits.length !== 1 ? 's' : ''} ·{' '} {files.length} file{files.length !== 1 ? 's' : ''} ·{' '} +{totalAdded}{' '} -{totalDeleted} + | null>(null) // Listen for app-action create-session events from custom buttons @@ -515,7 +517,16 @@ export function SessionSidebar() { const handleRefreshPRs = () => { if (!activeProject) return - loadPRs(activeProject.repoPath) + setRefreshingPRs(true) + // Refresh the list and — if a PR is currently open in the review panel — + // also refetch its details so reviews/checks/comments aren't stale. + const review = usePRReviewStore.getState() + const openPR = review.prNumber + const tasks: Promise[] = [loadPRs(activeProject.repoPath)] + if (openPR != null) { + tasks.push(review.loadPR(activeProject.repoPath, openPR, activeProject.id, true)) + } + Promise.allSettled(tasks).finally(() => setRefreshingPRs(false)) // Reset polling so next tick is a full interval from now if (pollIntervalRef.current) clearInterval(pollIntervalRef.current) pollIntervalRef.current = setInterval(() => { @@ -704,6 +715,7 @@ export function SessionSidebar() { { if (!activeProject) return loadClaudeWebSessions( @@ -784,8 +796,9 @@ export function SessionSidebar() {
diff --git a/src/renderer/components/ui/IconButton.tsx b/src/renderer/components/ui/IconButton.tsx index 0c97e8d..7e12729 100644 --- a/src/renderer/components/ui/IconButton.tsx +++ b/src/renderer/components/ui/IconButton.tsx @@ -6,6 +6,8 @@ interface IconButtonProps extends React.ButtonHTMLAttributes size?: 'sm' | 'md' variant?: 'ghost' | 'danger' tooltipSide?: 'top' | 'bottom' | 'left' + /** When true, spin the icon and disable the button to signal in-flight work. */ + loading?: boolean } const VARIANT_CLASSES: Record = { @@ -24,6 +26,8 @@ export function IconButton({ variant = 'ghost', tooltipSide, className = '', + loading = false, + disabled, children, ...rest }: IconButtonProps) { @@ -31,10 +35,12 @@ export function IconButton({ {children} : children} ) diff --git a/src/renderer/stores/prPreviewStore.ts b/src/renderer/stores/prPreviewStore.ts index e2a5891..0c4a8f5 100644 --- a/src/renderer/stores/prPreviewStore.ts +++ b/src/renderer/stores/prPreviewStore.ts @@ -23,6 +23,8 @@ interface PRPreviewState { commitDiff: string | null viewMode: 'single' | 'scroll' loading: boolean + /** True while a non-blocking refresh is in flight (preserves selection). */ + refreshing: boolean activate: (repoPath: string, branch: string, sessionId?: string) => Promise deactivate: (sessionId?: string) => void @@ -97,6 +99,7 @@ export const usePRPreviewStore = create((set, get) => ({ commitDiff: null, viewMode: 'single', loading: false, + refreshing: false, activate: async (repoPath, branch, sessionId) => { set({ active: true, loading: true, sessionId: sessionId ?? null }) @@ -243,8 +246,38 @@ export const usePRPreviewStore = create((set, get) => ({ }, refresh: async (repoPath) => { - const { baseBranch } = get() + const { baseBranch, selectedFilePath, selectedCommitHash } = get() if (!baseBranch) return - await get().setBaseBranch(repoPath, baseBranch) + set({ refreshing: true }) + const { addToast } = useToastStore.getState() + try { + const [committedFiles, compareDiff, commits, wFiles, wDiff] = await Promise.all([ + window.api.git.compareFiles(repoPath, baseBranch), + window.api.git.compareDiff(repoPath, baseBranch), + window.api.git.compareCommits(repoPath, baseBranch), + window.api.git.workingFilesPR(repoPath), + window.api.git.workingDiff(repoPath), + ]) + const allFiles = mergeFiles(committedFiles, wFiles) + const fullDiff = wDiff ? [compareDiff, wDiff].filter(Boolean).join('\n') : compareDiff + // Preserve selection if it still exists in the refreshed file list. + const stillThere = selectedFilePath && allFiles.some((f) => f.path === selectedFilePath) + // If user is viewing the working-changes pseudo-commit, refresh its diff too. + const commitDiffNext = + selectedCommitHash === WORKING_CHANGES_HASH ? wDiff || null : get().commitDiff + set({ + files: allFiles, + fullDiff, + workingDiff: wDiff || null, + workingFiles: wFiles, + commits, + commitDiff: commitDiffNext, + selectedFilePath: stillThere ? selectedFilePath : (allFiles[0]?.path ?? null), + refreshing: false, + }) + } catch (err) { + addToast('error', err instanceof Error ? err.message : String(err)) + set({ refreshing: false }) + } }, })) diff --git a/src/renderer/stores/prReviewStore.ts b/src/renderer/stores/prReviewStore.ts index d6a8b99..912fcfc 100644 --- a/src/renderer/stores/prReviewStore.ts +++ b/src/renderer/stores/prReviewStore.ts @@ -55,7 +55,7 @@ interface PRReviewState { blobCache: Record expandedLines: Record> - loadPR: (repoPath: string, prNumber: number, projectId?: string) => Promise + loadPR: (repoPath: string, prNumber: number, projectId?: string, force?: boolean) => Promise selectFile: (filePath: string) => void selectNextFile: () => void selectPrevFile: () => void @@ -136,16 +136,22 @@ export const usePRReviewStore = create((set, get) => ({ blobCache: {}, expandedLines: {}, - loadPR: async (repoPath, prNumber, projectId) => { - // Skip reload if this PR's data is already loaded - if (get().prNumber === prNumber && get().files.length > 0) return + loadPR: async (repoPath, prNumber, projectId, force = false) => { + // Skip reload if this PR's data is already loaded (unless forced) + if (!force && get().prNumber === prNumber && get().files.length > 0) return - set({ - loading: true, prNumber, files: [], fullDiff: null, fileDiffCache: {}, fileDiffLoading: null, - comments: [], mergeable: 'UNKNOWN', selectedFilePath: null, - detail: null, conversationComments: [], checks: [], activeTab: 'conversation', - blobCache: {}, expandedLines: {}, - }) + if (force) { + // Refresh in place — keep current data visible while refetching so the + // user doesn't see the panel flash empty. + set({ loading: true, prNumber }) + } else { + set({ + loading: true, prNumber, files: [], fullDiff: null, fileDiffCache: {}, fileDiffLoading: null, + comments: [], mergeable: 'UNKNOWN', selectedFilePath: null, + detail: null, conversationComments: [], checks: [], activeTab: 'conversation', + blobCache: {}, expandedLines: {}, + }) + } try { const [files, fullDiff, comments, mergeabilityResult, detail, conversationComments, checks, viewedFilesArr, commits, reviewThreads] = await Promise.all([ window.api.github.getFiles(repoPath, prNumber), @@ -159,20 +165,23 @@ export const usePRReviewStore = create((set, get) => ({ window.api.github.getCommits(repoPath, prNumber), window.api.github.getReviewThreads(repoPath, prNumber), ]) + // Preserve current selection if still present after refresh. + const prevSelected = get().selectedFilePath + const stillThere = prevSelected && files.some((f) => f.path === prevSelected) set({ files, fullDiff, comments, mergeable: mergeabilityResult.mergeable, loading: false, - selectedFilePath: files.length > 0 ? files[0].path : null, + selectedFilePath: stillThere ? prevSelected : (files.length > 0 ? files[0].path : null), detail, conversationComments, checks, viewedFiles: new Set(viewedFilesArr), commits, - selectedCommitHash: null, - commitDiff: null, + selectedCommitHash: force ? get().selectedCommitHash : null, + commitDiff: force ? get().commitDiff : null, reviewThreads, }) // Start polling if any checks are still running