Skip to content

Commit d29dd08

Browse files
backnotpropclaude
andauthored
feat: git add files from code review UI (#257)
* feat: add per-file git add from code review UI (#254) Adds the ability to `git add` individual files directly from the code review interface. Users can stage approved files then switch to the new "Unstaged changes" diff view to see only remaining work. - POST /api/git-add endpoint with worktree support - gitAddFile/gitResetFile utilities in packages/server/git.ts - "Staged changes" and "Unstaged changes" added to diff type dropdown - useGitAdd hook encapsulating all staging state and API logic - "Git Add" / "Added" toggle button in FileHeader (next to Viewed) - Visual indicator (left border) for staged files in sidebar FileTree Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * style: use green background tint for staged files instead of left border Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: support staged/unstaged diffs in worktrees and display stage errors Adds "staged" and "unstaged" to worktree sub-type allowlists (server parser, client parser, and runGitDiff switch), displays stageError in FileHeader, and documents /api/git-add in CLAUDE.md. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: clean up git add feature — fix stale closure, timeout leak, dedup validation - useGitAdd: use ref for stagedFiles to stabilize stageFile callback, clear error timeout on reset, drop unnecessary useMemo - App.tsx: wrap onFileViewed in useCallback - FileTree: merge duplicate staged count conditionals - review.ts: reuse exported validateFilePath for /api/file-content Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 8201fe3 commit d29dd08

10 files changed

Lines changed: 266 additions & 9 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@ Send Annotations → feedback sent to agent session
177177
| --------------------- | ------ | ------------------------------------------ |
178178
| `/api/diff` | GET | Returns `{ rawPatch, gitRef, origin }` |
179179
| `/api/file-content` | GET | Returns `{ oldContent, newContent }` for expandable diff context |
180+
| `/api/git-add` | POST | Stage/unstage a file (body: `{ filePath, undo? }`) |
180181
| `/api/feedback` | POST | Submit review (body: feedback, annotations, agentSwitch) |
181182
| `/api/image` | GET | Serve image by path query param |
182183
| `/api/upload` | POST | Upload image, returns `{ path, originalName }` |

packages/review-editor/App.tsx

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { getAgentSwitchSettings, getEffectiveAgentName } from '@plannotator/ui/u
1111
import { CodeAnnotation, CodeAnnotationType, SelectedLineRange } from '@plannotator/ui/types';
1212
import { useResizablePanel } from '@plannotator/ui/hooks/useResizablePanel';
1313
import { useCodeAnnotationDraft } from '@plannotator/ui/hooks/useCodeAnnotationDraft';
14+
import { useGitAdd } from './hooks/useGitAdd';
1415
import { useEditorAnnotations } from '@plannotator/ui/hooks/useEditorAnnotations';
1516
import { exportEditorAnnotations } from '@plannotator/ui/utils/parser';
1617
import { ResizeHandle } from '@plannotator/ui/components/ResizeHandle';
@@ -352,7 +353,7 @@ const ReviewApp: React.FC = () => {
352353
const lastColon = rest.lastIndexOf(':');
353354
if (lastColon !== -1) {
354355
const sub = rest.slice(lastColon + 1);
355-
if (['uncommitted', 'last-commit', 'branch'].includes(sub)) {
356+
if (['uncommitted', 'staged', 'unstaged', 'last-commit', 'branch'].includes(sub)) {
356357
return { activeWorktreePath: rest.slice(0, lastColon), activeDiffBase: sub };
357358
}
358359
}
@@ -361,6 +362,16 @@ const ReviewApp: React.FC = () => {
361362
return { activeWorktreePath: null, activeDiffBase: diffType };
362363
}, [diffType]);
363364

365+
// Git add/staging logic
366+
const handleFileViewedFromStage = useCallback(
367+
(path: string) => setViewedFiles(prev => new Set(prev).add(path)),
368+
[],
369+
);
370+
const { stagedFiles, stagingFile, canStageFiles, stageFile, resetStagedFiles, stageError } = useGitAdd({
371+
activeDiffBase,
372+
onFileViewed: handleFileViewedFromStage,
373+
});
374+
364375
// Shared helper: fetch a diff switch and update state
365376
const fetchDiffSwitch = useCallback(async (fullDiffType: string) => {
366377
setIsLoadingDiff(true);
@@ -385,13 +396,14 @@ const ReviewApp: React.FC = () => {
385396
setActiveFileIndex(0);
386397
setPendingSelection(null);
387398
setDiffError(data.error || null);
399+
resetStagedFiles();
388400
} catch (err) {
389401
console.error('Failed to switch diff:', err);
390402
setDiffError(err instanceof Error ? err.message : 'Failed to switch diff');
391403
} finally {
392404
setIsLoadingDiff(false);
393405
}
394-
}, []);
406+
}, [resetStagedFiles]);
395407

396408
// Switch diff type (uncommitted, last-commit, branch) — composes worktree prefix if active
397409
const handleDiffSwitch = useCallback(async (baseDiffType: string) => {
@@ -808,6 +820,7 @@ const ReviewApp: React.FC = () => {
808820
activeWorktreePath={activeWorktreePath}
809821
onSelectWorktree={handleWorktreeSwitch}
810822
currentBranch={gitContext?.currentBranch}
823+
stagedFiles={stagedFiles}
811824
/>
812825
<ResizeHandle {...fileTreeResize.handleProps} />
813826
</>
@@ -841,6 +854,11 @@ const ReviewApp: React.FC = () => {
841854
onDeleteAnnotation={handleDeleteAnnotation}
842855
isViewed={viewedFiles.has(activeFile.path)}
843856
onToggleViewed={() => handleToggleViewed(activeFile.path)}
857+
isStaged={stagedFiles.has(activeFile.path)}
858+
isStaging={stagingFile === activeFile.path}
859+
onStage={() => stageFile(activeFile.path)}
860+
canStage={canStageFiles}
861+
stageError={stageError}
844862
/>
845863
) : (
846864
<div className="h-full flex items-center justify-center">

packages/review-editor/components/DiffViewer.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ interface DiffViewerProps {
2525
onDeleteAnnotation: (id: string) => void;
2626
isViewed?: boolean;
2727
onToggleViewed?: () => void;
28+
isStaged?: boolean;
29+
isStaging?: boolean;
30+
onStage?: () => void;
31+
canStage?: boolean;
32+
stageError?: string | null;
2833
}
2934

3035
export const DiffViewer: React.FC<DiffViewerProps> = ({
@@ -42,6 +47,11 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
4247
onDeleteAnnotation,
4348
isViewed = false,
4449
onToggleViewed,
50+
isStaged = false,
51+
isStaging = false,
52+
onStage,
53+
canStage = false,
54+
stageError,
4555
}) => {
4656
const { theme } = useTheme();
4757
const containerRef = useRef<HTMLDivElement>(null);
@@ -180,6 +190,11 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
180190
patch={patch}
181191
isViewed={isViewed}
182192
onToggleViewed={onToggleViewed}
193+
isStaged={isStaged}
194+
isStaging={isStaging}
195+
onStage={onStage}
196+
canStage={canStage}
197+
stageError={stageError}
183198
/>
184199

185200
<div className="p-4">

packages/review-editor/components/FileHeader.tsx

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,24 @@ interface FileHeaderProps {
55
patch: string;
66
isViewed?: boolean;
77
onToggleViewed?: () => void;
8+
isStaged?: boolean;
9+
isStaging?: boolean;
10+
onStage?: () => void;
11+
canStage?: boolean;
12+
stageError?: string | null;
813
}
914

10-
/** Sticky file header with file path, Viewed toggle, and Copy Diff button */
15+
/** Sticky file header with file path, Viewed toggle, Git Add, and Copy Diff button */
1116
export const FileHeader: React.FC<FileHeaderProps> = ({
1217
filePath,
1318
patch,
1419
isViewed = false,
1520
onToggleViewed,
21+
isStaged = false,
22+
isStaging = false,
23+
onStage,
24+
canStage = false,
25+
stageError,
1626
}) => {
1727
const [copied, setCopied] = useState(false);
1828

@@ -42,6 +52,39 @@ export const FileHeader: React.FC<FileHeaderProps> = ({
4252
Viewed
4353
</button>
4454
)}
55+
{canStage && onStage && (
56+
<button
57+
onClick={onStage}
58+
disabled={isStaging}
59+
className={`text-xs px-2 py-1 rounded transition-colors flex items-center gap-1 ${
60+
isStaging
61+
? 'opacity-50 cursor-not-allowed text-muted-foreground'
62+
: isStaged
63+
? 'bg-primary/15 text-primary'
64+
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
65+
}`}
66+
title={isStaged ? "Unstage this file (git reset)" : "Stage this file (git add)"}
67+
>
68+
{isStaging ? (
69+
<svg className="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
70+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
71+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
72+
</svg>
73+
) : isStaged ? (
74+
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
75+
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
76+
</svg>
77+
) : (
78+
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
79+
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
80+
</svg>
81+
)}
82+
{isStaging ? 'Adding...' : isStaged ? 'Added' : 'Git Add'}
83+
</button>
84+
)}
85+
{stageError && (
86+
<span className="text-xs text-destructive">{stageError}</span>
87+
)}
4588
<button
4689
onClick={async () => {
4790
try {

packages/review-editor/components/FileTree.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ interface FileTreeProps {
3131
activeWorktreePath?: string | null;
3232
onSelectWorktree?: (path: string | null) => void;
3333
currentBranch?: string;
34+
stagedFiles?: Set<string>;
3435
}
3536

3637
export const FileTree: React.FC<FileTreeProps> = ({
@@ -52,6 +53,7 @@ export const FileTree: React.FC<FileTreeProps> = ({
5253
activeWorktreePath,
5354
onSelectWorktree,
5455
currentBranch,
56+
stagedFiles,
5557
}) => {
5658
// Keyboard navigation: j/k or arrow keys
5759
const handleKeyDown = useCallback((e: KeyboardEvent) => {
@@ -142,6 +144,14 @@ export const FileTree: React.FC<FileTreeProps> = ({
142144
Files
143145
</span>
144146
<div className="flex items-center gap-1.5">
147+
{stagedFiles && stagedFiles.size > 0 && (
148+
<>
149+
<span className="text-xs text-primary font-medium">
150+
{stagedFiles.size} added
151+
</span>
152+
<span className="text-muted-foreground/40">·</span>
153+
</>
154+
)}
145155
<span className="text-xs text-muted-foreground">
146156
{viewedFiles.size}/{files.length}
147157
</span>
@@ -247,6 +257,7 @@ export const FileTree: React.FC<FileTreeProps> = ({
247257
onToggleViewed={onToggleViewed}
248258
hideViewedFiles={hideViewedFiles}
249259
getAnnotationCount={getAnnotationCount}
260+
stagedFiles={stagedFiles}
250261
/>
251262
))}
252263
</div>

packages/review-editor/components/FileTreeNode.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ interface FileTreeNodeProps {
1111
onToggleViewed?: (filePath: string) => void;
1212
hideViewedFiles: boolean;
1313
getAnnotationCount: (filePath: string) => number;
14+
stagedFiles?: Set<string>;
1415
}
1516

1617
function hasVisibleChildren(
@@ -40,6 +41,7 @@ export const FileTreeNodeItem: React.FC<FileTreeNodeProps> = ({
4041
onToggleViewed,
4142
hideViewedFiles,
4243
getAnnotationCount,
44+
stagedFiles,
4345
}) => {
4446
const paddingLeft = 8 + node.depth * 12;
4547

@@ -86,6 +88,7 @@ export const FileTreeNodeItem: React.FC<FileTreeNodeProps> = ({
8688
onToggleViewed={onToggleViewed}
8789
hideViewedFiles={hideViewedFiles}
8890
getAnnotationCount={getAnnotationCount}
91+
stagedFiles={stagedFiles}
8992
/>
9093
))}
9194
</>
@@ -95,6 +98,7 @@ export const FileTreeNodeItem: React.FC<FileTreeNodeProps> = ({
9598
// File node
9699
const isActive = node.fileIndex === activeFileIndex;
97100
const isViewed = viewedFiles.has(node.path);
101+
const isStaged = stagedFiles?.has(node.path) ?? false;
98102
const annotationCount = getAnnotationCount(node.path);
99103

100104
if (hideViewedFiles && isViewed && !isActive) {
@@ -104,7 +108,7 @@ export const FileTreeNodeItem: React.FC<FileTreeNodeProps> = ({
104108
return (
105109
<button
106110
onClick={() => onSelectFile(node.fileIndex!)}
107-
className={`file-tree-item w-full text-left group ${isActive ? 'active' : ''} ${annotationCount > 0 ? 'has-annotations' : ''}`}
111+
className={`file-tree-item w-full text-left group ${isActive ? 'active' : ''} ${annotationCount > 0 ? 'has-annotations' : ''} ${isStaged ? 'staged' : ''}`}
108112
style={{ paddingLeft: paddingLeft + 15 }}
109113
>
110114
<div className="flex items-center gap-1.5 flex-1 min-w-0">
@@ -130,6 +134,9 @@ export const FileTreeNodeItem: React.FC<FileTreeNodeProps> = ({
130134
<span className="truncate">{node.name}</span>
131135
</div>
132136
<div className="flex items-center gap-1.5 flex-shrink-0 text-[10px]">
137+
{isStaged && (
138+
<span className="text-primary font-medium" title="Staged (git add)">+</span>
139+
)}
133140
{annotationCount > 0 && (
134141
<span className="text-primary font-medium">{annotationCount}</span>
135142
)}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { useState, useCallback, useRef } from 'react';
2+
3+
interface UseGitAddOptions {
4+
activeDiffBase: string;
5+
onFileViewed: (filePath: string) => void;
6+
}
7+
8+
interface UseGitAddReturn {
9+
stagedFiles: Set<string>;
10+
stagingFile: string | null;
11+
canStageFiles: boolean;
12+
stageFile: (filePath: string) => Promise<void>;
13+
resetStagedFiles: () => void;
14+
stageError: string | null;
15+
}
16+
17+
const STAGEABLE_DIFF_TYPES = new Set(['uncommitted', 'unstaged']);
18+
19+
export function useGitAdd({ activeDiffBase, onFileViewed }: UseGitAddOptions): UseGitAddReturn {
20+
const [stagedFiles, setStagedFiles] = useState<Set<string>>(new Set());
21+
const [stagingFile, setStagingFile] = useState<string | null>(null);
22+
const [stageError, setStageError] = useState<string | null>(null);
23+
const errorTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
24+
25+
const canStageFiles = STAGEABLE_DIFF_TYPES.has(activeDiffBase);
26+
27+
// Use a ref so stageFile doesn't need stagedFiles in its dependency array
28+
const stagedFilesRef = useRef(stagedFiles);
29+
stagedFilesRef.current = stagedFiles;
30+
31+
const stageFile = useCallback(async (filePath: string) => {
32+
const isUndo = stagedFilesRef.current.has(filePath);
33+
setStagingFile(filePath);
34+
setStageError(null);
35+
clearTimeout(errorTimeoutRef.current);
36+
37+
try {
38+
const res = await fetch('/api/git-add', {
39+
method: 'POST',
40+
headers: { 'Content-Type': 'application/json' },
41+
body: JSON.stringify({ filePath, undo: isUndo }),
42+
});
43+
44+
if (!res.ok) {
45+
const data = await res.json().catch(() => ({ error: 'Failed' }));
46+
throw new Error(data.error || 'Failed');
47+
}
48+
49+
setStagedFiles(prev => {
50+
const next = new Set(prev);
51+
if (isUndo) {
52+
next.delete(filePath);
53+
} else {
54+
next.add(filePath);
55+
}
56+
return next;
57+
});
58+
59+
// Auto-mark as viewed on stage (not on unstage)
60+
if (!isUndo) {
61+
onFileViewed(filePath);
62+
}
63+
} catch (err) {
64+
const message = err instanceof Error ? err.message : 'Git add failed';
65+
setStageError(message);
66+
errorTimeoutRef.current = setTimeout(() => setStageError(null), 3000);
67+
} finally {
68+
setStagingFile(null);
69+
}
70+
}, [onFileViewed]);
71+
72+
const resetStagedFiles = useCallback(() => {
73+
setStagedFiles(new Set());
74+
setStageError(null);
75+
clearTimeout(errorTimeoutRef.current);
76+
}, []);
77+
78+
return { stagedFiles, stagingFile, canStageFiles, stageFile, resetStagedFiles, stageError };
79+
}

packages/review-editor/index.css

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,18 @@ diffs-container {
347347
opacity: 0.8;
348348
}
349349

350+
.file-tree-item.staged {
351+
background: oklch(from var(--success) l c h / 0.1);
352+
}
353+
354+
.file-tree-item.staged:hover {
355+
background: var(--muted);
356+
}
357+
358+
.file-tree-item.staged.active {
359+
background: var(--primary);
360+
}
361+
350362
/* Annotation toolbar for code review */
351363
.review-toolbar {
352364
position: absolute;

0 commit comments

Comments
 (0)