feat(web): enhanced file panel — side-by-side diff, hide whitespace, inline review

Adds DiffSplitView component for side-by-side diff mode, whitespace-only
change filtering, inline review comments with thread/gutter cell UI, diff
preferences persistence, and write-file API support for in-browser editing.

Backend: hideWhitespace param on git diff endpoint, write_file route.
This commit is contained in:
2026-06-07 22:16:20 +00:00
parent c935687725
commit 31d8efe66a
15 changed files with 1247 additions and 47 deletions

View File

@@ -8,6 +8,7 @@ import { useRightRailDrawer } from '@/hooks/useRightRailDrawer';
import { useViewport } from '@/hooks/useViewport';
import { useProjectGit } from '@/hooks/useProjectGit';
import { useGitDiff } from '@/hooks/useGitDiff';
import { useDiffPreferences } from '@/hooks/useDiffPreferences';
import { FileViewerOverlay } from '@/components/FileViewerOverlay';
import { GitDiffView } from '@/components/GitDiffView';
import { Input } from '@/components/ui/input';
@@ -90,6 +91,15 @@ export function RightRail({ projectId, sessionId }: Props) {
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
// Diff toolbar state (integration with expandedPaths pending)
const { preferences: diffPrefs, updatePreferences: updateDiffPrefs } = useDiffPreferences();
// File editing state
const [editingFile, setEditingFile] = useState<string | null>(null);
const [editContent, setEditContent] = useState('');
const [editLoading, setEditLoading] = useState(false);
const [editError, setEditError] = useState<string | null>(null);
const openNewFile = useCallback(() => {
setNewFilePath('');
setNewFileContent('');
@@ -167,6 +177,44 @@ export function RightRail({ projectId, sessionId }: Props) {
});
}
async function startEdit(path: string) {
setEditingFile(path);
setEditLoading(true);
setEditError(null);
try {
const result = await api.projects.viewFile(projectId, path);
setEditContent(result.content);
} catch {
setEditError('Failed to load file');
setEditingFile(null);
} finally {
setEditLoading(false);
}
}
async function saveEdit() {
if (!editingFile) return;
try {
await api.projects.writeFile(projectId, editingFile, editContent);
setEditingFile(null);
setEditContent('');
sessionEvents.emit({ type: 'git_diff_refresh' });
} catch {
setEditError('Failed to save file');
}
}
function cancelEdit() {
setEditingFile(null);
setEditContent('');
setEditError(null);
}
// Cancel edit when switching tabs
useEffect(() => {
if (tab !== 'files') cancelEdit();
}, [tab]);
async function openFile(path: string) {
try {
const result = await api.projects.viewFile(projectId, path);
@@ -323,6 +371,30 @@ export function RightRail({ projectId, sessionId }: Props) {
) : (
<div className="text-xs text-muted-foreground px-2 py-4 text-center">No matches</div>
)
) : editingFile ? (
<div className="flex flex-col flex-1 overflow-hidden p-2 gap-2">
<div className="text-xs font-mono truncate text-muted-foreground">{editingFile}</div>
{editLoading ? (
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground">Loading...</div>
) : (
<>
<textarea
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
className="flex-1 font-mono text-xs p-2 rounded border bg-background resize-none outline-none focus:ring-1 focus:ring-ring"
onKeyDown={(e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') saveEdit();
if (e.key === 'Escape') cancelEdit();
}}
/>
{editError && <p className="text-xs text-destructive">{editError}</p>}
<div className="flex items-center gap-2 justify-end">
<button type="button" onClick={cancelEdit} className="text-xs px-2 py-1 rounded border hover:bg-muted">Cancel</button>
<button type="button" onClick={saveEdit} className="text-xs px-3 py-1 rounded bg-primary text-primary-foreground hover:bg-primary/90">Save</button>
</div>
</>
)}
</div>
) : (
<TreeLevel
parentPath=""
@@ -332,6 +404,7 @@ export function RightRail({ projectId, sessionId }: Props) {
depth={0}
onToggleDir={toggleDir}
onSelectFile={(path) => void openFile(path)}
onEditFile={startEdit}
/>
)}
</div>
@@ -345,6 +418,7 @@ export function RightRail({ projectId, sessionId }: Props) {
loading={gitLoading}
error={gitError}
mode={gitMode}
sessionId={sessionId}
onSelectMode={selectMode}
onRefresh={refreshDiff}
mutating={gitMutating}
@@ -355,6 +429,12 @@ export function RightRail({ projectId, sessionId }: Props) {
onDiscard={gitDiscard}
modeSuggestion={gitModeSuggestion}
pendingCount={pendingCount}
layout={diffPrefs.layout}
wrapLines={diffPrefs.wrapLines}
hideWhitespace={diffPrefs.hideWhitespace}
onLayoutChange={(layout) => updateDiffPrefs({ layout })}
onWrapLinesChange={(wrapLines) => updateDiffPrefs({ wrapLines })}
onHideWhitespaceChange={(hideWhitespace) => updateDiffPrefs({ hideWhitespace })}
/>
)}
</aside>
@@ -421,9 +501,10 @@ interface TreeLevelProps {
depth: number;
onToggleDir: (dirPath: string) => void;
onSelectFile: (path: string) => void;
onEditFile?: (path: string) => void;
}
function TreeLevel({ parentPath, entries, cache, expanded, depth, onToggleDir, onSelectFile }: TreeLevelProps) {
function TreeLevel({ parentPath, entries, cache, expanded, depth, onToggleDir, onSelectFile, onEditFile }: TreeLevelProps) {
const sorted = useMemo(() => {
const copy = [...entries];
copy.sort((a, b) => {
@@ -447,6 +528,9 @@ function TreeLevel({ parentPath, entries, cache, expanded, depth, onToggleDir, o
if (entry.kind === 'dir') onToggleDir(fullPath);
else onSelectFile(fullPath);
}}
onDoubleClick={() => {
if (entry.kind === 'file') onEditFile?.(fullPath);
}}
>
{entry.kind === 'dir' ? (
isExpanded ? <ChevronDown size={10} className="shrink-0" /> : <ChevronRight size={10} className="shrink-0" />
@@ -469,6 +553,7 @@ function TreeLevel({ parentPath, entries, cache, expanded, depth, onToggleDir, o
depth={depth + 1}
onToggleDir={onToggleDir}
onSelectFile={onSelectFile}
onEditFile={onEditFile}
/>
)}
</li>