feat: improve editors (#226)
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
'use client';
|
||||
|
||||
// Re-export from the unified diff-viewer component
|
||||
export { DiffViewInline } from './diff-viewer';
|
||||
// Re-export resolver-aware version
|
||||
export { DiffViewInlineResolved as DiffViewInline } from './diff-viewer-resolver';
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
'use client';
|
||||
|
||||
import { memo } from 'react';
|
||||
import { useEditorProvider } from '@/hooks/use-editor-resolver';
|
||||
import { DiffViewer as PierreDiffViewer, DiffViewInline as PierreDiffViewInline } from './diff-viewer';
|
||||
import { MonacoDiffViewer } from '@/components/editors/monaco/monaco-diff-viewer';
|
||||
import type { FileDiffData, DiffComment } from '@/lib/diff/types';
|
||||
|
||||
interface DiffViewerResolverProps {
|
||||
data: FileDiffData;
|
||||
enableComments?: boolean;
|
||||
sessionId?: string;
|
||||
onCommentAdd?: (comment: DiffComment) => void;
|
||||
onCommentDelete?: (commentId: string) => void;
|
||||
comments?: DiffComment[];
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
hideHeader?: boolean;
|
||||
onOpenFile?: (filePath: string) => void;
|
||||
onRevert?: (filePath: string) => void;
|
||||
wordWrap?: boolean;
|
||||
}
|
||||
|
||||
export const DiffViewerResolved = memo(function DiffViewerResolved(props: DiffViewerResolverProps) {
|
||||
const provider = useEditorProvider('diff-viewer');
|
||||
if (provider === 'monaco') {
|
||||
const { enableComments: _, ...rest } = props;
|
||||
return <MonacoDiffViewer {...rest} />;
|
||||
}
|
||||
return <PierreDiffViewer {...props} />;
|
||||
});
|
||||
|
||||
export function DiffViewInlineResolved({ data, className }: { data: FileDiffData; className?: string }) {
|
||||
const provider = useEditorProvider('chat-diff');
|
||||
if (provider === 'monaco') {
|
||||
return <MonacoDiffViewer data={data} compact hideHeader className={className} />;
|
||||
}
|
||||
return <PierreDiffViewInline data={data} className={className} />;
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import type { FileDiffOptions, FileDiffMetadata, DiffLineAnnotation, RenderHeade
|
||||
import { cn } from '@kandev/ui/lib/utils';
|
||||
import { Button } from '@kandev/ui/button';
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from '@kandev/ui/tooltip';
|
||||
import { IconCopy, IconTextWrap, IconLayoutRows, IconLayoutColumns, IconExternalLink, IconPlus, IconArrowBackUp } from '@tabler/icons-react';
|
||||
import { IconCopy, IconTextWrap, IconLayoutRows, IconLayoutColumns, IconPencil, IconPlus, IconArrowBackUp } from '@tabler/icons-react';
|
||||
import type { FileDiffData, DiffComment } from '@/lib/diff/types';
|
||||
import { useDiffComments } from './use-diff-comments';
|
||||
import { CommentForm } from './comment-form';
|
||||
@@ -407,10 +407,10 @@ export const DiffViewer = memo(function DiffViewer({
|
||||
className="h-6 w-6 p-0 cursor-pointer opacity-60 hover:opacity-100"
|
||||
onClick={() => onOpenFile(filePath)}
|
||||
>
|
||||
<IconExternalLink className="h-3.5 w-3.5" />
|
||||
<IconPencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Open in editor</TooltipContent>
|
||||
<TooltipContent>Edit</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { memo, useMemo } from 'react';
|
||||
import { DiffViewer } from './diff-viewer';
|
||||
import { DiffViewerResolved as DiffViewer } from './diff-viewer-resolver';
|
||||
import { transformGitDiff } from '@/lib/diff';
|
||||
import type { DiffComment } from '@/lib/diff/types';
|
||||
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
// Main components
|
||||
export { DiffViewer } from './diff-viewer';
|
||||
// Main components - resolver-aware
|
||||
export { DiffViewerResolved as DiffViewer } from './diff-viewer-resolver';
|
||||
export { DiffViewInlineResolved as DiffViewInline } from './diff-viewer-resolver';
|
||||
export { FileDiffViewer } from './file-diff-viewer';
|
||||
export { DiffViewInline } from './diff-view-inline';
|
||||
export { CommentForm } from './comment-form';
|
||||
export { CommentDisplay } from './comment-display';
|
||||
|
||||
// Direct access to Pierre implementation (for internal use)
|
||||
export { DiffViewer as PierreDiffViewer } from './diff-viewer';
|
||||
export { DiffViewInline as PierreDiffViewInline } from './diff-viewer';
|
||||
|
||||
// Hooks
|
||||
export { useDiffComments } from './use-diff-comments';
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
'use client';
|
||||
|
||||
import { useTheme } from 'next-themes';
|
||||
import CodeMirror from '@uiw/react-codemirror';
|
||||
import { vscodeDark, vscodeLight } from '@uiw/codemirror-theme-vscode';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import { IconCheck, IconCopy } from '@tabler/icons-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getCodeMirrorExtension } from '@/lib/languages';
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard';
|
||||
|
||||
type CodeBlockProps = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const getLanguageExtension = (language?: string) => {
|
||||
if (!language) return undefined;
|
||||
const lang = language.replace('language-', '').toLowerCase();
|
||||
return getCodeMirrorExtension(lang);
|
||||
};
|
||||
|
||||
export function CodeMirrorCodeBlock({ children, className }: CodeBlockProps) {
|
||||
const { copied, copy } = useCopyToClipboard();
|
||||
const { theme, systemTheme } = useTheme();
|
||||
const effectiveTheme = theme === 'system' ? systemTheme : theme;
|
||||
|
||||
const code = String(children).replace(/\n$/, '');
|
||||
const languageExtension = getLanguageExtension(className);
|
||||
|
||||
// Custom padding theme
|
||||
const paddingTheme = EditorView.theme({
|
||||
'&': {
|
||||
padding: '2px 2px',
|
||||
},
|
||||
});
|
||||
|
||||
const handleCopy = async () => {
|
||||
await copy(code);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative group/code-block my-4 w-fit max-w-full min-w-[50%]">
|
||||
{/* Copy button */}
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className={cn(
|
||||
'absolute top-2 right-2 z-10',
|
||||
'p-1.5 rounded-md',
|
||||
'bg-white/10 hover:bg-white/20',
|
||||
'transition-all duration-200',
|
||||
'opacity-0 group-hover/code-block:opacity-100',
|
||||
'cursor-pointer',
|
||||
)}
|
||||
title="Copy code"
|
||||
>
|
||||
{copied ? (
|
||||
<IconCheck className="h-3 w-3 text-green-400" />
|
||||
) : (
|
||||
<IconCopy className="h-3 w-3 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Code editor */}
|
||||
<CodeMirror
|
||||
value={code}
|
||||
theme={effectiveTheme === 'dark' ? vscodeDark : vscodeLight}
|
||||
extensions={languageExtension ? [languageExtension, EditorView.lineWrapping, paddingTheme] : [EditorView.lineWrapping, paddingTheme]}
|
||||
editable={false}
|
||||
basicSetup={{
|
||||
lineNumbers: false,
|
||||
highlightActiveLineGutter: false,
|
||||
highlightActiveLine: false,
|
||||
foldGutter: false,
|
||||
}}
|
||||
className="text-xs rounded-md overflow-hidden"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,649 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState, useRef } from 'react';
|
||||
import CodeMirror, { type ReactCodeMirrorRef } from '@uiw/react-codemirror';
|
||||
import { EditorView, gutter, GutterMarker, ViewPlugin, type ViewUpdate } from '@codemirror/view';
|
||||
import type { Extension } from '@codemirror/state';
|
||||
import { Decoration, type DecorationSet } from '@codemirror/view';
|
||||
import { getCodeMirrorExtensionFromPath } from '@/lib/languages';
|
||||
import { Button } from '@kandev/ui/button';
|
||||
import { IconDeviceFloppy, IconLoader2, IconTrash, IconTextWrap, IconTextWrapDisabled, IconMessagePlus } from '@tabler/icons-react';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip';
|
||||
import { formatDiffStats } from '@/lib/utils/file-diff';
|
||||
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 { useToast } from '@/components/toast-provider';
|
||||
import { EditorCommentPopover } from '@/components/task/editor-comment-popover';
|
||||
import { CommentViewPopover } from '@/components/task/comment-view-popover';
|
||||
|
||||
type FileEditorContentProps = {
|
||||
path: string;
|
||||
content: string;
|
||||
originalContent: string;
|
||||
isDirty: boolean;
|
||||
isSaving: boolean;
|
||||
sessionId?: string;
|
||||
worktreePath?: string;
|
||||
enableComments?: boolean;
|
||||
onChange: (newContent: string) => void;
|
||||
onSave: () => void;
|
||||
onDelete?: () => void;
|
||||
};
|
||||
|
||||
type TextSelection = {
|
||||
text: string;
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
position: { x: number; y: number };
|
||||
} | null;
|
||||
|
||||
type FloatingButtonPosition = {
|
||||
x: number;
|
||||
y: number;
|
||||
} | null;
|
||||
|
||||
type CommentViewState = {
|
||||
comments: DiffComment[];
|
||||
position: { x: number; y: number };
|
||||
} | null;
|
||||
|
||||
// Create comment line decorations
|
||||
function createCommentDecorations(
|
||||
view: EditorView,
|
||||
comments: DiffComment[]
|
||||
): DecorationSet {
|
||||
if (comments.length === 0) {
|
||||
return Decoration.none;
|
||||
}
|
||||
|
||||
const decorations: Array<{ from: number; decoration: Decoration }> = [];
|
||||
|
||||
// Get all lines that have comments
|
||||
const linesWithComments = new Set<number>();
|
||||
for (const comment of comments) {
|
||||
for (let line = comment.startLine; line <= comment.endLine; line++) {
|
||||
linesWithComments.add(line);
|
||||
}
|
||||
}
|
||||
|
||||
// Create line decorations for commented lines
|
||||
for (const lineNum of linesWithComments) {
|
||||
if (lineNum > view.state.doc.lines) continue;
|
||||
const line = view.state.doc.line(lineNum);
|
||||
decorations.push({
|
||||
from: line.from,
|
||||
decoration: Decoration.line({
|
||||
class: 'cm-comment-line',
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
decorations.sort((a, b) => a.from - b.from);
|
||||
return Decoration.set(decorations.map(d => d.decoration.range(d.from)));
|
||||
}
|
||||
|
||||
// Gutter marker for lines with comments - defined outside component to avoid re-creation
|
||||
class CommentGutterMarker extends GutterMarker {
|
||||
constructor(
|
||||
readonly lineComments: DiffComment[],
|
||||
readonly isFirstLine: boolean
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
toDOM() {
|
||||
const marker = document.createElement('div');
|
||||
marker.className = 'cm-comment-gutter-marker';
|
||||
marker.title = `${this.lineComments.length} comment${this.lineComments.length > 1 ? 's' : ''} - click to view`;
|
||||
|
||||
// Add icon only on the first line of a comment
|
||||
if (this.isFirstLine) {
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.setAttribute('width', '12');
|
||||
svg.setAttribute('height', '12');
|
||||
svg.setAttribute('viewBox', '0 0 24 24');
|
||||
svg.setAttribute('fill', 'none');
|
||||
svg.setAttribute('stroke', 'rgba(99, 102, 241, 0.9)');
|
||||
svg.setAttribute('stroke-width', '2');
|
||||
svg.setAttribute('stroke-linecap', 'round');
|
||||
svg.setAttribute('stroke-linejoin', 'round');
|
||||
|
||||
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
path.setAttribute('d', 'M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z');
|
||||
svg.appendChild(path);
|
||||
marker.appendChild(svg);
|
||||
}
|
||||
|
||||
return marker;
|
||||
}
|
||||
|
||||
eq(other: CommentGutterMarker) {
|
||||
return this.lineComments.length === other.lineComments.length && this.isFirstLine === other.isFirstLine;
|
||||
}
|
||||
}
|
||||
|
||||
export function CodeMirrorCodeEditor({
|
||||
path,
|
||||
content,
|
||||
originalContent,
|
||||
isDirty,
|
||||
isSaving,
|
||||
sessionId,
|
||||
worktreePath,
|
||||
enableComments = false,
|
||||
onChange,
|
||||
onSave,
|
||||
onDelete,
|
||||
}: FileEditorContentProps) {
|
||||
const [wrapEnabled, setWrapEnabled] = useState(true);
|
||||
const [textSelection, setTextSelection] = useState<TextSelection>(null);
|
||||
const [floatingButtonPos, setFloatingButtonPos] = useState<FloatingButtonPosition>(null);
|
||||
const [currentSelection, setCurrentSelection] = useState<{ text: string; startLine: number; endLine: number } | null>(null);
|
||||
const [commentView, setCommentView] = useState<CommentViewState>(null);
|
||||
const editorRef = useRef<ReactCodeMirrorRef>(null);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const mousePositionRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
|
||||
const { toast } = useToast();
|
||||
|
||||
// Comment store
|
||||
const addComment = useDiffCommentsStore((state) => state.addComment);
|
||||
const removeComment = useDiffCommentsStore((state) => state.removeComment);
|
||||
const comments = useFileComments(sessionId ?? '', path);
|
||||
|
||||
const langExt = getCodeMirrorExtensionFromPath(path);
|
||||
|
||||
// Create the comment decoration plugin
|
||||
const commentPlugin = useMemo(() => {
|
||||
return ViewPlugin.fromClass(
|
||||
class {
|
||||
decorations: DecorationSet;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = createCommentDecorations(view, comments);
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
if (update.docChanged || update.viewportChanged) {
|
||||
this.decorations = createCommentDecorations(update.view, comments);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ decorations: (v) => v.decorations }
|
||||
);
|
||||
}, [comments]);
|
||||
|
||||
// Create comment gutter
|
||||
const commentGutter = useMemo(() => {
|
||||
// Build a map of line number to comments that include that line
|
||||
const commentsByLine = new Map<number, DiffComment[]>();
|
||||
// Track which lines are the first line of at least one comment
|
||||
const firstLines = new Set<number>();
|
||||
|
||||
for (const comment of comments) {
|
||||
firstLines.add(comment.startLine);
|
||||
// Add to all lines in the range
|
||||
for (let lineNum = comment.startLine; lineNum <= comment.endLine; lineNum++) {
|
||||
const existing = commentsByLine.get(lineNum) || [];
|
||||
existing.push(comment);
|
||||
commentsByLine.set(lineNum, existing);
|
||||
}
|
||||
}
|
||||
|
||||
return gutter({
|
||||
class: 'cm-comment-gutter',
|
||||
lineMarker: (view, line) => {
|
||||
const lineNum = view.state.doc.lineAt(line.from).number;
|
||||
const lineComments = commentsByLine.get(lineNum);
|
||||
if (lineComments && lineComments.length > 0) {
|
||||
return new CommentGutterMarker(lineComments, firstLines.has(lineNum));
|
||||
}
|
||||
return null;
|
||||
},
|
||||
domEventHandlers: {
|
||||
click: (view, line, event) => {
|
||||
const lineNum = view.state.doc.lineAt(line.from).number;
|
||||
const lineComments = commentsByLine.get(lineNum);
|
||||
if (lineComments && lineComments.length > 0) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setCommentView({
|
||||
comments: lineComments,
|
||||
position: { x: (event as MouseEvent).clientX, y: (event as MouseEvent).clientY },
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
});
|
||||
}, [comments]);
|
||||
|
||||
// Track mouse position
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
mousePositionRef.current = { x: e.clientX, y: e.clientY };
|
||||
};
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
return () => document.removeEventListener('mousemove', handleMouseMove);
|
||||
}, []);
|
||||
|
||||
// Show floating button after selection ends (on mouseup)
|
||||
const handleSelectionEnd = useCallback(() => {
|
||||
if (!enableComments || !sessionId) return;
|
||||
|
||||
const view = editorRef.current?.view;
|
||||
if (!view) return;
|
||||
|
||||
const selection = view.state.selection.main;
|
||||
if (selection.empty) {
|
||||
setFloatingButtonPos(null);
|
||||
setCurrentSelection(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedText = view.state.sliceDoc(selection.from, selection.to);
|
||||
if (!selectedText.trim()) {
|
||||
setFloatingButtonPos(null);
|
||||
setCurrentSelection(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get line numbers
|
||||
const startLine = view.state.doc.lineAt(selection.from).number;
|
||||
const endLine = view.state.doc.lineAt(selection.to).number;
|
||||
|
||||
// Use mouse position for the floating button (where cursor is)
|
||||
setCurrentSelection({ text: selectedText, startLine, endLine });
|
||||
setFloatingButtonPos({ x: mousePositionRef.current.x, y: mousePositionRef.current.y });
|
||||
}, [enableComments, sessionId]);
|
||||
|
||||
// Listen for mouseup to show floating button after selection ends
|
||||
useEffect(() => {
|
||||
const wrapper = wrapperRef.current;
|
||||
if (!wrapper || !enableComments || !sessionId) return;
|
||||
|
||||
const handleMouseUp = (e: MouseEvent) => {
|
||||
// Don't show if clicking on the floating button itself
|
||||
if ((e.target as HTMLElement).closest('.floating-comment-btn')) {
|
||||
return;
|
||||
}
|
||||
// Small delay to let selection finalize
|
||||
setTimeout(handleSelectionEnd, 10);
|
||||
};
|
||||
|
||||
// Clear selection state when clicking (new selection starts)
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
// Don't clear if clicking on the floating button
|
||||
if ((e.target as HTMLElement).closest('.floating-comment-btn')) {
|
||||
return;
|
||||
}
|
||||
setFloatingButtonPos(null);
|
||||
};
|
||||
|
||||
wrapper.addEventListener('mouseup', handleMouseUp);
|
||||
wrapper.addEventListener('mousedown', handleMouseDown);
|
||||
return () => {
|
||||
wrapper.removeEventListener('mouseup', handleMouseUp);
|
||||
wrapper.removeEventListener('mousedown', handleMouseDown);
|
||||
};
|
||||
}, [enableComments, sessionId, handleSelectionEnd]);
|
||||
|
||||
// Clear floating button when selection is cleared via keyboard
|
||||
const selectionUpdateExtension = useMemo(() => {
|
||||
return EditorView.updateListener.of((update) => {
|
||||
if (update.selectionSet) {
|
||||
const selection = update.state.selection.main;
|
||||
if (selection.empty) {
|
||||
setFloatingButtonPos(null);
|
||||
setCurrentSelection(null);
|
||||
}
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const extensions: Extension[] = useMemo(() => {
|
||||
const exts: Extension[] = [
|
||||
EditorView.editable.of(true),
|
||||
EditorView.theme({
|
||||
'&': { backgroundColor: 'hsl(var(--background)) !important' },
|
||||
'.cm-gutters': { backgroundColor: 'hsl(var(--background)) !important', borderRight: 'none' },
|
||||
// Comment gutter
|
||||
'.cm-comment-gutter': {
|
||||
width: '22px',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
'.cm-comment-gutter .cm-gutterElement': {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '0',
|
||||
},
|
||||
// Marker styles - background color on the marker itself (indigo)
|
||||
'.cm-comment-gutter-marker': {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: 'rgba(99, 102, 241, 0.2)',
|
||||
},
|
||||
'.cm-comment-gutter-marker:hover': {
|
||||
backgroundColor: 'rgba(99, 102, 241, 0.35)',
|
||||
},
|
||||
// Comment line highlight with left border (indigo)
|
||||
'.cm-comment-line': {
|
||||
backgroundColor: 'rgba(99, 102, 241, 0.15) !important',
|
||||
borderLeft: '3px solid rgba(99, 102, 241, 0.6)',
|
||||
marginLeft: '-3px',
|
||||
paddingLeft: '3px',
|
||||
},
|
||||
}),
|
||||
commentGutter,
|
||||
commentPlugin,
|
||||
selectionUpdateExtension,
|
||||
];
|
||||
if (wrapEnabled) {
|
||||
exts.push(EditorView.lineWrapping);
|
||||
}
|
||||
if (langExt) {
|
||||
exts.push(langExt);
|
||||
}
|
||||
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]);
|
||||
|
||||
// Handle Cmd+I to open comment popover
|
||||
useEffect(() => {
|
||||
const wrapper = wrapperRef.current;
|
||||
if (!wrapper || !enableComments || !sessionId) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Check for Cmd+I (Mac) or Ctrl+I (Windows/Linux)
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'i') {
|
||||
if (!currentSelection || !floatingButtonPos) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
setTextSelection({
|
||||
...currentSelection,
|
||||
position: floatingButtonPos,
|
||||
});
|
||||
setFloatingButtonPos(null);
|
||||
}
|
||||
};
|
||||
|
||||
wrapper.addEventListener('keydown', handleKeyDown, true);
|
||||
return () => wrapper.removeEventListener('keydown', handleKeyDown, true);
|
||||
}, [enableComments, sessionId, currentSelection, floatingButtonPos]);
|
||||
|
||||
// Handle Cmd/Ctrl+S keyboard shortcut and Escape
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
|
||||
e.preventDefault();
|
||||
if (isDirty && !isSaving) {
|
||||
onSave();
|
||||
}
|
||||
}
|
||||
// Escape to close popovers
|
||||
if (e.key === 'Escape') {
|
||||
if (textSelection) setTextSelection(null);
|
||||
if (commentView) setCommentView(null);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isDirty, isSaving, onSave, textSelection, commentView]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(value: string) => {
|
||||
onChange(value);
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
// Open comment popover from floating button
|
||||
const handleFloatingButtonClick = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (!currentSelection || !floatingButtonPos) return;
|
||||
|
||||
setTextSelection({
|
||||
...currentSelection,
|
||||
position: floatingButtonPos,
|
||||
});
|
||||
setFloatingButtonPos(null);
|
||||
}, [currentSelection, floatingButtonPos]);
|
||||
|
||||
// Submit comment
|
||||
const handleCommentSubmit = useCallback((annotation: string) => {
|
||||
if (!textSelection || !sessionId) return;
|
||||
|
||||
const { text, startLine, endLine } = textSelection;
|
||||
|
||||
const comment: DiffComment = {
|
||||
id: `${path}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
sessionId,
|
||||
filePath: path,
|
||||
startLine,
|
||||
endLine,
|
||||
side: 'additions',
|
||||
codeContent: text,
|
||||
annotation,
|
||||
createdAt: new Date().toISOString(),
|
||||
status: 'pending',
|
||||
};
|
||||
|
||||
addComment(comment);
|
||||
setTextSelection(null);
|
||||
|
||||
// Clear selection in editor
|
||||
const view = editorRef.current?.view;
|
||||
if (view) {
|
||||
view.dispatch({
|
||||
selection: { anchor: view.state.selection.main.head },
|
||||
});
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Comment added',
|
||||
description: 'Your comment will be sent with your next message.',
|
||||
});
|
||||
}, [textSelection, sessionId, path, addComment, toast]);
|
||||
|
||||
// Close popover
|
||||
const handlePopoverClose = useCallback(() => {
|
||||
setTextSelection(null);
|
||||
}, []);
|
||||
|
||||
// Delete comment
|
||||
const handleDeleteComment = useCallback((commentId: string) => {
|
||||
if (!sessionId) return;
|
||||
removeComment(sessionId, path, commentId);
|
||||
|
||||
// Close the view if no more comments
|
||||
if (commentView && commentView.comments.length <= 1) {
|
||||
setCommentView(null);
|
||||
} else if (commentView) {
|
||||
setCommentView({
|
||||
...commentView,
|
||||
comments: commentView.comments.filter(c => c.id !== commentId),
|
||||
});
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Comment deleted',
|
||||
});
|
||||
}, [sessionId, path, removeComment, commentView, toast]);
|
||||
|
||||
// Close comment view
|
||||
const handleCommentViewClose = useCallback(() => {
|
||||
setCommentView(null);
|
||||
}, []);
|
||||
|
||||
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">
|
||||
{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" />
|
||||
<span>{comments.length} comment{comments.length > 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setWrapEnabled(!wrapEnabled)}
|
||||
className={`h-8 w-8 p-0 cursor-pointer ${wrapEnabled ? 'text-foreground' : 'text-muted-foreground'}`}
|
||||
>
|
||||
{wrapEnabled ? <IconTextWrap className="h-4 w-4" /> : <IconTextWrapDisabled className="h-4 w-4" />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{wrapEnabled ? 'Disable word wrap' : 'Enable word wrap'}</TooltipContent>
|
||||
</Tooltip>
|
||||
{onDelete && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onDelete}
|
||||
className="h-8 w-8 p-0 cursor-pointer text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<IconTrash className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Delete file</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
onClick={onSave}
|
||||
disabled={!isDirty || isSaving}
|
||||
className="cursor-pointer gap-2"
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<IconLoader2 className="h-4 w-4 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<IconDeviceFloppy className="h-4 w-4" />
|
||||
Save
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({navigator.platform.includes('Mac') ? '⌘' : 'Ctrl'}+S)
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CodeMirror editor */}
|
||||
<div className="flex-1 overflow-hidden relative">
|
||||
<CodeMirror
|
||||
ref={editorRef}
|
||||
value={content}
|
||||
height="100%"
|
||||
theme={vscodeDark}
|
||||
extensions={extensions}
|
||||
onChange={handleChange}
|
||||
basicSetup={{
|
||||
lineNumbers: true,
|
||||
foldGutter: true,
|
||||
highlightActiveLine: true,
|
||||
highlightSelectionMatches: true,
|
||||
searchKeymap: true,
|
||||
}}
|
||||
className="h-full overflow-auto text-xs"
|
||||
/>
|
||||
|
||||
{/* Floating "Add comment" button when text is selected */}
|
||||
{floatingButtonPos && !textSelection && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="floating-comment-btn fixed z-50 gap-1.5 shadow-lg animate-in fade-in-0 zoom-in-95 duration-100 cursor-pointer"
|
||||
style={{
|
||||
left: floatingButtonPos.x + 8,
|
||||
top: floatingButtonPos.y + 8,
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={handleFloatingButtonClick}
|
||||
>
|
||||
<IconMessagePlus className="h-3.5 w-3.5" />
|
||||
Comment
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Comment popover for adding new comment */}
|
||||
{textSelection && (
|
||||
<EditorCommentPopover
|
||||
selectedText={textSelection.text}
|
||||
lineRange={{ start: textSelection.startLine, end: textSelection.endLine }}
|
||||
position={textSelection.position}
|
||||
onSubmit={handleCommentSubmit}
|
||||
onClose={handlePopoverClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Comment view popover for viewing/deleting existing comments */}
|
||||
{commentView && (
|
||||
<CommentViewPopover
|
||||
comments={commentView.comments}
|
||||
position={commentView.position}
|
||||
onDelete={handleDeleteComment}
|
||||
onClose={handleCommentViewClose}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useCallback } from 'react';
|
||||
import {
|
||||
IconExternalLink,
|
||||
IconCopy,
|
||||
IconFolderShare,
|
||||
} from '@tabler/icons-react';
|
||||
import { Button } from '@kandev/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@kandev/ui/dropdown-menu';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip';
|
||||
import { useEditors } from '@/hooks/domains/settings/use-editors';
|
||||
import { useOpenSessionInEditor } from '@/hooks/use-open-session-in-editor';
|
||||
import { useOpenSessionFolder } from '@/hooks/use-open-session-folder';
|
||||
import { useAppStore } from '@/components/state-provider';
|
||||
import type { EditorOption } from '@/lib/types/http';
|
||||
|
||||
type FileActionsDropdownProps = {
|
||||
/** File path to open / copy */
|
||||
filePath: string;
|
||||
/** Session ID override — defaults to activeSessionId from store */
|
||||
sessionId?: string;
|
||||
/** Button size variant */
|
||||
size?: 'sm' | 'xs';
|
||||
/** Optional toast callback after copy */
|
||||
onCopied?: () => void;
|
||||
};
|
||||
|
||||
export function FileActionsDropdown({
|
||||
filePath,
|
||||
sessionId: sessionIdProp,
|
||||
size = 'xs',
|
||||
onCopied,
|
||||
}: FileActionsDropdownProps) {
|
||||
const storeSessionId = useAppStore((state) => state.tasks.activeSessionId);
|
||||
const sessionId = sessionIdProp ?? storeSessionId ?? null;
|
||||
const worktreePath = useAppStore((state) => {
|
||||
if (!sessionId) return undefined;
|
||||
return state.taskSessions.items[sessionId]?.worktree_path;
|
||||
});
|
||||
|
||||
const openEditor = useOpenSessionInEditor(sessionId);
|
||||
const openFolder = useOpenSessionFolder(sessionId);
|
||||
const { editors } = useEditors();
|
||||
const defaultEditorId = useAppStore((state) => state.userSettings.defaultEditorId);
|
||||
|
||||
const enabledEditors = useMemo(
|
||||
() =>
|
||||
editors.filter((editor: EditorOption) => {
|
||||
if (!editor.enabled) return false;
|
||||
if (editor.kind === 'built_in') return editor.installed;
|
||||
return true;
|
||||
}),
|
||||
[editors]
|
||||
);
|
||||
|
||||
const absolutePath = useMemo(() => {
|
||||
if (!worktreePath) return filePath;
|
||||
// Avoid double slashes
|
||||
const base = worktreePath.endsWith('/') ? worktreePath : `${worktreePath}/`;
|
||||
const rel = filePath.startsWith('/') ? filePath.slice(1) : filePath;
|
||||
return `${base}${rel}`;
|
||||
}, [worktreePath, filePath]);
|
||||
|
||||
const handleCopyPath = useCallback(() => {
|
||||
navigator.clipboard.writeText(absolutePath);
|
||||
onCopied?.();
|
||||
}, [absolutePath, onCopied]);
|
||||
|
||||
const handleOpenInEditor = useCallback(
|
||||
(editorId: string) => {
|
||||
void openEditor.open({ editorId, filePath });
|
||||
},
|
||||
[openEditor, filePath]
|
||||
);
|
||||
|
||||
const handleOpenFolder = useCallback(() => {
|
||||
void openFolder.open();
|
||||
}, [openFolder]);
|
||||
|
||||
const btnClass = size === 'xs'
|
||||
? 'h-6 w-6 p-0 cursor-pointer opacity-60 hover:opacity-100'
|
||||
: 'h-8 w-8 p-0 cursor-pointer text-muted-foreground hover:text-foreground';
|
||||
|
||||
const iconClass = size === 'xs' ? 'h-3.5 w-3.5' : 'h-4 w-4';
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className={btnClass}>
|
||||
<IconExternalLink className={iconClass} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Open with...</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
{enabledEditors.map((editor: EditorOption) => (
|
||||
<DropdownMenuItem
|
||||
key={editor.id}
|
||||
className="cursor-pointer text-xs"
|
||||
onClick={() => handleOpenInEditor(editor.id)}
|
||||
>
|
||||
{editor.name}
|
||||
{editor.id === defaultEditorId && (
|
||||
<span className="ml-auto text-[10px] text-muted-foreground">default</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{enabledEditors.length === 0 && (
|
||||
<DropdownMenuItem disabled className="text-xs">
|
||||
No editors configured
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="cursor-pointer text-xs" onClick={handleCopyPath}>
|
||||
<IconCopy className="h-3.5 w-3.5 mr-1.5" />
|
||||
Copy path
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="cursor-pointer text-xs" onClick={handleOpenFolder}>
|
||||
<IconFolderShare className="h-3.5 w-3.5 mr-1.5" />
|
||||
Open folder
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Shared state for controlling Monaco's built-in TS/JS provider suppression.
|
||||
// Separated into its own module (no heavy imports) so both monaco-loader.ts
|
||||
// and lsp-client-manager.ts can import it without circular dependencies or
|
||||
// pulling in the full monaco-editor bundle.
|
||||
|
||||
let _suppressed = false;
|
||||
|
||||
/** Returns true when built-in TS/JS providers should be suppressed (LSP active). */
|
||||
export function isBuiltinTsSuppressed(): boolean {
|
||||
return _suppressed;
|
||||
}
|
||||
|
||||
/** Set suppression state. Called synchronously from lsp-client-manager. */
|
||||
export function setBuiltinTsSuppressed(suppressed: boolean): void {
|
||||
_suppressed = suppressed;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
'use client';
|
||||
|
||||
import { useTheme } from 'next-themes';
|
||||
import Editor from '@monaco-editor/react';
|
||||
import { IconCheck, IconCopy } from '@tabler/icons-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getMonacoLanguageFromName } from '@/lib/editor/language-map';
|
||||
import { EDITOR_FONT_FAMILY } from '@/lib/theme/editor-theme';
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard';
|
||||
import { initMonacoThemes } from './monaco-init';
|
||||
|
||||
initMonacoThemes();
|
||||
|
||||
type MonacoCodeBlockProps = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function MonacoCodeBlock({ children, className }: MonacoCodeBlockProps) {
|
||||
const { copied, copy } = useCopyToClipboard();
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
const code = String(children).replace(/\n$/, '');
|
||||
const lang = className?.replace('language-', '').toLowerCase() ?? '';
|
||||
const language = getMonacoLanguageFromName(lang);
|
||||
const lineCount = code.split('\n').length;
|
||||
// Compact height: ~16px per line + 8px padding
|
||||
const height = Math.min(Math.max(lineCount * 16 + 8, 32), 500);
|
||||
|
||||
return (
|
||||
<div className="relative group/code-block my-4 w-fit max-w-full min-w-[50%]">
|
||||
<button
|
||||
onClick={() => copy(code)}
|
||||
className={cn(
|
||||
'absolute top-2 right-2 z-10',
|
||||
'p-1.5 rounded-md',
|
||||
'bg-white/10 hover:bg-white/20',
|
||||
'transition-all duration-200',
|
||||
'opacity-0 group-hover/code-block:opacity-100',
|
||||
'cursor-pointer',
|
||||
)}
|
||||
title="Copy code"
|
||||
>
|
||||
{copied ? (
|
||||
<IconCheck className="h-3 w-3 text-green-400" />
|
||||
) : (
|
||||
<IconCopy className="h-3 w-3 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<Editor
|
||||
height={height}
|
||||
language={language}
|
||||
value={code}
|
||||
theme={resolvedTheme === 'dark' ? 'kandev-dark' : 'kandev-light'}
|
||||
options={{
|
||||
readOnly: true,
|
||||
fontSize: 12,
|
||||
fontFamily: EDITOR_FONT_FAMILY,
|
||||
lineHeight: 16,
|
||||
minimap: { enabled: false },
|
||||
wordWrap: 'on',
|
||||
scrollBeyondLastLine: false,
|
||||
lineNumbers: 'off',
|
||||
glyphMargin: false,
|
||||
folding: false,
|
||||
renderLineHighlight: 'none',
|
||||
scrollbar: { vertical: 'hidden', horizontal: 'hidden' },
|
||||
overviewRulerLanes: 0,
|
||||
hideCursorInOverviewRuler: true,
|
||||
overviewRulerBorder: false,
|
||||
automaticLayout: true,
|
||||
domReadOnly: true,
|
||||
contextmenu: false,
|
||||
padding: { top: 4, bottom: 4 },
|
||||
}}
|
||||
className="rounded-md overflow-hidden text-xs"
|
||||
loading={null}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,719 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, 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';
|
||||
import { Button } from '@kandev/ui/button';
|
||||
import { IconDeviceFloppy, IconLoader2, IconTrash, IconTextWrap, IconTextWrapDisabled, IconMessagePlus, IconArrowsDiff, IconPlugConnected, IconPlugOff, IconAlertTriangle } from '@tabler/icons-react';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip';
|
||||
import { formatDiffStats } from '@/lib/utils/file-diff';
|
||||
import { toRelativePath } from '@/lib/utils';
|
||||
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 { 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 { 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 { consumePendingCursorPosition } from '@/hooks/use-file-editors';
|
||||
import { initMonacoThemes } from './monaco-init';
|
||||
|
||||
initMonacoThemes();
|
||||
|
||||
type MonacoCodeEditorProps = {
|
||||
path: string;
|
||||
content: string;
|
||||
originalContent: string;
|
||||
isDirty: boolean;
|
||||
isSaving: boolean;
|
||||
sessionId?: string;
|
||||
worktreePath?: string;
|
||||
enableComments?: boolean;
|
||||
onChange: (newContent: string) => void;
|
||||
onSave: () => void;
|
||||
onDelete?: () => void;
|
||||
};
|
||||
|
||||
type TextSelection = {
|
||||
text: string;
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
position: { x: number; y: number };
|
||||
} | null;
|
||||
|
||||
type FloatingButtonPosition = {
|
||||
x: number;
|
||||
y: number;
|
||||
} | null;
|
||||
|
||||
type CommentViewState = {
|
||||
comments: DiffComment[];
|
||||
position: { x: number; y: number };
|
||||
} | null;
|
||||
|
||||
function LspStatusButton({
|
||||
status,
|
||||
lspLanguage,
|
||||
onToggle,
|
||||
}: {
|
||||
status: LspStatus;
|
||||
lspLanguage: string | null;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
if (!lspLanguage) return null;
|
||||
|
||||
type StateConfig = { icon: React.ReactNode; tooltip: string; clickable: boolean };
|
||||
const configs: Record<string, StateConfig> = {
|
||||
disabled: {
|
||||
icon: <IconPlugOff className="h-3.5 w-3.5 text-muted-foreground/50" />,
|
||||
tooltip: 'LSP: Off \u2014 click to start',
|
||||
clickable: true,
|
||||
},
|
||||
connecting: {
|
||||
icon: <IconLoader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />,
|
||||
tooltip: 'LSP: Connecting...',
|
||||
clickable: true,
|
||||
},
|
||||
installing: {
|
||||
icon: <IconLoader2 className="h-3.5 w-3.5 animate-spin text-amber-500" />,
|
||||
tooltip: 'LSP: Installing language server...',
|
||||
clickable: false,
|
||||
},
|
||||
starting: {
|
||||
icon: <IconLoader2 className="h-3.5 w-3.5 animate-spin text-blue-500" />,
|
||||
tooltip: 'LSP: Starting language server...',
|
||||
clickable: true,
|
||||
},
|
||||
ready: {
|
||||
icon: <IconPlugConnected className="h-3.5 w-3.5 text-emerald-500" />,
|
||||
tooltip: 'LSP: Connected \u2014 click to stop',
|
||||
clickable: true,
|
||||
},
|
||||
stopping: {
|
||||
icon: <IconLoader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />,
|
||||
tooltip: 'LSP: Stopping...',
|
||||
clickable: false,
|
||||
},
|
||||
unavailable: {
|
||||
icon: <IconPlugOff className="h-3.5 w-3.5 text-muted-foreground" />,
|
||||
tooltip: `LSP: ${'reason' in status ? status.reason : 'Unavailable'}`,
|
||||
clickable: true,
|
||||
},
|
||||
error: {
|
||||
icon: <IconAlertTriangle className="h-3.5 w-3.5 text-yellow-500" />,
|
||||
tooltip: `LSP: ${'reason' in status ? status.reason : 'Error'}`,
|
||||
clickable: true,
|
||||
},
|
||||
};
|
||||
|
||||
const c = configs[status.state];
|
||||
if (!c) return null;
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 cursor-pointer"
|
||||
onClick={c.clickable ? onToggle : undefined}
|
||||
disabled={!c.clickable}
|
||||
>
|
||||
{c.icon}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{c.tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function MonacoCodeEditor({
|
||||
path,
|
||||
content,
|
||||
originalContent,
|
||||
isDirty,
|
||||
isSaving,
|
||||
sessionId,
|
||||
worktreePath,
|
||||
enableComments = false,
|
||||
onChange,
|
||||
onSave,
|
||||
onDelete,
|
||||
}: MonacoCodeEditorProps) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const [wrapEnabled, setWrapEnabled] = useState(true);
|
||||
const [textSelection, setTextSelection] = useState<TextSelection>(null);
|
||||
const [floatingButtonPos, setFloatingButtonPos] = useState<FloatingButtonPosition>(null);
|
||||
const [currentSelection, setCurrentSelection] = useState<{ text: string; startLine: number; endLine: number } | null>(null);
|
||||
const [commentView, setCommentView] = useState<CommentViewState>(null);
|
||||
const [showDiffIndicators, setShowDiffIndicators] = useState(true);
|
||||
const editorRef = useRef<monacoEditor.IStandaloneCodeEditor | null>(null);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const mousePositionRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
|
||||
const decorationsRef = useRef<monacoEditor.IEditorDecorationsCollection | null>(null);
|
||||
const diffDecorationsRef = useRef<monacoEditor.IEditorDecorationsCollection | null>(null);
|
||||
const disposablesRef = useRef<IDisposable[]>([]);
|
||||
const { toast } = useToast();
|
||||
|
||||
const addComment = useDiffCommentsStore((state) => state.addComment);
|
||||
const removeComment = useDiffCommentsStore((state) => state.removeComment);
|
||||
const comments = useFileComments(sessionId ?? '', path);
|
||||
|
||||
const language = getMonacoLanguage(path);
|
||||
|
||||
// LSP integration
|
||||
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
|
||||
const lspSessionId = sessionId ?? activeSessionId ?? null;
|
||||
const { status: lspStatus, lspLanguage, toggle: toggleLsp } = useLsp(lspSessionId, language);
|
||||
|
||||
const hasLspActive = lspStatus.state === 'ready';
|
||||
|
||||
// Use absolute path for Monaco model URI so the LSP server receives correct file:// URIs
|
||||
const monacoPath = worktreePath ? `${worktreePath}/${path}` : path;
|
||||
|
||||
// LSP document sync: open/close document when LSP becomes ready or component mounts/unmounts
|
||||
const documentUri = `file://${monacoPath}`;
|
||||
useEffect(() => {
|
||||
if (!hasLspActive || !lspSessionId || !lspLanguage) return;
|
||||
lspClientManager.openDocument(lspSessionId, lspLanguage, documentUri, language, content);
|
||||
return () => {
|
||||
lspClientManager.closeDocument(lspSessionId, lspLanguage, documentUri);
|
||||
};
|
||||
// Only run on mount/unmount and when LSP readiness changes — NOT on content changes
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hasLspActive, lspSessionId, lspLanguage, documentUri, language]);
|
||||
|
||||
// LSP document change: notify server of content changes (debounced)
|
||||
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);
|
||||
return () => {
|
||||
if (changeTimerRef.current) clearTimeout(changeTimerRef.current);
|
||||
};
|
||||
}, [hasLspActive, lspSessionId, lspLanguage, documentUri, content]);
|
||||
|
||||
// Toast on LSP status changes
|
||||
const lspStateForToast = lspStatus.state;
|
||||
const lspReasonForToast = 'reason' in lspStatus ? lspStatus.reason : null;
|
||||
useEffect(() => {
|
||||
if (lspStateForToast === 'installing') {
|
||||
toast({
|
||||
title: 'Installing language server',
|
||||
description: 'This may take a moment...',
|
||||
});
|
||||
} else if (lspStateForToast === 'unavailable' && lspReasonForToast) {
|
||||
toast({
|
||||
title: 'Language server not found',
|
||||
description: `${lspReasonForToast}. Enable auto-install in Settings → Editors.`,
|
||||
});
|
||||
} else if (lspStateForToast === 'error' && lspReasonForToast) {
|
||||
toast({
|
||||
title: 'LSP error',
|
||||
description: lspReasonForToast,
|
||||
});
|
||||
}
|
||||
}, [lspStateForToast, lspReasonForToast, toast]);
|
||||
|
||||
// Track mouse position
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
mousePositionRef.current = { x: e.clientX, y: e.clientY };
|
||||
};
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
return () => document.removeEventListener('mousemove', handleMouseMove);
|
||||
}, []);
|
||||
|
||||
// Handle editor mount
|
||||
const handleEditorDidMount: OnMount = useCallback(
|
||||
(editor) => {
|
||||
editorRef.current = editor;
|
||||
decorationsRef.current = editor.createDecorationsCollection([]);
|
||||
diffDecorationsRef.current = editor.createDecorationsCollection([]);
|
||||
|
||||
// Jump to pending cursor position (e.g. from LSP Go-to-Definition)
|
||||
const pendingPos = consumePendingCursorPosition(path);
|
||||
if (pendingPos) {
|
||||
editor.setPosition({ lineNumber: pendingPos.line, column: pendingPos.column });
|
||||
editor.revealLineInCenter(pendingPos.line);
|
||||
}
|
||||
|
||||
// Cmd/Ctrl+S to save
|
||||
editor.addCommand(
|
||||
// eslint-disable-next-line no-bitwise
|
||||
(window as { monaco?: typeof import('monaco-editor') }).monaco!.KeyMod.CtrlCmd |
|
||||
(window as { monaco?: typeof import('monaco-editor') }).monaco!.KeyCode.KeyS,
|
||||
() => {
|
||||
onSave();
|
||||
}
|
||||
);
|
||||
|
||||
// Selection change listener for comments
|
||||
if (enableComments && sessionId) {
|
||||
const disposable = editor.onDidChangeCursorSelection(() => {
|
||||
const selection = editor.getSelection();
|
||||
if (!selection || selection.isEmpty()) {
|
||||
setCurrentSelection(null);
|
||||
return;
|
||||
}
|
||||
const model = editor.getModel();
|
||||
if (!model) return;
|
||||
const text = model.getValueInRange(selection);
|
||||
if (!text.trim()) {
|
||||
setCurrentSelection(null);
|
||||
return;
|
||||
}
|
||||
setCurrentSelection({
|
||||
text,
|
||||
startLine: selection.startLineNumber,
|
||||
endLine: selection.endLineNumber,
|
||||
});
|
||||
});
|
||||
disposablesRef.current.push(disposable);
|
||||
}
|
||||
|
||||
// Gutter click for viewing comments
|
||||
const glyphDisposable = editor.onMouseDown((e) => {
|
||||
if (e.target.type === 2 /* GUTTER_GLYPH_MARGIN */ || e.target.type === 3 /* GUTTER_LINE_NUMBERS */) {
|
||||
const lineNumber = e.target.position?.lineNumber;
|
||||
if (!lineNumber) return;
|
||||
|
||||
const lineComments = comments.filter(
|
||||
(c) => lineNumber >= c.startLine && lineNumber <= c.endLine
|
||||
);
|
||||
if (lineComments.length > 0 && e.event.browserEvent) {
|
||||
setCommentView({
|
||||
comments: lineComments,
|
||||
position: { x: e.event.browserEvent.clientX, y: e.event.browserEvent.clientY },
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
disposablesRef.current.push(glyphDisposable);
|
||||
},
|
||||
[path, onSave, enableComments, sessionId, comments]
|
||||
);
|
||||
|
||||
// Cleanup disposables
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
for (const d of disposablesRef.current) d.dispose();
|
||||
disposablesRef.current = [];
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Update decorations when comments change
|
||||
useEffect(() => {
|
||||
if (!decorationsRef.current || !editorRef.current) return;
|
||||
|
||||
const decorations: monacoEditor.IModelDeltaDecoration[] = [];
|
||||
const linesWithComments = new Set<number>();
|
||||
const firstLines = new Set<number>();
|
||||
|
||||
for (const comment of comments) {
|
||||
firstLines.add(comment.startLine);
|
||||
for (let line = comment.startLine; line <= comment.endLine; line++) {
|
||||
linesWithComments.add(line);
|
||||
}
|
||||
}
|
||||
|
||||
for (const lineNum of linesWithComments) {
|
||||
decorations.push({
|
||||
range: {
|
||||
startLineNumber: lineNum,
|
||||
startColumn: 1,
|
||||
endLineNumber: lineNum,
|
||||
endColumn: 1,
|
||||
},
|
||||
options: {
|
||||
isWholeLine: true,
|
||||
className: 'monaco-comment-line',
|
||||
glyphMarginClassName: firstLines.has(lineNum)
|
||||
? 'monaco-comment-glyph'
|
||||
: 'monaco-comment-glyph-bg',
|
||||
glyphMarginHoverMessage: {
|
||||
value: `${comments.filter((c) => lineNum >= c.startLine && lineNum <= c.endLine).length} comment(s) - click to view`,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
decorationsRef.current.set(decorations);
|
||||
}, [comments]);
|
||||
|
||||
// Diff gutter indicators
|
||||
useEffect(() => {
|
||||
if (!diffDecorationsRef.current || !editorRef.current) return;
|
||||
|
||||
if (!showDiffIndicators || !isDirty || !originalContent) {
|
||||
diffDecorationsRef.current.set([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const changes = diffLines(originalContent, content);
|
||||
const decorations: monacoEditor.IModelDeltaDecoration[] = [];
|
||||
let currentLine = 1;
|
||||
|
||||
for (let i = 0; i < changes.length; i++) {
|
||||
const change = changes[i];
|
||||
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({
|
||||
range: { startLineNumber: currentLine + j, startColumn: 1, endLineNumber: currentLine + j, endColumn: 1 },
|
||||
options: { isWholeLine: true, linesDecorationsClassName: 'monaco-diff-modified-gutter' },
|
||||
});
|
||||
}
|
||||
currentLine += addedLineCount;
|
||||
i++; // skip the added part
|
||||
} 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 },
|
||||
options: { isWholeLine: true, linesDecorationsClassName: 'monaco-diff-deleted-gutter' },
|
||||
});
|
||||
}
|
||||
} 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 },
|
||||
options: { isWholeLine: true, linesDecorationsClassName: 'monaco-diff-added-gutter' },
|
||||
});
|
||||
}
|
||||
currentLine += lineCount;
|
||||
} else {
|
||||
// Unchanged
|
||||
currentLine += lineCount;
|
||||
}
|
||||
}
|
||||
|
||||
diffDecorationsRef.current.set(decorations);
|
||||
}, [content, originalContent, showDiffIndicators, isDirty]);
|
||||
|
||||
// Show floating button after mouse up
|
||||
useEffect(() => {
|
||||
const wrapper = wrapperRef.current;
|
||||
if (!wrapper || !enableComments || !sessionId) return;
|
||||
|
||||
const handleMouseUp = (e: MouseEvent) => {
|
||||
if ((e.target as HTMLElement).closest('.floating-comment-btn')) return;
|
||||
setTimeout(() => {
|
||||
if (currentSelection) {
|
||||
setFloatingButtonPos({ x: mousePositionRef.current.x, y: mousePositionRef.current.y });
|
||||
}
|
||||
}, 10);
|
||||
};
|
||||
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
if ((e.target as HTMLElement).closest('.floating-comment-btn')) return;
|
||||
setFloatingButtonPos(null);
|
||||
};
|
||||
|
||||
wrapper.addEventListener('mouseup', handleMouseUp);
|
||||
wrapper.addEventListener('mousedown', handleMouseDown);
|
||||
return () => {
|
||||
wrapper.removeEventListener('mouseup', handleMouseUp);
|
||||
wrapper.removeEventListener('mousedown', handleMouseDown);
|
||||
};
|
||||
}, [enableComments, sessionId, currentSelection]);
|
||||
|
||||
// Cmd+I for comment popover
|
||||
useEffect(() => {
|
||||
const wrapper = wrapperRef.current;
|
||||
if (!wrapper || !enableComments || !sessionId) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'i') {
|
||||
if (!currentSelection || !floatingButtonPos) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setTextSelection({ ...currentSelection, position: floatingButtonPos });
|
||||
setFloatingButtonPos(null);
|
||||
}
|
||||
};
|
||||
|
||||
wrapper.addEventListener('keydown', handleKeyDown, true);
|
||||
return () => wrapper.removeEventListener('keydown', handleKeyDown, true);
|
||||
}, [enableComments, sessionId, currentSelection, floatingButtonPos]);
|
||||
|
||||
// Escape to close popovers
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (textSelection) setTextSelection(null);
|
||||
if (commentView) setCommentView(null);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [textSelection, commentView]);
|
||||
|
||||
const handleChange: OnChange = useCallback(
|
||||
(value) => {
|
||||
if (value !== undefined) onChange(value);
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
const handleFloatingButtonClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!currentSelection || !floatingButtonPos) return;
|
||||
setTextSelection({ ...currentSelection, position: floatingButtonPos });
|
||||
setFloatingButtonPos(null);
|
||||
},
|
||||
[currentSelection, floatingButtonPos]
|
||||
);
|
||||
|
||||
const handleCommentSubmit = useCallback(
|
||||
(annotation: string) => {
|
||||
if (!textSelection || !sessionId) return;
|
||||
const comment: DiffComment = {
|
||||
id: `${path}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
sessionId,
|
||||
filePath: path,
|
||||
startLine: textSelection.startLine,
|
||||
endLine: textSelection.endLine,
|
||||
side: 'additions',
|
||||
codeContent: textSelection.text,
|
||||
annotation,
|
||||
createdAt: new Date().toISOString(),
|
||||
status: 'pending',
|
||||
};
|
||||
addComment(comment);
|
||||
setTextSelection(null);
|
||||
|
||||
// Clear editor selection
|
||||
const editor = editorRef.current;
|
||||
if (editor) {
|
||||
const pos = editor.getPosition();
|
||||
if (pos) editor.setSelection({ startLineNumber: pos.lineNumber, startColumn: pos.column, endLineNumber: pos.lineNumber, endColumn: pos.column });
|
||||
}
|
||||
|
||||
toast({ title: 'Comment added', description: 'Your comment will be sent with your next message.' });
|
||||
},
|
||||
[textSelection, sessionId, path, addComment, toast]
|
||||
);
|
||||
|
||||
const handlePopoverClose = useCallback(() => setTextSelection(null), []);
|
||||
|
||||
const handleDeleteComment = useCallback(
|
||||
(commentId: string) => {
|
||||
if (!sessionId) return;
|
||||
removeComment(sessionId, path, commentId);
|
||||
if (commentView && commentView.comments.length <= 1) {
|
||||
setCommentView(null);
|
||||
} else if (commentView) {
|
||||
setCommentView({
|
||||
...commentView,
|
||||
comments: commentView.comments.filter((c) => c.id !== commentId),
|
||||
});
|
||||
}
|
||||
toast({ title: 'Comment deleted' });
|
||||
},
|
||||
[sessionId, path, removeComment, commentView, toast]
|
||||
);
|
||||
|
||||
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]);
|
||||
|
||||
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">
|
||||
{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" />
|
||||
<span>{comments.length} comment{comments.length > 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
<LspStatusButton status={lspStatus} lspLanguage={lspLanguage} onToggle={toggleLsp} />
|
||||
{isDirty && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setShowDiffIndicators(!showDiffIndicators)}
|
||||
className={`h-8 w-8 p-0 cursor-pointer ${showDiffIndicators ? 'text-foreground' : 'text-muted-foreground'}`}
|
||||
>
|
||||
<IconArrowsDiff className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{showDiffIndicators ? 'Hide diff indicators' : 'Show diff indicators'}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setWrapEnabled(!wrapEnabled)}
|
||||
className={`h-8 w-8 p-0 cursor-pointer ${wrapEnabled ? 'text-foreground' : 'text-muted-foreground'}`}
|
||||
>
|
||||
{wrapEnabled ? <IconTextWrap className="h-4 w-4" /> : <IconTextWrapDisabled className="h-4 w-4" />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{wrapEnabled ? 'Disable word wrap' : 'Enable word wrap'}</TooltipContent>
|
||||
</Tooltip>
|
||||
<FileActionsDropdown filePath={path} sessionId={sessionId} size="sm" />
|
||||
{onDelete && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onDelete}
|
||||
className="h-8 w-8 p-0 cursor-pointer text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<IconTrash className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Delete file</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
onClick={onSave}
|
||||
disabled={!isDirty || isSaving}
|
||||
className="cursor-pointer gap-2"
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<IconLoader2 className="h-4 w-4 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<IconDeviceFloppy className="h-4 w-4" />
|
||||
Save
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({navigator.platform.includes('Mac') ? '\u2318' : 'Ctrl'}+S)
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Monaco editor */}
|
||||
<div className="flex-1 overflow-hidden relative">
|
||||
<Editor
|
||||
height="100%"
|
||||
language={language}
|
||||
path={monacoPath}
|
||||
value={content}
|
||||
theme={resolvedTheme === 'dark' ? 'kandev-dark' : 'kandev-light'}
|
||||
onChange={handleChange}
|
||||
onMount={handleEditorDidMount}
|
||||
options={{
|
||||
fontSize: EDITOR_FONT_SIZE,
|
||||
fontFamily: EDITOR_FONT_FAMILY,
|
||||
lineHeight: 18,
|
||||
minimap: { enabled: false },
|
||||
wordWrap: wrapEnabled ? 'on' : 'off',
|
||||
scrollBeyondLastLine: false,
|
||||
smoothScrolling: true,
|
||||
cursorSmoothCaretAnimation: 'on',
|
||||
glyphMargin: enableComments,
|
||||
folding: true,
|
||||
lineNumbers: 'on',
|
||||
renderLineHighlight: 'line',
|
||||
automaticLayout: true,
|
||||
scrollbar: {
|
||||
verticalScrollbarSize: 10,
|
||||
horizontalScrollbarSize: 10,
|
||||
},
|
||||
padding: { top: 4 },
|
||||
'semanticHighlighting.enabled': true,
|
||||
}}
|
||||
loading={
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground text-sm">
|
||||
Loading editor...
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Floating comment button */}
|
||||
{floatingButtonPos && !textSelection && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="floating-comment-btn fixed z-50 gap-1.5 shadow-lg animate-in fade-in-0 zoom-in-95 duration-100 cursor-pointer"
|
||||
style={{ left: floatingButtonPos.x + 8, top: floatingButtonPos.y + 8 }}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={handleFloatingButtonClick}
|
||||
>
|
||||
<IconMessagePlus className="h-3.5 w-3.5" />
|
||||
Comment
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{textSelection && (
|
||||
<EditorCommentPopover
|
||||
selectedText={textSelection.text}
|
||||
lineRange={{ start: textSelection.startLine, end: textSelection.endLine }}
|
||||
position={textSelection.position}
|
||||
onSubmit={handleCommentSubmit}
|
||||
onClose={handlePopoverClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
{commentView && (
|
||||
<CommentViewPopover
|
||||
comments={commentView.comments}
|
||||
position={commentView.position}
|
||||
onDelete={handleDeleteComment}
|
||||
onClose={handleCommentViewClose}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,555 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, 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';
|
||||
import { cn } from '@kandev/ui/lib/utils';
|
||||
import { Button } from '@kandev/ui/button';
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from '@kandev/ui/tooltip';
|
||||
import { IconCopy, IconTextWrap, IconLayoutRows, IconLayoutColumns, IconPencil, IconArrowBackUp, IconFoldDown, IconFold } from '@tabler/icons-react';
|
||||
import type { FileDiffData, DiffComment } from '@/lib/diff/types';
|
||||
import { getMonacoLanguage } from '@/lib/editor/language-map';
|
||||
import { EDITOR_FONT_FAMILY, EDITOR_FONT_SIZE } from '@/lib/theme/editor-theme';
|
||||
import { useDiffComments } from '@/components/diff/use-diff-comments';
|
||||
import { CommentForm } from '@/components/diff/comment-form';
|
||||
import { CommentDisplay } from '@/components/diff/comment-display';
|
||||
import { initMonacoThemes } from './monaco-init';
|
||||
|
||||
initMonacoThemes();
|
||||
|
||||
// Global view mode sync (same as pierre diff-viewer)
|
||||
const DIFF_VIEW_MODE_KEY = 'diff-view-mode';
|
||||
const DEFAULT_VIEW_MODE = 'unified' as const;
|
||||
const VIEW_MODE_CHANGE_EVENT = 'diff-view-mode-change';
|
||||
type ViewMode = 'split' | 'unified';
|
||||
|
||||
function getStoredViewMode(): ViewMode {
|
||||
if (typeof window === 'undefined') return DEFAULT_VIEW_MODE;
|
||||
const stored = localStorage.getItem(DIFF_VIEW_MODE_KEY);
|
||||
return stored === 'split' || stored === 'unified' ? stored : DEFAULT_VIEW_MODE;
|
||||
}
|
||||
|
||||
function setStoredViewMode(mode: ViewMode): void {
|
||||
localStorage.setItem(DIFF_VIEW_MODE_KEY, mode);
|
||||
window.dispatchEvent(new CustomEvent(VIEW_MODE_CHANGE_EVENT, { detail: mode }));
|
||||
}
|
||||
|
||||
function useGlobalViewMode(): [ViewMode, (mode: ViewMode) => void] {
|
||||
const subscribe = useCallback((callback: () => void) => {
|
||||
window.addEventListener(VIEW_MODE_CHANGE_EVENT, callback);
|
||||
window.addEventListener('storage', callback);
|
||||
return () => {
|
||||
window.removeEventListener(VIEW_MODE_CHANGE_EVENT, callback);
|
||||
window.removeEventListener('storage', callback);
|
||||
};
|
||||
}, []);
|
||||
const getSnapshot = useCallback(() => getStoredViewMode(), []);
|
||||
const getServerSnapshot = useCallback(() => DEFAULT_VIEW_MODE, []);
|
||||
const viewMode = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||
return [viewMode, setStoredViewMode];
|
||||
}
|
||||
|
||||
// Global folding toggle sync (same pattern as view mode)
|
||||
const DIFF_FOLD_KEY = 'diff-fold-unchanged';
|
||||
const DEFAULT_FOLD = true;
|
||||
const FOLD_CHANGE_EVENT = 'diff-fold-change';
|
||||
|
||||
function getStoredFolding(): boolean {
|
||||
if (typeof window === 'undefined') return DEFAULT_FOLD;
|
||||
const stored = localStorage.getItem(DIFF_FOLD_KEY);
|
||||
if (stored === null) return DEFAULT_FOLD;
|
||||
return stored === 'true';
|
||||
}
|
||||
|
||||
function setStoredFolding(fold: boolean): void {
|
||||
localStorage.setItem(DIFF_FOLD_KEY, String(fold));
|
||||
window.dispatchEvent(new CustomEvent(FOLD_CHANGE_EVENT, { detail: fold }));
|
||||
}
|
||||
|
||||
function useGlobalFolding(): [boolean, (fold: boolean) => void] {
|
||||
const subscribe = useCallback((callback: () => void) => {
|
||||
window.addEventListener(FOLD_CHANGE_EVENT, callback);
|
||||
window.addEventListener('storage', callback);
|
||||
return () => {
|
||||
window.removeEventListener(FOLD_CHANGE_EVENT, callback);
|
||||
window.removeEventListener('storage', callback);
|
||||
};
|
||||
}, []);
|
||||
const getSnapshot = useCallback(() => getStoredFolding(), []);
|
||||
const getServerSnapshot = useCallback(() => DEFAULT_FOLD, []);
|
||||
const foldUnchanged = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||
return [foldUnchanged, setStoredFolding];
|
||||
}
|
||||
|
||||
interface MonacoDiffViewerProps {
|
||||
data: FileDiffData;
|
||||
sessionId?: string;
|
||||
onCommentAdd?: (comment: DiffComment) => void;
|
||||
onCommentDelete?: (commentId: string) => void;
|
||||
comments?: DiffComment[];
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
hideHeader?: boolean;
|
||||
onOpenFile?: (filePath: string) => void;
|
||||
onRevert?: (filePath: string) => void;
|
||||
wordWrap?: boolean;
|
||||
}
|
||||
|
||||
export function MonacoDiffViewer({
|
||||
data,
|
||||
sessionId,
|
||||
onCommentAdd,
|
||||
onCommentDelete,
|
||||
comments: externalComments,
|
||||
className,
|
||||
compact = false,
|
||||
hideHeader = false,
|
||||
onOpenFile,
|
||||
onRevert,
|
||||
wordWrap: wordWrapProp,
|
||||
}: MonacoDiffViewerProps) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const [globalViewMode, setGlobalViewMode] = useGlobalViewMode();
|
||||
const [foldUnchanged, setFoldUnchanged] = useGlobalFolding();
|
||||
const [wordWrapLocal, setWordWrap] = useState(false);
|
||||
const wordWrap = wordWrapProp ?? wordWrapLocal;
|
||||
const diffEditorRef = useRef<monacoEditor.IStandaloneDiffEditor | null>(null);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Context menu state
|
||||
type ContextMenuState = {
|
||||
x: number;
|
||||
y: number;
|
||||
lineNumber: number;
|
||||
side: 'original' | 'modified';
|
||||
isChangedLine: boolean;
|
||||
lineContent: string;
|
||||
} | null;
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState>(null);
|
||||
|
||||
// Comment form state
|
||||
const [showCommentForm, setShowCommentForm] = useState(false);
|
||||
const [selectedLineRange, setSelectedLineRange] = useState<{ start: number; end: number; side: string } | null>(null);
|
||||
|
||||
const {
|
||||
comments: internalComments,
|
||||
addComment,
|
||||
removeComment,
|
||||
updateComment,
|
||||
editingCommentId,
|
||||
setEditingComment,
|
||||
} = useDiffComments({
|
||||
sessionId: sessionId || '',
|
||||
filePath: data.filePath,
|
||||
diff: data.diff,
|
||||
newContent: data.newContent,
|
||||
oldContent: data.oldContent,
|
||||
});
|
||||
|
||||
const comments = externalComments || internalComments;
|
||||
|
||||
const language = getMonacoLanguage(data.filePath);
|
||||
const showHeader = !hideHeader && !compact;
|
||||
|
||||
// Parse original/modified content from diff data
|
||||
// When only a unified diff string is provided (e.g. git diffs), reconstruct
|
||||
// the original and modified content by parsing the patch hunks.
|
||||
const { original, modified } = useMemo(() => {
|
||||
if (data.oldContent || data.newContent) {
|
||||
return {
|
||||
original: data.oldContent ?? '',
|
||||
modified: data.newContent ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
// Reconstruct from unified diff patch
|
||||
if (data.diff) {
|
||||
const originalLines: string[] = [];
|
||||
const modifiedLines: string[] = [];
|
||||
const lines = data.diff.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
// Skip diff headers
|
||||
if (line.startsWith('diff ') || line.startsWith('index ') ||
|
||||
line.startsWith('--- ') || line.startsWith('+++ ') ||
|
||||
line.startsWith('@@')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('-')) {
|
||||
originalLines.push(line.slice(1));
|
||||
} else if (line.startsWith('+')) {
|
||||
modifiedLines.push(line.slice(1));
|
||||
} else if (line.startsWith(' ') || line === '') {
|
||||
// Context line (or empty line between hunks)
|
||||
const content = line.startsWith(' ') ? line.slice(1) : line;
|
||||
originalLines.push(content);
|
||||
modifiedLines.push(content);
|
||||
} else if (line.startsWith('\\')) {
|
||||
// "\ No newline at end of file" — skip
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
original: originalLines.join('\n'),
|
||||
modified: modifiedLines.join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
return { original: '', modified: '' };
|
||||
}, [data.oldContent, data.newContent, data.diff]);
|
||||
|
||||
const handleDiffEditorMount: DiffOnMount = useCallback((editor) => {
|
||||
diffEditorRef.current = editor;
|
||||
|
||||
// Register context menu on both sides
|
||||
const modifiedEditor = editor.getModifiedEditor();
|
||||
const originalEditor = editor.getOriginalEditor();
|
||||
|
||||
const handleContextMenu = (side: 'original' | 'modified') => (e: monacoEditor.IEditorMouseEvent) => {
|
||||
if (e.target.position) {
|
||||
e.event.preventDefault();
|
||||
e.event.stopPropagation();
|
||||
const lineNumber = e.target.position.lineNumber;
|
||||
const targetEditor = side === 'modified' ? modifiedEditor : originalEditor;
|
||||
const model = targetEditor.getModel();
|
||||
const lineContent = model ? model.getLineContent(lineNumber) : '';
|
||||
const lineChanges = editor.getLineChanges();
|
||||
let isChangedLine = false;
|
||||
if (lineChanges) {
|
||||
for (const change of lineChanges) {
|
||||
if (side === 'modified') {
|
||||
if (lineNumber >= change.modifiedStartLineNumber && lineNumber <= change.modifiedEndLineNumber) {
|
||||
isChangedLine = true;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
if (lineNumber >= change.originalStartLineNumber && lineNumber <= change.originalEndLineNumber) {
|
||||
isChangedLine = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
setContextMenu({
|
||||
x: e.event.posx,
|
||||
y: e.event.posy,
|
||||
lineNumber,
|
||||
side,
|
||||
isChangedLine,
|
||||
lineContent,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
modifiedEditor.onContextMenu(handleContextMenu('modified'));
|
||||
originalEditor.onContextMenu(handleContextMenu('original'));
|
||||
}, []);
|
||||
|
||||
// Handle comment submission
|
||||
const handleCommentSubmit = useCallback(
|
||||
(content: string) => {
|
||||
if (!selectedLineRange) return;
|
||||
if (onCommentAdd && externalComments !== undefined) {
|
||||
const comment: DiffComment = {
|
||||
id: `${data.filePath}-${Date.now()}`,
|
||||
sessionId: sessionId || '',
|
||||
filePath: data.filePath,
|
||||
startLine: Math.min(selectedLineRange.start, selectedLineRange.end),
|
||||
endLine: Math.max(selectedLineRange.start, selectedLineRange.end),
|
||||
side: (selectedLineRange.side || 'additions') as DiffComment['side'],
|
||||
codeContent: '',
|
||||
annotation: content,
|
||||
createdAt: new Date().toISOString(),
|
||||
status: 'pending',
|
||||
};
|
||||
onCommentAdd(comment);
|
||||
} else if (sessionId) {
|
||||
addComment(
|
||||
{
|
||||
start: selectedLineRange.start,
|
||||
end: selectedLineRange.end,
|
||||
side: selectedLineRange.side as 'additions' | 'deletions',
|
||||
},
|
||||
content
|
||||
);
|
||||
}
|
||||
setShowCommentForm(false);
|
||||
setSelectedLineRange(null);
|
||||
},
|
||||
[selectedLineRange, sessionId, data.filePath, addComment, onCommentAdd, externalComments]
|
||||
);
|
||||
|
||||
const handleCommentDelete = useCallback(
|
||||
(commentId: string) => {
|
||||
if (onCommentDelete && externalComments !== undefined) {
|
||||
onCommentDelete(commentId);
|
||||
} else {
|
||||
removeComment(commentId);
|
||||
}
|
||||
},
|
||||
[removeComment, onCommentDelete, externalComments]
|
||||
);
|
||||
|
||||
const handleCommentUpdate = useCallback(
|
||||
(commentId: string, content: string) => {
|
||||
updateComment(commentId, { annotation: content });
|
||||
setEditingComment(null);
|
||||
},
|
||||
[updateComment, setEditingComment]
|
||||
);
|
||||
|
||||
// Close context menu on click or Escape
|
||||
useEffect(() => {
|
||||
if (!contextMenu) return;
|
||||
const handleClose = () => setContextMenu(null);
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setContextMenu(null);
|
||||
};
|
||||
window.addEventListener('mousedown', handleClose);
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener('mousedown', handleClose);
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [contextMenu]);
|
||||
|
||||
// Copy all changed lines from the diff
|
||||
const copyAllChangedLines = useCallback(() => {
|
||||
const editor = diffEditorRef.current;
|
||||
if (!editor) return;
|
||||
const lineChanges = editor.getLineChanges();
|
||||
if (!lineChanges) return;
|
||||
const modifiedModel = editor.getModifiedEditor().getModel();
|
||||
if (!modifiedModel) return;
|
||||
const changedLines: string[] = [];
|
||||
for (const change of lineChanges) {
|
||||
if (change.modifiedStartLineNumber <= change.modifiedEndLineNumber) {
|
||||
for (let i = change.modifiedStartLineNumber; i <= change.modifiedEndLineNumber; i++) {
|
||||
changedLines.push(modifiedModel.getLineContent(i));
|
||||
}
|
||||
}
|
||||
}
|
||||
navigator.clipboard.writeText(changedLines.join('\n'));
|
||||
setContextMenu(null);
|
||||
}, []);
|
||||
|
||||
if (!data.oldContent && !data.newContent && !data.diff) {
|
||||
return (
|
||||
<div className={cn(
|
||||
'rounded-md border border-border/50 bg-muted/20 p-4 text-muted-foreground',
|
||||
compact ? 'text-xs' : 'text-sm',
|
||||
className
|
||||
)}>
|
||||
No diff available
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className={cn('monaco-diff-viewer relative', className)}>
|
||||
{/* Toolbar */}
|
||||
{showHeader && (
|
||||
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border/50 bg-card/50 rounded-t-md text-xs text-muted-foreground">
|
||||
<span className="font-mono truncate">{data.filePath}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-6 w-6 p-0 cursor-pointer opacity-60 hover:opacity-100" onClick={() => navigator.clipboard.writeText(data.diff || '')}>
|
||||
<IconCopy className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Copy diff</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn('h-6 w-6 p-0 cursor-pointer opacity-60 hover:opacity-100', foldUnchanged && 'opacity-100 bg-muted')}
|
||||
onClick={() => setFoldUnchanged(!foldUnchanged)}
|
||||
>
|
||||
{foldUnchanged ? <IconFoldDown className="h-3.5 w-3.5" /> : <IconFold className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{foldUnchanged ? 'Show all lines' : 'Fold unchanged lines'}</TooltipContent>
|
||||
</Tooltip>
|
||||
{onRevert && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-6 w-6 p-0 cursor-pointer opacity-60 hover:opacity-100" onClick={() => onRevert(data.filePath)}>
|
||||
<IconArrowBackUp className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Revert changes</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn('h-6 w-6 p-0 cursor-pointer opacity-60 hover:opacity-100', wordWrap && 'opacity-100 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
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 cursor-pointer opacity-60 hover:opacity-100"
|
||||
onClick={() => setGlobalViewMode(globalViewMode === 'split' ? 'unified' : 'split')}
|
||||
>
|
||||
{globalViewMode === 'split' ? <IconLayoutRows className="h-3.5 w-3.5" /> : <IconLayoutColumns className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{globalViewMode === 'split' ? 'Switch to unified view' : 'Switch to split view'}</TooltipContent>
|
||||
</Tooltip>
|
||||
{onOpenFile && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-6 w-6 p-0 cursor-pointer opacity-60 hover:opacity-100" onClick={() => onOpenFile(data.filePath)}>
|
||||
<IconPencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Edit</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Diff editor */}
|
||||
<div className={cn('overflow-hidden', showHeader ? 'rounded-b-md' : 'rounded-md', 'border border-border/50')}>
|
||||
<DiffEditor
|
||||
height={compact ? '200px' : '400px'}
|
||||
language={language}
|
||||
original={original}
|
||||
modified={modified}
|
||||
theme={resolvedTheme === 'dark' ? 'kandev-dark' : 'kandev-light'}
|
||||
onMount={handleDiffEditorMount}
|
||||
options={{
|
||||
fontSize: compact ? 11 : EDITOR_FONT_SIZE,
|
||||
fontFamily: EDITOR_FONT_FAMILY,
|
||||
lineHeight: compact ? 16 : 18,
|
||||
minimap: { enabled: false },
|
||||
wordWrap: wordWrap ? 'on' : 'off',
|
||||
readOnly: true,
|
||||
contextmenu: false,
|
||||
renderSideBySide: globalViewMode === 'split',
|
||||
scrollBeyondLastLine: false,
|
||||
smoothScrolling: true,
|
||||
automaticLayout: true,
|
||||
renderOverviewRuler: false,
|
||||
hideUnchangedRegions: {
|
||||
enabled: foldUnchanged,
|
||||
contextLineCount: 3,
|
||||
minimumLineCount: 3,
|
||||
revealLineCount: 20,
|
||||
},
|
||||
folding: !compact,
|
||||
lineNumbers: compact ? 'off' : 'on',
|
||||
glyphMargin: false,
|
||||
scrollbar: {
|
||||
verticalScrollbarSize: 8,
|
||||
horizontalScrollbarSize: 8,
|
||||
alwaysConsumeMouseWheel: false,
|
||||
},
|
||||
padding: { top: 2 },
|
||||
}}
|
||||
loading={
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground text-sm">
|
||||
Loading diff...
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Comment annotations below the diff */}
|
||||
{comments.length > 0 && (
|
||||
<div className="mt-2 space-y-2">
|
||||
{comments.map((comment) => (
|
||||
<div key={comment.id} className="px-2">
|
||||
{editingCommentId === comment.id ? (
|
||||
<CommentForm
|
||||
initialContent={comment.annotation}
|
||||
onSubmit={(content) => handleCommentUpdate(comment.id, content)}
|
||||
onCancel={() => setEditingComment(null)}
|
||||
isEditing
|
||||
/>
|
||||
) : (
|
||||
<CommentDisplay
|
||||
comment={comment}
|
||||
onDelete={() => handleCommentDelete(comment.id)}
|
||||
onEdit={() => setEditingComment(comment.id)}
|
||||
showCode={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* New comment form */}
|
||||
{showCommentForm && selectedLineRange && (
|
||||
<div className="mt-2 px-2">
|
||||
<CommentForm
|
||||
onSubmit={handleCommentSubmit}
|
||||
onCancel={() => {
|
||||
setShowCommentForm(false);
|
||||
setSelectedLineRange(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Context menu */}
|
||||
{contextMenu && (
|
||||
<div
|
||||
className="fixed z-50 min-w-[180px] rounded-md border border-border bg-popover text-popover-foreground shadow-md ring-1 ring-border/10 py-1 text-xs"
|
||||
style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
className="flex w-full items-center px-3 py-1.5 hover:bg-accent hover:text-accent-foreground cursor-pointer"
|
||||
onClick={copyAllChangedLines}
|
||||
>
|
||||
Copy all changed lines
|
||||
</button>
|
||||
{contextMenu.isChangedLine && (
|
||||
<button
|
||||
className="flex w-full items-center px-3 py-1.5 hover:bg-accent hover:text-accent-foreground cursor-pointer"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(contextMenu.lineContent);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
>
|
||||
Copy line {contextMenu.lineNumber}
|
||||
</button>
|
||||
)}
|
||||
{onRevert && (
|
||||
<>
|
||||
<div className="my-1 border-t border-border" />
|
||||
<button
|
||||
className="flex w-full items-center px-3 py-1.5 hover:bg-accent hover:text-accent-foreground cursor-pointer text-destructive"
|
||||
onClick={() => {
|
||||
onRevert(data.filePath);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
>
|
||||
Revert all changes
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { Monaco } from '@monaco-editor/react';
|
||||
import type { editor as monacoEditor, Uri, IRange, IPosition } from 'monaco-editor';
|
||||
import { KANDEV_MONACO_DARK, KANDEV_MONACO_LIGHT } from '@/lib/theme/editor-theme';
|
||||
|
||||
let initialized = false;
|
||||
let monacoInstance: Monaco | null = null;
|
||||
|
||||
export function initMonacoThemes() {
|
||||
if (initialized || typeof window === 'undefined') return;
|
||||
initialized = true;
|
||||
|
||||
// Dynamic import ensures monaco-loader runs (which sets MonacoEnvironment +
|
||||
// loader.config) before any <Editor> component mounts.
|
||||
import('./monaco-loader').then(({ monaco }) => {
|
||||
monacoInstance = monaco;
|
||||
|
||||
monaco.editor.defineTheme('kandev-dark', KANDEV_MONACO_DARK);
|
||||
monaco.editor.defineTheme('kandev-light', KANDEV_MONACO_LIGHT);
|
||||
|
||||
// Disable Monaco's built-in TS/JS diagnostics — they can't resolve project-level
|
||||
// path aliases (@/, tsconfig paths). LSP diagnostics are applied separately via
|
||||
// setModelMarkers() and work independently of this setting.
|
||||
setMonacoDiagnostics(false);
|
||||
|
||||
// Enable JSX so .tsx/.jsx files parse correctly
|
||||
const tsDefaults = monaco.languages.typescript.typescriptDefaults;
|
||||
const jsDefaults = monaco.languages.typescript.javascriptDefaults;
|
||||
tsDefaults.setCompilerOptions({
|
||||
jsx: monaco.languages.typescript.JsxEmit.ReactJSX,
|
||||
allowJs: true,
|
||||
allowNonTsExtensions: true,
|
||||
target: monaco.languages.typescript.ScriptTarget.ESNext,
|
||||
module: monaco.languages.typescript.ModuleKind.ESNext,
|
||||
moduleResolution: monaco.languages.typescript.ModuleResolutionKind.NodeJs,
|
||||
});
|
||||
jsDefaults.setCompilerOptions({
|
||||
jsx: monaco.languages.typescript.JsxEmit.ReactJSX,
|
||||
allowJs: true,
|
||||
allowNonTsExtensions: true,
|
||||
target: monaco.languages.typescript.ScriptTarget.ESNext,
|
||||
module: monaco.languages.typescript.ModuleKind.ESNext,
|
||||
});
|
||||
|
||||
// Register editor opener to intercept Go-to-Definition navigation.
|
||||
// When the LSP returns a file:// URI, this opens the file in a dockview tab
|
||||
// instead of Monaco trying to navigate to a non-existent model.
|
||||
// Dynamic import to avoid circular dependency (lsp-client-manager → monaco-init).
|
||||
import('@/lib/lsp/lsp-client-manager').then(({ lspClientManager }) => {
|
||||
monaco.editor.registerEditorOpener({
|
||||
openCodeEditor(_source: monacoEditor.ICodeEditor, resource: Uri, selectionOrPosition?: IRange | IPosition) {
|
||||
const uri = resource.toString();
|
||||
const opener = lspClientManager.getFileOpener();
|
||||
if (opener && uri.startsWith('file:')) {
|
||||
// Extract line/column from the selection or position
|
||||
let line: number | undefined;
|
||||
let column: number | undefined;
|
||||
if (selectionOrPosition) {
|
||||
if ('lineNumber' in selectionOrPosition) {
|
||||
// IPosition
|
||||
line = selectionOrPosition.lineNumber;
|
||||
column = selectionOrPosition.column;
|
||||
} else if ('startLineNumber' in selectionOrPosition) {
|
||||
// IRange
|
||||
line = selectionOrPosition.startLineNumber;
|
||||
column = selectionOrPosition.startColumn;
|
||||
}
|
||||
}
|
||||
opener(uri, line, column);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function getMonacoInstance(): Monaco | null {
|
||||
return monacoInstance;
|
||||
}
|
||||
|
||||
export function setMonacoDiagnostics(enabled: boolean) {
|
||||
if (!monacoInstance) return;
|
||||
const tsDefaults = monacoInstance.languages.typescript.typescriptDefaults;
|
||||
const jsDefaults = monacoInstance.languages.typescript.javascriptDefaults;
|
||||
const diagOptions = {
|
||||
noSemanticValidation: !enabled,
|
||||
noSuggestionDiagnostics: !enabled,
|
||||
noSyntaxValidation: !enabled,
|
||||
};
|
||||
tsDefaults.setDiagnosticsOptions(diagOptions);
|
||||
jsDefaults.setDiagnosticsOptions(diagOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Suppress or restore Monaco's built-in TS/JS language providers (hover,
|
||||
* completions, definitions, references, etc.). When LSP is active these
|
||||
* should be suppressed to avoid duplicate results.
|
||||
*
|
||||
* Uses the shared flag in builtin-providers.ts which is checked both at
|
||||
* registration time (to decide whether to wrap a provider) and at call time
|
||||
* (to decide whether wrapped providers return empty results).
|
||||
*/
|
||||
export function setMonacoBuiltinProviders(enabled: boolean) {
|
||||
import('./builtin-providers').then(({ setBuiltinTsSuppressed }) => {
|
||||
setBuiltinTsSuppressed(!enabled);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { DiffEditor } from '@monaco-editor/react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { FileDiffData } from '@/lib/diff/types';
|
||||
import { getMonacoLanguage } from '@/lib/editor/language-map';
|
||||
import { EDITOR_FONT_FAMILY } from '@/lib/theme/editor-theme';
|
||||
import { initMonacoThemes } from './monaco-init';
|
||||
|
||||
initMonacoThemes();
|
||||
|
||||
type MonacoInlineDiffProps = {
|
||||
data: FileDiffData;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function MonacoInlineDiff({ data, className }: MonacoInlineDiffProps) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const language = getMonacoLanguage(data.filePath);
|
||||
|
||||
const { original, modified } = useMemo(() => {
|
||||
if (data.oldContent || data.newContent) {
|
||||
return {
|
||||
original: data.oldContent ?? '',
|
||||
modified: data.newContent ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
// Reconstruct from unified diff patch
|
||||
if (data.diff) {
|
||||
const originalLines: string[] = [];
|
||||
const modifiedLines: string[] = [];
|
||||
for (const line of data.diff.split('\n')) {
|
||||
if (line.startsWith('diff ') || line.startsWith('index ') ||
|
||||
line.startsWith('--- ') || line.startsWith('+++ ') ||
|
||||
line.startsWith('@@') || line.startsWith('\\')) continue;
|
||||
if (line.startsWith('-')) {
|
||||
originalLines.push(line.slice(1));
|
||||
} else if (line.startsWith('+')) {
|
||||
modifiedLines.push(line.slice(1));
|
||||
} else {
|
||||
const content = line.startsWith(' ') ? line.slice(1) : line;
|
||||
originalLines.push(content);
|
||||
modifiedLines.push(content);
|
||||
}
|
||||
}
|
||||
return { original: originalLines.join('\n'), modified: modifiedLines.join('\n') };
|
||||
}
|
||||
|
||||
return { original: '', modified: '' };
|
||||
}, [data.oldContent, data.newContent, data.diff]);
|
||||
|
||||
const lineCount = Math.max(
|
||||
original.split('\n').length,
|
||||
modified.split('\n').length
|
||||
);
|
||||
const height = Math.min(Math.max(lineCount * 16 + 8, 48), 300);
|
||||
|
||||
return (
|
||||
<div className={cn('mt-3 rounded-md border border-border/50 bg-background/60 px-3 py-2 text-xs overflow-hidden', className)}>
|
||||
<DiffEditor
|
||||
height={height}
|
||||
language={language}
|
||||
original={original}
|
||||
modified={modified}
|
||||
theme={resolvedTheme === 'dark' ? 'kandev-dark' : 'kandev-light'}
|
||||
options={{
|
||||
readOnly: true,
|
||||
fontSize: 11,
|
||||
fontFamily: EDITOR_FONT_FAMILY,
|
||||
lineHeight: 16,
|
||||
minimap: { enabled: false },
|
||||
wordWrap: 'on',
|
||||
renderSideBySide: false,
|
||||
scrollBeyondLastLine: false,
|
||||
lineNumbers: 'off',
|
||||
glyphMargin: false,
|
||||
folding: false,
|
||||
renderOverviewRuler: false,
|
||||
scrollbar: { vertical: 'hidden', horizontal: 'hidden', alwaysConsumeMouseWheel: false },
|
||||
automaticLayout: true,
|
||||
domReadOnly: true,
|
||||
contextmenu: false,
|
||||
padding: { top: 2, bottom: 2 },
|
||||
}}
|
||||
loading={null}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import * as monacoImport from 'monaco-editor';
|
||||
import type { Monaco } from '@monaco-editor/react';
|
||||
import type { IDisposable } from 'monaco-editor';
|
||||
import { loader } from '@monaco-editor/react';
|
||||
import { isBuiltinTsSuppressed } from './builtin-providers';
|
||||
|
||||
// Cast to Monaco type (from @monaco-editor/react) which has the full
|
||||
// languages.typescript typings. The main 'monaco-editor' export marks
|
||||
// languages.typescript as deprecated in favor of top-level typescript
|
||||
// namespace, but @monaco-editor/react's Monaco type still uses it.
|
||||
const monaco = monacoImport as unknown as Monaco;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Built-in TS/JS provider suppression
|
||||
// ---------------------------------------------------------------------------
|
||||
// Monaco v0.55.1's setModeConfiguration fires onDidChange, but the
|
||||
// tsMode.js setupMode() function never subscribes to it — providers are
|
||||
// registered once and never torn down. So we intercept provider registration
|
||||
// to wrap built-in TS/JS providers with a suppression flag check. When LSP
|
||||
// is active, the wrappers return null/empty instead of calling the original.
|
||||
//
|
||||
// Key: we check `isBuiltinTsSuppressed()` at REGISTRATION time. If the flag
|
||||
// is already true when a provider is registered, it means the LSP client is
|
||||
// registering its own providers — those should NOT be wrapped. Only providers
|
||||
// registered while the flag is false (Monaco's built-in ones) get wrapped.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TS_LANGUAGES = new Set([
|
||||
'typescript',
|
||||
'javascript',
|
||||
'typescriptreact',
|
||||
'javascriptreact',
|
||||
]);
|
||||
|
||||
// Configure workers before Monaco initializes.
|
||||
// The `new URL('...', import.meta.url)` pattern is recognized by Turbopack/webpack
|
||||
// to bundle each worker as a separate chunk.
|
||||
if (typeof window !== 'undefined') {
|
||||
self.MonacoEnvironment = {
|
||||
getWorker(_workerId: string, label: string) {
|
||||
switch (label) {
|
||||
case 'json':
|
||||
return new Worker(new URL('monaco-editor/esm/vs/language/json/json.worker.js', import.meta.url), { type: 'module' });
|
||||
case 'css':
|
||||
case 'scss':
|
||||
case 'less':
|
||||
return new Worker(new URL('monaco-editor/esm/vs/language/css/css.worker.js', import.meta.url), { type: 'module' });
|
||||
case 'html':
|
||||
case 'handlebars':
|
||||
case 'razor':
|
||||
return new Worker(new URL('monaco-editor/esm/vs/language/html/html.worker.js', import.meta.url), { type: 'module' });
|
||||
case 'typescript':
|
||||
case 'javascript':
|
||||
return new Worker(new URL('monaco-editor/esm/vs/language/typescript/ts.worker.js', import.meta.url), { type: 'module' });
|
||||
default:
|
||||
return new Worker(new URL('monaco-editor/esm/vs/editor/editor.worker.js', import.meta.url), { type: 'module' });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Tell @monaco-editor/react to use local monaco instance (no CDN fetch)
|
||||
loader.config({ monaco: monacoImport });
|
||||
|
||||
// Expose on window for code that accesses window.monaco (e.g. keybindings in editor)
|
||||
(window as { monaco?: Monaco }).monaco = monaco;
|
||||
|
||||
// Wrap built-in TS/JS provider registration so we can suppress them at call
|
||||
// time when an external LSP is active. This MUST happen before the first TS
|
||||
// model is created (which triggers the lazy tsMode setup), and since this
|
||||
// module runs at import time, it's guaranteed to be in place early enough.
|
||||
const langs = monacoImport.languages;
|
||||
|
||||
type ProviderRegistrationFn = (selector: string, provider: Record<string, unknown>, ...rest: unknown[]) => IDisposable;
|
||||
|
||||
function wrapRegistration(
|
||||
original: ProviderRegistrationFn,
|
||||
methodName: string,
|
||||
emptyResult: unknown,
|
||||
): ProviderRegistrationFn {
|
||||
return function (selector: string, provider: Record<string, unknown>, ...rest: unknown[]) {
|
||||
// Only wrap providers for TS/JS languages that are registered while
|
||||
// suppression is OFF (= Monaco's built-in providers). When suppression
|
||||
// is already ON at registration time, it means the LSP client is
|
||||
// registering its own providers — pass those through unwrapped.
|
||||
if (
|
||||
typeof selector === 'string' &&
|
||||
TS_LANGUAGES.has(selector) &&
|
||||
typeof provider[methodName] === 'function' &&
|
||||
!isBuiltinTsSuppressed()
|
||||
) {
|
||||
const origMethod = (provider[methodName] as (...a: unknown[]) => unknown).bind(provider);
|
||||
const wrapped = Object.create(provider);
|
||||
wrapped[methodName] = function (...args: unknown[]) {
|
||||
if (isBuiltinTsSuppressed()) return emptyResult;
|
||||
return origMethod(...args);
|
||||
};
|
||||
return original.call(langs, selector, wrapped, ...rest);
|
||||
}
|
||||
return original.call(langs, selector, provider, ...rest);
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- monkey-patching Monaco internals
|
||||
const l = langs as Record<string, any>;
|
||||
l.registerHoverProvider = wrapRegistration(l.registerHoverProvider.bind(langs), 'provideHover', null);
|
||||
l.registerCompletionItemProvider = wrapRegistration(l.registerCompletionItemProvider.bind(langs), 'provideCompletionItems', { suggestions: [] });
|
||||
l.registerSignatureHelpProvider = wrapRegistration(l.registerSignatureHelpProvider.bind(langs), 'provideSignatureHelp', null);
|
||||
l.registerDefinitionProvider = wrapRegistration(l.registerDefinitionProvider.bind(langs), 'provideDefinition', null);
|
||||
l.registerReferenceProvider = wrapRegistration(l.registerReferenceProvider.bind(langs), 'provideReferences', null);
|
||||
l.registerDocumentHighlightProvider = wrapRegistration(l.registerDocumentHighlightProvider.bind(langs), 'provideDocumentHighlights', null);
|
||||
l.registerCodeActionProvider = wrapRegistration(l.registerCodeActionProvider.bind(langs), 'provideCodeActions', null);
|
||||
l.registerRenameProvider = wrapRegistration(l.registerRenameProvider.bind(langs), 'provideRenameEdits', null);
|
||||
l.registerInlayHintsProvider = wrapRegistration(l.registerInlayHintsProvider.bind(langs), 'provideInlayHints', null);
|
||||
}
|
||||
|
||||
export { monaco };
|
||||
@@ -0,0 +1,5 @@
|
||||
'use client';
|
||||
|
||||
// Re-export the existing Pierre/diffs-based DiffViewer as PierreDiffViewer
|
||||
// The original implementation stays in components/diff/diff-viewer.tsx
|
||||
export { DiffViewer as PierreDiffViewer, DiffViewInline as PierreInlineDiff } from '@/components/diff/diff-viewer';
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* TipTap extension for comment highlight decorations.
|
||||
* Ported from the Milkdown ProseMirror plugin in markdown-editor.tsx.
|
||||
*/
|
||||
|
||||
import { Extension } from '@tiptap/core';
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
||||
import { Decoration, DecorationSet } from '@tiptap/pm/view';
|
||||
|
||||
export type CommentHighlight = {
|
||||
id: string;
|
||||
selectedText: string;
|
||||
comment: string;
|
||||
};
|
||||
|
||||
const MIN_COMMENT_TEXT_LENGTH = 3;
|
||||
|
||||
const commentHighlightPluginKey = new PluginKey<{
|
||||
deco: DecorationSet;
|
||||
comments: CommentHighlight[];
|
||||
}>('tiptap-comment-highlight');
|
||||
|
||||
// Helper to get plain text content and position mapping from a document
|
||||
function getTextWithPositions(doc: { descendants: (fn: (node: { isText: boolean; text?: string; type: { name: string }; isBlock: boolean }, pos: number) => void) => void }): { text: string; positions: number[] } {
|
||||
let text = '';
|
||||
const positions: number[] = [];
|
||||
|
||||
doc.descendants((node, pos) => {
|
||||
if (node.isText && node.text) {
|
||||
for (let i = 0; i < node.text.length; i++) {
|
||||
positions.push(pos + i);
|
||||
text += node.text[i];
|
||||
}
|
||||
} else if (node.type.name === 'hardBreak') {
|
||||
positions.push(-1);
|
||||
text += ' ';
|
||||
} else if (node.isBlock && text.length > 0 && !text.endsWith('\n')) {
|
||||
positions.push(-1);
|
||||
text += '\n';
|
||||
}
|
||||
});
|
||||
|
||||
return { text, positions };
|
||||
}
|
||||
|
||||
function normalizeForSearch(text: string): string {
|
||||
return text
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/[\t ]+/g, ' ')
|
||||
.replace(/\n+/g, '\n')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function createCommentDecorations(
|
||||
doc: Parameters<typeof DecorationSet.create>[0],
|
||||
comments: CommentHighlight[]
|
||||
): DecorationSet {
|
||||
const decorations: Decoration[] = [];
|
||||
|
||||
if (comments.length === 0) {
|
||||
return DecorationSet.empty;
|
||||
}
|
||||
|
||||
const { text: fullText, positions } = getTextWithPositions(doc);
|
||||
const normalizedFullText = normalizeForSearch(fullText);
|
||||
|
||||
for (const comment of comments) {
|
||||
const searchText = normalizeForSearch(comment.selectedText);
|
||||
if (!searchText || searchText.length < MIN_COMMENT_TEXT_LENGTH) continue;
|
||||
|
||||
let normalizedIndex = normalizedFullText.indexOf(searchText);
|
||||
|
||||
if (normalizedIndex === -1) {
|
||||
const firstLine = normalizeForSearch(comment.selectedText.split('\n')[0]);
|
||||
if (firstLine.length >= MIN_COMMENT_TEXT_LENGTH) {
|
||||
normalizedIndex = normalizedFullText.indexOf(firstLine);
|
||||
if (normalizedIndex !== -1) {
|
||||
addDecorationsForRange(normalizedIndex, firstLine.length, comment, fullText, positions, decorations);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
addDecorationsForRange(normalizedIndex, searchText.length, comment, fullText, positions, decorations);
|
||||
}
|
||||
|
||||
return DecorationSet.create(doc, decorations);
|
||||
}
|
||||
|
||||
function addDecorationsForRange(
|
||||
normalizedIndex: number,
|
||||
length: number,
|
||||
comment: CommentHighlight,
|
||||
fullText: string,
|
||||
positions: number[],
|
||||
decorations: Decoration[]
|
||||
) {
|
||||
const normalizedFullText = normalizeForSearch(fullText);
|
||||
const normalizedChars = normalizedFullText.split('');
|
||||
const origChars = fullText.split('');
|
||||
|
||||
const normToOrig: number[] = [];
|
||||
let oi = 0;
|
||||
for (let ni = 0; ni < normalizedChars.length; ni++) {
|
||||
while (
|
||||
oi < origChars.length &&
|
||||
origChars[oi].toLowerCase() !== normalizedChars[ni] &&
|
||||
/\s/.test(origChars[oi])
|
||||
) {
|
||||
oi++;
|
||||
}
|
||||
normToOrig[ni] = oi;
|
||||
oi++;
|
||||
}
|
||||
|
||||
const startOrig = normToOrig[normalizedIndex] ?? 0;
|
||||
const endOrig = normToOrig[normalizedIndex + length - 1] ?? startOrig;
|
||||
|
||||
let rangeStart = -1;
|
||||
|
||||
for (let i = startOrig; i <= endOrig && i < positions.length; i++) {
|
||||
const pos = positions[i];
|
||||
|
||||
if (pos === -1) {
|
||||
if (rangeStart !== -1) {
|
||||
const fromPos = positions[rangeStart];
|
||||
const toPos = positions[i - 1] + 1;
|
||||
if (fromPos >= 0 && toPos > fromPos) {
|
||||
decorations.push(
|
||||
Decoration.inline(fromPos, toPos, {
|
||||
class: 'comment-highlight',
|
||||
'data-comment-id': comment.id,
|
||||
title: comment.comment,
|
||||
})
|
||||
);
|
||||
}
|
||||
rangeStart = -1;
|
||||
}
|
||||
} else {
|
||||
if (rangeStart === -1) {
|
||||
rangeStart = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (rangeStart !== -1 && rangeStart < positions.length) {
|
||||
const fromPos = positions[rangeStart];
|
||||
const lastValidIndex = Math.min(endOrig, positions.length - 1);
|
||||
const toPos = positions[lastValidIndex] + 1;
|
||||
if (fromPos >= 0 && toPos > fromPos) {
|
||||
decorations.push(
|
||||
Decoration.inline(fromPos, toPos, {
|
||||
class: 'comment-highlight',
|
||||
'data-comment-id': comment.id,
|
||||
title: comment.comment,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const CommentHighlightExtension = Extension.create({
|
||||
name: 'commentHighlight',
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
comments: [] as CommentHighlight[],
|
||||
};
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const initialComments = this.options.comments as CommentHighlight[];
|
||||
|
||||
return [
|
||||
new Plugin({
|
||||
key: commentHighlightPluginKey,
|
||||
state: {
|
||||
init(_, state) {
|
||||
return {
|
||||
deco: createCommentDecorations(state.doc, initialComments),
|
||||
comments: initialComments,
|
||||
};
|
||||
},
|
||||
apply(tr, value, _, newState) {
|
||||
const newComments = tr.getMeta(commentHighlightPluginKey);
|
||||
if (newComments !== undefined) {
|
||||
return {
|
||||
deco: createCommentDecorations(newState.doc, newComments),
|
||||
comments: newComments,
|
||||
};
|
||||
}
|
||||
if (tr.docChanged) {
|
||||
return {
|
||||
deco: createCommentDecorations(newState.doc, value.comments),
|
||||
comments: value.comments,
|
||||
};
|
||||
}
|
||||
return value;
|
||||
},
|
||||
},
|
||||
props: {
|
||||
decorations(state) {
|
||||
const pluginState = commentHighlightPluginKey.getState(state);
|
||||
return pluginState?.deco ?? DecorationSet.empty;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
// Helper to update comments from outside
|
||||
export function updateCommentHighlights(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
editor: { view: { dispatch: (tr: any) => void; state: { tr: { setMeta: (key: PluginKey<any>, value: unknown) => any } } } },
|
||||
comments: CommentHighlight[]
|
||||
) {
|
||||
const tr = editor.view.state.tr.setMeta(commentHighlightPluginKey, comments);
|
||||
editor.view.dispatch(tr);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* TipTap node view extension for rendering mermaid diagrams.
|
||||
* Ported from the Milkdown mermaidCodeBlockView in markdown-editor.tsx.
|
||||
*/
|
||||
|
||||
import { Node, mergeAttributes } from '@tiptap/core';
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react';
|
||||
import { createElement, useEffect, useRef, useState } from 'react';
|
||||
import mermaid from 'mermaid';
|
||||
|
||||
let mermaidInitialized = false;
|
||||
let mermaidIdCounter = 0;
|
||||
|
||||
function initMermaid(theme: 'dark' | 'light' = 'dark') {
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: theme === 'dark' ? 'dark' : 'default',
|
||||
securityLevel: 'loose',
|
||||
});
|
||||
mermaidInitialized = true;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function MermaidNodeView({ node }: { node: any }) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const code = node.textContent;
|
||||
|
||||
useEffect(() => {
|
||||
if (!mermaidInitialized) initMermaid();
|
||||
if (!containerRef.current || !code.trim()) return;
|
||||
|
||||
const id = `mermaid-${++mermaidIdCounter}`;
|
||||
mermaid
|
||||
.render(id, code)
|
||||
.then(({ svg }) => {
|
||||
if (containerRef.current) {
|
||||
containerRef.current.innerHTML = svg;
|
||||
setError(null);
|
||||
}
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
setError(err.message);
|
||||
});
|
||||
}, [code]);
|
||||
|
||||
return createElement(
|
||||
NodeViewWrapper,
|
||||
{ className: 'mermaid-container' },
|
||||
error
|
||||
? createElement('pre', { className: 'mermaid-error' }, `Error rendering diagram: ${error}`)
|
||||
: createElement('div', { ref: containerRef })
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom CodeBlock extension that renders mermaid blocks as diagrams.
|
||||
* For non-mermaid blocks, uses default rendering.
|
||||
*/
|
||||
export const MermaidCodeBlock = Node.create({
|
||||
name: 'mermaidCodeBlock',
|
||||
group: 'block',
|
||||
content: 'text*',
|
||||
marks: '',
|
||||
code: true,
|
||||
defining: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
language: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute('data-language'),
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.language) return {};
|
||||
return { 'data-language': attributes.language };
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'pre',
|
||||
preserveWhitespace: 'full',
|
||||
getAttrs: (node) => {
|
||||
const el = node as HTMLElement;
|
||||
const code = el.querySelector('code');
|
||||
const lang = code?.className?.match(/language-(\w+)/)?.[1];
|
||||
if (lang === 'mermaid') return { language: 'mermaid' };
|
||||
return false; // Let default code block handle non-mermaid
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['pre', mergeAttributes(HTMLAttributes), ['code', {}, 0]];
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(MermaidNodeView);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,236 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { useEditor, EditorContent } from '@tiptap/react';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import Placeholder from '@tiptap/extension-placeholder';
|
||||
import Link from '@tiptap/extension-link';
|
||||
import TaskList from '@tiptap/extension-task-list';
|
||||
import TaskItem from '@tiptap/extension-task-item';
|
||||
import { Table } from '@tiptap/extension-table';
|
||||
import { TableRow } from '@tiptap/extension-table-row';
|
||||
import { TableCell } from '@tiptap/extension-table-cell';
|
||||
import { TableHeader } from '@tiptap/extension-table-header';
|
||||
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';
|
||||
import { Markdown } from 'tiptap-markdown';
|
||||
import { common, createLowlight } from 'lowlight';
|
||||
import { CommentHighlightExtension, updateCommentHighlights, type CommentHighlight } from './tiptap-comment-highlight';
|
||||
|
||||
export type { CommentHighlight };
|
||||
|
||||
export type TextSelection = {
|
||||
text: string;
|
||||
position: { x: number; y: number };
|
||||
};
|
||||
|
||||
type TipTapPlanEditorProps = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
onSelectionChange?: (selection: TextSelection | null) => void;
|
||||
onSelectionEnd?: (selection: TextSelection | null) => void;
|
||||
comments?: CommentHighlight[];
|
||||
onCommentClick?: (id: string, position: { x: number; y: number }) => void;
|
||||
};
|
||||
|
||||
const MIN_COMMENT_TEXT_LENGTH = 3;
|
||||
|
||||
const lowlight = createLowlight(common);
|
||||
|
||||
export function TipTapPlanEditor({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Start typing...',
|
||||
onSelectionChange,
|
||||
onSelectionEnd,
|
||||
comments = [],
|
||||
onCommentClick,
|
||||
}: TipTapPlanEditorProps) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const onChangeRef = useRef(onChange);
|
||||
const onSelectionChangeRef = useRef(onSelectionChange);
|
||||
const onSelectionEndRef = useRef(onSelectionEnd);
|
||||
const onCommentClickRef = useRef(onCommentClick);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
|
||||
// Keep refs updated
|
||||
useEffect(() => { onChangeRef.current = onChange; }, [onChange]);
|
||||
useEffect(() => { onSelectionChangeRef.current = onSelectionChange; }, [onSelectionChange]);
|
||||
useEffect(() => { onSelectionEndRef.current = onSelectionEnd; }, [onSelectionEnd]);
|
||||
useEffect(() => { onCommentClickRef.current = onCommentClick; }, [onCommentClick]);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
codeBlock: false, // We use CodeBlockLowlight instead
|
||||
}),
|
||||
CodeBlockLowlight.configure({ lowlight }),
|
||||
Markdown.configure({
|
||||
html: true,
|
||||
transformPastedText: true,
|
||||
transformCopiedText: true,
|
||||
}),
|
||||
Placeholder.configure({ placeholder }),
|
||||
Link.configure({ openOnClick: false }),
|
||||
TaskList,
|
||||
TaskItem.configure({ nested: true }),
|
||||
Table.configure({ resizable: false }),
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
CommentHighlightExtension.configure({ comments }),
|
||||
],
|
||||
content: value,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: 'tiptap-plan-editor',
|
||||
},
|
||||
},
|
||||
onUpdate: ({ editor: ed }) => {
|
||||
// tiptap-markdown adds getMarkdown() to the editor storage
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const md = (ed.storage as any).markdown?.getMarkdown?.() as string | undefined;
|
||||
onChangeRef.current(md ?? ed.getText());
|
||||
},
|
||||
onCreate: () => {
|
||||
setIsReady(true);
|
||||
},
|
||||
});
|
||||
|
||||
// Update comment decorations when comments change
|
||||
useEffect(() => {
|
||||
if (!editor || !isReady) return;
|
||||
try {
|
||||
updateCommentHighlights(editor as Parameters<typeof updateCommentHighlights>[0], comments);
|
||||
} catch {
|
||||
// Editor might not be ready yet
|
||||
}
|
||||
}, [comments, editor, isReady]);
|
||||
|
||||
// Set up click handler for comment highlights via event delegation
|
||||
useEffect(() => {
|
||||
const wrapper = wrapperRef.current;
|
||||
if (!wrapper) return;
|
||||
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const commentHighlight = target.closest('.comment-highlight');
|
||||
if (commentHighlight && onCommentClickRef.current) {
|
||||
const commentId = commentHighlight.getAttribute('data-comment-id');
|
||||
if (commentId) {
|
||||
onCommentClickRef.current(commentId, { x: e.clientX, y: e.clientY });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
wrapper.addEventListener('click', handleClick);
|
||||
return () => wrapper.removeEventListener('click', handleClick);
|
||||
}, []);
|
||||
|
||||
// Handle mouseup to show floating button when selection ends
|
||||
useEffect(() => {
|
||||
const wrapper = wrapperRef.current;
|
||||
if (!wrapper) return;
|
||||
|
||||
const handleMouseUp = (e: MouseEvent) => {
|
||||
if (!onSelectionEndRef.current) return;
|
||||
const mouseX = e.clientX;
|
||||
const mouseY = e.clientY;
|
||||
|
||||
setTimeout(() => {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.isCollapsed || !selection.toString().trim()) {
|
||||
onSelectionEndRef.current?.(null);
|
||||
return;
|
||||
}
|
||||
const selectedText = selection.toString().trim();
|
||||
if (selectedText.length < MIN_COMMENT_TEXT_LENGTH) {
|
||||
onSelectionEndRef.current?.(null);
|
||||
return;
|
||||
}
|
||||
onSelectionEndRef.current?.({
|
||||
text: selectedText,
|
||||
position: { x: mouseX, y: mouseY },
|
||||
});
|
||||
}, 10);
|
||||
};
|
||||
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
if ((e.target as HTMLElement).closest('.floating-comment-btn')) return;
|
||||
onSelectionEndRef.current?.(null);
|
||||
};
|
||||
|
||||
wrapper.addEventListener('mouseup', handleMouseUp);
|
||||
wrapper.addEventListener('mousedown', handleMouseDown);
|
||||
return () => {
|
||||
wrapper.removeEventListener('mouseup', handleMouseUp);
|
||||
wrapper.removeEventListener('mousedown', handleMouseDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Handle Cmd+I to trigger selection popover
|
||||
useEffect(() => {
|
||||
const wrapper = wrapperRef.current;
|
||||
if (!wrapper) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'i') {
|
||||
if (!onSelectionChangeRef.current) return;
|
||||
const selection = window.getSelection();
|
||||
if (!selection) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
|
||||
if (selection.isCollapsed || !selection.toString().trim()) return;
|
||||
const selectedText = selection.toString().trim();
|
||||
if (selectedText.length < MIN_COMMENT_TEXT_LENGTH) return;
|
||||
|
||||
const focusNode = selection.focusNode;
|
||||
const focusOffset = selection.focusOffset;
|
||||
|
||||
if (focusNode) {
|
||||
const focusRange = document.createRange();
|
||||
focusRange.setStart(focusNode, focusOffset);
|
||||
focusRange.setEnd(focusNode, focusOffset);
|
||||
const focusRect = focusRange.getBoundingClientRect();
|
||||
|
||||
let x = focusRect.right || focusRect.left;
|
||||
let y = focusRect.bottom || focusRect.top;
|
||||
|
||||
if (x === 0 && y === 0) {
|
||||
const range = selection.getRangeAt(0);
|
||||
const rects = range.getClientRects();
|
||||
const lastRect = rects.length > 0 ? rects[rects.length - 1] : range.getBoundingClientRect();
|
||||
x = lastRect.right;
|
||||
y = lastRect.bottom;
|
||||
}
|
||||
|
||||
onSelectionChangeRef.current({
|
||||
text: selectedText,
|
||||
position: { x, y },
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
wrapper.addEventListener('keydown', handleKeyDown, true);
|
||||
return () => wrapper.removeEventListener('keydown', handleKeyDown, true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className={`tiptap-plan-wrapper h-full relative ${resolvedTheme === 'dark' ? 'dark' : ''}`}
|
||||
>
|
||||
<EditorContent editor={editor} className="h-full" />
|
||||
{!isReady && (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-muted-foreground text-sm bg-background/80">
|
||||
Loading editor...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ type ReviewDialogProps = {
|
||||
baseBranch?: string;
|
||||
taskTitle?: string;
|
||||
onSendComments: (comments: DiffComment[]) => void;
|
||||
onOpenFile?: (filePath: string) => void;
|
||||
gitStatusFiles: Record<string, FileInfo> | null;
|
||||
cumulativeDiff: CumulativeDiff | null;
|
||||
};
|
||||
@@ -34,6 +35,7 @@ export const ReviewDialog = memo(function ReviewDialog({
|
||||
baseBranch,
|
||||
taskTitle,
|
||||
onSendComments,
|
||||
onOpenFile,
|
||||
gitStatusFiles,
|
||||
cumulativeDiff,
|
||||
}: ReviewDialogProps) {
|
||||
@@ -257,6 +259,7 @@ export const ReviewDialog = memo(function ReviewDialog({
|
||||
wordWrap={wordWrap}
|
||||
onToggleReviewed={handleToggleReviewed}
|
||||
onDiscard={handleDiscard}
|
||||
onOpenFile={onOpenFile}
|
||||
fileRefs={fileRefs}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
import { memo, useEffect, useRef, useState, useCallback } from 'react';
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconCopy,
|
||||
IconArrowBackUp,
|
||||
IconPencil,
|
||||
} from '@tabler/icons-react';
|
||||
import { Checkbox } from '@kandev/ui/checkbox';
|
||||
import { Button } from '@kandev/ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip';
|
||||
import { FileDiffViewer } from '@/components/diff';
|
||||
import { useToast } from '@/components/toast-provider';
|
||||
import { FileActionsDropdown } from '@/components/editors/file-actions-dropdown';
|
||||
import type { ReviewFile } from './types';
|
||||
|
||||
type ReviewDiffListProps = {
|
||||
@@ -22,6 +22,7 @@ type ReviewDiffListProps = {
|
||||
wordWrap: boolean;
|
||||
onToggleReviewed: (path: string, reviewed: boolean) => void;
|
||||
onDiscard: (path: string) => void;
|
||||
onOpenFile?: (filePath: string) => void;
|
||||
fileRefs: Map<string, React.RefObject<HTMLDivElement | null>>;
|
||||
};
|
||||
|
||||
@@ -34,6 +35,7 @@ export const ReviewDiffList = memo(function ReviewDiffList({
|
||||
wordWrap,
|
||||
onToggleReviewed,
|
||||
onDiscard,
|
||||
onOpenFile,
|
||||
fileRefs,
|
||||
}: ReviewDiffListProps) {
|
||||
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -51,6 +53,7 @@ export const ReviewDiffList = memo(function ReviewDiffList({
|
||||
wordWrap={wordWrap}
|
||||
onToggleReviewed={onToggleReviewed}
|
||||
onDiscard={onDiscard}
|
||||
onOpenFile={onOpenFile}
|
||||
sectionRef={fileRefs.get(file.path)}
|
||||
scrollContainer={scrollContainerRef}
|
||||
/>
|
||||
@@ -68,6 +71,7 @@ type FileDiffSectionProps = {
|
||||
wordWrap: boolean;
|
||||
onToggleReviewed: (path: string, reviewed: boolean) => void;
|
||||
onDiscard: (path: string) => void;
|
||||
onOpenFile?: (filePath: string) => void;
|
||||
sectionRef?: React.RefObject<HTMLDivElement | null>;
|
||||
scrollContainer: React.RefObject<HTMLDivElement | null>;
|
||||
};
|
||||
@@ -81,13 +85,13 @@ function FileDiffSection({
|
||||
wordWrap,
|
||||
onToggleReviewed,
|
||||
onDiscard,
|
||||
onOpenFile,
|
||||
sectionRef,
|
||||
scrollContainer,
|
||||
}: FileDiffSectionProps) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
||||
const autoMarkedRef = useRef(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
// Lazy rendering: mount the diff viewer only when section becomes visible
|
||||
useEffect(() => {
|
||||
@@ -144,11 +148,6 @@ function FileDiffSection({
|
||||
[file.path, onToggleReviewed]
|
||||
);
|
||||
|
||||
const handleCopyPath = useCallback(() => {
|
||||
navigator.clipboard.writeText(file.path);
|
||||
toast({ title: 'Copied', description: file.path, variant: 'success' });
|
||||
}, [file.path, toast]);
|
||||
|
||||
const handleDiscard = useCallback(() => {
|
||||
onDiscard(file.path);
|
||||
}, [file.path, onDiscard]);
|
||||
@@ -184,20 +183,25 @@ function FileDiffSection({
|
||||
|
||||
{/* File actions */}
|
||||
<div className="flex items-center gap-0.5">
|
||||
{/* Copy path */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 cursor-pointer opacity-60 hover:opacity-100"
|
||||
onClick={handleCopyPath}
|
||||
>
|
||||
<IconCopy className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Copy file path</TooltipContent>
|
||||
</Tooltip>
|
||||
{/* Edit in editor tab */}
|
||||
{onOpenFile && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 cursor-pointer opacity-60 hover:opacity-100"
|
||||
onClick={() => onOpenFile(file.path)}
|
||||
>
|
||||
<IconPencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Edit</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Open with external editor / copy path / open folder */}
|
||||
<FileActionsDropdown filePath={file.path} sessionId={sessionId} size="xs" />
|
||||
|
||||
{/* Discard changes */}
|
||||
{file.source === 'uncommitted' && (
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { IconEdit, IconTrash } from '@tabler/icons-react';
|
||||
import { IconEdit, IconTrash, IconChevronDown, IconExternalLink, IconInfoCircle } from '@tabler/icons-react';
|
||||
import { Badge } from '@kandev/ui/badge';
|
||||
import { Button } from '@kandev/ui/button';
|
||||
import { Checkbox } from '@kandev/ui/checkbox';
|
||||
import { Input } from '@kandev/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@kandev/ui/select';
|
||||
import { Separator } from '@kandev/ui/separator';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip';
|
||||
import { Switch } from '@kandev/ui/switch';
|
||||
import { Textarea } from '@kandev/ui/textarea';
|
||||
import { SettingsPageTemplate } from '@/components/settings/settings-page-template';
|
||||
import { Combobox, type ComboboxOption } from '@/components/combobox';
|
||||
import { useAppStore } from '@/components/state-provider';
|
||||
@@ -13,6 +18,7 @@ import { EditableCard } from '@/components/settings/editable-card';
|
||||
import { useEditors } from '@/hooks/domains/settings/use-editors';
|
||||
import { createEditor, deleteEditor, updateEditor, updateUserSettings } from '@/lib/api';
|
||||
import { useRequest } from '@/lib/http/use-request';
|
||||
import { LSP_DEFAULT_CONFIGS } from '@/lib/lsp/lsp-client-manager';
|
||||
import type { EditorOption } from '@/lib/types/http';
|
||||
|
||||
type CustomKind = 'custom_command' | 'custom_remote_ssh' | 'custom_hosted_url';
|
||||
@@ -36,6 +42,21 @@ const CUSTOM_KIND_OPTIONS: Array<{ value: CustomKind; label: string }> = [
|
||||
|
||||
const PLACEHOLDER_HINT = '{cwd} {file} {rel} {line} {column}';
|
||||
|
||||
const LSP_LANGUAGE_OPTIONS = [
|
||||
{ id: 'typescript', label: 'TypeScript / JavaScript', binary: 'typescript-language-server',
|
||||
docsUrl: 'https://github.com/typescript-language-server/typescript-language-server#workspace-configuration',
|
||||
installHint: 'Installs typescript-language-server and typescript via npm into ~/.kandev/lsp-servers/' },
|
||||
{ id: 'go', label: 'Go', binary: 'gopls',
|
||||
docsUrl: 'https://github.com/golang/tools/blob/master/gopls/doc/settings.md',
|
||||
installHint: 'Runs "go install golang.org/x/tools/gopls@latest". Requires Go to be installed.' },
|
||||
{ id: 'rust', label: 'Rust', binary: 'rust-analyzer',
|
||||
docsUrl: 'https://rust-analyzer.github.io/book/configuration.html',
|
||||
installHint: 'Downloads the rust-analyzer binary from GitHub releases into ~/.kandev/lsp-servers/' },
|
||||
{ id: 'python', label: 'Python', binary: 'pyright-langserver',
|
||||
docsUrl: 'https://microsoft.github.io/pyright/#/settings',
|
||||
installHint: 'Installs pyright via npm into ~/.kandev/lsp-servers/' },
|
||||
];
|
||||
|
||||
function getCustomKindLabel(kind: string) {
|
||||
return CUSTOM_KIND_OPTIONS.find((option) => option.value === kind)?.label ?? 'Custom';
|
||||
}
|
||||
@@ -288,6 +309,98 @@ export function EditorsSettings() {
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
|
||||
// LSP settings
|
||||
const [lspAutoStartLanguages, setLspAutoStartLanguages] = useState<string[]>(
|
||||
() => currentUserSettings.lspAutoStartLanguages ?? []
|
||||
);
|
||||
const [lspAutoInstallLanguages, setLspAutoInstallLanguages] = useState<string[]>(
|
||||
() => currentUserSettings.lspAutoInstallLanguages ?? []
|
||||
);
|
||||
const [baselineLspAutoStart, setBaselineLspAutoStart] = useState<string[]>(
|
||||
() => currentUserSettings.lspAutoStartLanguages ?? []
|
||||
);
|
||||
const [baselineLspAutoInstall, setBaselineLspAutoInstall] = useState<string[]>(
|
||||
() => currentUserSettings.lspAutoInstallLanguages ?? []
|
||||
);
|
||||
|
||||
// LSP server configs (per-language JSON overrides)
|
||||
const initConfigStrings = useCallback((): Record<string, string> => {
|
||||
const configs = currentUserSettings.lspServerConfigs ?? {};
|
||||
const result: Record<string, string> = {};
|
||||
for (const [lang, config] of Object.entries(configs)) {
|
||||
if (config && Object.keys(config).length > 0) {
|
||||
result[lang] = JSON.stringify(config, null, 2);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [currentUserSettings.lspServerConfigs]);
|
||||
const [lspConfigStrings, setLspConfigStrings] = useState<Record<string, string>>(initConfigStrings);
|
||||
const [baselineLspConfigStrings, setBaselineLspConfigStrings] = useState<Record<string, string>>(initConfigStrings);
|
||||
const [expandedConfigLang, setExpandedConfigLang] = useState<string | null>(null);
|
||||
const [lspConfigErrors, setLspConfigErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const toggleAutoStart = useCallback((langId: string, checked: boolean) => {
|
||||
setLspAutoStartLanguages((prev) =>
|
||||
checked ? [...prev, langId] : prev.filter((id) => id !== langId)
|
||||
);
|
||||
}, []);
|
||||
|
||||
const toggleAutoInstall = useCallback((langId: string, checked: boolean) => {
|
||||
setLspAutoInstallLanguages((prev) =>
|
||||
checked ? [...prev, langId] : prev.filter((id) => id !== langId)
|
||||
);
|
||||
}, []);
|
||||
|
||||
const updateLspConfigString = useCallback((langId: string, value: string) => {
|
||||
setLspConfigStrings((prev) => {
|
||||
if (!value.trim()) {
|
||||
const next = { ...prev };
|
||||
delete next[langId];
|
||||
return next;
|
||||
}
|
||||
return { ...prev, [langId]: value };
|
||||
});
|
||||
// Validate JSON
|
||||
if (!value.trim()) {
|
||||
setLspConfigErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[langId];
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||
setLspConfigErrors((prev) => ({ ...prev, [langId]: 'Must be a JSON object' }));
|
||||
} else {
|
||||
setLspConfigErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[langId];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
setLspConfigErrors((prev) => ({ ...prev, [langId]: 'Invalid JSON' }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const parseLspConfigs = useCallback((): Record<string, Record<string, unknown>> | null => {
|
||||
const result: Record<string, Record<string, unknown>> = {};
|
||||
for (const [lang, str] of Object.entries(lspConfigStrings)) {
|
||||
if (!str.trim()) continue;
|
||||
try {
|
||||
const parsed = JSON.parse(str);
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return null;
|
||||
result[lang] = parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [lspConfigStrings]);
|
||||
|
||||
|
||||
const customEditors = useMemo(() => {
|
||||
const items = editors.filter(isCustomEditor);
|
||||
return items.slice().sort((a, b) => {
|
||||
@@ -350,17 +463,32 @@ export function EditorsSettings() {
|
||||
}));
|
||||
}, [availableEditors, defaultEditorId]);
|
||||
|
||||
const isDirty = defaultEditorId !== baselineDefaultId;
|
||||
const lspAutoStartDirty =
|
||||
lspAutoStartLanguages.length !== baselineLspAutoStart.length ||
|
||||
lspAutoStartLanguages.some((id) => !baselineLspAutoStart.includes(id));
|
||||
const lspAutoInstallDirty =
|
||||
lspAutoInstallLanguages.length !== baselineLspAutoInstall.length ||
|
||||
lspAutoInstallLanguages.some((id) => !baselineLspAutoInstall.includes(id));
|
||||
const lspConfigsDirty = JSON.stringify(lspConfigStrings) !== JSON.stringify(baselineLspConfigStrings);
|
||||
const isDirty = defaultEditorId !== baselineDefaultId || lspAutoStartDirty || lspAutoInstallDirty || lspConfigsDirty;
|
||||
|
||||
const saveDefaultRequest = useRequest(async () => {
|
||||
const payload = {
|
||||
const parsedConfigs = parseLspConfigs();
|
||||
if (parsedConfigs === null) return; // Invalid JSON, don't save
|
||||
const payload: Parameters<typeof updateUserSettings>[0] = {
|
||||
workspace_id: currentUserSettings.workspaceId ?? '',
|
||||
board_id: currentUserSettings.boardId ?? '',
|
||||
repository_ids: currentUserSettings.repositoryIds ?? [],
|
||||
default_editor_id: defaultEditorId || undefined,
|
||||
lsp_auto_start_languages: lspAutoStartLanguages,
|
||||
lsp_auto_install_languages: lspAutoInstallLanguages,
|
||||
lsp_server_configs: parsedConfigs,
|
||||
};
|
||||
const response = await updateUserSettings(payload, { cache: 'no-store' });
|
||||
setBaselineDefaultId(defaultEditorId);
|
||||
setBaselineLspAutoStart([...lspAutoStartLanguages]);
|
||||
setBaselineLspAutoInstall([...lspAutoInstallLanguages]);
|
||||
setBaselineLspConfigStrings({ ...lspConfigStrings });
|
||||
if (response?.settings) {
|
||||
setUserSettings({
|
||||
workspaceId: response.settings.workspace_id || null,
|
||||
@@ -372,6 +500,9 @@ export function EditorsSettings() {
|
||||
enablePreviewOnClick: response.settings.enable_preview_on_click ?? false,
|
||||
chatSubmitKey: response.settings.chat_submit_key ?? 'cmd_enter',
|
||||
reviewAutoMarkOnScroll: response.settings.review_auto_mark_on_scroll ?? true,
|
||||
lspAutoStartLanguages: response.settings.lsp_auto_start_languages ?? [],
|
||||
lspAutoInstallLanguages: response.settings.lsp_auto_install_languages ?? [],
|
||||
lspServerConfigs: response.settings.lsp_server_configs ?? {},
|
||||
loaded: true,
|
||||
});
|
||||
}
|
||||
@@ -426,17 +557,141 @@ export function EditorsSettings() {
|
||||
return (
|
||||
<SettingsPageTemplate
|
||||
title="Editors"
|
||||
description="Choose which editor opens files and worktrees"
|
||||
description="Configure the included code editor and external editors"
|
||||
isDirty={isDirty}
|
||||
saveStatus={saveDefaultRequest.status}
|
||||
onSave={() => {
|
||||
void saveDefaultRequest.run();
|
||||
}}
|
||||
>
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium text-foreground">Default editor</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="space-y-6">
|
||||
{/* ── File Editor ────────────────────────────────────── */}
|
||||
<div className="space-y-4">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
File Editor
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-foreground">Language Servers</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Auto-start language servers when opening files to get diagnostics, hover info, and go-to-definition. You can also toggle each server on/off per file.
|
||||
<br />
|
||||
When enabled, install your project's dependencies (e.g. <code className="text-[11px] bg-muted px-1 rounded">npm install</code> via repository setup scripts) to avoid missing type errors.
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{LSP_LANGUAGE_OPTIONS.map((lang) => (
|
||||
<div key={lang.id} className="rounded-lg border border-border/60 bg-background px-4 py-3 space-y-2.5">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-foreground">{lang.label}</div>
|
||||
<div className="text-xs text-muted-foreground">{lang.binary}</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">Auto-start</span>
|
||||
<Switch
|
||||
checked={lspAutoStartLanguages.includes(lang.id)}
|
||||
onCheckedChange={(checked) => toggleAutoStart(lang.id, checked === true)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={`lsp-install-${lang.id}`}
|
||||
checked={lspAutoInstallLanguages.includes(lang.id)}
|
||||
onCheckedChange={(checked) => toggleAutoInstall(lang.id, checked === true)}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
<label htmlFor={`lsp-install-${lang.id}`} className="text-xs text-muted-foreground cursor-pointer">
|
||||
Auto-install if not found
|
||||
</label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<IconInfoCircle className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-[260px] text-xs">
|
||||
{lang.installHint}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-foreground">Server Configuration</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Override settings sent to each language server via <code className="text-[11px] bg-muted px-1 rounded">workspace/configuration</code>. JSON format.
|
||||
</div>
|
||||
</div>
|
||||
{LSP_LANGUAGE_OPTIONS.map((lang) => {
|
||||
const isExpanded = expandedConfigLang === lang.id;
|
||||
const configStr = lspConfigStrings[lang.id] ?? '';
|
||||
const defaultConfig = LSP_DEFAULT_CONFIGS[lang.id];
|
||||
const hasDefaults = defaultConfig && Object.keys(defaultConfig).length > 0;
|
||||
const error = lspConfigErrors[lang.id];
|
||||
return (
|
||||
<div key={lang.id} className="rounded-lg border border-border/60 bg-background overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between px-4 py-2.5 text-left hover:bg-muted/50 transition-colors"
|
||||
onClick={() => setExpandedConfigLang(isExpanded ? null : lang.id)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-foreground">{lang.label}</span>
|
||||
{configStr.trim() && (
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">custom</Badge>
|
||||
)}
|
||||
</div>
|
||||
<IconChevronDown
|
||||
className={`h-4 w-4 text-muted-foreground transition-transform ${isExpanded ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="border-t border-border/60 px-4 py-3 space-y-2">
|
||||
{hasDefaults && (
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
Defaults: <code className="bg-muted px-1 rounded">{JSON.stringify(defaultConfig)}</code>
|
||||
</div>
|
||||
)}
|
||||
<a
|
||||
href={lang.docsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
View available settings
|
||||
<IconExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
<Textarea
|
||||
value={configStr}
|
||||
onChange={(e) => updateLspConfigString(lang.id, e.target.value)}
|
||||
placeholder={hasDefaults ? JSON.stringify(defaultConfig, null, 2) : '{\n \n}'}
|
||||
className="font-mono text-xs min-h-[80px] resize-y"
|
||||
rows={4}
|
||||
/>
|
||||
{error && (
|
||||
<div className="text-xs text-destructive">{error}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* ── External Editors ───────────────────────────────── */}
|
||||
<div className="space-y-6">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
External Editors
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium text-foreground">Default</div>
|
||||
<div className="min-w-[280px]">
|
||||
<Combobox
|
||||
options={defaultOptions}
|
||||
@@ -452,126 +707,124 @@ export function EditorsSettings() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-medium text-foreground">Custom editors</div>
|
||||
<Button type="button" variant="outline" onClick={() => setIsAdding(true)}>
|
||||
Add custom editor
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isAdding && (
|
||||
<EditorForm
|
||||
title="New custom editor"
|
||||
initialState={defaultFormState()}
|
||||
onCancel={() => setIsAdding(false)}
|
||||
onSave={(state) => {
|
||||
void createRequest.run(state);
|
||||
}}
|
||||
submitLabel="Add editor"
|
||||
isSaving={createRequest.isLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{customEditors.length === 0 && !isAdding && (
|
||||
<div className="rounded-lg border border-dashed border-border/70 bg-muted/30 p-4 text-sm text-muted-foreground">
|
||||
No custom editors yet.
|
||||
</div>
|
||||
)}
|
||||
{customEditors.map((editor) => {
|
||||
return (
|
||||
<EditableCard
|
||||
key={editor.id}
|
||||
isEditing={editingId === editor.id}
|
||||
historyId={`editor-${editor.id}`}
|
||||
onOpen={() => setEditingId(editor.id)}
|
||||
onClose={() => setEditingId(null)}
|
||||
renderEdit={({ close }) => (
|
||||
<EditorForm
|
||||
title={`Edit ${editor.name}`}
|
||||
initialState={formStateFromEditor(editor)}
|
||||
onCancel={close}
|
||||
onSave={(state) => {
|
||||
void updateRequest.run(editor.id, state).then(() => {
|
||||
close();
|
||||
});
|
||||
}}
|
||||
submitLabel="Save changes"
|
||||
isSaving={updateRequest.isLoading}
|
||||
/>
|
||||
)}
|
||||
renderPreview={({ open }) => (
|
||||
<div
|
||||
className="rounded-lg border border-border/70 bg-background p-4 flex items-center justify-between gap-3 cursor-pointer"
|
||||
onClick={open}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-sm text-foreground truncate">
|
||||
{editor.name}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{getCustomEditorSummary(editor)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="cursor-pointer"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
open();
|
||||
}}
|
||||
>
|
||||
<IconEdit className="h-4 w-4" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="cursor-pointer"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void deleteRequest.run(editor.id);
|
||||
}}
|
||||
>
|
||||
<IconTrash className="h-4 w-4" />
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-medium text-foreground">Custom Editors</div>
|
||||
<Button type="button" variant="outline" onClick={() => setIsAdding(true)}>
|
||||
Add custom editor
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{builtInEditors.length === 0 ? null : (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Supported Editors
|
||||
</div>
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
{builtInEditors.map((editor) => (
|
||||
<div
|
||||
key={editor.id}
|
||||
className="rounded-lg border border-border/60 bg-background px-3 py-2 flex items-center justify-between"
|
||||
>
|
||||
<span className="text-sm text-foreground truncate">{editor.name}</span>
|
||||
<Badge variant={editor.installed ? 'secondary' : 'outline'}>
|
||||
{editor.installed ? 'Installed' : 'Not installed'}
|
||||
</Badge>
|
||||
{isAdding && (
|
||||
<EditorForm
|
||||
title="New custom editor"
|
||||
initialState={defaultFormState()}
|
||||
onCancel={() => setIsAdding(false)}
|
||||
onSave={(state) => {
|
||||
void createRequest.run(state);
|
||||
}}
|
||||
submitLabel="Add editor"
|
||||
isSaving={createRequest.isLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{customEditors.length === 0 && !isAdding && (
|
||||
<div className="rounded-lg border border-dashed border-border/70 bg-muted/30 p-4 text-sm text-muted-foreground">
|
||||
No custom editors yet.
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
{customEditors.map((editor) => {
|
||||
return (
|
||||
<EditableCard
|
||||
key={editor.id}
|
||||
isEditing={editingId === editor.id}
|
||||
historyId={`editor-${editor.id}`}
|
||||
onOpen={() => setEditingId(editor.id)}
|
||||
onClose={() => setEditingId(null)}
|
||||
renderEdit={({ close }) => (
|
||||
<EditorForm
|
||||
title={`Edit ${editor.name}`}
|
||||
initialState={formStateFromEditor(editor)}
|
||||
onCancel={close}
|
||||
onSave={(state) => {
|
||||
void updateRequest.run(editor.id, state).then(() => {
|
||||
close();
|
||||
});
|
||||
}}
|
||||
submitLabel="Save changes"
|
||||
isSaving={updateRequest.isLoading}
|
||||
/>
|
||||
)}
|
||||
renderPreview={({ open }) => (
|
||||
<div
|
||||
className="rounded-lg border border-border/70 bg-background p-4 flex items-center justify-between gap-3 cursor-pointer"
|
||||
onClick={open}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-sm text-foreground truncate">
|
||||
{editor.name}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{getCustomEditorSummary(editor)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="cursor-pointer"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
open();
|
||||
}}
|
||||
>
|
||||
<IconEdit className="h-4 w-4" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="cursor-pointer"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void deleteRequest.run(editor.id);
|
||||
}}
|
||||
>
|
||||
<IconTrash className="h-4 w-4" />
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{builtInEditors.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium text-foreground">Supported Editors</div>
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
{builtInEditors.map((editor) => (
|
||||
<div
|
||||
key={editor.id}
|
||||
className="rounded-lg border border-border/60 bg-background px-3 py-2 flex items-center justify-between"
|
||||
>
|
||||
<span className="text-sm text-foreground truncate">{editor.name}</span>
|
||||
<Badge variant={editor.installed ? 'secondary' : 'outline'}>
|
||||
{editor.installed ? 'Installed' : 'Not installed'}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SettingsPageTemplate>
|
||||
);
|
||||
|
||||
@@ -1,80 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { useTheme } from 'next-themes';
|
||||
import CodeMirror from '@uiw/react-codemirror';
|
||||
import { vscodeDark, vscodeLight } from '@uiw/codemirror-theme-vscode';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import { IconCheck, IconCopy } from '@tabler/icons-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getCodeMirrorExtension } from '@/lib/languages';
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard';
|
||||
import { useEditorProvider } from '@/hooks/use-editor-resolver';
|
||||
import { MonacoCodeBlock } from '@/components/editors/monaco/monaco-code-block';
|
||||
import { CodeMirrorCodeBlock } from '@/components/editors/codemirror/codemirror-code-block';
|
||||
|
||||
type CodeBlockProps = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const getLanguageExtension = (language?: string) => {
|
||||
if (!language) return undefined;
|
||||
const lang = language.replace('language-', '').toLowerCase();
|
||||
return getCodeMirrorExtension(lang);
|
||||
};
|
||||
|
||||
export function CodeBlock({ children, className }: CodeBlockProps) {
|
||||
const { copied, copy } = useCopyToClipboard();
|
||||
const { theme, systemTheme } = useTheme();
|
||||
const effectiveTheme = theme === 'system' ? systemTheme : theme;
|
||||
|
||||
const code = String(children).replace(/\n$/, '');
|
||||
const languageExtension = getLanguageExtension(className);
|
||||
|
||||
// Custom padding theme
|
||||
const paddingTheme = EditorView.theme({
|
||||
'&': {
|
||||
padding: '2px 2px',
|
||||
},
|
||||
});
|
||||
|
||||
const handleCopy = async () => {
|
||||
await copy(code);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative group/code-block my-4 w-fit max-w-full min-w-[50%]">
|
||||
{/* Copy button */}
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className={cn(
|
||||
'absolute top-2 right-2 z-10',
|
||||
'p-1.5 rounded-md',
|
||||
'bg-white/10 hover:bg-white/20',
|
||||
'transition-all duration-200',
|
||||
'opacity-0 group-hover/code-block:opacity-100',
|
||||
'cursor-pointer',
|
||||
)}
|
||||
title="Copy code"
|
||||
>
|
||||
{copied ? (
|
||||
<IconCheck className="h-3 w-3 text-green-400" />
|
||||
) : (
|
||||
<IconCopy className="h-3 w-3 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Code editor */}
|
||||
<CodeMirror
|
||||
value={code}
|
||||
theme={effectiveTheme === 'dark' ? vscodeDark : vscodeLight}
|
||||
extensions={languageExtension ? [languageExtension, EditorView.lineWrapping, paddingTheme] : [EditorView.lineWrapping, paddingTheme]}
|
||||
editable={false}
|
||||
basicSetup={{
|
||||
lineNumbers: false,
|
||||
highlightActiveLineGutter: false,
|
||||
highlightActiveLine: false,
|
||||
foldGutter: false,
|
||||
}}
|
||||
className="text-xs rounded-md overflow-hidden"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
export function CodeBlock(props: CodeBlockProps) {
|
||||
const provider = useEditorProvider('chat-code-block');
|
||||
return provider === 'monaco'
|
||||
? <MonacoCodeBlock {...props} />
|
||||
: <CodeMirrorCodeBlock {...props} />;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useEditorProvider } from '@/hooks/use-editor-resolver';
|
||||
import { MonacoInlineDiff } from '@/components/editors/monaco/monaco-inline-diff';
|
||||
import { DiffViewInline } from '@/components/diff';
|
||||
import type { FileDiffData } from '@/lib/diff/types';
|
||||
|
||||
type DiffViewBlockProps = {
|
||||
/** Diff data in the new format */
|
||||
data: FileDiffData;
|
||||
/** Additional class name */
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function DiffViewBlock({ data, className }: DiffViewBlockProps) {
|
||||
const provider = useEditorProvider('chat-diff');
|
||||
|
||||
if (provider === 'monaco') {
|
||||
return <MonacoInlineDiff data={data} className={className} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("mt-3 rounded-md border border-border/50 bg-background/60 px-3 py-2 text-xs", className)}>
|
||||
<DiffViewInline data={data} />
|
||||
|
||||
@@ -42,6 +42,7 @@ import { useFileEditors } from '@/hooks/use-file-editors';
|
||||
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';
|
||||
|
||||
// Panel components
|
||||
import { TaskSessionSidebar, NewTaskButton } from './task-session-sidebar';
|
||||
@@ -500,6 +501,9 @@ export const DockviewDesktopLayout = memo(function DockviewDesktopLayout({
|
||||
const effectiveSessionId = useAppStore((state) => state.tasks.activeSessionId) ?? sessionId ?? null;
|
||||
const hasDevScript = Boolean(repository?.dev_script?.trim());
|
||||
|
||||
// Connect LSP Go-to-Definition navigation to dockview file tabs
|
||||
useLspFileOpener();
|
||||
|
||||
// Keep sessionIdRef in sync for use inside event handlers
|
||||
useEffect(() => {
|
||||
sessionIdRef.current = effectiveSessionId;
|
||||
|
||||
@@ -1,22 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState, useRef } from 'react';
|
||||
import CodeMirror, { type ReactCodeMirrorRef } from '@uiw/react-codemirror';
|
||||
import { EditorView, gutter, GutterMarker, ViewPlugin, type ViewUpdate } from '@codemirror/view';
|
||||
import type { Extension } from '@codemirror/state';
|
||||
import { Decoration, type DecorationSet } from '@codemirror/view';
|
||||
import { getCodeMirrorExtensionFromPath } from '@/lib/languages';
|
||||
import { Button } from '@kandev/ui/button';
|
||||
import { IconDeviceFloppy, IconLoader2, IconTrash, IconTextWrap, IconTextWrapDisabled, IconMessagePlus } from '@tabler/icons-react';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip';
|
||||
import { formatDiffStats } from '@/lib/utils/file-diff';
|
||||
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 { useToast } from '@/components/toast-provider';
|
||||
import { EditorCommentPopover } from './editor-comment-popover';
|
||||
import { CommentViewPopover } from './comment-view-popover';
|
||||
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 = {
|
||||
path: string;
|
||||
@@ -32,618 +18,9 @@ type FileEditorContentProps = {
|
||||
onDelete?: () => void;
|
||||
};
|
||||
|
||||
type TextSelection = {
|
||||
text: string;
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
position: { x: number; y: number };
|
||||
} | null;
|
||||
|
||||
type FloatingButtonPosition = {
|
||||
x: number;
|
||||
y: number;
|
||||
} | null;
|
||||
|
||||
type CommentViewState = {
|
||||
comments: DiffComment[];
|
||||
position: { x: number; y: number };
|
||||
} | null;
|
||||
|
||||
// Create comment line decorations
|
||||
function createCommentDecorations(
|
||||
view: EditorView,
|
||||
comments: DiffComment[]
|
||||
): DecorationSet {
|
||||
if (comments.length === 0) {
|
||||
return Decoration.none;
|
||||
}
|
||||
|
||||
const decorations: Array<{ from: number; decoration: Decoration }> = [];
|
||||
|
||||
// Get all lines that have comments
|
||||
const linesWithComments = new Set<number>();
|
||||
for (const comment of comments) {
|
||||
for (let line = comment.startLine; line <= comment.endLine; line++) {
|
||||
linesWithComments.add(line);
|
||||
}
|
||||
}
|
||||
|
||||
// Create line decorations for commented lines
|
||||
for (const lineNum of linesWithComments) {
|
||||
if (lineNum > view.state.doc.lines) continue;
|
||||
const line = view.state.doc.line(lineNum);
|
||||
decorations.push({
|
||||
from: line.from,
|
||||
decoration: Decoration.line({
|
||||
class: 'cm-comment-line',
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
decorations.sort((a, b) => a.from - b.from);
|
||||
return Decoration.set(decorations.map(d => d.decoration.range(d.from)));
|
||||
}
|
||||
|
||||
// Gutter marker for lines with comments - defined outside component to avoid re-creation
|
||||
class CommentGutterMarker extends GutterMarker {
|
||||
constructor(
|
||||
readonly lineComments: DiffComment[],
|
||||
readonly isFirstLine: boolean
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
toDOM() {
|
||||
const marker = document.createElement('div');
|
||||
marker.className = 'cm-comment-gutter-marker';
|
||||
marker.title = `${this.lineComments.length} comment${this.lineComments.length > 1 ? 's' : ''} - click to view`;
|
||||
|
||||
// Add icon only on the first line of a comment
|
||||
if (this.isFirstLine) {
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.setAttribute('width', '12');
|
||||
svg.setAttribute('height', '12');
|
||||
svg.setAttribute('viewBox', '0 0 24 24');
|
||||
svg.setAttribute('fill', 'none');
|
||||
svg.setAttribute('stroke', 'rgba(99, 102, 241, 0.9)');
|
||||
svg.setAttribute('stroke-width', '2');
|
||||
svg.setAttribute('stroke-linecap', 'round');
|
||||
svg.setAttribute('stroke-linejoin', 'round');
|
||||
|
||||
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
path.setAttribute('d', 'M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z');
|
||||
svg.appendChild(path);
|
||||
marker.appendChild(svg);
|
||||
}
|
||||
|
||||
return marker;
|
||||
}
|
||||
|
||||
eq(other: CommentGutterMarker) {
|
||||
return this.lineComments.length === other.lineComments.length && this.isFirstLine === other.isFirstLine;
|
||||
}
|
||||
}
|
||||
|
||||
export function FileEditorContent({
|
||||
path,
|
||||
content,
|
||||
originalContent,
|
||||
isDirty,
|
||||
isSaving,
|
||||
sessionId,
|
||||
worktreePath,
|
||||
enableComments = false,
|
||||
onChange,
|
||||
onSave,
|
||||
onDelete,
|
||||
}: FileEditorContentProps) {
|
||||
const [wrapEnabled, setWrapEnabled] = useState(true);
|
||||
const [textSelection, setTextSelection] = useState<TextSelection>(null);
|
||||
const [floatingButtonPos, setFloatingButtonPos] = useState<FloatingButtonPosition>(null);
|
||||
const [currentSelection, setCurrentSelection] = useState<{ text: string; startLine: number; endLine: number } | null>(null);
|
||||
const [commentView, setCommentView] = useState<CommentViewState>(null);
|
||||
const editorRef = useRef<ReactCodeMirrorRef>(null);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const mousePositionRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
|
||||
const { toast } = useToast();
|
||||
|
||||
// Comment store
|
||||
const addComment = useDiffCommentsStore((state) => state.addComment);
|
||||
const removeComment = useDiffCommentsStore((state) => state.removeComment);
|
||||
const comments = useFileComments(sessionId ?? '', path);
|
||||
|
||||
const langExt = getCodeMirrorExtensionFromPath(path);
|
||||
|
||||
// Create the comment decoration plugin
|
||||
const commentPlugin = useMemo(() => {
|
||||
return ViewPlugin.fromClass(
|
||||
class {
|
||||
decorations: DecorationSet;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = createCommentDecorations(view, comments);
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
if (update.docChanged || update.viewportChanged) {
|
||||
this.decorations = createCommentDecorations(update.view, comments);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ decorations: (v) => v.decorations }
|
||||
);
|
||||
}, [comments]);
|
||||
|
||||
// Create comment gutter
|
||||
const commentGutter = useMemo(() => {
|
||||
// Build a map of line number to comments that include that line
|
||||
const commentsByLine = new Map<number, DiffComment[]>();
|
||||
// Track which lines are the first line of at least one comment
|
||||
const firstLines = new Set<number>();
|
||||
|
||||
for (const comment of comments) {
|
||||
firstLines.add(comment.startLine);
|
||||
// Add to all lines in the range
|
||||
for (let lineNum = comment.startLine; lineNum <= comment.endLine; lineNum++) {
|
||||
const existing = commentsByLine.get(lineNum) || [];
|
||||
existing.push(comment);
|
||||
commentsByLine.set(lineNum, existing);
|
||||
}
|
||||
}
|
||||
|
||||
return gutter({
|
||||
class: 'cm-comment-gutter',
|
||||
lineMarker: (view, line) => {
|
||||
const lineNum = view.state.doc.lineAt(line.from).number;
|
||||
const lineComments = commentsByLine.get(lineNum);
|
||||
if (lineComments && lineComments.length > 0) {
|
||||
return new CommentGutterMarker(lineComments, firstLines.has(lineNum));
|
||||
}
|
||||
return null;
|
||||
},
|
||||
domEventHandlers: {
|
||||
click: (view, line, event) => {
|
||||
const lineNum = view.state.doc.lineAt(line.from).number;
|
||||
const lineComments = commentsByLine.get(lineNum);
|
||||
if (lineComments && lineComments.length > 0) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setCommentView({
|
||||
comments: lineComments,
|
||||
position: { x: (event as MouseEvent).clientX, y: (event as MouseEvent).clientY },
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
});
|
||||
}, [comments]);
|
||||
|
||||
// Track mouse position
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
mousePositionRef.current = { x: e.clientX, y: e.clientY };
|
||||
};
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
return () => document.removeEventListener('mousemove', handleMouseMove);
|
||||
}, []);
|
||||
|
||||
// Show floating button after selection ends (on mouseup)
|
||||
const handleSelectionEnd = useCallback(() => {
|
||||
if (!enableComments || !sessionId) return;
|
||||
|
||||
const view = editorRef.current?.view;
|
||||
if (!view) return;
|
||||
|
||||
const selection = view.state.selection.main;
|
||||
if (selection.empty) {
|
||||
setFloatingButtonPos(null);
|
||||
setCurrentSelection(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedText = view.state.sliceDoc(selection.from, selection.to);
|
||||
if (!selectedText.trim()) {
|
||||
setFloatingButtonPos(null);
|
||||
setCurrentSelection(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get line numbers
|
||||
const startLine = view.state.doc.lineAt(selection.from).number;
|
||||
const endLine = view.state.doc.lineAt(selection.to).number;
|
||||
|
||||
// Use mouse position for the floating button (where cursor is)
|
||||
setCurrentSelection({ text: selectedText, startLine, endLine });
|
||||
setFloatingButtonPos({ x: mousePositionRef.current.x, y: mousePositionRef.current.y });
|
||||
}, [enableComments, sessionId]);
|
||||
|
||||
// Listen for mouseup to show floating button after selection ends
|
||||
useEffect(() => {
|
||||
const wrapper = wrapperRef.current;
|
||||
if (!wrapper || !enableComments || !sessionId) return;
|
||||
|
||||
const handleMouseUp = (e: MouseEvent) => {
|
||||
// Don't show if clicking on the floating button itself
|
||||
if ((e.target as HTMLElement).closest('.floating-comment-btn')) {
|
||||
return;
|
||||
}
|
||||
// Small delay to let selection finalize
|
||||
setTimeout(handleSelectionEnd, 10);
|
||||
};
|
||||
|
||||
// Clear selection state when clicking (new selection starts)
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
// Don't clear if clicking on the floating button
|
||||
if ((e.target as HTMLElement).closest('.floating-comment-btn')) {
|
||||
return;
|
||||
}
|
||||
setFloatingButtonPos(null);
|
||||
};
|
||||
|
||||
wrapper.addEventListener('mouseup', handleMouseUp);
|
||||
wrapper.addEventListener('mousedown', handleMouseDown);
|
||||
return () => {
|
||||
wrapper.removeEventListener('mouseup', handleMouseUp);
|
||||
wrapper.removeEventListener('mousedown', handleMouseDown);
|
||||
};
|
||||
}, [enableComments, sessionId, handleSelectionEnd]);
|
||||
|
||||
// Clear floating button when selection is cleared via keyboard
|
||||
const selectionUpdateExtension = useMemo(() => {
|
||||
return EditorView.updateListener.of((update) => {
|
||||
if (update.selectionSet) {
|
||||
const selection = update.state.selection.main;
|
||||
if (selection.empty) {
|
||||
setFloatingButtonPos(null);
|
||||
setCurrentSelection(null);
|
||||
}
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const extensions: Extension[] = useMemo(() => {
|
||||
const exts: Extension[] = [
|
||||
EditorView.editable.of(true),
|
||||
EditorView.theme({
|
||||
'&': { backgroundColor: 'hsl(var(--background)) !important' },
|
||||
'.cm-gutters': { backgroundColor: 'hsl(var(--background)) !important', borderRight: 'none' },
|
||||
// Comment gutter
|
||||
'.cm-comment-gutter': {
|
||||
width: '22px',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
'.cm-comment-gutter .cm-gutterElement': {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '0',
|
||||
},
|
||||
// Marker styles - background color on the marker itself (indigo)
|
||||
'.cm-comment-gutter-marker': {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: 'rgba(99, 102, 241, 0.2)',
|
||||
},
|
||||
'.cm-comment-gutter-marker:hover': {
|
||||
backgroundColor: 'rgba(99, 102, 241, 0.35)',
|
||||
},
|
||||
// Comment line highlight with left border (indigo)
|
||||
'.cm-comment-line': {
|
||||
backgroundColor: 'rgba(99, 102, 241, 0.15) !important',
|
||||
borderLeft: '3px solid rgba(99, 102, 241, 0.6)',
|
||||
marginLeft: '-3px',
|
||||
paddingLeft: '3px',
|
||||
},
|
||||
}),
|
||||
commentGutter,
|
||||
commentPlugin,
|
||||
selectionUpdateExtension,
|
||||
];
|
||||
if (wrapEnabled) {
|
||||
exts.push(EditorView.lineWrapping);
|
||||
}
|
||||
if (langExt) {
|
||||
exts.push(langExt);
|
||||
}
|
||||
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]);
|
||||
|
||||
// Handle Cmd+I to open comment popover
|
||||
useEffect(() => {
|
||||
const wrapper = wrapperRef.current;
|
||||
if (!wrapper || !enableComments || !sessionId) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Check for Cmd+I (Mac) or Ctrl+I (Windows/Linux)
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'i') {
|
||||
if (!currentSelection || !floatingButtonPos) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
setTextSelection({
|
||||
...currentSelection,
|
||||
position: floatingButtonPos,
|
||||
});
|
||||
setFloatingButtonPos(null);
|
||||
}
|
||||
};
|
||||
|
||||
wrapper.addEventListener('keydown', handleKeyDown, true);
|
||||
return () => wrapper.removeEventListener('keydown', handleKeyDown, true);
|
||||
}, [enableComments, sessionId, currentSelection, floatingButtonPos]);
|
||||
|
||||
// Handle Cmd/Ctrl+S keyboard shortcut and Escape
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
|
||||
e.preventDefault();
|
||||
if (isDirty && !isSaving) {
|
||||
onSave();
|
||||
}
|
||||
}
|
||||
// Escape to close popovers
|
||||
if (e.key === 'Escape') {
|
||||
if (textSelection) setTextSelection(null);
|
||||
if (commentView) setCommentView(null);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isDirty, isSaving, onSave, textSelection, commentView]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(value: string) => {
|
||||
onChange(value);
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
// Open comment popover from floating button
|
||||
const handleFloatingButtonClick = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (!currentSelection || !floatingButtonPos) return;
|
||||
|
||||
setTextSelection({
|
||||
...currentSelection,
|
||||
position: floatingButtonPos,
|
||||
});
|
||||
setFloatingButtonPos(null);
|
||||
}, [currentSelection, floatingButtonPos]);
|
||||
|
||||
// Submit comment
|
||||
const handleCommentSubmit = useCallback((annotation: string) => {
|
||||
if (!textSelection || !sessionId) return;
|
||||
|
||||
const { text, startLine, endLine } = textSelection;
|
||||
|
||||
const comment: DiffComment = {
|
||||
id: `${path}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
sessionId,
|
||||
filePath: path,
|
||||
startLine,
|
||||
endLine,
|
||||
side: 'additions',
|
||||
codeContent: text,
|
||||
annotation,
|
||||
createdAt: new Date().toISOString(),
|
||||
status: 'pending',
|
||||
};
|
||||
|
||||
addComment(comment);
|
||||
setTextSelection(null);
|
||||
|
||||
// Clear selection in editor
|
||||
const view = editorRef.current?.view;
|
||||
if (view) {
|
||||
view.dispatch({
|
||||
selection: { anchor: view.state.selection.main.head },
|
||||
});
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Comment added',
|
||||
description: 'Your comment will be sent with your next message.',
|
||||
});
|
||||
}, [textSelection, sessionId, path, addComment, toast]);
|
||||
|
||||
// Close popover
|
||||
const handlePopoverClose = useCallback(() => {
|
||||
setTextSelection(null);
|
||||
}, []);
|
||||
|
||||
// Delete comment
|
||||
const handleDeleteComment = useCallback((commentId: string) => {
|
||||
if (!sessionId) return;
|
||||
removeComment(sessionId, path, commentId);
|
||||
|
||||
// Close the view if no more comments
|
||||
if (commentView && commentView.comments.length <= 1) {
|
||||
setCommentView(null);
|
||||
} else if (commentView) {
|
||||
setCommentView({
|
||||
...commentView,
|
||||
comments: commentView.comments.filter(c => c.id !== commentId),
|
||||
});
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Comment deleted',
|
||||
});
|
||||
}, [sessionId, path, removeComment, commentView, toast]);
|
||||
|
||||
// Close comment view
|
||||
const handleCommentViewClose = useCallback(() => {
|
||||
setCommentView(null);
|
||||
}, []);
|
||||
|
||||
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">
|
||||
{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" />
|
||||
<span>{comments.length} comment{comments.length > 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setWrapEnabled(!wrapEnabled)}
|
||||
className={`h-8 w-8 p-0 cursor-pointer ${wrapEnabled ? 'text-foreground' : 'text-muted-foreground'}`}
|
||||
>
|
||||
{wrapEnabled ? <IconTextWrap className="h-4 w-4" /> : <IconTextWrapDisabled className="h-4 w-4" />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{wrapEnabled ? 'Disable word wrap' : 'Enable word wrap'}</TooltipContent>
|
||||
</Tooltip>
|
||||
{onDelete && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onDelete}
|
||||
className="h-8 w-8 p-0 cursor-pointer text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<IconTrash className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Delete file</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
onClick={onSave}
|
||||
disabled={!isDirty || isSaving}
|
||||
className="cursor-pointer gap-2"
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<IconLoader2 className="h-4 w-4 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<IconDeviceFloppy className="h-4 w-4" />
|
||||
Save
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({navigator.platform.includes('Mac') ? '⌘' : 'Ctrl'}+S)
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CodeMirror editor */}
|
||||
<div className="flex-1 overflow-hidden relative">
|
||||
<CodeMirror
|
||||
ref={editorRef}
|
||||
value={content}
|
||||
height="100%"
|
||||
theme={vscodeDark}
|
||||
extensions={extensions}
|
||||
onChange={handleChange}
|
||||
basicSetup={{
|
||||
lineNumbers: true,
|
||||
foldGutter: true,
|
||||
highlightActiveLine: true,
|
||||
highlightSelectionMatches: true,
|
||||
searchKeymap: true,
|
||||
}}
|
||||
className="h-full overflow-auto text-xs"
|
||||
/>
|
||||
|
||||
{/* Floating "Add comment" button when text is selected */}
|
||||
{floatingButtonPos && !textSelection && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="floating-comment-btn fixed z-50 gap-1.5 shadow-lg animate-in fade-in-0 zoom-in-95 duration-100 cursor-pointer"
|
||||
style={{
|
||||
left: floatingButtonPos.x + 8,
|
||||
top: floatingButtonPos.y + 8,
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={handleFloatingButtonClick}
|
||||
>
|
||||
<IconMessagePlus className="h-3.5 w-3.5" />
|
||||
Comment
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Comment popover for adding new comment */}
|
||||
{textSelection && (
|
||||
<EditorCommentPopover
|
||||
selectedText={textSelection.text}
|
||||
lineRange={{ start: textSelection.startLine, end: textSelection.endLine }}
|
||||
position={textSelection.position}
|
||||
onSubmit={handleCommentSubmit}
|
||||
onClose={handlePopoverClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Comment view popover for viewing/deleting existing comments */}
|
||||
{commentView && (
|
||||
<CommentViewPopover
|
||||
comments={commentView.comments}
|
||||
position={commentView.position}
|
||||
onDelete={handleDeleteComment}
|
||||
onClose={handleCommentViewClose}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
export function FileEditorContent(props: FileEditorContentProps) {
|
||||
const provider = useEditorProvider('code-editor');
|
||||
return provider === 'monaco'
|
||||
? <MonacoCodeEditor {...props} />
|
||||
: <CodeMirrorCodeEditor {...props} />;
|
||||
}
|
||||
|
||||
@@ -1,605 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { Editor, rootCtx, defaultValueCtx, editorViewCtx } from '@milkdown/core';
|
||||
import { nord } from '@milkdown/theme-nord';
|
||||
import { commonmark, codeBlockSchema } from '@milkdown/preset-commonmark';
|
||||
import { gfm } from '@milkdown/preset-gfm';
|
||||
import { listener, listenerCtx } from '@milkdown/plugin-listener';
|
||||
import { Milkdown, MilkdownProvider, useEditor } from '@milkdown/react';
|
||||
import { Plugin, PluginKey } from '@milkdown/prose/state';
|
||||
import { Decoration, DecorationSet } from '@milkdown/prose/view';
|
||||
import { $prose, $view } from '@milkdown/utils';
|
||||
import type { Node as ProseMirrorNode } from '@milkdown/prose/model';
|
||||
import mermaid from 'mermaid';
|
||||
|
||||
export type TextSelection = {
|
||||
text: string;
|
||||
position: { x: number; y: number };
|
||||
};
|
||||
|
||||
export type CommentHighlight = {
|
||||
id: string;
|
||||
selectedText: string;
|
||||
comment: string;
|
||||
};
|
||||
|
||||
type MilkdownEditorProps = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
onSelectionChange?: (selection: TextSelection | null) => void;
|
||||
/** Called when selection ends (mouseup) with selected text - for showing floating button */
|
||||
onSelectionEnd?: (selection: TextSelection | null) => void;
|
||||
comments?: CommentHighlight[];
|
||||
onCommentClick?: (id: string, position: { x: number; y: number }) => void;
|
||||
};
|
||||
|
||||
// Minimum text length required for comment highlighting
|
||||
const MIN_COMMENT_TEXT_LENGTH = 3;
|
||||
|
||||
// Initialize mermaid with default config
|
||||
let mermaidInitialized = false;
|
||||
function initMermaid(theme: 'dark' | 'light' = 'dark') {
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: theme === 'dark' ? 'dark' : 'default',
|
||||
securityLevel: 'loose',
|
||||
});
|
||||
mermaidInitialized = true;
|
||||
}
|
||||
|
||||
// Unique ID counter for mermaid diagrams
|
||||
let mermaidIdCounter = 0;
|
||||
|
||||
// Create mermaid node view for code blocks using $view
|
||||
const mermaidCodeBlockView = $view(codeBlockSchema.node, () => {
|
||||
return (node: ProseMirrorNode) => {
|
||||
const language = node.attrs.language as string;
|
||||
|
||||
// If not mermaid, return null to use default rendering
|
||||
if (language !== 'mermaid') {
|
||||
// Create default code block rendering
|
||||
const pre = document.createElement('pre');
|
||||
const code = document.createElement('code');
|
||||
code.textContent = node.textContent;
|
||||
if (language) {
|
||||
code.className = `language-${language}`;
|
||||
}
|
||||
pre.appendChild(code);
|
||||
return {
|
||||
dom: pre,
|
||||
contentDOM: code,
|
||||
};
|
||||
}
|
||||
|
||||
// Track current content for comparison in update
|
||||
let currentContent = node.textContent;
|
||||
|
||||
// Create container for mermaid diagram
|
||||
const dom = document.createElement('div');
|
||||
dom.className = 'mermaid-container';
|
||||
|
||||
// Initialize mermaid if not already done
|
||||
if (!mermaidInitialized) {
|
||||
initMermaid();
|
||||
}
|
||||
|
||||
// Render the mermaid diagram
|
||||
const id = `mermaid-${++mermaidIdCounter}`;
|
||||
mermaid.render(id, currentContent).then(({ svg }: { svg: string }) => {
|
||||
dom.innerHTML = svg;
|
||||
}).catch((err: Error) => {
|
||||
dom.innerHTML = `<pre class="mermaid-error">Error rendering diagram: ${err.message}</pre>`;
|
||||
});
|
||||
|
||||
return {
|
||||
dom,
|
||||
contentDOM: null,
|
||||
update: (updatedNode: ProseMirrorNode) => {
|
||||
if (updatedNode.type.name !== 'code_block') return false;
|
||||
const updatedLang = updatedNode.attrs.language as string;
|
||||
if (updatedLang !== 'mermaid') return false;
|
||||
const newContent = updatedNode.textContent;
|
||||
if (newContent !== currentContent) {
|
||||
currentContent = newContent; // Update tracked content
|
||||
const newId = `mermaid-${++mermaidIdCounter}`;
|
||||
mermaid.render(newId, newContent).then(({ svg }: { svg: string }) => {
|
||||
dom.innerHTML = svg;
|
||||
}).catch((err: Error) => {
|
||||
dom.innerHTML = `<pre class="mermaid-error">Error rendering diagram: ${err.message}</pre>`;
|
||||
});
|
||||
}
|
||||
return true;
|
||||
},
|
||||
};
|
||||
};
|
||||
});
|
||||
|
||||
// Plugin key for managing comment highlights
|
||||
const commentHighlightPluginKey = new PluginKey<{
|
||||
deco: DecorationSet;
|
||||
comments: CommentHighlight[];
|
||||
}>('comment-highlight');
|
||||
|
||||
// Plugin key for placeholder
|
||||
const placeholderPluginKey = new PluginKey<DecorationSet>('placeholder');
|
||||
|
||||
// Create placeholder plugin that shows text when editor is empty
|
||||
function createPlaceholderPlugin(placeholder: string) {
|
||||
return $prose(() => {
|
||||
return new Plugin({
|
||||
key: placeholderPluginKey,
|
||||
props: {
|
||||
decorations(state) {
|
||||
const doc = state.doc;
|
||||
// Check if document is empty (only has one empty paragraph)
|
||||
if (doc.childCount === 1 && doc.firstChild?.type.name === 'paragraph' && doc.firstChild.content.size === 0) {
|
||||
return DecorationSet.create(doc, [
|
||||
Decoration.widget(1, () => {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'editor-placeholder';
|
||||
span.textContent = placeholder;
|
||||
return span;
|
||||
}, { side: 0 }),
|
||||
]);
|
||||
}
|
||||
return DecorationSet.empty;
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Helper to get plain text content and position mapping from a document range
|
||||
function getTextWithPositions(doc: Parameters<typeof DecorationSet.create>[0]): { text: string; positions: number[] } {
|
||||
let text = '';
|
||||
const positions: number[] = []; // Maps text index to document position
|
||||
|
||||
doc.descendants((node, pos) => {
|
||||
if (node.isText && node.text) {
|
||||
for (let i = 0; i < node.text.length; i++) {
|
||||
positions.push(pos + i);
|
||||
text += node.text[i];
|
||||
}
|
||||
} else if (node.type.name === 'hardbreak') {
|
||||
// Handle hardbreak as a space/newline
|
||||
positions.push(-1);
|
||||
text += ' ';
|
||||
} else if (node.isBlock && text.length > 0 && !text.endsWith('\n')) {
|
||||
// Add newline between blocks
|
||||
positions.push(-1); // -1 indicates a synthetic newline
|
||||
text += '\n';
|
||||
}
|
||||
});
|
||||
|
||||
return { text, positions };
|
||||
}
|
||||
|
||||
// Normalize text for comparison (collapse whitespace, normalize newlines)
|
||||
function normalizeForSearch(text: string): string {
|
||||
return text
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/[\t ]+/g, ' ')
|
||||
.replace(/\n+/g, '\n')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
// Create decorations for comment highlights
|
||||
function createCommentDecorations(
|
||||
doc: Parameters<typeof DecorationSet.create>[0],
|
||||
comments: CommentHighlight[]
|
||||
): DecorationSet {
|
||||
const decorations: Decoration[] = [];
|
||||
|
||||
if (comments.length === 0) {
|
||||
return DecorationSet.empty;
|
||||
}
|
||||
|
||||
// Get the full text content with position mapping
|
||||
const { text: fullText, positions } = getTextWithPositions(doc);
|
||||
const normalizedFullText = normalizeForSearch(fullText);
|
||||
|
||||
comments.forEach((comment) => {
|
||||
const searchText = normalizeForSearch(comment.selectedText);
|
||||
|
||||
if (!searchText || searchText.length < MIN_COMMENT_TEXT_LENGTH) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the text in the normalized full document text
|
||||
const normalizedIndex = normalizedFullText.indexOf(searchText);
|
||||
|
||||
if (normalizedIndex === -1) {
|
||||
// Try with just the first line as fallback
|
||||
const firstLine = normalizeForSearch(comment.selectedText.split('\n')[0]);
|
||||
if (firstLine.length >= MIN_COMMENT_TEXT_LENGTH) {
|
||||
const fallbackIndex = normalizedFullText.indexOf(firstLine);
|
||||
if (fallbackIndex !== -1) {
|
||||
createDecorationForRange(fallbackIndex, firstLine.length, comment, fullText, positions, decorations);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
createDecorationForRange(normalizedIndex, searchText.length, comment, fullText, positions, decorations);
|
||||
});
|
||||
|
||||
return DecorationSet.create(doc, decorations);
|
||||
}
|
||||
|
||||
// Helper to create decoration(s) for a text range, handling multi-line selections
|
||||
function createDecorationForRange(
|
||||
normalizedIndex: number,
|
||||
length: number,
|
||||
comment: CommentHighlight,
|
||||
fullText: string,
|
||||
positions: number[],
|
||||
decorations: Decoration[]
|
||||
) {
|
||||
// Map normalized index back to original text index
|
||||
const normalizedFullText = normalizeForSearch(fullText);
|
||||
|
||||
const normalizedChars = normalizedFullText.split('');
|
||||
const origChars = fullText.split('');
|
||||
|
||||
// Map from normalized position to original position
|
||||
const normToOrig: number[] = [];
|
||||
let oi = 0;
|
||||
for (let ni = 0; ni < normalizedChars.length; ni++) {
|
||||
// Skip whitespace differences in original
|
||||
while (oi < origChars.length &&
|
||||
origChars[oi].toLowerCase() !== normalizedChars[ni] &&
|
||||
/\s/.test(origChars[oi])) {
|
||||
oi++;
|
||||
}
|
||||
normToOrig[ni] = oi;
|
||||
oi++;
|
||||
}
|
||||
|
||||
const startOrig = normToOrig[normalizedIndex] ?? 0;
|
||||
const endOrig = normToOrig[normalizedIndex + length - 1] ?? startOrig;
|
||||
|
||||
// Now create decorations for each contiguous range (split by newlines/block boundaries)
|
||||
let rangeStart = -1;
|
||||
|
||||
for (let i = startOrig; i <= endOrig && i < positions.length; i++) {
|
||||
const pos = positions[i];
|
||||
|
||||
if (pos === -1) {
|
||||
// Hit a block boundary - close current range if open
|
||||
if (rangeStart !== -1) {
|
||||
const fromPos = positions[rangeStart];
|
||||
const toPos = positions[i - 1] + 1;
|
||||
if (fromPos >= 0 && toPos > fromPos) {
|
||||
decorations.push(Decoration.inline(fromPos, toPos, {
|
||||
class: 'comment-highlight',
|
||||
'data-comment-id': comment.id,
|
||||
title: comment.comment,
|
||||
}));
|
||||
}
|
||||
rangeStart = -1;
|
||||
}
|
||||
} else {
|
||||
// Valid position - start range if not started
|
||||
if (rangeStart === -1) {
|
||||
rangeStart = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close final range
|
||||
if (rangeStart !== -1 && rangeStart < positions.length) {
|
||||
const fromPos = positions[rangeStart];
|
||||
const lastValidIndex = Math.min(endOrig, positions.length - 1);
|
||||
const toPos = positions[lastValidIndex] + 1;
|
||||
if (fromPos >= 0 && toPos > fromPos) {
|
||||
decorations.push(Decoration.inline(fromPos, toPos, {
|
||||
class: 'comment-highlight',
|
||||
'data-comment-id': comment.id,
|
||||
title: comment.comment,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create the comment highlight plugin
|
||||
function createCommentHighlightPlugin(initialComments: CommentHighlight[]) {
|
||||
return $prose(() => {
|
||||
return new Plugin({
|
||||
key: commentHighlightPluginKey,
|
||||
state: {
|
||||
init(_, state) {
|
||||
return {
|
||||
deco: createCommentDecorations(state.doc, initialComments),
|
||||
comments: initialComments,
|
||||
};
|
||||
},
|
||||
apply(tr, value, _, newState) {
|
||||
// Check if comments were updated via meta
|
||||
const newComments = tr.getMeta(commentHighlightPluginKey);
|
||||
if (newComments !== undefined) {
|
||||
return {
|
||||
deco: createCommentDecorations(newState.doc, newComments),
|
||||
comments: newComments,
|
||||
};
|
||||
}
|
||||
// If document changed, remap decorations
|
||||
if (tr.docChanged) {
|
||||
return {
|
||||
deco: createCommentDecorations(newState.doc, value.comments),
|
||||
comments: value.comments,
|
||||
};
|
||||
}
|
||||
return value;
|
||||
},
|
||||
},
|
||||
props: {
|
||||
decorations(state) {
|
||||
const pluginState = commentHighlightPluginKey.getState(state);
|
||||
return pluginState?.deco ?? DecorationSet.empty;
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function MilkdownEditorInner({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Start typing...',
|
||||
onSelectionChange,
|
||||
onSelectionEnd,
|
||||
comments = [],
|
||||
onCommentClick,
|
||||
}: MilkdownEditorProps) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
// Capture initial value at mount - editor manages its own state after that
|
||||
const initialValueRef = useRef(value);
|
||||
const placeholderRef = useRef(placeholder);
|
||||
const onChangeRef = useRef(onChange);
|
||||
const onSelectionChangeRef = useRef(onSelectionChange);
|
||||
const onSelectionEndRef = useRef(onSelectionEnd);
|
||||
const commentsRef = useRef(comments);
|
||||
const onCommentClickRef = useRef(onCommentClick);
|
||||
|
||||
// Keep refs updated
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
}, [onChange]);
|
||||
|
||||
useEffect(() => {
|
||||
onSelectionChangeRef.current = onSelectionChange;
|
||||
}, [onSelectionChange]);
|
||||
|
||||
useEffect(() => {
|
||||
onSelectionEndRef.current = onSelectionEnd;
|
||||
}, [onSelectionEnd]);
|
||||
|
||||
useEffect(() => {
|
||||
commentsRef.current = comments;
|
||||
}, [comments]);
|
||||
|
||||
useEffect(() => {
|
||||
onCommentClickRef.current = onCommentClick;
|
||||
}, [onCommentClick]);
|
||||
|
||||
// Set up click handler for comment highlights via event delegation
|
||||
useEffect(() => {
|
||||
const wrapper = wrapperRef.current;
|
||||
if (!wrapper) return;
|
||||
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const commentHighlight = target.closest('.comment-highlight');
|
||||
if (commentHighlight && onCommentClickRef.current) {
|
||||
const commentId = commentHighlight.getAttribute('data-comment-id');
|
||||
if (commentId) {
|
||||
onCommentClickRef.current(commentId, { x: e.clientX, y: e.clientY });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
wrapper.addEventListener('click', handleClick);
|
||||
return () => wrapper.removeEventListener('click', handleClick);
|
||||
}, []);
|
||||
|
||||
// Handle mouseup to show floating button when selection ends
|
||||
useEffect(() => {
|
||||
const wrapper = wrapperRef.current;
|
||||
if (!wrapper) return;
|
||||
|
||||
const handleMouseUp = (e: MouseEvent) => {
|
||||
if (!onSelectionEndRef.current) return;
|
||||
|
||||
// Capture mouse position immediately
|
||||
const mouseX = e.clientX;
|
||||
const mouseY = e.clientY;
|
||||
|
||||
// Small delay to let selection finalize
|
||||
setTimeout(() => {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.isCollapsed || !selection.toString().trim()) {
|
||||
onSelectionEndRef.current?.(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedText = selection.toString().trim();
|
||||
if (selectedText.length < MIN_COMMENT_TEXT_LENGTH) {
|
||||
onSelectionEndRef.current?.(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Use mouse position (where cursor is) instead of selection end
|
||||
onSelectionEndRef.current?.({
|
||||
text: selectedText,
|
||||
position: { x: mouseX, y: mouseY },
|
||||
});
|
||||
}, 10);
|
||||
};
|
||||
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
// Don't clear if clicking on the floating button
|
||||
if ((e.target as HTMLElement).closest('.floating-comment-btn')) {
|
||||
return;
|
||||
}
|
||||
// Clear selection state when starting new selection
|
||||
onSelectionEndRef.current?.(null);
|
||||
};
|
||||
|
||||
wrapper.addEventListener('mouseup', handleMouseUp);
|
||||
wrapper.addEventListener('mousedown', handleMouseDown);
|
||||
return () => {
|
||||
wrapper.removeEventListener('mouseup', handleMouseUp);
|
||||
wrapper.removeEventListener('mousedown', handleMouseDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Handle Cmd+I to trigger selection popover (overrides italic formatting)
|
||||
useEffect(() => {
|
||||
const wrapper = wrapperRef.current;
|
||||
if (!wrapper) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Check for Cmd+I (Mac) or Ctrl+I (Windows/Linux)
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'i') {
|
||||
// Only handle if we have a selection callback
|
||||
if (!onSelectionChangeRef.current) return;
|
||||
|
||||
const selection = window.getSelection();
|
||||
if (!selection) return;
|
||||
|
||||
// Prevent Milkdown's italic formatting immediately
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
|
||||
// Now check if we have valid selected text
|
||||
if (selection.isCollapsed || !selection.toString().trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedText = selection.toString().trim();
|
||||
if (selectedText.length < MIN_COMMENT_TEXT_LENGTH) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get position from the focus point (end of selection where cursor is)
|
||||
const focusNode = selection.focusNode;
|
||||
const focusOffset = selection.focusOffset;
|
||||
|
||||
if (focusNode) {
|
||||
// Create a range at the focus point to get its position
|
||||
const focusRange = document.createRange();
|
||||
focusRange.setStart(focusNode, focusOffset);
|
||||
focusRange.setEnd(focusNode, focusOffset);
|
||||
const focusRect = focusRange.getBoundingClientRect();
|
||||
|
||||
// If focusRect has no width (collapsed), use its position
|
||||
// Otherwise fall back to the selection's last rect
|
||||
let x = focusRect.right || focusRect.left;
|
||||
let y = focusRect.bottom || focusRect.top;
|
||||
|
||||
// If position is 0,0 (can happen), fall back to last rect of selection
|
||||
if (x === 0 && y === 0) {
|
||||
const range = selection.getRangeAt(0);
|
||||
const rects = range.getClientRects();
|
||||
const lastRect = rects.length > 0 ? rects[rects.length - 1] : range.getBoundingClientRect();
|
||||
x = lastRect.right;
|
||||
y = lastRect.bottom;
|
||||
}
|
||||
|
||||
onSelectionChangeRef.current({
|
||||
text: selectedText,
|
||||
position: { x, y },
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Attach to wrapper element in capture phase to intercept before Milkdown
|
||||
wrapper.addEventListener('keydown', handleKeyDown, true);
|
||||
return () => wrapper.removeEventListener('keydown', handleKeyDown, true);
|
||||
}, []);
|
||||
|
||||
// Create the highlight plugin once with initial comments
|
||||
const highlightPlugin = useCallback(
|
||||
() => createCommentHighlightPlugin(commentsRef.current),
|
||||
[]
|
||||
);
|
||||
|
||||
// Create the placeholder plugin once
|
||||
const placeholderPlugin = useCallback(
|
||||
() => createPlaceholderPlugin(placeholderRef.current),
|
||||
[]
|
||||
);
|
||||
|
||||
const { loading, get } = useEditor((root) =>
|
||||
Editor.make()
|
||||
.config((ctx) => {
|
||||
ctx.set(rootCtx, root);
|
||||
ctx.set(defaultValueCtx, initialValueRef.current);
|
||||
ctx.get(listenerCtx).markdownUpdated((_, markdown) => {
|
||||
onChangeRef.current(markdown);
|
||||
});
|
||||
})
|
||||
.config(nord)
|
||||
.use(commonmark)
|
||||
.use(gfm)
|
||||
.use(mermaidCodeBlockView)
|
||||
.use(listener)
|
||||
.use(highlightPlugin())
|
||||
.use(placeholderPlugin())
|
||||
);
|
||||
|
||||
// Update decorations when comments change
|
||||
useEffect(() => {
|
||||
const editor = get();
|
||||
if (!editor || loading) return;
|
||||
|
||||
try {
|
||||
// Get the editor view and dispatch a transaction to update comments
|
||||
editor.action((ctx) => {
|
||||
try {
|
||||
const view = ctx.get(editorViewCtx);
|
||||
if (view) {
|
||||
const tr = view.state.tr.setMeta(commentHighlightPluginKey, comments);
|
||||
view.dispatch(tr);
|
||||
}
|
||||
} catch {
|
||||
// editorViewCtx not available yet
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
// Editor might not be ready
|
||||
}
|
||||
}, [comments, get, loading]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className={`milkdown-wrapper h-full relative ${resolvedTheme === 'dark' ? 'dark' : ''}`}
|
||||
>
|
||||
<Milkdown />
|
||||
{loading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-muted-foreground text-sm bg-background/80">
|
||||
Loading editor...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MarkdownEditor(props: MilkdownEditorProps) {
|
||||
// Generate a unique key on each mount to force fresh Milkdown instance
|
||||
const [instanceId] = useState(() => Math.random().toString(36).slice(2));
|
||||
|
||||
return (
|
||||
<MilkdownProvider key={instanceId}>
|
||||
<MilkdownEditorInner {...props} />
|
||||
</MilkdownProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ 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';
|
||||
|
||||
type TaskChangesPanelProps = {
|
||||
@@ -46,7 +47,11 @@ type TaskChangesPanelProps = {
|
||||
const TaskChangesPanel = memo(function TaskChangesPanel({
|
||||
selectedDiff,
|
||||
onClearSelected,
|
||||
onOpenFile: onOpenFileProp,
|
||||
}: TaskChangesPanelProps) {
|
||||
const { openFile: panelOpenFile } = usePanelActions();
|
||||
const handleOpenFile = onOpenFileProp ?? panelOpenFile;
|
||||
|
||||
const [splitView, setSplitView] = useState(() => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
return localStorage.getItem('diff-view-mode') === 'split';
|
||||
@@ -418,6 +423,7 @@ const TaskChangesPanel = memo(function TaskChangesPanel({
|
||||
wordWrap={wordWrap}
|
||||
onToggleReviewed={handleToggleReviewed}
|
||||
onDiscard={handleDiscard}
|
||||
onOpenFile={handleOpenFile}
|
||||
fileRefs={fileRefs}
|
||||
/>
|
||||
) : null}
|
||||
@@ -432,6 +438,7 @@ const TaskChangesPanel = memo(function TaskChangesPanel({
|
||||
baseBranch={baseBranch}
|
||||
taskTitle={taskTitle}
|
||||
onSendComments={handleDialogSendComments}
|
||||
onOpenFile={handleOpenFile}
|
||||
gitStatusFiles={gitStatus?.files ?? null}
|
||||
cumulativeDiff={cumulativeDiff}
|
||||
/>
|
||||
|
||||
@@ -11,14 +11,14 @@ import { useTaskPlan } from '@/hooks/domains/session/use-task-plan';
|
||||
import { useAppStore } from '@/components/state-provider';
|
||||
import { PlanSelectionPopover } from './plan-selection-popover';
|
||||
import type { PlanComment } from './plan-comments';
|
||||
import type { TextSelection, CommentHighlight } from './markdown-editor';
|
||||
import type { TextSelection, CommentHighlight } from '@/components/editors/tiptap/tiptap-plan-editor';
|
||||
import type { DocumentComment } from '@/lib/state/slices/ui/types';
|
||||
|
||||
const EMPTY_COMMENTS: DocumentComment[] = [];
|
||||
|
||||
// Dynamic import to avoid SSR issues with Milkdown
|
||||
const MarkdownEditor = dynamic(
|
||||
() => import('./markdown-editor').then((mod) => mod.MarkdownEditor),
|
||||
// Dynamic import to avoid SSR issues with TipTap
|
||||
const PlanEditor = dynamic(
|
||||
() => import('@/components/editors/tiptap/tiptap-plan-editor').then((mod) => mod.TipTapPlanEditor),
|
||||
{ ssr: false, loading: () => <div className="flex h-full items-center justify-center text-muted-foreground text-sm">Loading editor...</div> }
|
||||
);
|
||||
|
||||
@@ -309,7 +309,7 @@ export const TaskPlanPanel = memo(function TaskPlanPanel({ taskId, visible = tru
|
||||
ref={editorWrapperRef}
|
||||
onClick={handleEmptyStateClick}
|
||||
>
|
||||
<MarkdownEditor
|
||||
<PlanEditor
|
||||
key={`${taskId}-${editorKey}`}
|
||||
value={draftContent}
|
||||
onChange={setDraftContent}
|
||||
|
||||
Reference in New Issue
Block a user