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:
206
apps/web/src/components/DiffSplitView.tsx
Normal file
206
apps/web/src/components/DiffSplitView.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import { useMemo, useRef, useEffect, useState } from 'react';
|
||||
import { codeToHtml } from 'shiki';
|
||||
import type { GitDiffFile } from '@/api/types';
|
||||
import { parseDiff, buildSplitRows, reconstructNewContent, type SplitRow } from '@/utils/diff-layout';
|
||||
import { inferLanguage } from '@/lib/attachments';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface DiffSplitViewProps {
|
||||
file: GitDiffFile;
|
||||
wrapLines?: boolean;
|
||||
}
|
||||
|
||||
/** Side-by-side split diff renderer. Left = deletions, right = additions. */
|
||||
export function DiffSplitView({ file, wrapLines = false }: DiffSplitViewProps) {
|
||||
// ── Edge cases (rendered before hooks) ──────────────────────────────────
|
||||
if (file.is_binary) {
|
||||
return <p className="text-xs text-muted-foreground italic px-2 py-1">Binary file</p>;
|
||||
}
|
||||
if (file.is_too_large) {
|
||||
return <p className="text-xs text-muted-foreground italic px-2 py-1">Diff too large to display</p>;
|
||||
}
|
||||
if (file.change_type === 'untracked' && !file.diff_body) {
|
||||
return <p className="text-xs text-muted-foreground italic px-2 py-1">Untracked file</p>;
|
||||
}
|
||||
if (!file.diff_body) {
|
||||
return <p className="text-xs text-muted-foreground italic px-2 py-1">No diff content</p>;
|
||||
}
|
||||
|
||||
return <DiffSplitViewInner file={file} wrapLines={wrapLines} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inner component — assumes file.diff_body is non-null.
|
||||
* Separated so the early-return edge cases above don't violate rules of hooks.
|
||||
*/
|
||||
function DiffSplitViewInner({ file, wrapLines }: { file: GitDiffFile; wrapLines: boolean }) {
|
||||
// ── Parse diff ───────────────────────────────────────────────────────────
|
||||
const parsed = useMemo(() => parseDiff(file.diff_body!), [file.diff_body]);
|
||||
const parsedFile = parsed[0];
|
||||
|
||||
const rows = useMemo(() => {
|
||||
if (!parsedFile) return [] as SplitRow[];
|
||||
return buildSplitRows(parsedFile);
|
||||
}, [parsedFile]);
|
||||
|
||||
const newContent = useMemo(() => {
|
||||
if (!parsedFile) return '';
|
||||
return reconstructNewContent(parsedFile.hunks);
|
||||
}, [parsedFile]);
|
||||
|
||||
// ── Syntax highlighting ──────────────────────────────────────────────────
|
||||
const [highlightedLines, setHighlightedLines] = useState<string[] | null>(null);
|
||||
const [highlighting, setHighlighting] = useState(false);
|
||||
const highlightKeyRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!newContent) return;
|
||||
if (highlightKeyRef.current === newContent) return;
|
||||
highlightKeyRef.current = newContent;
|
||||
|
||||
let cancelled = false;
|
||||
setHighlighting(true);
|
||||
setHighlightedLines(null);
|
||||
|
||||
const lang = inferLanguage(file.path) ?? 'plaintext';
|
||||
|
||||
void codeToHtml(newContent, { lang, theme: 'github-dark' })
|
||||
.then((html) => {
|
||||
if (cancelled) return;
|
||||
const container = document.createElement('div');
|
||||
// eslint-disable-next-line no-unsanitized/property
|
||||
container.innerHTML = html;
|
||||
const codeEl = container.querySelector('code');
|
||||
if (codeEl) {
|
||||
const lineSpans = codeEl.querySelectorAll('.line');
|
||||
setHighlightedLines(Array.from(lineSpans, (span) => span.innerHTML));
|
||||
} else {
|
||||
setHighlightedLines(null);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setHighlightedLines(null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setHighlighting(false);
|
||||
});
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [newContent, file.path]);
|
||||
|
||||
// ── Build new-line-number → highlighted-HTML map ───────────────────────
|
||||
// Walk the hunks counting only add/context lines (which form the new file)
|
||||
// and map each 1-based new-line-number to its highlighted HTML string.
|
||||
const newLineHtmlMap = useMemo(() => {
|
||||
if (!highlightedLines || !parsedFile) return new Map<number, string>();
|
||||
const map = new Map<number, string>();
|
||||
let idx = 0;
|
||||
for (const hunk of parsedFile.hunks) {
|
||||
let newLineNo = hunk.newStart;
|
||||
for (const line of hunk.lines) {
|
||||
if (line.type === 'header') continue;
|
||||
if (line.type === 'add' || line.type === 'context') {
|
||||
if (idx < highlightedLines.length) {
|
||||
map.set(newLineNo, highlightedLines[idx]!);
|
||||
}
|
||||
idx++;
|
||||
newLineNo++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [highlightedLines, parsedFile]);
|
||||
|
||||
// ── Render ───────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div className={cn('text-[11px] font-mono overflow-x-auto', wrapLines && 'break-all')}>
|
||||
{highlighting && (
|
||||
<p className="text-xs text-muted-foreground px-2 py-1">Highlighting…</p>
|
||||
)}
|
||||
<table className="w-full border-collapse">
|
||||
<colgroup>
|
||||
<col className="w-[40px]" />
|
||||
<col />
|
||||
<col className="w-px" />
|
||||
<col className="w-[40px]" />
|
||||
<col />
|
||||
</colgroup>
|
||||
<tbody>
|
||||
{rows.map((row, idx) => {
|
||||
if (row.kind === 'header') {
|
||||
return (
|
||||
<tr key={`h-${idx}`} className="bg-muted/30">
|
||||
<td
|
||||
colSpan={5}
|
||||
className="text-muted-foreground text-[11px] px-2 py-0.5 select-none"
|
||||
>
|
||||
{row.content}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
const left = row.left;
|
||||
const right = row.right;
|
||||
|
||||
const leftBg = left?.type === 'remove' ? 'bg-red-950/30' : '';
|
||||
const rightBg = right?.type === 'add' ? 'bg-green-950/30' : '';
|
||||
|
||||
const leftHtml = left?.lineNumber != null ? newLineHtmlMap.get(left.lineNumber) : undefined;
|
||||
const rightHtml = right?.lineNumber != null ? newLineHtmlMap.get(right.lineNumber) : undefined;
|
||||
|
||||
return (
|
||||
<tr key={`p-${idx}`} className="hover:bg-muted/10">
|
||||
<td className={cn(leftBg, 'border-r border-border/20 align-top')}>
|
||||
<span className="text-muted-foreground text-right pr-1 select-none text-[11px] block">
|
||||
{left?.lineNumber ?? ''}
|
||||
</span>
|
||||
</td>
|
||||
<td className={cn(leftBg, 'align-top')}>
|
||||
<div
|
||||
className={cn(
|
||||
'pl-2 text-[11px]',
|
||||
wrapLines ? 'whitespace-pre-wrap break-all' : 'whitespace-pre',
|
||||
)}
|
||||
>
|
||||
{left ? (
|
||||
leftHtml ? (
|
||||
// eslint-disable-next-line no-unsanitized/property
|
||||
<span dangerouslySetInnerHTML={{ __html: leftHtml }} />
|
||||
) : (
|
||||
<span>{left.content}</span>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
<td className="border-l border-border/30 w-px p-0" />
|
||||
<td className={cn(rightBg, 'border-r border-border/20 align-top')}>
|
||||
<span className="text-muted-foreground text-right pr-1 select-none text-[11px] block">
|
||||
{right?.lineNumber ?? ''}
|
||||
</span>
|
||||
</td>
|
||||
<td className={cn(rightBg, 'align-top')}>
|
||||
<div
|
||||
className={cn(
|
||||
'pl-2 text-[11px]',
|
||||
wrapLines ? 'whitespace-pre-wrap break-all' : 'whitespace-pre',
|
||||
)}
|
||||
>
|
||||
{right ? (
|
||||
rightHtml ? (
|
||||
// eslint-disable-next-line no-unsanitized/property
|
||||
<span dangerouslySetInnerHTML={{ __html: rightHtml }} />
|
||||
) : (
|
||||
<span>{right.content}</span>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,13 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { ChevronDown, ChevronRight, GitBranch, RefreshCw, Trash2 } from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ChevronDown, ChevronRight, Columns2, GitBranch, ListChevronsDownUp, ListChevronsUpDown, AlignJustify, Pilcrow, RefreshCw, Trash2, WrapText } from 'lucide-react';
|
||||
import { codeToHtml } from 'shiki';
|
||||
import type { GitDiffFile, GitDiffMode, GitDiffResult, GitDiscardFileInfo } from '@/api/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { DiffSplitView } from './DiffSplitView';
|
||||
import { InlineReviewGutterCell } from './InlineReviewGutterCell';
|
||||
import { InlineReviewEditor } from './InlineReviewEditor';
|
||||
import { InlineReviewThread } from './InlineReviewThread';
|
||||
import { useDiffComments } from '@/stores/useDiffCommentStore';
|
||||
|
||||
interface WriteProps {
|
||||
mutating: boolean;
|
||||
@@ -18,12 +23,19 @@ interface Props extends WriteProps {
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
mode: GitDiffMode;
|
||||
sessionId?: string;
|
||||
onSelectMode: (m: GitDiffMode) => void;
|
||||
onRefresh: () => void;
|
||||
/** FIX 4: non-null when the repo's dirty state suggests a different mode than the pinned one. */
|
||||
modeSuggestion?: GitDiffMode | null;
|
||||
/** FIX 5: pending-changes count from the Coder pane — shown in empty state as a hint. */
|
||||
pendingCount?: number;
|
||||
layout: 'unified' | 'split';
|
||||
wrapLines: boolean;
|
||||
hideWhitespace: boolean;
|
||||
onLayoutChange: (layout: 'unified' | 'split') => void;
|
||||
onWrapLinesChange: (wrap: boolean) => void;
|
||||
onHideWhitespaceChange: (hide: boolean) => void;
|
||||
}
|
||||
|
||||
const CHANGE_TYPE_LABELS: Record<string, string> = {
|
||||
@@ -99,6 +111,12 @@ function FileDiffRow({
|
||||
onStage,
|
||||
onUnstage,
|
||||
onDiscardRequest,
|
||||
layout,
|
||||
wrapLines,
|
||||
expanded,
|
||||
onToggleExpand,
|
||||
sessionId,
|
||||
diffMode,
|
||||
}: {
|
||||
file: GitDiffFile;
|
||||
uncommitted: boolean;
|
||||
@@ -106,11 +124,21 @@ function FileDiffRow({
|
||||
onStage: (path: string) => void;
|
||||
onUnstage: (path: string) => void;
|
||||
onDiscardRequest: (file: GitDiffFile) => void;
|
||||
layout: 'unified' | 'split';
|
||||
wrapLines: boolean;
|
||||
expanded: boolean;
|
||||
onToggleExpand: (path: string) => void;
|
||||
sessionId?: string;
|
||||
diffMode?: string;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [html, setHtml] = useState<string | null>(null);
|
||||
const [highlighting, setHighlighting] = useState(false);
|
||||
const highlightRef = useRef<HTMLDivElement | null>(null);
|
||||
const [showEditor, setShowEditor] = useState(false);
|
||||
const commentKey = `${file.path}:${file.change_type}`;
|
||||
const diffModeVal = diffMode ?? '';
|
||||
const { comments, addComment, updateComment, deleteComment } = useDiffComments(sessionId ?? '', diffModeVal);
|
||||
const fileComments = comments.get(commentKey) ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
if (!expanded || !file.diff_body) return;
|
||||
@@ -136,13 +164,27 @@ function FileDiffRow({
|
||||
const typeColor = CHANGE_TYPE_COLORS[file.change_type] ?? 'text-muted-foreground';
|
||||
const displayPath = file.old_path ? `${file.old_path} → ${file.path}` : file.path;
|
||||
|
||||
const handleAddComment = (body: string) => {
|
||||
const comment = { id: crypto.randomUUID(), body, createdAt: Date.now(), updatedAt: Date.now() };
|
||||
addComment(commentKey, comment);
|
||||
setShowEditor(false);
|
||||
};
|
||||
|
||||
const handleEditComment = (id: string, body: string) => {
|
||||
updateComment(commentKey, id, body);
|
||||
};
|
||||
|
||||
const handleDeleteComment = (id: string) => {
|
||||
deleteComment(commentKey, id);
|
||||
};
|
||||
|
||||
return (
|
||||
<li className="border-b border-border/30 last:border-0">
|
||||
<div className="flex items-center group">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 flex items-center gap-1.5 px-2 py-1.5 text-xs hover:bg-muted/40 text-left max-md:min-h-[44px] min-w-0"
|
||||
onClick={() => setExpanded((p) => !p)}
|
||||
onClick={() => onToggleExpand(file.path)}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
{expanded
|
||||
@@ -203,23 +245,54 @@ function FileDiffRow({
|
||||
<p className="text-xs text-muted-foreground italic px-2 py-1">Untracked — not yet staged</p>
|
||||
)}
|
||||
{!file.is_binary && !file.is_too_large && file.diff_body && (
|
||||
<>
|
||||
{highlighting && (
|
||||
<p className="text-xs text-muted-foreground px-2 py-1">Highlighting…</p>
|
||||
)}
|
||||
{!highlighting && html !== null ? (
|
||||
<div
|
||||
ref={highlightRef}
|
||||
className="text-[11px] overflow-x-auto rounded bg-[#0d1117] [&_pre]:!p-2 [&_pre]:!m-0 [&_pre]:overflow-x-auto"
|
||||
layout === 'split' ? (
|
||||
<DiffSplitView file={file} wrapLines={wrapLines} />
|
||||
) : (
|
||||
<>
|
||||
{highlighting && (
|
||||
<p className="text-xs text-muted-foreground px-2 py-1">Highlighting…</p>
|
||||
)}
|
||||
{!highlighting && html !== null ? (
|
||||
<div
|
||||
ref={highlightRef}
|
||||
className="text-[11px] overflow-x-auto rounded bg-[#0d1117] [&_pre]:!p-2 [&_pre]:!m-0 [&_pre]:overflow-x-auto"
|
||||
/>
|
||||
) : (
|
||||
!highlighting && (
|
||||
<pre className={cn(
|
||||
'text-[11px] overflow-x-auto rounded bg-muted/30 p-2',
|
||||
wrapLines ? 'whitespace-pre-wrap break-all' : 'whitespace-pre',
|
||||
)}>
|
||||
{file.diff_body}
|
||||
</pre>
|
||||
)
|
||||
)}
|
||||
{/* Comment button */}
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowEditor(!showEditor)}
|
||||
className="text-[10px] text-muted-foreground hover:text-foreground flex items-center gap-0.5 px-1 py-0.5 rounded hover:bg-muted/40"
|
||||
>
|
||||
<span>{showEditor ? 'Cancel' : 'Comment'}</span>
|
||||
</button>
|
||||
<span className="text-[10px] text-muted-foreground/50">
|
||||
{fileComments.length > 0 && `${fileComments.length} comment${fileComments.length > 1 ? 's' : ''}`}
|
||||
</span>
|
||||
</div>
|
||||
{showEditor && (
|
||||
<InlineReviewEditor
|
||||
onSave={handleAddComment}
|
||||
onCancel={() => setShowEditor(false)}
|
||||
/>
|
||||
)}
|
||||
<InlineReviewThread
|
||||
comments={fileComments}
|
||||
onEditComment={handleEditComment}
|
||||
onDeleteComment={handleDeleteComment}
|
||||
/>
|
||||
) : (
|
||||
!highlighting && (
|
||||
<pre className="text-[11px] overflow-x-auto rounded bg-muted/30 p-2 whitespace-pre">
|
||||
{file.diff_body}
|
||||
</pre>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -242,11 +315,41 @@ export function GitDiffView({
|
||||
onDiscard,
|
||||
modeSuggestion,
|
||||
pendingCount,
|
||||
layout,
|
||||
wrapLines,
|
||||
hideWhitespace,
|
||||
onLayoutChange,
|
||||
onWrapLinesChange,
|
||||
onHideWhitespaceChange,
|
||||
sessionId,
|
||||
}: Props) {
|
||||
const [commitMessage, setCommitMessage] = useState('');
|
||||
const [discardTarget, setDiscardTarget] = useState<DiscardConfirmState | null>(null);
|
||||
const [lastAction, setLastAction] = useState<string | null>(null);
|
||||
const lastActionTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [expandedFiles, setExpandedFiles] = useState<Set<string>>(new Set());
|
||||
|
||||
const allExpandedComputed = useMemo(
|
||||
() => result !== null && (result.files?.length ?? 0) > 0 && result.files.every((f) => expandedFiles.has(f.path)),
|
||||
[result, expandedFiles],
|
||||
);
|
||||
|
||||
const handleExpandAllChange = useCallback((expand: boolean) => {
|
||||
if (expand && result?.files) {
|
||||
setExpandedFiles(new Set(result.files.map((f) => f.path)));
|
||||
} else {
|
||||
setExpandedFiles(new Set());
|
||||
}
|
||||
}, [result?.files]);
|
||||
|
||||
const handleToggleExpand = useCallback((path: string) => {
|
||||
setExpandedFiles((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(path)) next.delete(path);
|
||||
else next.add(path);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
function flashAction(msg: string) {
|
||||
setLastAction(msg);
|
||||
@@ -378,6 +481,83 @@ export function GitDiffView({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Diff toolbar */}
|
||||
<div className="flex items-center gap-1 px-2 py-1 border-b shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onLayoutChange('unified')}
|
||||
className={cn(
|
||||
'text-xs px-2 py-0.5 rounded flex items-center gap-1 max-md:min-h-[44px]',
|
||||
layout === 'unified'
|
||||
? 'bg-muted text-foreground font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
title="Unified diff"
|
||||
>
|
||||
<AlignJustify size={12} />
|
||||
Unified
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onLayoutChange('split')}
|
||||
className={cn(
|
||||
'text-xs px-2 py-0.5 rounded flex items-center gap-1 max-md:min-h-[44px]',
|
||||
layout === 'split'
|
||||
? 'bg-muted text-foreground font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
title="Split diff"
|
||||
>
|
||||
<Columns2 size={12} />
|
||||
Split
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onHideWhitespaceChange(!hideWhitespace)}
|
||||
className={cn(
|
||||
'p-1 rounded max-md:min-h-[44px] max-md:min-w-[44px]',
|
||||
hideWhitespace
|
||||
? 'bg-muted text-foreground font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
title={hideWhitespace ? 'Show whitespace' : 'Hide whitespace'}
|
||||
>
|
||||
<Pilcrow size={12} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onWrapLinesChange(!wrapLines)}
|
||||
className={cn(
|
||||
'p-1 rounded max-md:min-h-[44px] max-md:min-w-[44px]',
|
||||
wrapLines
|
||||
? 'bg-muted text-foreground font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
title={wrapLines ? 'Unwrap lines' : 'Wrap lines'}
|
||||
>
|
||||
<WrapText size={12} />
|
||||
</button>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleExpandAllChange(!allExpandedComputed)}
|
||||
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||
title={allExpandedComputed ? 'Collapse all' : 'Expand all'}
|
||||
>
|
||||
{allExpandedComputed ? <ListChevronsDownUp size={12} /> : <ListChevronsUpDown size={12} />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRefresh}
|
||||
disabled={loading || mutating}
|
||||
className="p-1 rounded hover:bg-muted text-muted-foreground disabled:opacity-40 max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||
aria-label="Refresh diff"
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshCw size={12} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Committed-mode base label */}
|
||||
{result.mode === 'committed' && base_label && (
|
||||
<div className="px-2 py-1 text-[10px] text-muted-foreground border-b flex items-center gap-1 shrink-0">
|
||||
@@ -445,6 +625,12 @@ export function GitDiffView({
|
||||
onStage={handleStage}
|
||||
onUnstage={handleUnstage}
|
||||
onDiscardRequest={handleDiscardRequest}
|
||||
layout={layout}
|
||||
wrapLines={wrapLines}
|
||||
expanded={expandedFiles.has(file.path)}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
sessionId={sessionId}
|
||||
diffMode={mode}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
60
apps/web/src/components/InlineReviewEditor.tsx
Normal file
60
apps/web/src/components/InlineReviewEditor.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface InlineReviewEditorProps {
|
||||
initialBody?: string;
|
||||
onSave: (body: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function InlineReviewEditor({ initialBody = '', onSave, onCancel }: InlineReviewEditorProps) {
|
||||
const [text, setText] = useState(initialBody);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
textareaRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
onCancel();
|
||||
}
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter' && text.trim()) {
|
||||
onSave(text.trim());
|
||||
}
|
||||
},
|
||||
[onCancel, onSave, text],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mx-2 my-1 rounded border border-border/80 bg-popover p-2 shadow-sm">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Add a comment..."
|
||||
rows={3}
|
||||
className="w-full resize-none bg-transparent text-[13px] text-foreground placeholder:text-muted-foreground/60 outline-none"
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-1.5 mt-1.5 border-t border-border/40 pt-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="text-xs px-2 py-1 rounded hover:bg-muted text-muted-foreground"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!text.trim()}
|
||||
onClick={() => onSave(text.trim())}
|
||||
className="text-xs px-2 py-1 rounded bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-40"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
apps/web/src/components/InlineReviewGutterCell.tsx
Normal file
43
apps/web/src/components/InlineReviewGutterCell.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Plus } from 'lucide-react';
|
||||
|
||||
interface InlineReviewGutterCellProps {
|
||||
lineNumber: number | null;
|
||||
type: 'add' | 'remove' | 'context' | 'header' | null;
|
||||
hasComments: boolean;
|
||||
canComment: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function InlineReviewGutterCell({
|
||||
lineNumber,
|
||||
type,
|
||||
hasComments,
|
||||
canComment,
|
||||
onClick,
|
||||
}: InlineReviewGutterCellProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex items-center justify-end pr-1 min-w-[40px] h-5 text-[11px] font-mono select-none',
|
||||
type === 'add' && 'bg-green-950/30',
|
||||
type === 'remove' && 'bg-red-950/30',
|
||||
type === 'context' && 'bg-muted/10',
|
||||
canComment && 'cursor-pointer group',
|
||||
)}
|
||||
onClick={canComment ? onClick : undefined}
|
||||
>
|
||||
<span className="text-muted-foreground/70">
|
||||
{lineNumber != null ? lineNumber : ''}
|
||||
</span>
|
||||
{canComment && (
|
||||
<span className="absolute left-0.5 hidden group-hover:flex items-center justify-center w-4 h-4 rounded text-muted-foreground hover:text-foreground">
|
||||
<Plus size={12} />
|
||||
</span>
|
||||
)}
|
||||
{hasComments && (
|
||||
<span className="absolute left-0.5 w-1.5 h-1.5 rounded-full bg-blue-400" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
92
apps/web/src/components/InlineReviewThread.tsx
Normal file
92
apps/web/src/components/InlineReviewThread.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useState } from 'react';
|
||||
import { MessageSquare, Pencil, Trash2 } from 'lucide-react';
|
||||
import type { DiffComment } from '@/stores/useDiffCommentStore';
|
||||
import { InlineReviewEditor } from './InlineReviewEditor';
|
||||
|
||||
interface InlineReviewThreadProps {
|
||||
comments: DiffComment[];
|
||||
onEditComment: (id: string, body: string) => void;
|
||||
onDeleteComment: (id: string) => void;
|
||||
}
|
||||
|
||||
export function InlineReviewThread({
|
||||
comments,
|
||||
onEditComment,
|
||||
onDeleteComment,
|
||||
}: InlineReviewThreadProps) {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editBody, setEditBody] = useState('');
|
||||
|
||||
if (comments.length === 0) return null;
|
||||
|
||||
const handleStartEdit = (id: string, body: string) => {
|
||||
setEditingId(id);
|
||||
setEditBody(body);
|
||||
};
|
||||
|
||||
const handleSaveEdit = (body: string) => {
|
||||
if (editingId) {
|
||||
onEditComment(editingId, body);
|
||||
setEditingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditingId(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ml-1 border-l-2 border-blue-400/40 pl-2 my-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="flex items-center gap-1 text-[10px] text-muted-foreground hover:text-foreground mb-0.5"
|
||||
>
|
||||
<MessageSquare size={10} />
|
||||
<span>{comments.length} comment{comments.length > 1 ? 's' : ''}</span>
|
||||
<span className="text-[9px]">{expanded ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="space-y-1">
|
||||
{comments.map((comment) => (
|
||||
<div key={comment.id} className="text-xs">
|
||||
{editingId === comment.id ? (
|
||||
<InlineReviewEditor
|
||||
initialBody={editBody}
|
||||
onSave={handleSaveEdit}
|
||||
onCancel={handleCancelEdit}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-start gap-1 group">
|
||||
<span className="flex-1 text-foreground/90 leading-relaxed whitespace-pre-wrap">
|
||||
{comment.body}
|
||||
</span>
|
||||
<div className="hidden group-hover:flex items-center gap-0.5 shrink-0 mt-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleStartEdit(comment.id, comment.body)}
|
||||
className="p-0.5 rounded hover:bg-muted text-muted-foreground"
|
||||
title="Edit"
|
||||
>
|
||||
<Pencil size={10} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDeleteComment(comment.id)}
|
||||
className="p-0.5 rounded hover:bg-muted text-muted-foreground hover:text-destructive"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={10} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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