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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user