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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ Send Annotations → feedback sent to agent session
| --------------------- | ------ | ------------------------------------------ |
| `/api/diff` | GET | Returns `{ rawPatch, gitRef, origin }` |
| `/api/file-content` | GET | Returns `{ oldContent, newContent }` for expandable diff context |
| `/api/git-add` | POST | Stage/unstage a file (body: `{ filePath, undo? }`) |
| `/api/feedback` | POST | Submit review (body: feedback, annotations, agentSwitch) |
| `/api/image` | GET | Serve image by path query param |
| `/api/upload` | POST | Upload image, returns `{ path, originalName }` |
Expand Down
22 changes: 20 additions & 2 deletions packages/review-editor/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { getAgentSwitchSettings, getEffectiveAgentName } from '@plannotator/ui/u
import { CodeAnnotation, CodeAnnotationType, SelectedLineRange } from '@plannotator/ui/types';
import { useResizablePanel } from '@plannotator/ui/hooks/useResizablePanel';
import { useCodeAnnotationDraft } from '@plannotator/ui/hooks/useCodeAnnotationDraft';
import { useGitAdd } from './hooks/useGitAdd';
import { useEditorAnnotations } from '@plannotator/ui/hooks/useEditorAnnotations';
import { exportEditorAnnotations } from '@plannotator/ui/utils/parser';
import { ResizeHandle } from '@plannotator/ui/components/ResizeHandle';
Expand Down Expand Up @@ -352,7 +353,7 @@ const ReviewApp: React.FC = () => {
const lastColon = rest.lastIndexOf(':');
if (lastColon !== -1) {
const sub = rest.slice(lastColon + 1);
if (['uncommitted', 'last-commit', 'branch'].includes(sub)) {
if (['uncommitted', 'staged', 'unstaged', 'last-commit', 'branch'].includes(sub)) {
return { activeWorktreePath: rest.slice(0, lastColon), activeDiffBase: sub };
}
}
Expand All @@ -361,6 +362,16 @@ const ReviewApp: React.FC = () => {
return { activeWorktreePath: null, activeDiffBase: diffType };
}, [diffType]);

// Git add/staging logic
const handleFileViewedFromStage = useCallback(
(path: string) => setViewedFiles(prev => new Set(prev).add(path)),
[],
);
const { stagedFiles, stagingFile, canStageFiles, stageFile, resetStagedFiles, stageError } = useGitAdd({
activeDiffBase,
onFileViewed: handleFileViewedFromStage,
});

// Shared helper: fetch a diff switch and update state
const fetchDiffSwitch = useCallback(async (fullDiffType: string) => {
setIsLoadingDiff(true);
Expand All @@ -385,13 +396,14 @@ const ReviewApp: React.FC = () => {
setActiveFileIndex(0);
setPendingSelection(null);
setDiffError(data.error || null);
resetStagedFiles();
} catch (err) {
console.error('Failed to switch diff:', err);
setDiffError(err instanceof Error ? err.message : 'Failed to switch diff');
} finally {
setIsLoadingDiff(false);
}
}, []);
}, [resetStagedFiles]);

// Switch diff type (uncommitted, last-commit, branch) — composes worktree prefix if active
const handleDiffSwitch = useCallback(async (baseDiffType: string) => {
Expand Down Expand Up @@ -808,6 +820,7 @@ const ReviewApp: React.FC = () => {
activeWorktreePath={activeWorktreePath}
onSelectWorktree={handleWorktreeSwitch}
currentBranch={gitContext?.currentBranch}
stagedFiles={stagedFiles}
/>
<ResizeHandle {...fileTreeResize.handleProps} />
</>
Expand Down Expand Up @@ -841,6 +854,11 @@ const ReviewApp: React.FC = () => {
onDeleteAnnotation={handleDeleteAnnotation}
isViewed={viewedFiles.has(activeFile.path)}
onToggleViewed={() => handleToggleViewed(activeFile.path)}
isStaged={stagedFiles.has(activeFile.path)}
isStaging={stagingFile === activeFile.path}
onStage={() => stageFile(activeFile.path)}
canStage={canStageFiles}
stageError={stageError}
/>
) : (
<div className="h-full flex items-center justify-center">
Expand Down
15 changes: 15 additions & 0 deletions packages/review-editor/components/DiffViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ interface DiffViewerProps {
onDeleteAnnotation: (id: string) => void;
isViewed?: boolean;
onToggleViewed?: () => void;
isStaged?: boolean;
isStaging?: boolean;
onStage?: () => void;
canStage?: boolean;
stageError?: string | null;
}

export const DiffViewer: React.FC<DiffViewerProps> = ({
Expand All @@ -42,6 +47,11 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
onDeleteAnnotation,
isViewed = false,
onToggleViewed,
isStaged = false,
isStaging = false,
onStage,
canStage = false,
stageError,
}) => {
const { theme } = useTheme();
const containerRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -180,6 +190,11 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
patch={patch}
isViewed={isViewed}
onToggleViewed={onToggleViewed}
isStaged={isStaged}
isStaging={isStaging}
onStage={onStage}
canStage={canStage}
stageError={stageError}
/>

<div className="p-4">
Expand Down
45 changes: 44 additions & 1 deletion packages/review-editor/components/FileHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,24 @@ interface FileHeaderProps {
patch: string;
isViewed?: boolean;
onToggleViewed?: () => void;
isStaged?: boolean;
isStaging?: boolean;
onStage?: () => void;
canStage?: boolean;
stageError?: string | null;
}

/** Sticky file header with file path, Viewed toggle, and Copy Diff button */
/** Sticky file header with file path, Viewed toggle, Git Add, and Copy Diff button */
export const FileHeader: React.FC<FileHeaderProps> = ({
filePath,
patch,
isViewed = false,
onToggleViewed,
isStaged = false,
isStaging = false,
onStage,
canStage = false,
stageError,
}) => {
const [copied, setCopied] = useState(false);

Expand Down Expand Up @@ -42,6 +52,39 @@ export const FileHeader: React.FC<FileHeaderProps> = ({
Viewed
</button>
)}
{canStage && onStage && (
<button
onClick={onStage}
disabled={isStaging}
className={`text-xs px-2 py-1 rounded transition-colors flex items-center gap-1 ${
isStaging
? 'opacity-50 cursor-not-allowed text-muted-foreground'
: isStaged
? 'bg-primary/15 text-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
}`}
title={isStaged ? "Unstage this file (git reset)" : "Stage this file (git add)"}
>
{isStaging ? (
<svg className="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<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" />
</svg>
) : isStaged ? (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
) : (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
)}
{isStaging ? 'Adding...' : isStaged ? 'Added' : 'Git Add'}
</button>
)}
{stageError && (
<span className="text-xs text-destructive">{stageError}</span>
)}
<button
onClick={async () => {
try {
Expand Down
11 changes: 11 additions & 0 deletions packages/review-editor/components/FileTree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ interface FileTreeProps {
activeWorktreePath?: string | null;
onSelectWorktree?: (path: string | null) => void;
currentBranch?: string;
stagedFiles?: Set<string>;
}

export const FileTree: React.FC<FileTreeProps> = ({
Expand All @@ -52,6 +53,7 @@ export const FileTree: React.FC<FileTreeProps> = ({
activeWorktreePath,
onSelectWorktree,
currentBranch,
stagedFiles,
}) => {
// Keyboard navigation: j/k or arrow keys
const handleKeyDown = useCallback((e: KeyboardEvent) => {
Expand Down Expand Up @@ -142,6 +144,14 @@ export const FileTree: React.FC<FileTreeProps> = ({
Files
</span>
<div className="flex items-center gap-1.5">
{stagedFiles && stagedFiles.size > 0 && (
<>
<span className="text-xs text-primary font-medium">
{stagedFiles.size} added
</span>
<span className="text-muted-foreground/40">·</span>
</>
)}
<span className="text-xs text-muted-foreground">
{viewedFiles.size}/{files.length}
</span>
Expand Down Expand Up @@ -247,6 +257,7 @@ export const FileTree: React.FC<FileTreeProps> = ({
onToggleViewed={onToggleViewed}
hideViewedFiles={hideViewedFiles}
getAnnotationCount={getAnnotationCount}
stagedFiles={stagedFiles}
/>
))}
</div>
Expand Down
9 changes: 8 additions & 1 deletion packages/review-editor/components/FileTreeNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ interface FileTreeNodeProps {
onToggleViewed?: (filePath: string) => void;
hideViewedFiles: boolean;
getAnnotationCount: (filePath: string) => number;
stagedFiles?: Set<string>;
}

function hasVisibleChildren(
Expand Down Expand Up @@ -40,6 +41,7 @@ export const FileTreeNodeItem: React.FC<FileTreeNodeProps> = ({
onToggleViewed,
hideViewedFiles,
getAnnotationCount,
stagedFiles,
}) => {
const paddingLeft = 8 + node.depth * 12;

Expand Down Expand Up @@ -86,6 +88,7 @@ export const FileTreeNodeItem: React.FC<FileTreeNodeProps> = ({
onToggleViewed={onToggleViewed}
hideViewedFiles={hideViewedFiles}
getAnnotationCount={getAnnotationCount}
stagedFiles={stagedFiles}
/>
))}
</>
Expand All @@ -95,6 +98,7 @@ export const FileTreeNodeItem: React.FC<FileTreeNodeProps> = ({
// File node
const isActive = node.fileIndex === activeFileIndex;
const isViewed = viewedFiles.has(node.path);
const isStaged = stagedFiles?.has(node.path) ?? false;
const annotationCount = getAnnotationCount(node.path);

if (hideViewedFiles && isViewed && !isActive) {
Expand All @@ -104,7 +108,7 @@ export const FileTreeNodeItem: React.FC<FileTreeNodeProps> = ({
return (
<button
onClick={() => onSelectFile(node.fileIndex!)}
className={`file-tree-item w-full text-left group ${isActive ? 'active' : ''} ${annotationCount > 0 ? 'has-annotations' : ''}`}
className={`file-tree-item w-full text-left group ${isActive ? 'active' : ''} ${annotationCount > 0 ? 'has-annotations' : ''} ${isStaged ? 'staged' : ''}`}
style={{ paddingLeft: paddingLeft + 15 }}
>
<div className="flex items-center gap-1.5 flex-1 min-w-0">
Expand All @@ -130,6 +134,9 @@ export const FileTreeNodeItem: React.FC<FileTreeNodeProps> = ({
<span className="truncate">{node.name}</span>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0 text-[10px]">
{isStaged && (
<span className="text-primary font-medium" title="Staged (git add)">+</span>
)}
{annotationCount > 0 && (
<span className="text-primary font-medium">{annotationCount}</span>
)}
Expand Down
79 changes: 79 additions & 0 deletions packages/review-editor/hooks/useGitAdd.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { useState, useCallback, useRef } from 'react';

interface UseGitAddOptions {
activeDiffBase: string;
onFileViewed: (filePath: string) => void;
}

interface UseGitAddReturn {
stagedFiles: Set<string>;
stagingFile: string | null;
canStageFiles: boolean;
stageFile: (filePath: string) => Promise<void>;
resetStagedFiles: () => void;
stageError: string | null;
}

const STAGEABLE_DIFF_TYPES = new Set(['uncommitted', 'unstaged']);

export function useGitAdd({ activeDiffBase, onFileViewed }: UseGitAddOptions): UseGitAddReturn {
const [stagedFiles, setStagedFiles] = useState<Set<string>>(new Set());
const [stagingFile, setStagingFile] = useState<string | null>(null);
const [stageError, setStageError] = useState<string | null>(null);
const errorTimeoutRef = useRef<ReturnType<typeof setTimeout>>();

const canStageFiles = STAGEABLE_DIFF_TYPES.has(activeDiffBase);

// Use a ref so stageFile doesn't need stagedFiles in its dependency array
const stagedFilesRef = useRef(stagedFiles);
stagedFilesRef.current = stagedFiles;

const stageFile = useCallback(async (filePath: string) => {
const isUndo = stagedFilesRef.current.has(filePath);
setStagingFile(filePath);
setStageError(null);
clearTimeout(errorTimeoutRef.current);

try {
const res = await fetch('/api/git-add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filePath, undo: isUndo }),
});

if (!res.ok) {
const data = await res.json().catch(() => ({ error: 'Failed' }));
throw new Error(data.error || 'Failed');
}

setStagedFiles(prev => {
const next = new Set(prev);
if (isUndo) {
next.delete(filePath);
} else {
next.add(filePath);
}
return next;
});

// Auto-mark as viewed on stage (not on unstage)
if (!isUndo) {
onFileViewed(filePath);
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Git add failed';
setStageError(message);
errorTimeoutRef.current = setTimeout(() => setStageError(null), 3000);
} finally {
setStagingFile(null);
}
}, [onFileViewed]);

const resetStagedFiles = useCallback(() => {
setStagedFiles(new Set());
setStageError(null);
clearTimeout(errorTimeoutRef.current);
}, []);

return { stagedFiles, stagingFile, canStageFiles, stageFile, resetStagedFiles, stageError };
}
12 changes: 12 additions & 0 deletions packages/review-editor/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,18 @@ diffs-container {
opacity: 0.8;
}

.file-tree-item.staged {
background: oklch(from var(--success) l c h / 0.1);
}

.file-tree-item.staged:hover {
background: var(--muted);
}

.file-tree-item.staged.active {
background: var(--primary);
}

/* Annotation toolbar for code review */
.review-toolbar {
position: absolute;
Expand Down
Loading
Loading