feat: improve changes panel (#234)
This commit is contained in:
@@ -14,13 +14,15 @@ import { toRelativePath } from '@/lib/utils';
|
||||
import { vscodeDark } from '@uiw/codemirror-theme-vscode';
|
||||
import { useDiffCommentsStore, useFileComments } from '@/lib/state/slices/diff-comments/diff-comments-slice';
|
||||
import type { DiffComment } from '@/lib/diff/types';
|
||||
import { computeLineDiffStats } from '@/lib/diff';
|
||||
import { useToast } from '@/components/toast-provider';
|
||||
import { EditorCommentPopover } from '@/components/task/editor-comment-popover';
|
||||
import { CommentViewPopover } from '@/components/task/comment-view-popover';
|
||||
import { PanelHeaderBarSplit } from '@/components/task/panel-primitives';
|
||||
import { useDockviewStore } from '@/lib/state/dockview-store';
|
||||
|
||||
type FileEditorContentProps = {
|
||||
path: string;
|
||||
content: string;
|
||||
originalContent: string;
|
||||
isDirty: boolean;
|
||||
isSaving: boolean;
|
||||
@@ -126,7 +128,6 @@ class CommentGutterMarker extends GutterMarker {
|
||||
|
||||
export function CodeMirrorCodeEditor({
|
||||
path,
|
||||
content,
|
||||
originalContent,
|
||||
isDirty,
|
||||
isSaving,
|
||||
@@ -137,6 +138,12 @@ export function CodeMirrorCodeEditor({
|
||||
onSave,
|
||||
onDelete,
|
||||
}: FileEditorContentProps) {
|
||||
// Read initial content from the store once (not reactive)
|
||||
const [initialContent] = useState(
|
||||
() => useDockviewStore.getState().openFiles.get(path)?.content ?? ''
|
||||
);
|
||||
const contentRef = useRef(initialContent);
|
||||
|
||||
const [wrapEnabled, setWrapEnabled] = useState(true);
|
||||
const [textSelection, setTextSelection] = useState<TextSelection>(null);
|
||||
const [floatingButtonPos, setFloatingButtonPos] = useState<FloatingButtonPosition>(null);
|
||||
@@ -354,33 +361,15 @@ export function CodeMirrorCodeEditor({
|
||||
return exts;
|
||||
}, [langExt, wrapEnabled, commentGutter, commentPlugin, selectionUpdateExtension]);
|
||||
|
||||
// Calculate diff stats when dirty
|
||||
const diffStats = useMemo(() => {
|
||||
if (!isDirty) return null;
|
||||
|
||||
const originalLines = originalContent.split('\n');
|
||||
const currentLines = content.split('\n');
|
||||
|
||||
let additions = 0;
|
||||
let deletions = 0;
|
||||
|
||||
const maxLen = Math.max(originalLines.length, currentLines.length);
|
||||
for (let i = 0; i < maxLen; i++) {
|
||||
const origLine = originalLines[i];
|
||||
const currLine = currentLines[i];
|
||||
|
||||
if (origLine === undefined && currLine !== undefined) {
|
||||
additions++;
|
||||
} else if (origLine !== undefined && currLine === undefined) {
|
||||
deletions++;
|
||||
} else if (origLine !== currLine) {
|
||||
additions++;
|
||||
deletions++;
|
||||
}
|
||||
}
|
||||
|
||||
return { additions, deletions };
|
||||
}, [isDirty, content, originalContent]);
|
||||
// Diff stats — updated via onChange, not React props
|
||||
const [diffStats, setDiffStats] = useState<{ additions: number; deletions: number } | null>(null);
|
||||
const statsTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const computeDiffStats = useCallback(() => {
|
||||
if (!isDirty) { setDiffStats(null); return; }
|
||||
setDiffStats(computeLineDiffStats(originalContent, contentRef.current));
|
||||
}, [isDirty, originalContent]);
|
||||
// Compute on mount and when settings change
|
||||
useEffect(() => { computeDiffStats(); }, [computeDiffStats]);
|
||||
|
||||
// Handle Cmd+I to open comment popover
|
||||
useEffect(() => {
|
||||
@@ -429,9 +418,13 @@ export function CodeMirrorCodeEditor({
|
||||
|
||||
const handleChange = useCallback(
|
||||
(value: string) => {
|
||||
contentRef.current = value;
|
||||
onChange(value);
|
||||
// Debounce diff stats update
|
||||
if (statsTimerRef.current) clearTimeout(statsTimerRef.current);
|
||||
statsTimerRef.current = setTimeout(computeDiffStats, 300);
|
||||
},
|
||||
[onChange]
|
||||
[onChange, computeDiffStats]
|
||||
);
|
||||
|
||||
// Open comment popover from floating button
|
||||
@@ -517,16 +510,19 @@ export function CodeMirrorCodeEditor({
|
||||
return (
|
||||
<div ref={wrapperRef} className="flex h-full flex-col rounded-lg">
|
||||
{/* Editor header with save button */}
|
||||
<div className="flex items-center justify-between px-2 border-foreground/10 border-b">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="font-mono">{toRelativePath(path, worktreePath)}</span>
|
||||
{isDirty && diffStats && (
|
||||
<span className="text-xs text-yellow-500">
|
||||
{formatDiffStats(diffStats.additions, diffStats.deletions)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<PanelHeaderBarSplit
|
||||
left={
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="font-mono">{toRelativePath(path, worktreePath)}</span>
|
||||
{isDirty && diffStats && (
|
||||
<span className="text-xs text-yellow-500">
|
||||
{formatDiffStats(diffStats.additions, diffStats.deletions)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
right={
|
||||
<div className="flex items-center gap-1">
|
||||
{enableComments && sessionId && comments.length > 0 && (
|
||||
<div className="flex items-center gap-1 px-2 py-1 text-xs text-primary">
|
||||
<IconMessagePlus className="h-3.5 w-3.5" />
|
||||
@@ -583,14 +579,15 @@ export function CodeMirrorCodeEditor({
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* CodeMirror editor */}
|
||||
<div className="flex-1 overflow-hidden relative">
|
||||
<CodeMirror
|
||||
ref={editorRef}
|
||||
value={content}
|
||||
value={initialContent}
|
||||
height="100%"
|
||||
theme={vscodeDark}
|
||||
extensions={extensions}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState, useRef } from 'react';
|
||||
import { useCallback, useEffect, useState, useRef } from 'react';
|
||||
import Editor, { type OnMount, type OnChange } from '@monaco-editor/react';
|
||||
import type { editor as monacoEditor, IDisposable } from 'monaco-editor';
|
||||
import { useTheme } from 'next-themes';
|
||||
@@ -13,15 +13,18 @@ import { getMonacoLanguage } from '@/lib/editor/language-map';
|
||||
import { EDITOR_FONT_FAMILY, EDITOR_FONT_SIZE } from '@/lib/theme/editor-theme';
|
||||
import { useDiffCommentsStore, useFileComments } from '@/lib/state/slices/diff-comments/diff-comments-slice';
|
||||
import type { DiffComment } from '@/lib/diff/types';
|
||||
import { computeLineDiffStats } from '@/lib/diff';
|
||||
import { useToast } from '@/components/toast-provider';
|
||||
import { EditorCommentPopover } from '@/components/task/editor-comment-popover';
|
||||
import { CommentViewPopover } from '@/components/task/comment-view-popover';
|
||||
import { diffLines } from 'diff';
|
||||
import { FileActionsDropdown } from '@/components/editors/file-actions-dropdown';
|
||||
import { PanelHeaderBarSplit } from '@/components/task/panel-primitives';
|
||||
import { useAppStore } from '@/components/state-provider';
|
||||
import { useLsp } from '@/hooks/use-lsp';
|
||||
import type { LspStatus } from '@/lib/lsp/lsp-client-manager';
|
||||
import { lspClientManager } from '@/lib/lsp/lsp-client-manager';
|
||||
import { useDockviewStore } from '@/lib/state/dockview-store';
|
||||
import { consumePendingCursorPosition } from '@/hooks/use-file-editors';
|
||||
import { initMonacoThemes } from './monaco-init';
|
||||
|
||||
@@ -29,7 +32,6 @@ initMonacoThemes();
|
||||
|
||||
type MonacoCodeEditorProps = {
|
||||
path: string;
|
||||
content: string;
|
||||
originalContent: string;
|
||||
isDirty: boolean;
|
||||
isSaving: boolean;
|
||||
@@ -136,7 +138,6 @@ function LspStatusButton({
|
||||
|
||||
export function MonacoCodeEditor({
|
||||
path,
|
||||
content,
|
||||
originalContent,
|
||||
isDirty,
|
||||
isSaving,
|
||||
@@ -148,6 +149,14 @@ export function MonacoCodeEditor({
|
||||
onDelete,
|
||||
}: MonacoCodeEditorProps) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
// Read initial content from the store once (not reactive — Monaco manages content internally)
|
||||
const [initialContent] = useState(
|
||||
() => useDockviewStore.getState().openFiles.get(path)?.content ?? ''
|
||||
);
|
||||
// Track current content via ref for non-React uses (diff decorations, LSP sync, diff stats)
|
||||
const contentRef = useRef(initialContent);
|
||||
|
||||
const [wrapEnabled, setWrapEnabled] = useState(true);
|
||||
const [textSelection, setTextSelection] = useState<TextSelection>(null);
|
||||
const [floatingButtonPos, setFloatingButtonPos] = useState<FloatingButtonPosition>(null);
|
||||
@@ -157,6 +166,8 @@ export function MonacoCodeEditor({
|
||||
const editorRef = useRef<monacoEditor.IStandaloneCodeEditor | null>(null);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const mousePositionRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
|
||||
const onSaveRef = useRef(onSave);
|
||||
onSaveRef.current = onSave;
|
||||
const decorationsRef = useRef<monacoEditor.IEditorDecorationsCollection | null>(null);
|
||||
const diffDecorationsRef = useRef<monacoEditor.IEditorDecorationsCollection | null>(null);
|
||||
const disposablesRef = useRef<IDisposable[]>([]);
|
||||
@@ -182,7 +193,7 @@ export function MonacoCodeEditor({
|
||||
const documentUri = `file://${monacoPath}`;
|
||||
useEffect(() => {
|
||||
if (!hasLspActive || !lspSessionId || !lspLanguage) return;
|
||||
lspClientManager.openDocument(lspSessionId, lspLanguage, documentUri, language, content);
|
||||
lspClientManager.openDocument(lspSessionId, lspLanguage, documentUri, language, contentRef.current);
|
||||
return () => {
|
||||
lspClientManager.closeDocument(lspSessionId, lspLanguage, documentUri);
|
||||
};
|
||||
@@ -190,18 +201,29 @@ export function MonacoCodeEditor({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hasLspActive, lspSessionId, lspLanguage, documentUri, language]);
|
||||
|
||||
// LSP document change: notify server of content changes (debounced)
|
||||
// LSP document change: notify server of content changes via Monaco model events (not React props)
|
||||
const changeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(() => {
|
||||
if (!hasLspActive || !lspSessionId || !lspLanguage) return;
|
||||
if (changeTimerRef.current) clearTimeout(changeTimerRef.current);
|
||||
changeTimerRef.current = setTimeout(() => {
|
||||
lspClientManager.changeDocument(lspSessionId, lspLanguage, documentUri, content);
|
||||
}, 300);
|
||||
const editor = editorRef.current;
|
||||
if (!editor || !hasLspActive || !lspSessionId || !lspLanguage) return;
|
||||
|
||||
const model = editor.getModel();
|
||||
if (!model) return;
|
||||
|
||||
const disposable = model.onDidChangeContent(() => {
|
||||
if (changeTimerRef.current) clearTimeout(changeTimerRef.current);
|
||||
changeTimerRef.current = setTimeout(() => {
|
||||
lspClientManager.changeDocument(lspSessionId, lspLanguage, documentUri, contentRef.current);
|
||||
}, 300);
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (changeTimerRef.current) clearTimeout(changeTimerRef.current);
|
||||
disposable.dispose();
|
||||
};
|
||||
}, [hasLspActive, lspSessionId, lspLanguage, documentUri, content]);
|
||||
// Re-run when LSP readiness or editor instance changes
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hasLspActive, lspSessionId, lspLanguage, documentUri]);
|
||||
|
||||
// Toast on LSP status changes
|
||||
const lspStateForToast = lspStatus.state;
|
||||
@@ -234,9 +256,25 @@ export function MonacoCodeEditor({
|
||||
return () => document.removeEventListener('mousemove', handleMouseMove);
|
||||
}, []);
|
||||
|
||||
// Cmd/Ctrl+S to save — uses DOM keydown on wrapper instead of Monaco's addCommand
|
||||
// because addCommand registers globally and conflicts with multiple editor instances.
|
||||
useEffect(() => {
|
||||
const wrapper = wrapperRef.current;
|
||||
if (!wrapper) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onSaveRef.current();
|
||||
}
|
||||
};
|
||||
wrapper.addEventListener('keydown', handler);
|
||||
return () => wrapper.removeEventListener('keydown', handler);
|
||||
}, []);
|
||||
|
||||
// Handle editor mount
|
||||
const handleEditorDidMount: OnMount = useCallback(
|
||||
(editor) => {
|
||||
(editor, monaco) => {
|
||||
editorRef.current = editor;
|
||||
decorationsRef.current = editor.createDecorationsCollection([]);
|
||||
diffDecorationsRef.current = editor.createDecorationsCollection([]);
|
||||
@@ -248,14 +286,9 @@ export function MonacoCodeEditor({
|
||||
editor.revealLineInCenter(pendingPos.line);
|
||||
}
|
||||
|
||||
// Cmd/Ctrl+S to save
|
||||
editor.addCommand(
|
||||
(window as { monaco?: typeof import('monaco-editor') }).monaco!.KeyMod.CtrlCmd |
|
||||
(window as { monaco?: typeof import('monaco-editor') }).monaco!.KeyCode.KeyS,
|
||||
() => {
|
||||
onSave();
|
||||
}
|
||||
);
|
||||
// Cmd/Ctrl+S is handled via DOM keydown on the wrapper (see useEffect below)
|
||||
// because Monaco's addCommand registers globally and conflicts when
|
||||
// multiple editor instances exist.
|
||||
|
||||
// Selection change listener for comments
|
||||
if (enableComments && sessionId) {
|
||||
@@ -300,7 +333,7 @@ export function MonacoCodeEditor({
|
||||
});
|
||||
disposablesRef.current.push(glyphDisposable);
|
||||
},
|
||||
[path, onSave, enableComments, sessionId, comments]
|
||||
[path, enableComments, sessionId, comments]
|
||||
);
|
||||
|
||||
// Cleanup disposables
|
||||
@@ -350,8 +383,9 @@ export function MonacoCodeEditor({
|
||||
decorationsRef.current.set(decorations);
|
||||
}, [comments]);
|
||||
|
||||
// Diff gutter indicators
|
||||
useEffect(() => {
|
||||
// Diff gutter indicators — driven by Monaco model change events, not React props.
|
||||
// We compute decorations using contentRef (updated by onChange) + originalContent prop.
|
||||
const updateDiffDecorations = useCallback(() => {
|
||||
if (!diffDecorationsRef.current || !editorRef.current) return;
|
||||
|
||||
if (!showDiffIndicators || !isDirty || !originalContent) {
|
||||
@@ -359,7 +393,8 @@ export function MonacoCodeEditor({
|
||||
return;
|
||||
}
|
||||
|
||||
const changes = diffLines(originalContent, content);
|
||||
const currentContent = contentRef.current;
|
||||
const changes = diffLines(originalContent, currentContent);
|
||||
const decorations: monacoEditor.IModelDeltaDecoration[] = [];
|
||||
let currentLine = 1;
|
||||
|
||||
@@ -368,10 +403,8 @@ export function MonacoCodeEditor({
|
||||
const lineCount = change.count ?? 0;
|
||||
|
||||
if (change.removed) {
|
||||
// Check if next change is an addition (modified lines)
|
||||
const next = changes[i + 1];
|
||||
if (next && next.added) {
|
||||
// Modified: removed + added pair
|
||||
const addedLineCount = next.count ?? 0;
|
||||
for (let j = 0; j < addedLineCount; j++) {
|
||||
decorations.push({
|
||||
@@ -380,9 +413,8 @@ export function MonacoCodeEditor({
|
||||
});
|
||||
}
|
||||
currentLine += addedLineCount;
|
||||
i++; // skip the added part
|
||||
i++;
|
||||
} else {
|
||||
// Standalone deletion: red indicator at previous line
|
||||
const indicatorLine = Math.max(1, currentLine - 1);
|
||||
decorations.push({
|
||||
range: { startLineNumber: indicatorLine, startColumn: 1, endLineNumber: indicatorLine, endColumn: 1 },
|
||||
@@ -390,7 +422,6 @@ export function MonacoCodeEditor({
|
||||
});
|
||||
}
|
||||
} else if (change.added) {
|
||||
// Standalone addition
|
||||
for (let j = 0; j < lineCount; j++) {
|
||||
decorations.push({
|
||||
range: { startLineNumber: currentLine + j, startColumn: 1, endLineNumber: currentLine + j, endColumn: 1 },
|
||||
@@ -399,13 +430,34 @@ export function MonacoCodeEditor({
|
||||
}
|
||||
currentLine += lineCount;
|
||||
} else {
|
||||
// Unchanged
|
||||
currentLine += lineCount;
|
||||
}
|
||||
}
|
||||
|
||||
diffDecorationsRef.current.set(decorations);
|
||||
}, [content, originalContent, showDiffIndicators, isDirty]);
|
||||
}, [originalContent, showDiffIndicators, isDirty]);
|
||||
|
||||
// Update diff decorations on model content changes (debounced) and when settings change
|
||||
const diffTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(() => {
|
||||
// Update immediately for settings changes (toggle, save)
|
||||
updateDiffDecorations();
|
||||
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
const model = editor.getModel();
|
||||
if (!model) return;
|
||||
|
||||
const disposable = model.onDidChangeContent(() => {
|
||||
if (diffTimerRef.current) clearTimeout(diffTimerRef.current);
|
||||
diffTimerRef.current = setTimeout(updateDiffDecorations, 150);
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (diffTimerRef.current) clearTimeout(diffTimerRef.current);
|
||||
disposable.dispose();
|
||||
};
|
||||
}, [updateDiffDecorations]);
|
||||
|
||||
// Show floating button after mouse up
|
||||
useEffect(() => {
|
||||
@@ -467,7 +519,10 @@ export function MonacoCodeEditor({
|
||||
|
||||
const handleChange: OnChange = useCallback(
|
||||
(value) => {
|
||||
if (value !== undefined) onChange(value);
|
||||
if (value !== undefined) {
|
||||
contentRef.current = value;
|
||||
onChange(value);
|
||||
}
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
@@ -534,37 +589,49 @@ export function MonacoCodeEditor({
|
||||
|
||||
const handleCommentViewClose = useCallback(() => setCommentView(null), []);
|
||||
|
||||
// Diff stats
|
||||
const diffStats = useMemo(() => {
|
||||
if (!isDirty) return null;
|
||||
const originalLines = originalContent.split('\n');
|
||||
const currentLines = content.split('\n');
|
||||
let additions = 0;
|
||||
let deletions = 0;
|
||||
const maxLen = Math.max(originalLines.length, currentLines.length);
|
||||
for (let i = 0; i < maxLen; i++) {
|
||||
const origLine = originalLines[i];
|
||||
const currLine = currentLines[i];
|
||||
if (origLine === undefined && currLine !== undefined) additions++;
|
||||
else if (origLine !== undefined && currLine === undefined) deletions++;
|
||||
else if (origLine !== currLine) { additions++; deletions++; }
|
||||
}
|
||||
return { additions, deletions };
|
||||
}, [isDirty, content, originalContent]);
|
||||
// Diff stats — updated via Monaco model events, not React props
|
||||
const [diffStats, setDiffStats] = useState<{ additions: number; deletions: number } | null>(null);
|
||||
const computeDiffStats = useCallback(() => {
|
||||
if (!isDirty) { setDiffStats(null); return; }
|
||||
setDiffStats(computeLineDiffStats(originalContent, contentRef.current));
|
||||
}, [isDirty, originalContent]);
|
||||
|
||||
const statsTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(() => {
|
||||
computeDiffStats();
|
||||
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
const model = editor.getModel();
|
||||
if (!model) return;
|
||||
|
||||
const disposable = model.onDidChangeContent(() => {
|
||||
if (statsTimerRef.current) clearTimeout(statsTimerRef.current);
|
||||
statsTimerRef.current = setTimeout(computeDiffStats, 300);
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (statsTimerRef.current) clearTimeout(statsTimerRef.current);
|
||||
disposable.dispose();
|
||||
};
|
||||
}, [computeDiffStats]);
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className="flex h-full flex-col rounded-lg">
|
||||
{/* Editor header */}
|
||||
<div className="flex items-center justify-between px-2 border-foreground/10 border-b">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="font-mono">{toRelativePath(path, worktreePath)}</span>
|
||||
{isDirty && diffStats && (
|
||||
<span className="text-xs text-yellow-500">
|
||||
{formatDiffStats(diffStats.additions, diffStats.deletions)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<PanelHeaderBarSplit
|
||||
left={
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="font-mono">{toRelativePath(path, worktreePath)}</span>
|
||||
{isDirty && diffStats && (
|
||||
<span className="text-xs text-yellow-500">
|
||||
{formatDiffStats(diffStats.additions, diffStats.deletions)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
right={
|
||||
<div className="flex items-center gap-1">
|
||||
{enableComments && sessionId && comments.length > 0 && (
|
||||
<div className="flex items-center gap-1 px-2 py-1 text-xs text-primary">
|
||||
<IconMessagePlus className="h-3.5 w-3.5" />
|
||||
@@ -638,8 +705,9 @@ export function MonacoCodeEditor({
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Monaco editor */}
|
||||
<div className="flex-1 overflow-hidden relative">
|
||||
@@ -647,10 +715,11 @@ export function MonacoCodeEditor({
|
||||
height="100%"
|
||||
language={language}
|
||||
path={monacoPath}
|
||||
value={content}
|
||||
defaultValue={initialContent}
|
||||
theme={resolvedTheme === 'dark' ? 'kandev-dark' : 'kandev-light'}
|
||||
onChange={handleChange}
|
||||
onMount={handleEditorDidMount}
|
||||
keepCurrentModel
|
||||
options={{
|
||||
fontSize: EDITOR_FONT_SIZE,
|
||||
fontFamily: EDITOR_FONT_FAMILY,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
||||
import { DiffEditor, type DiffOnMount } from '@monaco-editor/react';
|
||||
import type { editor as monacoEditor } from 'monaco-editor';
|
||||
import { useTheme } from 'next-themes';
|
||||
@@ -117,6 +117,16 @@ export function MonacoDiffViewer({
|
||||
const diffEditorRef = useRef<monacoEditor.IStandaloneDiffEditor | null>(null);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Fix: reset the DiffEditor model BEFORE @monaco-editor/react's useEffect cleanup
|
||||
// disposes the models. useLayoutEffect cleanup runs before useEffect cleanup,
|
||||
// so the DiffEditorWidget releases model references before models are disposed.
|
||||
// This prevents "TextModel got disposed before DiffEditorWidget model got reset".
|
||||
useLayoutEffect(() => {
|
||||
return () => {
|
||||
try { diffEditorRef.current?.setModel(null); } catch { /* already disposed */ }
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Context menu state
|
||||
type ContextMenuState = {
|
||||
x: number;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { DiffEditor } from '@monaco-editor/react';
|
||||
import { useCallback, useLayoutEffect, useMemo, useRef } from 'react';
|
||||
import { DiffEditor, type DiffOnMount } from '@monaco-editor/react';
|
||||
import type { editor as monacoEditor } from 'monaco-editor';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { FileDiffData } from '@/lib/diff/types';
|
||||
@@ -19,6 +20,18 @@ type MonacoInlineDiffProps = {
|
||||
export function MonacoInlineDiff({ data, className }: MonacoInlineDiffProps) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const language = getMonacoLanguage(data.filePath);
|
||||
const diffEditorRef = useRef<monacoEditor.IStandaloneDiffEditor | null>(null);
|
||||
|
||||
const handleMount: DiffOnMount = useCallback((editor) => {
|
||||
diffEditorRef.current = editor;
|
||||
}, []);
|
||||
|
||||
// Fix: reset model before @monaco-editor/react's cleanup disposes models
|
||||
useLayoutEffect(() => {
|
||||
return () => {
|
||||
try { diffEditorRef.current?.setModel(null); } catch { /* already disposed */ }
|
||||
};
|
||||
}, []);
|
||||
|
||||
const { original, modified } = useMemo(() => {
|
||||
if (data.oldContent || data.newContent) {
|
||||
@@ -66,6 +79,7 @@ export function MonacoInlineDiff({ data, className }: MonacoInlineDiffProps) {
|
||||
original={original}
|
||||
modified={modified}
|
||||
theme={resolvedTheme === 'dark' ? 'kandev-dark' : 'kandev-light'}
|
||||
onMount={handleMount}
|
||||
options={{
|
||||
readOnly: true,
|
||||
fontSize: 11,
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { IDockviewPanelProps } from 'dockview-react';
|
||||
import { IconRefresh, IconExternalLink } from '@tabler/icons-react';
|
||||
import { Button } from '@kandev/ui/button';
|
||||
import { Input } from '@kandev/ui/input';
|
||||
import { PanelRoot, PanelBody, PanelToolbar } from './panel-primitives';
|
||||
import { PanelRoot, PanelBody, PanelHeaderBar } from './panel-primitives';
|
||||
import { useAppStore } from '@/components/state-provider';
|
||||
import { detectPreviewUrlFromOutput } from '@/lib/preview-url-detector';
|
||||
|
||||
@@ -62,7 +62,7 @@ export const BrowserPanel = memo(function BrowserPanel(
|
||||
|
||||
return (
|
||||
<PanelRoot>
|
||||
<PanelToolbar>
|
||||
<PanelHeaderBar>
|
||||
<Input
|
||||
value={displayDraft}
|
||||
onChange={(e) => setUrlDraft(e.target.value)}
|
||||
@@ -95,7 +95,7 @@ export const BrowserPanel = memo(function BrowserPanel(
|
||||
>
|
||||
<IconRefresh className="h-4 w-4" />
|
||||
</Button>
|
||||
</PanelToolbar>
|
||||
</PanelHeaderBar>
|
||||
|
||||
<PanelBody padding={false} scroll={false}>
|
||||
{showIframeDelayed ? (
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,177 @@
|
||||
'use client';
|
||||
|
||||
import { memo, useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import type { IDockviewPanelProps } from 'dockview-react';
|
||||
import { IconLoader2 } from '@tabler/icons-react';
|
||||
import { PanelRoot, PanelBody } from './panel-primitives';
|
||||
import { FileDiffViewer } from '@/components/diff';
|
||||
import { useAppStore } from '@/components/state-provider';
|
||||
import { useSessionCommits } from '@/hooks/domains/session/use-session-commits';
|
||||
import { getWebSocketClient } from '@/lib/ws/connection';
|
||||
import { useToast } from '@/components/toast-provider';
|
||||
import { usePanelActions } from '@/hooks/use-panel-actions';
|
||||
import type { FileInfo } from '@/lib/state/store';
|
||||
|
||||
type CommitDetailParams = {
|
||||
commitSha: string;
|
||||
};
|
||||
|
||||
function formatRelativeTime(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMin = Math.floor(diffMs / 60000);
|
||||
if (diffMin < 1) return 'just now';
|
||||
if (diffMin < 60) return `${diffMin} minute${diffMin !== 1 ? 's' : ''} ago`;
|
||||
const diffHours = Math.floor(diffMin / 60);
|
||||
if (diffHours < 24) return `${diffHours} hour${diffHours !== 1 ? 's' : ''} ago`;
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
if (diffDays < 30) return `${diffDays} day${diffDays !== 1 ? 's' : ''} ago`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
function getInitials(name: string): string {
|
||||
return name
|
||||
.split(/\s+/)
|
||||
.map((w) => w[0])
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.join('')
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
const CommitDetailPanel = memo(function CommitDetailPanel(
|
||||
props: IDockviewPanelProps<CommitDetailParams>,
|
||||
) {
|
||||
const commitSha = props.params.commitSha;
|
||||
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
|
||||
const { commits } = useSessionCommits(activeSessionId ?? null);
|
||||
const { toast } = useToast();
|
||||
const { openFile } = usePanelActions();
|
||||
|
||||
const [files, setFiles] = useState<Record<string, FileInfo> | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Find commit metadata
|
||||
const commit = useMemo(
|
||||
() => commits.find((c) => c.commit_sha === commitSha),
|
||||
[commits, commitSha],
|
||||
);
|
||||
|
||||
// Update tab title
|
||||
useEffect(() => {
|
||||
if (commit) {
|
||||
const shortSha = commitSha.slice(0, 7);
|
||||
const msg = commit.commit_message.length > 30
|
||||
? commit.commit_message.slice(0, 30) + '...'
|
||||
: commit.commit_message;
|
||||
props.api.setTitle(`${shortSha} ${msg}`);
|
||||
}
|
||||
}, [commit, commitSha, props.api]);
|
||||
|
||||
// Fetch diff
|
||||
const fetchDiff = useCallback(async () => {
|
||||
if (!activeSessionId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const ws = getWebSocketClient();
|
||||
if (!ws) return;
|
||||
const response = await ws.request<{ success: boolean; files: Record<string, FileInfo> }>(
|
||||
'session.commit_diff',
|
||||
{ session_id: activeSessionId, commit_sha: commitSha },
|
||||
10000,
|
||||
);
|
||||
if (response?.success && response.files) {
|
||||
setFiles(response.files);
|
||||
}
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Failed to load commit diff',
|
||||
description: err instanceof Error ? err.message : 'An unexpected error occurred',
|
||||
variant: 'error',
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [activeSessionId, commitSha, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDiff();
|
||||
}, [fetchDiff]);
|
||||
|
||||
const fileEntries = useMemo(() => {
|
||||
if (!files) return [];
|
||||
return Object.entries(files).sort(([a], [b]) => a.localeCompare(b));
|
||||
}, [files]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<PanelRoot>
|
||||
<PanelBody>
|
||||
<div className="flex items-center justify-center h-full gap-2 text-muted-foreground text-sm">
|
||||
<IconLoader2 className="h-4 w-4 animate-spin" />
|
||||
Loading commit...
|
||||
</div>
|
||||
</PanelBody>
|
||||
</PanelRoot>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PanelRoot>
|
||||
<PanelBody padding={false} scroll>
|
||||
<div className="p-3">
|
||||
{/* Commit header */}
|
||||
{commit && (
|
||||
<div className="mb-4 pb-3 border-b border-border">
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Author avatar */}
|
||||
<div className="flex items-center justify-center size-8 rounded-full bg-muted text-xs font-semibold text-muted-foreground shrink-0">
|
||||
{getInitials(commit.author_name)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-foreground leading-snug">
|
||||
{commit.commit_message}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{commit.author_name}
|
||||
<span className="mx-1.5">·</span>
|
||||
{formatRelativeTime(commit.committed_at)}
|
||||
<span className="mx-1.5">·</span>
|
||||
<code className="font-mono text-[11px]">{commitSha.slice(0, 7)}</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File diffs */}
|
||||
{fileEntries.length === 0 && !loading && (
|
||||
<div className="text-sm text-muted-foreground text-center py-8">
|
||||
No files in this commit
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fileEntries.map(([path, file]) => (
|
||||
<div key={path} className="mb-2">
|
||||
{file.diff ? (
|
||||
<FileDiffViewer
|
||||
filePath={path}
|
||||
diff={file.diff}
|
||||
status={file.status}
|
||||
onOpenFile={openFile}
|
||||
/>
|
||||
) : (
|
||||
<div className="px-3 py-2 text-xs text-muted-foreground">
|
||||
{path} — binary or empty diff
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</PanelBody>
|
||||
</PanelRoot>
|
||||
);
|
||||
});
|
||||
|
||||
export { CommitDetailPanel };
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, memo } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState, memo } from 'react';
|
||||
import {
|
||||
DockviewReact,
|
||||
DockviewDefaultTab,
|
||||
@@ -43,6 +43,8 @@ import { startProcess } from '@/lib/api';
|
||||
import { createUserShell } from '@/lib/api/domains/user-shell-api';
|
||||
import { useRepositoryScripts } from '@/hooks/domains/workspace/use-repository-scripts';
|
||||
import { useLspFileOpener } from '@/hooks/use-lsp-file-opener';
|
||||
import { useSessionGitStatus } from '@/hooks/domains/session/use-session-git-status';
|
||||
import { useSessionCommits } from '@/hooks/domains/session/use-session-commits';
|
||||
|
||||
// Panel components
|
||||
import { TaskSessionSidebar, NewTaskButton } from './task-session-sidebar';
|
||||
@@ -54,7 +56,15 @@ import { TaskPlanPanel } from './task-plan-panel';
|
||||
import { FileEditorPanel } from './file-editor-panel';
|
||||
import { TerminalPanel } from './terminal-panel';
|
||||
import { BrowserPanel } from './browser-panel';
|
||||
import { CommitDetailPanel } from './commit-detail-panel';
|
||||
import { PreviewController } from './preview/preview-controller';
|
||||
import { ReviewDialog } from '@/components/review/review-dialog';
|
||||
import { useCumulativeDiff } from '@/hooks/domains/session/use-cumulative-diff';
|
||||
import { useGitOperations } from '@/hooks/use-git-operations';
|
||||
import { formatReviewCommentsAsMarkdown } from '@/components/task/chat/messages/review-comments-attachment';
|
||||
import { getWebSocketClient } from '@/lib/ws/connection';
|
||||
import { useToast } from '@/components/toast-provider';
|
||||
import type { DiffComment } from '@/lib/diff/types';
|
||||
|
||||
import type { Repository, RepositoryScript, Task, ProcessInfo } from '@/lib/types/http';
|
||||
import type { ProcessStatusEntry } from '@/lib/state/slices';
|
||||
@@ -131,14 +141,50 @@ function DiffViewerPanelComponent() {
|
||||
);
|
||||
}
|
||||
|
||||
function ChangesPanelWrapper() {
|
||||
function ChangesPanelWrapper(props: IDockviewPanelProps) {
|
||||
const addDiffViewerPanel = useDockviewStore((s) => s.addDiffViewerPanel);
|
||||
const addCommitDetailPanel = useDockviewStore((s) => s.addCommitDetailPanel);
|
||||
const { openFile } = useFileEditors();
|
||||
|
||||
const handleSelectDiff = useCallback((path: string, content?: string) => {
|
||||
addDiffViewerPanel(path, content);
|
||||
// Dynamic title with file count
|
||||
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
|
||||
const gitStatus = useSessionGitStatus(activeSessionId);
|
||||
const { commits } = useSessionCommits(activeSessionId ?? null);
|
||||
|
||||
const fileCount = gitStatus?.files ? Object.keys(gitStatus.files).length : 0;
|
||||
const totalCount = fileCount + commits.length;
|
||||
|
||||
useEffect(() => {
|
||||
const title = totalCount > 0 ? `Changes (${totalCount})` : 'Changes';
|
||||
if (props.api.title !== title) {
|
||||
props.api.setTitle(title);
|
||||
}
|
||||
}, [totalCount, props.api]);
|
||||
|
||||
const handleOpenFile = useCallback((path: string) => {
|
||||
openFile(path);
|
||||
}, [openFile]);
|
||||
|
||||
const handleOpenCommitDetail = useCallback((sha: string) => {
|
||||
addCommitDetailPanel(sha);
|
||||
}, [addCommitDetailPanel]);
|
||||
|
||||
const handleOpenDiffAll = useCallback(() => {
|
||||
addDiffViewerPanel();
|
||||
}, [addDiffViewerPanel]);
|
||||
|
||||
return <ChangesPanel onSelectDiff={handleSelectDiff} />;
|
||||
const handleOpenReview = useCallback(() => {
|
||||
window.dispatchEvent(new CustomEvent('open-review-dialog'));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ChangesPanel
|
||||
onOpenFile={handleOpenFile}
|
||||
onOpenCommitDetail={handleOpenCommitDetail}
|
||||
onOpenDiffAll={handleOpenDiffAll}
|
||||
onOpenReview={handleOpenReview}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FilesPanelWrapper() {
|
||||
@@ -165,6 +211,7 @@ const components: Record<string, React.FunctionComponent<IDockviewPanelProps>> =
|
||||
chat: ChatPanel,
|
||||
'diff-viewer': DiffViewerPanelComponent,
|
||||
'file-editor': FileEditorPanel,
|
||||
'commit-detail': CommitDetailPanel,
|
||||
changes: ChangesPanelWrapper,
|
||||
files: FilesPanelWrapper,
|
||||
terminal: TerminalPanel,
|
||||
@@ -501,6 +548,47 @@ export const DockviewDesktopLayout = memo(function DockviewDesktopLayout({
|
||||
const effectiveSessionId = useAppStore((state) => state.tasks.activeSessionId) ?? sessionId ?? null;
|
||||
const hasDevScript = Boolean(repository?.dev_script?.trim());
|
||||
|
||||
// Review dialog state (lives here so it's available from any panel)
|
||||
const [reviewDialogOpen, setReviewDialogOpen] = useState(false);
|
||||
const { toast } = useToast();
|
||||
const activeTaskId = useAppStore((state) => state.tasks.activeTaskId);
|
||||
const taskTitle = useAppStore((state) => {
|
||||
if (!activeTaskId) return undefined;
|
||||
return state.kanban.tasks.find((t: { id: string }) => t.id === activeTaskId)?.title;
|
||||
});
|
||||
const baseBranch = useAppStore((state) => {
|
||||
if (!effectiveSessionId) return undefined;
|
||||
return state.taskSessions.items[effectiveSessionId]?.base_branch;
|
||||
});
|
||||
const reviewGitStatus = useSessionGitStatus(effectiveSessionId);
|
||||
const { diff: reviewCumulativeDiff } = useCumulativeDiff(effectiveSessionId);
|
||||
const { openFile: reviewOpenFile } = useFileEditors();
|
||||
|
||||
const handleReviewSendComments = useCallback(
|
||||
(comments: DiffComment[]) => {
|
||||
if (!activeTaskId || !effectiveSessionId || comments.length === 0) return;
|
||||
const client = getWebSocketClient();
|
||||
if (!client) return;
|
||||
const markdown = formatReviewCommentsAsMarkdown(comments);
|
||||
client.request('message.add', {
|
||||
task_id: activeTaskId,
|
||||
session_id: effectiveSessionId,
|
||||
content: markdown,
|
||||
}, 10000).catch(() => {
|
||||
toast({ title: 'Failed to send comments', variant: 'error' });
|
||||
});
|
||||
setReviewDialogOpen(false);
|
||||
},
|
||||
[activeTaskId, effectiveSessionId, toast]
|
||||
);
|
||||
|
||||
// Listen for open-review-dialog events from any panel
|
||||
useEffect(() => {
|
||||
const handler = () => setReviewDialogOpen(true);
|
||||
window.addEventListener('open-review-dialog', handler);
|
||||
return () => window.removeEventListener('open-review-dialog', handler);
|
||||
}, []);
|
||||
|
||||
// Connect LSP Go-to-Definition navigation to dockview file tabs
|
||||
useLspFileOpener();
|
||||
|
||||
@@ -693,6 +781,19 @@ export const DockviewDesktopLayout = memo(function DockviewDesktopLayout({
|
||||
defaultRenderer="always"
|
||||
className="h-full"
|
||||
/>
|
||||
{effectiveSessionId && (
|
||||
<ReviewDialog
|
||||
open={reviewDialogOpen}
|
||||
onOpenChange={setReviewDialogOpen}
|
||||
sessionId={effectiveSessionId}
|
||||
baseBranch={baseBranch}
|
||||
taskTitle={taskTitle}
|
||||
onSendComments={handleReviewSendComments}
|
||||
onOpenFile={reviewOpenFile}
|
||||
gitStatusFiles={reviewGitStatus?.files ?? null}
|
||||
cumulativeDiff={reviewCumulativeDiff}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { memo } from 'react';
|
||||
import { useEditorProvider } from '@/hooks/use-editor-resolver';
|
||||
import { MonacoCodeEditor } from '@/components/editors/monaco/monaco-code-editor';
|
||||
import { CodeMirrorCodeEditor } from '@/components/editors/codemirror/codemirror-code-editor';
|
||||
|
||||
type FileEditorContentProps = {
|
||||
export type FileEditorContentProps = {
|
||||
path: string;
|
||||
content: string;
|
||||
originalContent: string;
|
||||
isDirty: boolean;
|
||||
isSaving: boolean;
|
||||
@@ -18,9 +18,9 @@ type FileEditorContentProps = {
|
||||
onDelete?: () => void;
|
||||
};
|
||||
|
||||
export function FileEditorContent(props: FileEditorContentProps) {
|
||||
export const FileEditorContent = memo(function FileEditorContent(props: FileEditorContentProps) {
|
||||
const provider = useEditorProvider('code-editor');
|
||||
return provider === 'monaco'
|
||||
? <MonacoCodeEditor {...props} />
|
||||
: <CodeMirrorCodeEditor {...props} />;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { memo, useEffect, useRef } from 'react';
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { IDockviewPanelProps } from 'dockview-react';
|
||||
import { PanelRoot, PanelBody } from './panel-primitives';
|
||||
import { FileEditorContent } from './file-editor-content';
|
||||
@@ -18,8 +18,15 @@ export const FileEditorPanel = memo(function FileEditorPanel(
|
||||
props: IDockviewPanelProps<{ path: string }>
|
||||
) {
|
||||
const path = props.params.path;
|
||||
const fileState = useDockviewStore((s) => s.openFiles.get(path));
|
||||
|
||||
// Subscribe to individual metadata fields — NOT the full fileState object.
|
||||
// This prevents re-renders on every keystroke (content changes don't trigger re-render).
|
||||
const hasFile = useDockviewStore((s) => s.openFiles.has(path));
|
||||
const isDirty = useDockviewStore((s) => s.openFiles.get(path)?.isDirty ?? false);
|
||||
const isBinary = useDockviewStore((s) => s.openFiles.get(path)?.isBinary ?? false);
|
||||
const originalContent = useDockviewStore((s) => s.openFiles.get(path)?.originalContent ?? '');
|
||||
const setFileState = useDockviewStore((s) => s.setFileState);
|
||||
|
||||
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
|
||||
const activeSession = useAppStore((state) =>
|
||||
activeSessionId ? state.taskSessions.items[activeSessionId] ?? null : null
|
||||
@@ -29,7 +36,7 @@ export const FileEditorPanel = memo(function FileEditorPanel(
|
||||
// Self-load file content when state is missing (e.g. after hot reload clears the store)
|
||||
const loadingRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (fileState || loadingRef.current || !activeSessionId) return;
|
||||
if (hasFile || loadingRef.current || !activeSessionId) return;
|
||||
loadingRef.current = true;
|
||||
|
||||
const client = getWebSocketClient();
|
||||
@@ -52,9 +59,20 @@ export const FileEditorPanel = memo(function FileEditorPanel(
|
||||
})
|
||||
.catch(() => { /* will stay on loading state */ })
|
||||
.finally(() => { loadingRef.current = false; });
|
||||
}, [fileState, activeSessionId, path, setFileState]);
|
||||
}, [hasFile, activeSessionId, path, setFileState]);
|
||||
|
||||
if (!fileState) {
|
||||
// Stable callbacks — avoid creating new arrow functions on every render
|
||||
const onChange = useCallback(
|
||||
(newContent: string) => handleFileChange(path, newContent),
|
||||
[handleFileChange, path]
|
||||
);
|
||||
const onSave = useCallback(() => saveFile(path), [saveFile, path]);
|
||||
const onDelete = useCallback(() => deleteFile(path), [deleteFile, path]);
|
||||
|
||||
// Read image content imperatively (only needed for non-editable image display)
|
||||
const [imageContent, setImageContent] = useState<string | null>(null);
|
||||
|
||||
if (!hasFile) {
|
||||
return (
|
||||
<PanelRoot>
|
||||
<PanelBody padding={false} scroll={false} className="flex items-center justify-center text-muted-foreground text-sm">
|
||||
@@ -65,19 +83,26 @@ export const FileEditorPanel = memo(function FileEditorPanel(
|
||||
}
|
||||
|
||||
const extCategory = getFileCategory(path);
|
||||
const category = fileState.isBinary
|
||||
const category = isBinary
|
||||
? extCategory === 'image'
|
||||
? 'image'
|
||||
: 'binary'
|
||||
: 'text';
|
||||
|
||||
if (category === 'image') {
|
||||
// Read content imperatively for image display (not edited, so no need for reactive subscription)
|
||||
const content = imageContent ?? (() => {
|
||||
const c = useDockviewStore.getState().openFiles.get(path)?.content ?? '';
|
||||
// Schedule state update outside of render to avoid setState-during-render
|
||||
queueMicrotask(() => setImageContent(c));
|
||||
return c;
|
||||
})();
|
||||
return (
|
||||
<PanelRoot>
|
||||
<PanelBody padding={false} scroll={false}>
|
||||
<FileImageViewer
|
||||
path={path}
|
||||
content={fileState.content}
|
||||
content={content}
|
||||
worktreePath={activeSession?.worktree_path ?? undefined}
|
||||
/>
|
||||
</PanelBody>
|
||||
@@ -103,16 +128,15 @@ export const FileEditorPanel = memo(function FileEditorPanel(
|
||||
<PanelBody padding={false} scroll={false}>
|
||||
<FileEditorContent
|
||||
path={path}
|
||||
content={fileState.content}
|
||||
originalContent={fileState.originalContent}
|
||||
isDirty={fileState.isDirty}
|
||||
originalContent={originalContent}
|
||||
isDirty={isDirty}
|
||||
isSaving={savingFiles.has(path)}
|
||||
sessionId={activeSessionId || undefined}
|
||||
worktreePath={activeSession?.worktree_path ?? undefined}
|
||||
enableComments={!!activeSessionId}
|
||||
onChange={(newContent) => handleFileChange(path, newContent)}
|
||||
onSave={() => saveFile(path)}
|
||||
onDelete={() => deleteFile(path)}
|
||||
onChange={onChange}
|
||||
onSave={onSave}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
</PanelBody>
|
||||
</PanelRoot>
|
||||
|
||||
@@ -46,7 +46,7 @@ const FilesPanel = memo(function FilesPanel({ onOpenFile }: FilesPanelProps) {
|
||||
|
||||
return (
|
||||
<PanelRoot>
|
||||
<PanelBody>
|
||||
<PanelBody padding={false}>
|
||||
{activeSessionId ? (
|
||||
<FileBrowser
|
||||
sessionId={activeSessionId}
|
||||
|
||||
@@ -73,3 +73,43 @@ export function PanelToolbar({ children, className }: PanelToolbarProps) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type PanelHeaderBarProps = {
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/** Fixed-height panel header bar. Renders children directly. */
|
||||
export function PanelHeaderBar({ children, className }: PanelHeaderBarProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-3 h-[30px] border-b border-border shrink-0 bg-card',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type PanelHeaderBarSplitProps = {
|
||||
left?: ReactNode;
|
||||
right?: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/** Panel header bar with left/right slots separated by a spacer. */
|
||||
export function PanelHeaderBarSplit({ left, right, className }: PanelHeaderBarSplitProps) {
|
||||
return (
|
||||
<PanelHeaderBar className={className}>
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
{left}
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
{right}
|
||||
</div>
|
||||
</PanelHeaderBar>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -639,7 +639,6 @@ export const TaskCenterPanel = memo(function TaskCenterPanel({
|
||||
) : (
|
||||
<FileEditorContent
|
||||
path={tab.path}
|
||||
content={tab.content}
|
||||
originalContent={tab.originalContent}
|
||||
isDirty={tab.isDirty}
|
||||
isSaving={savingFiles.has(tab.path)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { memo, useMemo, useCallback, createRef, useState, useEffect, useRef } from 'react';
|
||||
import { PanelRoot, PanelBody, PanelToolbar } from './panel-primitives';
|
||||
import { PanelRoot, PanelBody, PanelHeaderBarSplit } from './panel-primitives';
|
||||
import {
|
||||
IconSettings,
|
||||
IconTextWrap,
|
||||
@@ -30,10 +30,8 @@ import { getWebSocketClient } from '@/lib/ws/connection';
|
||||
import { updateUserSettings } from '@/lib/api';
|
||||
import { formatReviewCommentsAsMarkdown } from '@/components/task/chat/messages/review-comments-attachment';
|
||||
import { ReviewDiffList } from '@/components/review/review-diff-list';
|
||||
import { ReviewDialog } from '@/components/review/review-dialog';
|
||||
import type { ReviewFile } from '@/components/review/types';
|
||||
import { hashDiff, normalizeDiffContent } from '@/components/review/types';
|
||||
import type { DiffComment } from '@/lib/diff/types';
|
||||
import { usePanelActions } from '@/hooks/use-panel-actions';
|
||||
import type { SelectedDiff } from './task-layout';
|
||||
import { useIsTaskArchived, ArchivedPanelPlaceholder } from './task-archived-context';
|
||||
@@ -59,8 +57,6 @@ const TaskChangesPanel = memo(function TaskChangesPanel({
|
||||
return localStorage.getItem('diff-view-mode') === 'split';
|
||||
});
|
||||
const [wordWrap, setWordWrap] = useState(false);
|
||||
const [reviewDialogOpen, setReviewDialogOpen] = useState(false);
|
||||
|
||||
const { toast } = useToast();
|
||||
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
|
||||
const activeTaskId = useAppStore((state) => state.tasks.activeTaskId);
|
||||
@@ -277,24 +273,6 @@ const TaskChangesPanel = memo(function TaskChangesPanel({
|
||||
markCommentsSent(comments.map((c) => c.id));
|
||||
}, [activeSessionId, activeTaskId, getPendingComments, markCommentsSent, toast]);
|
||||
|
||||
const handleDialogSendComments = useCallback(
|
||||
(comments: DiffComment[]) => {
|
||||
if (!activeTaskId || !activeSessionId || comments.length === 0) return;
|
||||
const client = getWebSocketClient();
|
||||
if (!client) return;
|
||||
const markdown = formatReviewCommentsAsMarkdown(comments);
|
||||
client.request('message.add', {
|
||||
task_id: activeTaskId,
|
||||
session_id: activeSessionId,
|
||||
content: markdown,
|
||||
}, 10000).catch(() => {
|
||||
toast({ title: 'Failed to send comments', variant: 'error' });
|
||||
});
|
||||
setReviewDialogOpen(false);
|
||||
},
|
||||
[activeTaskId, activeSessionId, toast]
|
||||
);
|
||||
|
||||
const reviewedCount = reviewedFiles.size;
|
||||
const totalCount = allFiles.length;
|
||||
const progressPercent = totalCount > 0 ? (reviewedCount / totalCount) * 100 : 0;
|
||||
@@ -304,108 +282,103 @@ const TaskChangesPanel = memo(function TaskChangesPanel({
|
||||
return (
|
||||
<PanelRoot>
|
||||
{/* Top bar */}
|
||||
<PanelToolbar className="bg-card/50 min-h-[36px]">
|
||||
{/* Settings cog */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="sm" variant="ghost" className="px-1.5 h-7 cursor-pointer">
|
||||
<IconSettings className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-64">
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer gap-2"
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
handleToggleAutoMark(!autoMarkOnScroll);
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={autoMarkOnScroll}
|
||||
className="pointer-events-none"
|
||||
/>
|
||||
<span className="text-sm flex-1">Auto-mark reviewed on scroll</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Progress bar */}
|
||||
{totalCount > 0 && (
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="w-20 h-1 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-indigo-500 rounded-full transition-all duration-300"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[11px] text-muted-foreground whitespace-nowrap">
|
||||
{reviewedCount}/{totalCount} Reviewed
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Expand to review dialog */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="px-1.5 h-7 cursor-pointer"
|
||||
onClick={() => setReviewDialogOpen(true)}
|
||||
>
|
||||
<IconArrowsMaximize className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Expand review</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Word wrap */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className={`px-1.5 h-7 cursor-pointer ${wordWrap ? 'bg-muted' : ''}`}
|
||||
onClick={() => setWordWrap(!wordWrap)}
|
||||
>
|
||||
<IconTextWrap className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Toggle word wrap</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Split/Unified */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="px-1.5 h-7 cursor-pointer"
|
||||
onClick={() => handleToggleSplitView(!splitView)}
|
||||
>
|
||||
{splitView ? (
|
||||
<IconLayoutRows className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<IconLayoutColumns className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{splitView ? 'Unified view' : 'Split view'}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Fix Comments */}
|
||||
{totalCommentCount > 0 && (
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs cursor-pointer" onClick={handleFixComments}>
|
||||
<IconMessageForward className="h-3.5 w-3.5" />
|
||||
Fix
|
||||
<span className="ml-0.5 rounded-full bg-blue-500/30 px-1 py-0 text-[10px] font-medium text-blue-600 dark:text-blue-400">
|
||||
{totalCommentCount}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
</PanelToolbar>
|
||||
<PanelHeaderBarSplit
|
||||
left={
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="sm" variant="ghost" className="px-1.5 h-5 cursor-pointer">
|
||||
<IconSettings className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-64">
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer gap-2"
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
handleToggleAutoMark(!autoMarkOnScroll);
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={autoMarkOnScroll}
|
||||
className="pointer-events-none"
|
||||
/>
|
||||
<span className="text-sm flex-1">Auto-mark reviewed on scroll</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{totalCount > 0 && (
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="w-20 h-1 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-indigo-500 rounded-full transition-all duration-300"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[11px] text-muted-foreground whitespace-nowrap">
|
||||
{reviewedCount}/{totalCount} Reviewed
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
right={
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="px-1.5 h-5 cursor-pointer"
|
||||
onClick={() => window.dispatchEvent(new CustomEvent('open-review-dialog'))}
|
||||
>
|
||||
<IconArrowsMaximize className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Expand review</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className={`px-1.5 h-5 cursor-pointer ${wordWrap ? 'bg-muted' : ''}`}
|
||||
onClick={() => setWordWrap(!wordWrap)}
|
||||
>
|
||||
<IconTextWrap className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Toggle word wrap</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="px-1.5 h-5 cursor-pointer"
|
||||
onClick={() => handleToggleSplitView(!splitView)}
|
||||
>
|
||||
{splitView ? (
|
||||
<IconLayoutRows className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<IconLayoutColumns className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{splitView ? 'Unified view' : 'Split view'}</TooltipContent>
|
||||
</Tooltip>
|
||||
{totalCommentCount > 0 && (
|
||||
<Button size="sm" variant="outline" className="h-5 text-xs cursor-pointer" onClick={handleFixComments}>
|
||||
<IconMessageForward className="h-3.5 w-3.5" />
|
||||
Fix
|
||||
<span className="ml-0.5 rounded-full bg-blue-500/30 px-1 py-0 text-[10px] font-medium text-blue-600 dark:text-blue-400">
|
||||
{totalCommentCount}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<PanelBody padding={false} scroll={false} className="overflow-hidden">
|
||||
@@ -432,21 +405,6 @@ const TaskChangesPanel = memo(function TaskChangesPanel({
|
||||
/>
|
||||
) : null}
|
||||
</PanelBody>
|
||||
|
||||
{/* Review Dialog */}
|
||||
{activeSessionId && (
|
||||
<ReviewDialog
|
||||
open={reviewDialogOpen}
|
||||
onOpenChange={setReviewDialogOpen}
|
||||
sessionId={activeSessionId}
|
||||
baseBranch={baseBranch}
|
||||
taskTitle={taskTitle}
|
||||
onSendComments={handleDialogSendComments}
|
||||
onOpenFile={handleOpenFile}
|
||||
gitStatusFiles={gitStatus?.files ?? null}
|
||||
cumulativeDiff={cumulativeDiff}
|
||||
/>
|
||||
)}
|
||||
</PanelRoot>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useDockviewStore, type FileEditorState } from '@/lib/state/dockview-store';
|
||||
import { useAppStore } from '@/components/state-provider';
|
||||
import { getWebSocketClient } from '@/lib/ws/connection';
|
||||
@@ -28,20 +28,29 @@ export function consumePendingCursorPosition(path: string): { line: number; colu
|
||||
return pos;
|
||||
}
|
||||
|
||||
/** Read openFiles from the store without subscribing to changes. */
|
||||
function getOpenFiles() {
|
||||
return useDockviewStore.getState().openFiles;
|
||||
}
|
||||
|
||||
export function useFileEditors() {
|
||||
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
|
||||
const { toast } = useToast();
|
||||
const [savingFiles, setSavingFiles] = useState<Set<string>>(new Set());
|
||||
|
||||
const openFiles = useDockviewStore((s) => s.openFiles);
|
||||
// No reactive subscription to openFiles — callbacks use getState() instead.
|
||||
// Persistence is handled by a zustand store subscriber (see below).
|
||||
const setFileState = useDockviewStore((s) => s.setFileState);
|
||||
const updateFileState = useDockviewStore((s) => s.updateFileState);
|
||||
const removeFileState = useDockviewStore((s) => s.removeFileState);
|
||||
const clearFileStates = useDockviewStore((s) => s.clearFileStates);
|
||||
const addFileEditorPanel = useDockviewStore((s) => s.addFileEditorPanel);
|
||||
const isRestoringLayout = useDockviewStore((s) => s.isRestoringLayout);
|
||||
const api = useDockviewStore((s) => s.api);
|
||||
|
||||
// Use refs for callbacks that don't need to cause re-renders of consumers.
|
||||
const activeSessionIdRef = useRef(activeSessionId);
|
||||
activeSessionIdRef.current = activeSessionId;
|
||||
|
||||
// Restore file tabs from sessionStorage on session change.
|
||||
// The module-level guard prevents multiple hook instances from running this.
|
||||
useEffect(() => {
|
||||
@@ -116,15 +125,20 @@ export function useFileEditors() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeSessionId]);
|
||||
|
||||
// Persist open file tabs to sessionStorage
|
||||
// Persist open file tabs via zustand store subscriber — does NOT cause React re-renders.
|
||||
// This replaces the old useEffect that subscribed reactively to `openFiles`.
|
||||
useEffect(() => {
|
||||
if (!activeSessionId) return;
|
||||
// Don't persist while restoration is still loading tabs or during layout restore
|
||||
if (_restorationInProgress) return;
|
||||
if (isRestoringLayout) return;
|
||||
const tabsToSave = Array.from(openFiles.values()).map(({ path, name }) => ({ path, name }));
|
||||
saveOpenFileTabs(activeSessionId, tabsToSave);
|
||||
}, [activeSessionId, openFiles, isRestoringLayout]);
|
||||
const unsub = useDockviewStore.subscribe((state, prevState) => {
|
||||
if (state.openFiles === prevState.openFiles) return;
|
||||
const sessionId = activeSessionIdRef.current;
|
||||
if (!sessionId) return;
|
||||
if (_restorationInProgress) return;
|
||||
if (state.isRestoringLayout) return;
|
||||
const tabsToSave = Array.from(state.openFiles.values()).map(({ path, name }) => ({ path, name }));
|
||||
saveOpenFileTabs(sessionId, tabsToSave);
|
||||
});
|
||||
return unsub;
|
||||
}, []);
|
||||
|
||||
// Persist active center panel to sessionStorage
|
||||
useEffect(() => {
|
||||
@@ -152,11 +166,12 @@ export function useFileEditors() {
|
||||
|
||||
const openFile = useCallback(async (filePath: string) => {
|
||||
const client = getWebSocketClient();
|
||||
const currentSessionId = activeSessionId;
|
||||
const currentSessionId = activeSessionIdRef.current;
|
||||
if (!client || !currentSessionId) return;
|
||||
|
||||
// If already open, just focus it
|
||||
if (openFiles.has(filePath)) {
|
||||
// Read current state without subscribing
|
||||
const files = getOpenFiles();
|
||||
if (files.has(filePath)) {
|
||||
const name = filePath.split('/').pop() || filePath;
|
||||
addFileEditorPanel(filePath, name);
|
||||
return;
|
||||
@@ -186,29 +201,32 @@ export function useFileEditors() {
|
||||
variant: 'error',
|
||||
});
|
||||
}
|
||||
}, [activeSessionId, openFiles, setFileState, addFileEditorPanel, toast]);
|
||||
}, [setFileState, addFileEditorPanel, toast]);
|
||||
|
||||
const handleFileChange = useCallback((path: string, newContent: string) => {
|
||||
const file = openFiles.get(path);
|
||||
// Read current state without subscribing
|
||||
const file = getOpenFiles().get(path);
|
||||
if (!file) return;
|
||||
updateFileState(path, {
|
||||
content: newContent,
|
||||
isDirty: newContent !== file.originalContent,
|
||||
});
|
||||
}, [openFiles, updateFileState]);
|
||||
}, [updateFileState]);
|
||||
|
||||
const saveFile = useCallback(async (path: string) => {
|
||||
const file = openFiles.get(path);
|
||||
// Read current state without subscribing
|
||||
const file = getOpenFiles().get(path);
|
||||
if (!file || !file.isDirty) return;
|
||||
|
||||
const client = getWebSocketClient();
|
||||
if (!client || !activeSessionId) return;
|
||||
const currentSessionId = activeSessionIdRef.current;
|
||||
if (!client || !currentSessionId) return;
|
||||
|
||||
setSavingFiles((prev) => new Set(prev).add(path));
|
||||
|
||||
try {
|
||||
const diff = generateUnifiedDiff(file.originalContent, file.content, file.path);
|
||||
const response = await updateFileContent(client, activeSessionId, path, diff, file.originalHash);
|
||||
const response = await updateFileContent(client, currentSessionId, path, diff, file.originalHash);
|
||||
|
||||
if (response.success && response.new_hash) {
|
||||
updateFileState(path, {
|
||||
@@ -244,14 +262,15 @@ export function useFileEditors() {
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [openFiles, activeSessionId, updateFileState, toast]);
|
||||
}, [updateFileState, toast]);
|
||||
|
||||
const deleteFileAction = useCallback(async (path: string) => {
|
||||
const client = getWebSocketClient();
|
||||
if (!client || !activeSessionId) return;
|
||||
const currentSessionId = activeSessionIdRef.current;
|
||||
if (!client || !currentSessionId) return;
|
||||
|
||||
try {
|
||||
const response = await deleteFile(client, activeSessionId, path);
|
||||
const response = await deleteFile(client, currentSessionId, path);
|
||||
if (response.success) {
|
||||
// Remove dockview panel (which will trigger removeFileState via onDidRemovePanel)
|
||||
const dockApi = useDockviewStore.getState().api;
|
||||
@@ -273,10 +292,9 @@ export function useFileEditors() {
|
||||
variant: 'error',
|
||||
});
|
||||
}
|
||||
}, [activeSessionId, toast]);
|
||||
}, [toast]);
|
||||
|
||||
return {
|
||||
openFiles,
|
||||
savingFiles,
|
||||
openFile,
|
||||
saveFile,
|
||||
|
||||
@@ -174,3 +174,26 @@ export function extractCodeFromContent(
|
||||
const lines = content.split('\n');
|
||||
return lines.slice(startLine - 1, endLine).join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute simple line-level diff stats between two strings.
|
||||
* Returns the number of added and deleted lines.
|
||||
*/
|
||||
export function computeLineDiffStats(
|
||||
original: string,
|
||||
current: string,
|
||||
): { additions: number; deletions: number } {
|
||||
const originalLines = original.split('\n');
|
||||
const currentLines = current.split('\n');
|
||||
let additions = 0;
|
||||
let deletions = 0;
|
||||
const maxLen = Math.max(originalLines.length, currentLines.length);
|
||||
for (let i = 0; i < maxLen; i++) {
|
||||
const origLine = originalLines[i];
|
||||
const currLine = currentLines[i];
|
||||
if (origLine === undefined && currLine !== undefined) additions++;
|
||||
else if (origLine !== undefined && currLine === undefined) deletions++;
|
||||
else if (origLine !== currLine) { additions++; deletions++; }
|
||||
}
|
||||
return { additions, deletions };
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ export {
|
||||
normalizeDiffString,
|
||||
extractCodeFromDiff,
|
||||
extractCodeFromContent,
|
||||
computeLineDiffStats,
|
||||
type FileMutation,
|
||||
type ModifyFilePayload,
|
||||
} from './adapter';
|
||||
|
||||
@@ -47,6 +47,7 @@ type DockviewStore = {
|
||||
addChangesPanel: (groupId?: string) => void;
|
||||
addFilesPanel: (groupId?: string) => void;
|
||||
addDiffViewerPanel: (path?: string, content?: string, groupId?: string) => void;
|
||||
addCommitDetailPanel: (sha: string, groupId?: string) => void;
|
||||
addFileEditorPanel: (path: string, name: string, quiet?: boolean) => void;
|
||||
addBrowserPanel: (url?: string, groupId?: string) => void;
|
||||
addPlanPanel: (groupId?: string) => void;
|
||||
@@ -380,6 +381,19 @@ export const useDockviewStore = create<DockviewStore>((set, get) => ({
|
||||
});
|
||||
},
|
||||
|
||||
addCommitDetailPanel: (sha, groupId) => {
|
||||
const { api, centerGroupId } = get();
|
||||
if (!api) return;
|
||||
const panelId = `commit:${sha}`;
|
||||
focusOrAddPanel(api, {
|
||||
id: panelId,
|
||||
component: 'commit-detail',
|
||||
title: sha.slice(0, 7),
|
||||
params: { commitSha: sha },
|
||||
position: { referenceGroup: groupId ?? centerGroupId },
|
||||
});
|
||||
},
|
||||
|
||||
addFileEditorPanel: (path, name, quiet) => {
|
||||
const { api, centerGroupId } = get();
|
||||
if (!api) return;
|
||||
|
||||
Reference in New Issue
Block a user