mirror of
https://github.com/siteboon/claudecodeui.git
synced 2025-12-18 10:09:33 +00:00
feat(editor): Change Code Editor to show diffs in source control panel and during messaging.
Add merge view and minimap extensions to CodeMirror for enhanced code editing capabilities. Increase Express JSON and URL-encoded payload limits from default (100kb) to 50mb to support larger file operations and git diffs.
This commit is contained in:
@@ -170,7 +170,7 @@ const safeLocalStorage = {
|
||||
};
|
||||
|
||||
// Memoized message component to prevent unnecessary re-renders
|
||||
const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFileOpen, onShowSettings, autoExpandTools, showRawParameters, showThinking }) => {
|
||||
const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFileOpen, onShowSettings, autoExpandTools, showRawParameters, showThinking, selectedProject }) => {
|
||||
const isGrouped = prevMessage && prevMessage.type === message.type &&
|
||||
((prevMessage.type === 'assistant') ||
|
||||
(prevMessage.type === 'user') ||
|
||||
@@ -315,14 +315,37 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
📝 View edit diff for
|
||||
<button
|
||||
onClick={(e) => {
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onFileOpen && onFileOpen(input.file_path, {
|
||||
old_string: input.old_string,
|
||||
new_string: input.new_string
|
||||
});
|
||||
if (!onFileOpen) return;
|
||||
|
||||
try {
|
||||
// Fetch the current file (after the edit)
|
||||
const response = await api.readFile(selectedProject?.name, input.file_path);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok || data.error) {
|
||||
console.error('Failed to fetch file:', data.error);
|
||||
onFileOpen(input.file_path);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentContent = data.content || '';
|
||||
|
||||
// Reverse apply the edit: replace new_string back to old_string to get the file BEFORE the edit
|
||||
const oldContent = currentContent.replace(input.new_string, input.old_string);
|
||||
|
||||
// Pass the full file before and after the edit
|
||||
onFileOpen(input.file_path, {
|
||||
old_string: oldContent,
|
||||
new_string: currentContent
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error preparing diff:', error);
|
||||
onFileOpen(input.file_path);
|
||||
}
|
||||
}}
|
||||
className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 underline font-mono"
|
||||
>
|
||||
@@ -332,11 +355,35 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
||||
<div className="mt-3">
|
||||
<div className="bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-gray-100 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => onFileOpen && onFileOpen(input.file_path, {
|
||||
old_string: input.old_string,
|
||||
new_string: input.new_string
|
||||
})}
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!onFileOpen) return;
|
||||
|
||||
try {
|
||||
// Fetch the current file (after the edit)
|
||||
const response = await api.readFile(selectedProject?.name, input.file_path);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok || data.error) {
|
||||
console.error('Failed to fetch file:', data.error);
|
||||
onFileOpen(input.file_path);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentContent = data.content || '';
|
||||
// Reverse apply the edit: replace new_string back to old_string
|
||||
const oldContent = currentContent.replace(input.new_string, input.old_string);
|
||||
|
||||
// Pass the full file before and after the edit
|
||||
onFileOpen(input.file_path, {
|
||||
old_string: oldContent,
|
||||
new_string: currentContent
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error preparing diff:', error);
|
||||
onFileOpen(input.file_path);
|
||||
}
|
||||
}}
|
||||
className="text-xs font-mono text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 truncate underline cursor-pointer"
|
||||
>
|
||||
{input.file_path}
|
||||
@@ -416,15 +463,33 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
||||
<svg className="w-4 h-4 transition-transform details-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
📄 Creating new file:
|
||||
<button
|
||||
onClick={(e) => {
|
||||
📄 Creating new file:
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onFileOpen && onFileOpen(input.file_path, {
|
||||
old_string: '',
|
||||
new_string: input.content
|
||||
});
|
||||
if (!onFileOpen) return;
|
||||
|
||||
try {
|
||||
// Fetch the written file from disk
|
||||
const response = await api.readFile(selectedProject?.name, input.file_path);
|
||||
const data = await response.json();
|
||||
|
||||
const newContent = (response.ok && !data.error) ? data.content || '' : input.content || '';
|
||||
|
||||
// New file: old_string is empty, new_string is the full file
|
||||
onFileOpen(input.file_path, {
|
||||
old_string: '',
|
||||
new_string: newContent
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error preparing diff:', error);
|
||||
// Fallback to tool input content
|
||||
onFileOpen(input.file_path, {
|
||||
old_string: '',
|
||||
new_string: input.content || ''
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 underline font-mono"
|
||||
>
|
||||
@@ -434,11 +499,31 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
||||
<div className="mt-3">
|
||||
<div className="bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-gray-100 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => onFileOpen && onFileOpen(input.file_path, {
|
||||
old_string: '',
|
||||
new_string: input.content
|
||||
})}
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!onFileOpen) return;
|
||||
|
||||
try {
|
||||
// Fetch the written file from disk
|
||||
const response = await api.readFile(selectedProject?.name, input.file_path);
|
||||
const data = await response.json();
|
||||
|
||||
const newContent = (response.ok && !data.error) ? data.content || '' : input.content || '';
|
||||
|
||||
// New file: old_string is empty, new_string is the full file
|
||||
onFileOpen(input.file_path, {
|
||||
old_string: '',
|
||||
new_string: newContent
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error preparing diff:', error);
|
||||
// Fallback to tool input content
|
||||
onFileOpen(input.file_path, {
|
||||
old_string: '',
|
||||
new_string: input.content || ''
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="text-xs font-mono text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 truncate underline cursor-pointer"
|
||||
>
|
||||
{input.file_path}
|
||||
@@ -578,7 +663,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
||||
return (
|
||||
<div className="mt-2 text-sm text-blue-700 dark:text-blue-300">
|
||||
Read{' '}
|
||||
<button
|
||||
<button
|
||||
onClick={() => onFileOpen && onFileOpen(input.file_path)}
|
||||
className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 underline font-mono"
|
||||
>
|
||||
@@ -835,8 +920,28 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="font-medium">File updated successfully</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onFileOpen && onFileOpen(fileEditMatch[1])}
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!onFileOpen) return;
|
||||
|
||||
// Fetch FULL file content with diff from git
|
||||
try {
|
||||
const response = await authenticatedFetch(`/api/git/file-with-diff?project=${encodeURIComponent(selectedProject?.name)}&file=${encodeURIComponent(fileEditMatch[1])}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.error && data.oldContent !== undefined && data.currentContent !== undefined) {
|
||||
onFileOpen(fileEditMatch[1], {
|
||||
old_string: data.oldContent || '',
|
||||
new_string: data.currentContent || ''
|
||||
});
|
||||
} else {
|
||||
onFileOpen(fileEditMatch[1]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching file diff:', error);
|
||||
onFileOpen(fileEditMatch[1]);
|
||||
}
|
||||
}}
|
||||
className="text-xs font-mono bg-green-100 dark:bg-green-800/30 px-2 py-1 rounded text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 underline cursor-pointer"
|
||||
>
|
||||
{fileEditMatch[1]}
|
||||
@@ -853,8 +958,28 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="font-medium">File created successfully</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onFileOpen && onFileOpen(fileCreateMatch[1])}
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!onFileOpen) return;
|
||||
|
||||
// Fetch FULL file content with diff from git
|
||||
try {
|
||||
const response = await authenticatedFetch(`/api/git/file-with-diff?project=${encodeURIComponent(selectedProject?.name)}&file=${encodeURIComponent(fileCreateMatch[1])}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.error && data.oldContent !== undefined && data.currentContent !== undefined) {
|
||||
onFileOpen(fileCreateMatch[1], {
|
||||
old_string: data.oldContent || '',
|
||||
new_string: data.currentContent || ''
|
||||
});
|
||||
} else {
|
||||
onFileOpen(fileCreateMatch[1]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching file diff:', error);
|
||||
onFileOpen(fileCreateMatch[1]);
|
||||
}
|
||||
}}
|
||||
className="text-xs font-mono bg-green-100 dark:bg-green-800/30 px-2 py-1 rounded text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 underline cursor-pointer"
|
||||
>
|
||||
{fileCreateMatch[1]}
|
||||
@@ -1019,7 +1144,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
||||
return (
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border-l-2 border-blue-300 dark:border-blue-600 pl-3 py-1 mb-2 text-sm text-blue-700 dark:text-blue-300">
|
||||
📖 Read{' '}
|
||||
<button
|
||||
<button
|
||||
onClick={() => onFileOpen && onFileOpen(input.file_path)}
|
||||
className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 underline font-mono"
|
||||
>
|
||||
@@ -2370,8 +2495,6 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
// Only reloads if the session is NOT active (respecting Session Protection System)
|
||||
useEffect(() => {
|
||||
if (externalMessageUpdate > 0 && selectedSession && selectedProject) {
|
||||
console.log('🔄 Reloading messages due to external CLI update');
|
||||
|
||||
const reloadExternalMessages = async () => {
|
||||
try {
|
||||
const provider = localStorage.getItem('selected-provider') || 'claude';
|
||||
@@ -2467,7 +2590,6 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
// Handle WebSocket messages
|
||||
if (messages.length > 0) {
|
||||
const latestMessage = messages[messages.length - 1];
|
||||
console.log('🔵 WebSocket message received:', latestMessage.type, latestMessage);
|
||||
|
||||
// Filter messages by session ID to prevent cross-session interference
|
||||
// Skip filtering for global messages that apply to all sessions
|
||||
@@ -2887,16 +3009,8 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
// Get session ID from message or fall back to current session
|
||||
const completedSessionId = latestMessage.sessionId || currentSessionId || sessionStorage.getItem('pendingSessionId');
|
||||
|
||||
console.log('🎯 claude-complete received:', {
|
||||
completedSessionId,
|
||||
currentSessionId,
|
||||
match: completedSessionId === currentSessionId,
|
||||
isNew: !currentSessionId
|
||||
});
|
||||
|
||||
// Update UI state if this is the current session OR if we don't have a session ID yet (new session)
|
||||
if (completedSessionId === currentSessionId || !currentSessionId) {
|
||||
console.log('✅ Stopping loading state');
|
||||
setIsLoading(false);
|
||||
setCanAbortSession(false);
|
||||
setClaudeStatus(null);
|
||||
@@ -3204,16 +3318,13 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
const fetchInitialTokenUsage = async () => {
|
||||
try {
|
||||
const url = `/api/projects/${selectedProject.name}/sessions/${selectedSession.id}/token-usage`;
|
||||
console.log('📊 Fetching initial token usage from:', url);
|
||||
|
||||
const response = await authenticatedFetch(url);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
console.log('✅ Initial token usage loaded:', data);
|
||||
setTokenBudget(data);
|
||||
} else {
|
||||
console.log('⚠️ No token usage data available for this session yet');
|
||||
setTokenBudget(null);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -3978,6 +4089,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
autoExpandTools={autoExpandTools}
|
||||
showRawParameters={showRawParameters}
|
||||
showThinking={showThinking}
|
||||
selectedProject={selectedProject}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import CodeMirror from '@uiw/react-codemirror';
|
||||
import { javascript } from '@codemirror/lang-javascript';
|
||||
import { python } from '@codemirror/lang-python';
|
||||
@@ -7,8 +7,9 @@ import { css } from '@codemirror/lang-css';
|
||||
import { json } from '@codemirror/lang-json';
|
||||
import { markdown } from '@codemirror/lang-markdown';
|
||||
import { oneDark } from '@codemirror/theme-one-dark';
|
||||
import { EditorView, Decoration } from '@codemirror/view';
|
||||
import { StateField, StateEffect, RangeSetBuilder } from '@codemirror/state';
|
||||
import { EditorView, showPanel, ViewPlugin } from '@codemirror/view';
|
||||
import { unifiedMergeView, getChunks } from '@codemirror/merge';
|
||||
import { showMinimap } from '@replit/codemirror-minimap';
|
||||
import { X, Save, Download, Maximize2, Minimize2, Eye, EyeOff } from 'lucide-react';
|
||||
import { api } from '../utils/api';
|
||||
|
||||
@@ -21,90 +22,150 @@ function CodeEditor({ file, onClose, projectPath }) {
|
||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||
const [showDiff, setShowDiff] = useState(!!file.diffInfo);
|
||||
const [wordWrap, setWordWrap] = useState(false);
|
||||
const editorRef = useRef(null);
|
||||
|
||||
// Create diff highlighting
|
||||
const diffEffect = StateEffect.define();
|
||||
|
||||
const diffField = StateField.define({
|
||||
create() {
|
||||
return Decoration.none;
|
||||
},
|
||||
update(decorations, tr) {
|
||||
decorations = decorations.map(tr.changes);
|
||||
|
||||
for (let effect of tr.effects) {
|
||||
if (effect.is(diffEffect)) {
|
||||
decorations = effect.value;
|
||||
// Create minimap extension with chunk-based gutters
|
||||
const minimapExtension = useMemo(() => {
|
||||
if (!file.diffInfo || !showDiff) return [];
|
||||
|
||||
const gutters = {};
|
||||
|
||||
return [
|
||||
showMinimap.compute(['doc'], (state) => {
|
||||
// Get actual chunks from merge view
|
||||
const chunksData = getChunks(state);
|
||||
const chunks = chunksData?.chunks || [];
|
||||
|
||||
// Clear previous gutters
|
||||
Object.keys(gutters).forEach(key => delete gutters[key]);
|
||||
|
||||
// Mark lines that are part of chunks
|
||||
chunks.forEach(chunk => {
|
||||
// Mark the lines in the B side (current document)
|
||||
const fromLine = state.doc.lineAt(chunk.fromB).number;
|
||||
const toLine = state.doc.lineAt(Math.min(chunk.toB, state.doc.length)).number;
|
||||
|
||||
for (let lineNum = fromLine; lineNum <= toLine; lineNum++) {
|
||||
gutters[lineNum] = isDarkMode ? 'rgba(34, 197, 94, 0.8)' : 'rgba(34, 197, 94, 1)';
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
create: () => ({ dom: document.createElement('div') }),
|
||||
displayText: 'blocks',
|
||||
showOverlay: 'always',
|
||||
gutters: [gutters]
|
||||
};
|
||||
})
|
||||
];
|
||||
}, [file.diffInfo, showDiff, isDarkMode]);
|
||||
|
||||
// Create extension to scroll to first chunk on mount
|
||||
const scrollToFirstChunkExtension = useMemo(() => {
|
||||
if (!file.diffInfo || !showDiff) return [];
|
||||
|
||||
return [
|
||||
ViewPlugin.fromClass(class {
|
||||
constructor(view) {
|
||||
// Delay to ensure merge view is fully initialized
|
||||
setTimeout(() => {
|
||||
const chunksData = getChunks(view.state);
|
||||
const chunks = chunksData?.chunks || [];
|
||||
|
||||
if (chunks.length > 0) {
|
||||
const firstChunk = chunks[0];
|
||||
|
||||
// Scroll to the first chunk
|
||||
view.dispatch({
|
||||
effects: EditorView.scrollIntoView(firstChunk.fromB, { y: 'center' })
|
||||
});
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
return decorations;
|
||||
},
|
||||
provide: f => EditorView.decorations.from(f)
|
||||
});
|
||||
|
||||
const createDiffDecorations = (content, diffInfo) => {
|
||||
if (!diffInfo || !showDiff) return Decoration.none;
|
||||
|
||||
const builder = new RangeSetBuilder();
|
||||
const lines = content.split('\n');
|
||||
const oldLines = diffInfo.old_string.split('\n');
|
||||
|
||||
// Find the line where the old content starts
|
||||
let startLineIndex = -1;
|
||||
for (let i = 0; i <= lines.length - oldLines.length; i++) {
|
||||
let matches = true;
|
||||
for (let j = 0; j < oldLines.length; j++) {
|
||||
if (lines[i + j] !== oldLines[j]) {
|
||||
matches = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (matches) {
|
||||
startLineIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
update() {}
|
||||
destroy() {}
|
||||
})
|
||||
];
|
||||
}, [file.diffInfo, showDiff]);
|
||||
|
||||
if (startLineIndex >= 0) {
|
||||
let pos = 0;
|
||||
// Calculate position to start of old content
|
||||
for (let i = 0; i < startLineIndex; i++) {
|
||||
pos += lines[i].length + 1; // +1 for newline
|
||||
}
|
||||
|
||||
// Highlight old lines (to be removed)
|
||||
for (let i = 0; i < oldLines.length; i++) {
|
||||
const lineStart = pos;
|
||||
const lineEnd = pos + oldLines[i].length;
|
||||
builder.add(lineStart, lineEnd, Decoration.line({
|
||||
class: isDarkMode ? 'diff-removed-dark' : 'diff-removed-light'
|
||||
}));
|
||||
pos += oldLines[i].length + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return builder.finish();
|
||||
};
|
||||
// Create diff navigation panel extension
|
||||
const diffNavigationPanel = useMemo(() => {
|
||||
if (!file.diffInfo || !showDiff) return [];
|
||||
|
||||
// Diff decoration theme
|
||||
const diffTheme = EditorView.theme({
|
||||
'.diff-removed-light': {
|
||||
backgroundColor: '#fef2f2',
|
||||
borderLeft: '3px solid #ef4444'
|
||||
},
|
||||
'.diff-removed-dark': {
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.1)',
|
||||
borderLeft: '3px solid #ef4444'
|
||||
},
|
||||
'.diff-added-light': {
|
||||
backgroundColor: '#f0fdf4',
|
||||
borderLeft: '3px solid #22c55e'
|
||||
},
|
||||
'.diff-added-dark': {
|
||||
backgroundColor: 'rgba(34, 197, 94, 0.1)',
|
||||
borderLeft: '3px solid #22c55e'
|
||||
}
|
||||
});
|
||||
const createPanel = (view) => {
|
||||
const dom = document.createElement('div');
|
||||
dom.className = 'cm-diff-navigation-panel';
|
||||
|
||||
let currentIndex = 0;
|
||||
|
||||
const updatePanel = () => {
|
||||
// Use getChunks API to get ALL chunks regardless of viewport
|
||||
const chunksData = getChunks(view.state);
|
||||
const chunks = chunksData?.chunks || [];
|
||||
const chunkCount = chunks.length;
|
||||
|
||||
dom.innerHTML = `
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<span style="font-weight: 500;">${chunkCount > 0 ? `${currentIndex + 1}/${chunkCount}` : '0'} changes</span>
|
||||
<button class="cm-diff-nav-btn cm-diff-nav-prev" title="Previous change" ${chunkCount === 0 ? 'disabled' : ''}>
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="cm-diff-nav-btn cm-diff-nav-next" title="Next change" ${chunkCount === 0 ? 'disabled' : ''}>
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const prevBtn = dom.querySelector('.cm-diff-nav-prev');
|
||||
const nextBtn = dom.querySelector('.cm-diff-nav-next');
|
||||
|
||||
prevBtn?.addEventListener('click', () => {
|
||||
if (chunks.length === 0) return;
|
||||
currentIndex = currentIndex > 0 ? currentIndex - 1 : chunks.length - 1;
|
||||
|
||||
// Navigate to the chunk - use fromB which is the position in the current document
|
||||
const chunk = chunks[currentIndex];
|
||||
if (chunk) {
|
||||
// Scroll to the start of the chunk in the B side (current document)
|
||||
view.dispatch({
|
||||
effects: EditorView.scrollIntoView(chunk.fromB, { y: 'center' })
|
||||
});
|
||||
}
|
||||
updatePanel();
|
||||
});
|
||||
|
||||
nextBtn?.addEventListener('click', () => {
|
||||
if (chunks.length === 0) return;
|
||||
currentIndex = currentIndex < chunks.length - 1 ? currentIndex + 1 : 0;
|
||||
|
||||
// Navigate to the chunk - use fromB which is the position in the current document
|
||||
const chunk = chunks[currentIndex];
|
||||
if (chunk) {
|
||||
// Scroll to the start of the chunk in the B side (current document)
|
||||
view.dispatch({
|
||||
effects: EditorView.scrollIntoView(chunk.fromB, { y: 'center' })
|
||||
});
|
||||
}
|
||||
updatePanel();
|
||||
});
|
||||
};
|
||||
|
||||
updatePanel();
|
||||
|
||||
return {
|
||||
top: true,
|
||||
dom,
|
||||
update: updatePanel
|
||||
};
|
||||
};
|
||||
|
||||
return [showPanel.of(createPanel)];
|
||||
}, [file.diffInfo, showDiff]);
|
||||
|
||||
// Get language extension based on file extension
|
||||
const getLanguageExtension = (filename) => {
|
||||
@@ -139,13 +200,24 @@ function CodeEditor({ file, onClose, projectPath }) {
|
||||
const loadFileContent = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
|
||||
// If we have diffInfo with both old and new content, we can show the diff directly
|
||||
// This handles both GitPanel (full content) and ChatInterface (full content from API)
|
||||
if (file.diffInfo && file.diffInfo.new_string !== undefined && file.diffInfo.old_string !== undefined) {
|
||||
// Use the new_string as the content to display
|
||||
// The unifiedMergeView will compare it against old_string
|
||||
setContent(file.diffInfo.new_string);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, load from disk
|
||||
const response = await api.readFile(file.projectName, file.path);
|
||||
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load file: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
|
||||
const data = await response.json();
|
||||
setContent(data.content);
|
||||
} catch (error) {
|
||||
@@ -159,37 +231,41 @@ function CodeEditor({ file, onClose, projectPath }) {
|
||||
loadFileContent();
|
||||
}, [file, projectPath]);
|
||||
|
||||
// Update diff decorations when content or diff info changes
|
||||
const editorRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (editorRef.current && content && file.diffInfo && showDiff) {
|
||||
const decorations = createDiffDecorations(content, file.diffInfo);
|
||||
const view = editorRef.current.view;
|
||||
if (view) {
|
||||
view.dispatch({
|
||||
effects: diffEffect.of(decorations)
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [content, file.diffInfo, showDiff, isDarkMode]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
console.log('Saving file:', {
|
||||
projectName: file.projectName,
|
||||
path: file.path,
|
||||
contentLength: content?.length
|
||||
});
|
||||
|
||||
const response = await api.saveFile(file.projectName, file.path, content);
|
||||
|
||||
console.log('Save response:', {
|
||||
status: response.status,
|
||||
ok: response.ok,
|
||||
contentType: response.headers.get('content-type')
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || `Save failed: ${response.status}`);
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || `Save failed: ${response.status}`);
|
||||
} else {
|
||||
const textError = await response.text();
|
||||
console.error('Non-JSON error response:', textError);
|
||||
throw new Error(`Save failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// Show success feedback
|
||||
console.log('Save successful:', result);
|
||||
|
||||
setSaveSuccess(true);
|
||||
setTimeout(() => setSaveSuccess(false), 2000); // Hide after 2 seconds
|
||||
|
||||
setTimeout(() => setSaveSuccess(false), 2000);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error saving file:', error);
|
||||
alert(`Error saving file: ${error.message}`);
|
||||
@@ -258,11 +334,80 @@ function CodeEditor({ file, onClose, projectPath }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`fixed inset-0 z-50 ${
|
||||
// Mobile: native fullscreen, Desktop: modal with backdrop
|
||||
'md:bg-black/50 md:flex md:items-center md:justify-center md:p-4'
|
||||
} ${isFullscreen ? 'md:p-0' : ''}`}>
|
||||
<div className={`bg-white shadow-2xl flex flex-col ${
|
||||
<>
|
||||
<style>
|
||||
{`
|
||||
/* Light background for full line changes */
|
||||
.cm-deletedChunk {
|
||||
background-color: ${isDarkMode ? 'rgba(239, 68, 68, 0.15)' : 'rgba(255, 235, 235, 1)'} !important;
|
||||
border-left: 3px solid ${isDarkMode ? 'rgba(239, 68, 68, 0.6)' : 'rgb(239, 68, 68)'} !important;
|
||||
padding-left: 4px !important;
|
||||
}
|
||||
|
||||
.cm-insertedChunk {
|
||||
background-color: ${isDarkMode ? 'rgba(34, 197, 94, 0.15)' : 'rgba(230, 255, 237, 1)'} !important;
|
||||
border-left: 3px solid ${isDarkMode ? 'rgba(34, 197, 94, 0.6)' : 'rgb(34, 197, 94)'} !important;
|
||||
padding-left: 4px !important;
|
||||
}
|
||||
|
||||
/* Override linear-gradient underline and use solid darker background for partial changes */
|
||||
.cm-editor.cm-merge-b .cm-changedText {
|
||||
background: ${isDarkMode ? 'rgba(34, 197, 94, 0.4)' : 'rgba(34, 197, 94, 0.3)'} !important;
|
||||
padding-top: 2px !important;
|
||||
padding-bottom: 2px !important;
|
||||
margin-top: -2px !important;
|
||||
margin-bottom: -2px !important;
|
||||
}
|
||||
|
||||
.cm-editor .cm-deletedChunk .cm-changedText {
|
||||
background: ${isDarkMode ? 'rgba(239, 68, 68, 0.4)' : 'rgba(239, 68, 68, 0.3)'} !important;
|
||||
padding-top: 2px !important;
|
||||
padding-bottom: 2px !important;
|
||||
margin-top: -2px !important;
|
||||
margin-bottom: -2px !important;
|
||||
}
|
||||
|
||||
/* Minimap gutter styling */
|
||||
.cm-gutter.cm-gutter-minimap {
|
||||
background-color: ${isDarkMode ? '#1e1e1e' : '#f5f5f5'};
|
||||
}
|
||||
|
||||
/* Diff navigation panel styling */
|
||||
.cm-diff-navigation-panel {
|
||||
padding: 8px 12px;
|
||||
background-color: ${isDarkMode ? '#1f2937' : '#ffffff'};
|
||||
border-bottom: 1px solid ${isDarkMode ? '#374151' : '#e5e7eb'};
|
||||
color: ${isDarkMode ? '#d1d5db' : '#374151'};
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.cm-diff-nav-btn {
|
||||
padding: 4px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.cm-diff-nav-btn:hover {
|
||||
background-color: ${isDarkMode ? '#374151' : '#f3f4f6'};
|
||||
}
|
||||
|
||||
.cm-diff-nav-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<div className={`fixed inset-0 z-50 ${
|
||||
// Mobile: native fullscreen, Desktop: modal with backdrop
|
||||
'md:bg-black/50 md:flex md:items-center md:justify-center md:p-4'
|
||||
} ${isFullscreen ? 'md:p-0' : ''}`}>
|
||||
<div className={`bg-white shadow-2xl flex flex-col ${
|
||||
// Mobile: always fullscreen, Desktop: modal sizing
|
||||
'w-full h-full md:rounded-lg md:shadow-2xl' +
|
||||
(isFullscreen ? ' md:w-full md:h-full md:rounded-none' : ' md:w-full md:max-w-6xl md:h-[80vh] md:max-h-[80vh]')
|
||||
@@ -287,7 +432,7 @@ function CodeEditor({ file, onClose, projectPath }) {
|
||||
<p className="text-sm text-gray-500 truncate">{file.path}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center gap-1 md:gap-2 flex-shrink-0">
|
||||
{file.diffInfo && (
|
||||
<button
|
||||
@@ -298,19 +443,19 @@ function CodeEditor({ file, onClose, projectPath }) {
|
||||
{showDiff ? <EyeOff className="w-5 h-5 md:w-4 md:h-4" /> : <Eye className="w-5 h-5 md:w-4 md:h-4" />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
||||
<button
|
||||
onClick={() => setWordWrap(!wordWrap)}
|
||||
className={`p-2 md:p-2 rounded-md hover:bg-gray-100 min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center ${
|
||||
wordWrap
|
||||
? 'text-blue-600 bg-blue-50'
|
||||
wordWrap
|
||||
? 'text-blue-600 bg-blue-50'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
title={wordWrap ? 'Disable word wrap' : 'Enable word wrap'}
|
||||
>
|
||||
<span className="text-sm md:text-xs font-mono font-bold">↵</span>
|
||||
</button>
|
||||
|
||||
|
||||
<button
|
||||
onClick={() => setIsDarkMode(!isDarkMode)}
|
||||
className="p-2 md:p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center"
|
||||
@@ -318,7 +463,7 @@ function CodeEditor({ file, onClose, projectPath }) {
|
||||
>
|
||||
<span className="text-lg md:text-base">{isDarkMode ? '☀️' : '🌙'}</span>
|
||||
</button>
|
||||
|
||||
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="p-2 md:p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center"
|
||||
@@ -326,13 +471,13 @@ function CodeEditor({ file, onClose, projectPath }) {
|
||||
>
|
||||
<Download className="w-5 h-5 md:w-4 md:h-4" />
|
||||
</button>
|
||||
|
||||
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className={`px-3 py-2 text-white rounded-md disabled:opacity-50 flex items-center gap-2 transition-colors min-h-[44px] md:min-h-0 ${
|
||||
saveSuccess
|
||||
? 'bg-green-600 hover:bg-green-700'
|
||||
saveSuccess
|
||||
? 'bg-green-600 hover:bg-green-700'
|
||||
: 'bg-blue-600 hover:bg-blue-700'
|
||||
}`}
|
||||
>
|
||||
@@ -350,7 +495,7 @@ function CodeEditor({ file, onClose, projectPath }) {
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
|
||||
<button
|
||||
onClick={toggleFullscreen}
|
||||
className="hidden md:flex p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 items-center justify-center"
|
||||
@@ -358,7 +503,7 @@ function CodeEditor({ file, onClose, projectPath }) {
|
||||
>
|
||||
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
|
||||
</button>
|
||||
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 md:p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center"
|
||||
@@ -377,8 +522,21 @@ function CodeEditor({ file, onClose, projectPath }) {
|
||||
onChange={setContent}
|
||||
extensions={[
|
||||
...getLanguageExtension(file.name),
|
||||
diffField,
|
||||
diffTheme,
|
||||
...(file.diffInfo && showDiff && file.diffInfo.old_string !== undefined
|
||||
? [
|
||||
unifiedMergeView({
|
||||
original: file.diffInfo.old_string,
|
||||
mergeControls: false,
|
||||
highlightChanges: true,
|
||||
syntaxHighlightDeletions: false,
|
||||
gutter: true
|
||||
// NOTE: NO collapseUnchanged - this shows the full file!
|
||||
}),
|
||||
...minimapExtension,
|
||||
...scrollToFirstChunkExtension,
|
||||
...diffNavigationPanel
|
||||
]
|
||||
: []),
|
||||
...(wordWrap ? [EditorView.lineWrapping] : [])
|
||||
]}
|
||||
theme={isDarkMode ? oneDark : undefined}
|
||||
@@ -409,14 +567,15 @@ function CodeEditor({ file, onClose, projectPath }) {
|
||||
<span>Characters: {content.length}</span>
|
||||
<span>Language: {file.name.split('.').pop()?.toUpperCase() || 'Text'}</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Press Ctrl+S to save • Esc to close
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default CodeEditor;
|
||||
export default CodeEditor;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { MicButton } from './MicButton.jsx';
|
||||
import { authenticatedFetch } from '../utils/api';
|
||||
import DiffViewer from './DiffViewer.jsx';
|
||||
|
||||
function GitPanel({ selectedProject, isMobile }) {
|
||||
function GitPanel({ selectedProject, isMobile, onFileOpen }) {
|
||||
const [gitStatus, setGitStatus] = useState(null);
|
||||
const [gitDiff, setGitDiff] = useState({});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -107,6 +107,12 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
for (const file of data.added || []) {
|
||||
fetchFileDiff(file);
|
||||
}
|
||||
for (const file of data.deleted || []) {
|
||||
fetchFileDiff(file);
|
||||
}
|
||||
for (const file of data.untracked || []) {
|
||||
fetchFileDiff(file);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching git status:', error);
|
||||
@@ -402,7 +408,7 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
try {
|
||||
const response = await authenticatedFetch(`/api/git/diff?project=${encodeURIComponent(selectedProject.name)}&file=${encodeURIComponent(filePath)}`);
|
||||
const data = await response.json();
|
||||
|
||||
|
||||
if (!data.error && data.diff) {
|
||||
setGitDiff(prev => ({
|
||||
...prev,
|
||||
@@ -414,6 +420,36 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileOpen = async (filePath) => {
|
||||
if (!onFileOpen) return;
|
||||
|
||||
try {
|
||||
// Fetch file content with diff information
|
||||
const response = await authenticatedFetch(`/api/git/file-with-diff?project=${encodeURIComponent(selectedProject.name)}&file=${encodeURIComponent(filePath)}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
console.error('Error fetching file with diff:', data.error);
|
||||
// Fallback: open without diff info
|
||||
onFileOpen(filePath);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create diffInfo object for CodeEditor
|
||||
const diffInfo = {
|
||||
old_string: data.oldContent || '',
|
||||
new_string: data.currentContent || ''
|
||||
};
|
||||
|
||||
// Open file with diff information
|
||||
onFileOpen(filePath, diffInfo);
|
||||
} catch (error) {
|
||||
console.error('Error opening file:', error);
|
||||
// Fallback: open without diff info
|
||||
onFileOpen(filePath);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchRecentCommits = async () => {
|
||||
try {
|
||||
const response = await authenticatedFetch(`/api/git/commits?project=${encodeURIComponent(selectedProject.name)}&limit=10`);
|
||||
@@ -610,14 +646,28 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={`rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 dark:focus:ring-blue-400 dark:bg-gray-800 dark:checked:bg-blue-600 ${isMobile ? 'mr-1.5' : 'mr-2'}`}
|
||||
/>
|
||||
<div
|
||||
className="flex items-center flex-1 cursor-pointer"
|
||||
onClick={() => toggleFileExpanded(filePath)}
|
||||
<div
|
||||
className="flex items-center flex-1"
|
||||
>
|
||||
<div className={`p-0.5 hover:bg-gray-200 dark:hover:bg-gray-700 rounded ${isMobile ? 'mr-1' : 'mr-2'}`}>
|
||||
<div
|
||||
className={`p-0.5 hover:bg-gray-200 dark:hover:bg-gray-700 rounded cursor-pointer ${isMobile ? 'mr-1' : 'mr-2'}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleFileExpanded(filePath);
|
||||
}}
|
||||
>
|
||||
<ChevronRight className={`w-3 h-3 transition-transform duration-200 ease-in-out ${isExpanded ? 'rotate-90' : 'rotate-0'}`} />
|
||||
</div>
|
||||
<span className={`flex-1 truncate ${isMobile ? 'text-xs' : 'text-sm'}`}>{filePath}</span>
|
||||
<span
|
||||
className={`flex-1 truncate ${isMobile ? 'text-xs' : 'text-sm'} cursor-pointer hover:text-blue-600 dark:hover:text-blue-400 hover:underline`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleFileOpen(filePath);
|
||||
}}
|
||||
title="Click to open file"
|
||||
>
|
||||
{filePath}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{(status === 'M' || status === 'D') && (
|
||||
<button
|
||||
|
||||
@@ -451,7 +451,7 @@ function MainContent({
|
||||
/>
|
||||
</div>
|
||||
<div className={`h-full overflow-hidden ${activeTab === 'git' ? 'block' : 'hidden'}`}>
|
||||
<GitPanel selectedProject={selectedProject} isMobile={isMobile} />
|
||||
<GitPanel selectedProject={selectedProject} isMobile={isMobile} onFileOpen={handleFileOpen} />
|
||||
</div>
|
||||
{shouldShowTasksTab && (
|
||||
<div className={`h-full ${activeTab === 'tasks' ? 'block' : 'hidden'}`}>
|
||||
|
||||
Reference in New Issue
Block a user