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:
simos
2025-10-31 00:37:20 +00:00
parent eda89ef147
commit d2f02558a1
9 changed files with 644 additions and 204 deletions

32
package-lock.json generated
View File

@@ -16,7 +16,9 @@
"@codemirror/lang-json": "^6.0.1", "@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-markdown": "^6.3.3", "@codemirror/lang-markdown": "^6.3.3",
"@codemirror/lang-python": "^6.2.1", "@codemirror/lang-python": "^6.2.1",
"@codemirror/merge": "^6.11.1",
"@codemirror/theme-one-dark": "^6.1.2", "@codemirror/theme-one-dark": "^6.1.2",
"@replit/codemirror-minimap": "^0.5.2",
"@tailwindcss/typography": "^0.5.16", "@tailwindcss/typography": "^0.5.16",
"@uiw/react-codemirror": "^4.23.13", "@uiw/react-codemirror": "^4.23.13",
"@xterm/addon-clipboard": "^0.1.0", "@xterm/addon-clipboard": "^0.1.0",
@@ -536,6 +538,19 @@
"crelt": "^1.0.5" "crelt": "^1.0.5"
} }
}, },
"node_modules/@codemirror/merge": {
"version": "6.11.1",
"resolved": "https://registry.npmjs.org/@codemirror/merge/-/merge-6.11.1.tgz",
"integrity": "sha512-NleJ//mSmcal3jRdm9WwOVMUaJWvP2h69K96z3xTDJnde/nsMnLt9qfKUBkycWm5iO3/g4Zd69XTuTFErTZ72A==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/highlight": "^1.0.0",
"style-mod": "^4.1.0"
}
},
"node_modules/@codemirror/search": { "node_modules/@codemirror/search": {
"version": "6.5.11", "version": "6.5.11",
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.11.tgz", "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.11.tgz",
@@ -2450,6 +2465,23 @@
"node": ">=14.0.0" "node": ">=14.0.0"
} }
}, },
"node_modules/@replit/codemirror-minimap": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/@replit/codemirror-minimap/-/codemirror-minimap-0.5.2.tgz",
"integrity": "sha512-eNAtpr0hOG09/5zqAQ5PkgZEb3V/MHi30zentCxiR73r+utR2m9yVMCpBmfsWbb8mWxUWhMGPiHxM5hFtnscQA==",
"license": "MIT",
"dependencies": {
"crelt": "^1.0.5"
},
"peerDependencies": {
"@codemirror/language": "^6.9.1",
"@codemirror/lint": "^6.4.2",
"@codemirror/state": "^6.3.1",
"@codemirror/view": "^6.21.3",
"@lezer/common": "^1.1.0",
"@lezer/highlight": "^1.1.6"
}
},
"node_modules/@rolldown/pluginutils": { "node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27", "version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",

View File

@@ -46,11 +46,15 @@
"@codemirror/lang-json": "^6.0.1", "@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-markdown": "^6.3.3", "@codemirror/lang-markdown": "^6.3.3",
"@codemirror/lang-python": "^6.2.1", "@codemirror/lang-python": "^6.2.1",
"@codemirror/merge": "^6.11.1",
"@codemirror/theme-one-dark": "^6.1.2", "@codemirror/theme-one-dark": "^6.1.2",
"@replit/codemirror-minimap": "^0.5.2",
"@tailwindcss/typography": "^0.5.16", "@tailwindcss/typography": "^0.5.16",
"@uiw/react-codemirror": "^4.23.13", "@uiw/react-codemirror": "^4.23.13",
"@xterm/addon-clipboard": "^0.1.0", "@xterm/addon-clipboard": "^0.1.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-webgl": "^0.18.0", "@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"better-sqlite3": "^12.2.0", "better-sqlite3": "^12.2.0",
"chokidar": "^4.0.3", "chokidar": "^4.0.3",
@@ -75,9 +79,7 @@
"sqlite": "^5.1.1", "sqlite": "^5.1.1",
"sqlite3": "^5.1.7", "sqlite3": "^5.1.7",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"ws": "^8.14.2", "ws": "^8.14.2"
"@xterm/xterm": "^5.5.0",
"@xterm/addon-fit": "^0.10.0"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.2.43", "@types/react": "^18.2.43",

View File

@@ -172,7 +172,8 @@ const wss = new WebSocketServer({
app.locals.wss = wss; app.locals.wss = wss;
app.use(cors()); app.use(cors());
app.use(express.json()); app.use(express.json({ limit: '50mb' }));
app.use(express.urlencoded({ limit: '50mb', extended: true }));
// Optional API key validation (if configured) // Optional API key validation (if configured)
app.use('/api', validateApiKey); app.use('/api', validateApiKey);
@@ -408,7 +409,10 @@ app.get('/api/projects/:projectName/file', authenticateToken, async (req, res) =
return res.status(404).json({ error: 'Project not found' }); return res.status(404).json({ error: 'Project not found' });
} }
const resolved = path.resolve(filePath); // Handle both absolute and relative paths
const resolved = path.isAbsolute(filePath)
? path.resolve(filePath)
: path.resolve(projectRoot, filePath);
const normalizedRoot = path.resolve(projectRoot) + path.sep; const normalizedRoot = path.resolve(projectRoot) + path.sep;
if (!resolved.startsWith(normalizedRoot)) { if (!resolved.startsWith(normalizedRoot)) {
return res.status(403).json({ error: 'Path must be under project root' }); return res.status(403).json({ error: 'Path must be under project root' });
@@ -504,21 +508,15 @@ app.put('/api/projects/:projectName/file', authenticateToken, async (req, res) =
return res.status(404).json({ error: 'Project not found' }); return res.status(404).json({ error: 'Project not found' });
} }
const resolved = path.resolve(filePath); // Handle both absolute and relative paths
const resolved = path.isAbsolute(filePath)
? path.resolve(filePath)
: path.resolve(projectRoot, filePath);
const normalizedRoot = path.resolve(projectRoot) + path.sep; const normalizedRoot = path.resolve(projectRoot) + path.sep;
if (!resolved.startsWith(normalizedRoot)) { if (!resolved.startsWith(normalizedRoot)) {
return res.status(403).json({ error: 'Path must be under project root' }); return res.status(403).json({ error: 'Path must be under project root' });
} }
// Create backup of original file
try {
const backupPath = resolved + '.backup.' + Date.now();
await fsPromises.copyFile(resolved, backupPath);
console.log('📋 Created backup:', backupPath);
} catch (backupError) {
console.warn('Could not create backup:', backupError.message);
}
// Write the new content // Write the new content
await fsPromises.writeFile(resolved, content, 'utf8'); await fsPromises.writeFile(resolved, content, 'utf8');

View File

@@ -21,6 +21,35 @@ async function getActualProjectPath(projectName) {
} }
} }
// Helper function to strip git diff headers
function stripDiffHeaders(diff) {
if (!diff) return '';
const lines = diff.split('\n');
const filteredLines = [];
let startIncluding = false;
for (const line of lines) {
// Skip all header lines including diff --git, index, file mode, and --- / +++ file paths
if (line.startsWith('diff --git') ||
line.startsWith('index ') ||
line.startsWith('new file mode') ||
line.startsWith('deleted file mode') ||
line.startsWith('---') ||
line.startsWith('+++')) {
continue;
}
// Start including lines from @@ hunk headers onwards
if (line.startsWith('@@') || startIncluding) {
startIncluding = true;
filteredLines.push(line);
}
}
return filteredLines.join('\n');
}
// Helper function to validate git repository // Helper function to validate git repository
async function validateGitRepository(projectPath) { async function validateGitRepository(projectPath) {
try { try {
@@ -124,9 +153,10 @@ router.get('/diff', async (req, res) => {
// Validate git repository // Validate git repository
await validateGitRepository(projectPath); await validateGitRepository(projectPath);
// Check if file is untracked // Check if file is untracked or deleted
const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath }); const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath });
const isUntracked = statusOutput.startsWith('??'); const isUntracked = statusOutput.startsWith('??');
const isDeleted = statusOutput.trim().startsWith('D ') || statusOutput.trim().startsWith(' D');
let diff; let diff;
if (isUntracked) { if (isUntracked) {
@@ -135,6 +165,12 @@ router.get('/diff', async (req, res) => {
const lines = fileContent.split('\n'); const lines = fileContent.split('\n');
diff = `--- /dev/null\n+++ b/${file}\n@@ -0,0 +1,${lines.length} @@\n` + diff = `--- /dev/null\n+++ b/${file}\n@@ -0,0 +1,${lines.length} @@\n` +
lines.map(line => `+${line}`).join('\n'); lines.map(line => `+${line}`).join('\n');
} else if (isDeleted) {
// For deleted files, show the entire file content from HEAD as deletions
const { stdout: fileContent } = await execAsync(`git show HEAD:"${file}"`, { cwd: projectPath });
const lines = fileContent.split('\n');
diff = `--- a/${file}\n+++ /dev/null\n@@ -1,${lines.length} +0,0 @@\n` +
lines.map(line => `-${line}`).join('\n');
} else { } else {
// Get diff for tracked files // Get diff for tracked files
// First check for unstaged changes (working tree vs index) // First check for unstaged changes (working tree vs index)
@@ -142,11 +178,11 @@ router.get('/diff', async (req, res) => {
if (unstagedDiff) { if (unstagedDiff) {
// Show unstaged changes if they exist // Show unstaged changes if they exist
diff = unstagedDiff; diff = stripDiffHeaders(unstagedDiff);
} else { } else {
// If no unstaged changes, check for staged changes (index vs HEAD) // If no unstaged changes, check for staged changes (index vs HEAD)
const { stdout: stagedDiff } = await execAsync(`git diff --cached -- "${file}"`, { cwd: projectPath }); const { stdout: stagedDiff } = await execAsync(`git diff --cached -- "${file}"`, { cwd: projectPath });
diff = stagedDiff || ''; diff = stripDiffHeaders(stagedDiff) || '';
} }
} }
@@ -157,6 +193,61 @@ router.get('/diff', async (req, res) => {
} }
}); });
// Get file content with diff information for CodeEditor
router.get('/file-with-diff', async (req, res) => {
const { project, file } = req.query;
if (!project || !file) {
return res.status(400).json({ error: 'Project name and file path are required' });
}
try {
const projectPath = await getActualProjectPath(project);
// Validate git repository
await validateGitRepository(projectPath);
// Check file status
const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath });
const isUntracked = statusOutput.startsWith('??');
const isDeleted = statusOutput.trim().startsWith('D ') || statusOutput.trim().startsWith(' D');
let currentContent = '';
let oldContent = '';
if (isDeleted) {
// For deleted files, get content from HEAD
const { stdout: headContent } = await execAsync(`git show HEAD:"${file}"`, { cwd: projectPath });
oldContent = headContent;
currentContent = headContent; // Show the deleted content in editor
} else {
// Get current file content
currentContent = await fs.readFile(path.join(projectPath, file), 'utf-8');
if (!isUntracked) {
// Get the old content from HEAD for tracked files
try {
const { stdout: headContent } = await execAsync(`git show HEAD:"${file}"`, { cwd: projectPath });
oldContent = headContent;
} catch (error) {
// File might be newly added to git (staged but not committed)
oldContent = '';
}
}
}
res.json({
currentContent,
oldContent,
isDeleted,
isUntracked
});
} catch (error) {
console.error('Git file-with-diff error:', error);
res.json({ error: error.message });
}
});
// Commit changes // Commit changes
router.post('/commit', async (req, res) => { router.post('/commit', async (req, res) => {
const { project, message, files } = req.body; const { project, message, files } = req.body;

View File

@@ -184,11 +184,7 @@ function AppContent() {
if (!isSessionActive) { if (!isSessionActive) {
// Session is not active - safe to reload messages // Session is not active - safe to reload messages
console.log('🔄 External CLI update detected for current session:', changedSessionId);
setExternalMessageUpdate(prev => prev + 1); setExternalMessageUpdate(prev => prev + 1);
} else {
// Session is active - skip reload to avoid interrupting user
console.log('⏸️ External update paused - session is active:', changedSessionId);
} }
} }
} }

View File

@@ -170,7 +170,7 @@ const safeLocalStorage = {
}; };
// Memoized message component to prevent unnecessary re-renders // 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 && const isGrouped = prevMessage && prevMessage.type === message.type &&
((prevMessage.type === 'assistant') || ((prevMessage.type === 'assistant') ||
(prevMessage.type === 'user') || (prevMessage.type === 'user') ||
@@ -316,13 +316,36 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
</svg> </svg>
📝 View edit diff for 📝 View edit diff for
<button <button
onClick={(e) => { onClick={async (e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
onFileOpen && onFileOpen(input.file_path, { if (!onFileOpen) return;
old_string: input.old_string,
new_string: input.new_string 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" className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 underline font-mono"
> >
@@ -333,10 +356,34 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
<div className="bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden"> <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"> <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 <button
onClick={() => onFileOpen && onFileOpen(input.file_path, { onClick={async () => {
old_string: input.old_string, if (!onFileOpen) return;
new_string: input.new_string
})} 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" 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} {input.file_path}
@@ -418,13 +465,31 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
</svg> </svg>
📄 Creating new file: 📄 Creating new file:
<button <button
onClick={(e) => { onClick={async (e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
onFileOpen && onFileOpen(input.file_path, { if (!onFileOpen) return;
old_string: '',
new_string: input.content 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" className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 underline font-mono"
> >
@@ -435,10 +500,30 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
<div className="bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden"> <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"> <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 <button
onClick={() => onFileOpen && onFileOpen(input.file_path, { onClick={async () => {
old_string: '', if (!onFileOpen) return;
new_string: input.content
})} 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" 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} {input.file_path}
@@ -836,7 +921,27 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
<span className="font-medium">File updated successfully</span> <span className="font-medium">File updated successfully</span>
</div> </div>
<button <button
onClick={() => onFileOpen && onFileOpen(fileEditMatch[1])} 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" 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]} {fileEditMatch[1]}
@@ -854,7 +959,27 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
<span className="font-medium">File created successfully</span> <span className="font-medium">File created successfully</span>
</div> </div>
<button <button
onClick={() => onFileOpen && onFileOpen(fileCreateMatch[1])} 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" 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]} {fileCreateMatch[1]}
@@ -2370,8 +2495,6 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
// Only reloads if the session is NOT active (respecting Session Protection System) // Only reloads if the session is NOT active (respecting Session Protection System)
useEffect(() => { useEffect(() => {
if (externalMessageUpdate > 0 && selectedSession && selectedProject) { if (externalMessageUpdate > 0 && selectedSession && selectedProject) {
console.log('🔄 Reloading messages due to external CLI update');
const reloadExternalMessages = async () => { const reloadExternalMessages = async () => {
try { try {
const provider = localStorage.getItem('selected-provider') || 'claude'; const provider = localStorage.getItem('selected-provider') || 'claude';
@@ -2467,7 +2590,6 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
// Handle WebSocket messages // Handle WebSocket messages
if (messages.length > 0) { if (messages.length > 0) {
const latestMessage = messages[messages.length - 1]; const latestMessage = messages[messages.length - 1];
console.log('🔵 WebSocket message received:', latestMessage.type, latestMessage);
// Filter messages by session ID to prevent cross-session interference // Filter messages by session ID to prevent cross-session interference
// Skip filtering for global messages that apply to all sessions // 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 // Get session ID from message or fall back to current session
const completedSessionId = latestMessage.sessionId || currentSessionId || sessionStorage.getItem('pendingSessionId'); 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) // 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) { if (completedSessionId === currentSessionId || !currentSessionId) {
console.log('✅ Stopping loading state');
setIsLoading(false); setIsLoading(false);
setCanAbortSession(false); setCanAbortSession(false);
setClaudeStatus(null); setClaudeStatus(null);
@@ -3204,16 +3318,13 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
const fetchInitialTokenUsage = async () => { const fetchInitialTokenUsage = async () => {
try { try {
const url = `/api/projects/${selectedProject.name}/sessions/${selectedSession.id}/token-usage`; const url = `/api/projects/${selectedProject.name}/sessions/${selectedSession.id}/token-usage`;
console.log('📊 Fetching initial token usage from:', url);
const response = await authenticatedFetch(url); const response = await authenticatedFetch(url);
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
console.log('✅ Initial token usage loaded:', data);
setTokenBudget(data); setTokenBudget(data);
} else { } else {
console.log('⚠️ No token usage data available for this session yet');
setTokenBudget(null); setTokenBudget(null);
} }
} catch (error) { } catch (error) {
@@ -3978,6 +4089,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
autoExpandTools={autoExpandTools} autoExpandTools={autoExpandTools}
showRawParameters={showRawParameters} showRawParameters={showRawParameters}
showThinking={showThinking} showThinking={showThinking}
selectedProject={selectedProject}
/> />
); );
})} })}

View File

@@ -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 CodeMirror from '@uiw/react-codemirror';
import { javascript } from '@codemirror/lang-javascript'; import { javascript } from '@codemirror/lang-javascript';
import { python } from '@codemirror/lang-python'; import { python } from '@codemirror/lang-python';
@@ -7,8 +7,9 @@ import { css } from '@codemirror/lang-css';
import { json } from '@codemirror/lang-json'; import { json } from '@codemirror/lang-json';
import { markdown } from '@codemirror/lang-markdown'; import { markdown } from '@codemirror/lang-markdown';
import { oneDark } from '@codemirror/theme-one-dark'; import { oneDark } from '@codemirror/theme-one-dark';
import { EditorView, Decoration } from '@codemirror/view'; import { EditorView, showPanel, ViewPlugin } from '@codemirror/view';
import { StateField, StateEffect, RangeSetBuilder } from '@codemirror/state'; import { unifiedMergeView, getChunks } from '@codemirror/merge';
import { showMinimap } from '@replit/codemirror-minimap';
import { X, Save, Download, Maximize2, Minimize2, Eye, EyeOff } from 'lucide-react'; import { X, Save, Download, Maximize2, Minimize2, Eye, EyeOff } from 'lucide-react';
import { api } from '../utils/api'; import { api } from '../utils/api';
@@ -21,90 +22,150 @@ function CodeEditor({ file, onClose, projectPath }) {
const [saveSuccess, setSaveSuccess] = useState(false); const [saveSuccess, setSaveSuccess] = useState(false);
const [showDiff, setShowDiff] = useState(!!file.diffInfo); const [showDiff, setShowDiff] = useState(!!file.diffInfo);
const [wordWrap, setWordWrap] = useState(false); const [wordWrap, setWordWrap] = useState(false);
const editorRef = useRef(null);
// Create diff highlighting // Create minimap extension with chunk-based gutters
const diffEffect = StateEffect.define(); const minimapExtension = useMemo(() => {
if (!file.diffInfo || !showDiff) return [];
const diffField = StateField.define({ const gutters = {};
create() {
return Decoration.none;
},
update(decorations, tr) {
decorations = decorations.map(tr.changes);
for (let effect of tr.effects) { return [
if (effect.is(diffEffect)) { showMinimap.compute(['doc'], (state) => {
decorations = effect.value; // 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) => { update() {}
if (!diffInfo || !showDiff) return Decoration.none; destroy() {}
})
];
}, [file.diffInfo, showDiff]);
const builder = new RangeSetBuilder(); // Create diff navigation panel extension
const lines = content.split('\n'); const diffNavigationPanel = useMemo(() => {
const oldLines = diffInfo.old_string.split('\n'); if (!file.diffInfo || !showDiff) return [];
// Find the line where the old content starts const createPanel = (view) => {
let startLineIndex = -1; const dom = document.createElement('div');
for (let i = 0; i <= lines.length - oldLines.length; i++) { dom.className = 'cm-diff-navigation-panel';
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;
}
}
if (startLineIndex >= 0) { let currentIndex = 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) const updatePanel = () => {
for (let i = 0; i < oldLines.length; i++) { // Use getChunks API to get ALL chunks regardless of viewport
const lineStart = pos; const chunksData = getChunks(view.state);
const lineEnd = pos + oldLines[i].length; const chunks = chunksData?.chunks || [];
builder.add(lineStart, lineEnd, Decoration.line({ const chunkCount = chunks.length;
class: isDarkMode ? 'diff-removed-dark' : 'diff-removed-light'
}));
pos += oldLines[i].length + 1;
}
}
return builder.finish(); 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>
`;
// Diff decoration theme const prevBtn = dom.querySelector('.cm-diff-nav-prev');
const diffTheme = EditorView.theme({ const nextBtn = dom.querySelector('.cm-diff-nav-next');
'.diff-removed-light': {
backgroundColor: '#fef2f2', prevBtn?.addEventListener('click', () => {
borderLeft: '3px solid #ef4444' if (chunks.length === 0) return;
}, currentIndex = currentIndex > 0 ? currentIndex - 1 : chunks.length - 1;
'.diff-removed-dark': {
backgroundColor: 'rgba(239, 68, 68, 0.1)', // Navigate to the chunk - use fromB which is the position in the current document
borderLeft: '3px solid #ef4444' const chunk = chunks[currentIndex];
}, if (chunk) {
'.diff-added-light': { // Scroll to the start of the chunk in the B side (current document)
backgroundColor: '#f0fdf4', view.dispatch({
borderLeft: '3px solid #22c55e' effects: EditorView.scrollIntoView(chunk.fromB, { y: 'center' })
}, });
'.diff-added-dark': { }
backgroundColor: 'rgba(34, 197, 94, 0.1)', updatePanel();
borderLeft: '3px solid #22c55e' });
}
}); 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 // Get language extension based on file extension
const getLanguageExtension = (filename) => { const getLanguageExtension = (filename) => {
@@ -140,6 +201,17 @@ function CodeEditor({ file, onClose, projectPath }) {
try { try {
setLoading(true); 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); const response = await api.readFile(file.projectName, file.path);
if (!response.ok) { if (!response.ok) {
@@ -159,36 +231,40 @@ function CodeEditor({ file, onClose, projectPath }) {
loadFileContent(); loadFileContent();
}, [file, projectPath]); }, [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 () => { const handleSave = async () => {
setSaving(true); setSaving(true);
try { try {
console.log('Saving file:', {
projectName: file.projectName,
path: file.path,
contentLength: content?.length
});
const response = await api.saveFile(file.projectName, file.path, content); 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) { if (!response.ok) {
const errorData = await response.json(); const contentType = response.headers.get('content-type');
throw new Error(errorData.error || `Save failed: ${response.status}`); 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(); const result = await response.json();
console.log('Save successful:', result);
// Show success feedback
setSaveSuccess(true); setSaveSuccess(true);
setTimeout(() => setSaveSuccess(false), 2000); // Hide after 2 seconds setTimeout(() => setSaveSuccess(false), 2000);
} catch (error) { } catch (error) {
console.error('Error saving file:', error); console.error('Error saving file:', error);
@@ -258,11 +334,80 @@ function CodeEditor({ file, onClose, projectPath }) {
} }
return ( return (
<div className={`fixed inset-0 z-50 ${ <>
// Mobile: native fullscreen, Desktop: modal with backdrop <style>
'md:bg-black/50 md:flex md:items-center md:justify-center md:p-4' {`
} ${isFullscreen ? 'md:p-0' : ''}`}> /* Light background for full line changes */
<div className={`bg-white shadow-2xl flex flex-col ${ .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 // Mobile: always fullscreen, Desktop: modal sizing
'w-full h-full md:rounded-lg md:shadow-2xl' + '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]') (isFullscreen ? ' md:w-full md:h-full md:rounded-none' : ' md:w-full md:max-w-6xl md:h-[80vh] md:max-h-[80vh]')
@@ -377,8 +522,21 @@ function CodeEditor({ file, onClose, projectPath }) {
onChange={setContent} onChange={setContent}
extensions={[ extensions={[
...getLanguageExtension(file.name), ...getLanguageExtension(file.name),
diffField, ...(file.diffInfo && showDiff && file.diffInfo.old_string !== undefined
diffTheme, ? [
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] : []) ...(wordWrap ? [EditorView.lineWrapping] : [])
]} ]}
theme={isDarkMode ? oneDark : undefined} theme={isDarkMode ? oneDark : undefined}
@@ -416,6 +574,7 @@ function CodeEditor({ file, onClose, projectPath }) {
</div> </div>
</div> </div>
</div> </div>
</>
); );
} }

View File

@@ -4,7 +4,7 @@ import { MicButton } from './MicButton.jsx';
import { authenticatedFetch } from '../utils/api'; import { authenticatedFetch } from '../utils/api';
import DiffViewer from './DiffViewer.jsx'; import DiffViewer from './DiffViewer.jsx';
function GitPanel({ selectedProject, isMobile }) { function GitPanel({ selectedProject, isMobile, onFileOpen }) {
const [gitStatus, setGitStatus] = useState(null); const [gitStatus, setGitStatus] = useState(null);
const [gitDiff, setGitDiff] = useState({}); const [gitDiff, setGitDiff] = useState({});
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -107,6 +107,12 @@ function GitPanel({ selectedProject, isMobile }) {
for (const file of data.added || []) { for (const file of data.added || []) {
fetchFileDiff(file); fetchFileDiff(file);
} }
for (const file of data.deleted || []) {
fetchFileDiff(file);
}
for (const file of data.untracked || []) {
fetchFileDiff(file);
}
} }
} catch (error) { } catch (error) {
console.error('Error fetching git status:', error); console.error('Error fetching git status:', error);
@@ -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 () => { const fetchRecentCommits = async () => {
try { try {
const response = await authenticatedFetch(`/api/git/commits?project=${encodeURIComponent(selectedProject.name)}&limit=10`); const response = await authenticatedFetch(`/api/git/commits?project=${encodeURIComponent(selectedProject.name)}&limit=10`);
@@ -611,13 +647,27 @@ function GitPanel({ selectedProject, isMobile }) {
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'}`} 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 <div
className="flex items-center flex-1 cursor-pointer" className="flex items-center flex-1"
onClick={() => toggleFileExpanded(filePath)}
> >
<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'}`} /> <ChevronRight className={`w-3 h-3 transition-transform duration-200 ease-in-out ${isExpanded ? 'rotate-90' : 'rotate-0'}`} />
</div> </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"> <div className="flex items-center gap-1">
{(status === 'M' || status === 'D') && ( {(status === 'M' || status === 'D') && (
<button <button

View File

@@ -451,7 +451,7 @@ function MainContent({
/> />
</div> </div>
<div className={`h-full overflow-hidden ${activeTab === 'git' ? 'block' : 'hidden'}`}> <div className={`h-full overflow-hidden ${activeTab === 'git' ? 'block' : 'hidden'}`}>
<GitPanel selectedProject={selectedProject} isMobile={isMobile} /> <GitPanel selectedProject={selectedProject} isMobile={isMobile} onFileOpen={handleFileOpen} />
</div> </div>
{shouldShowTasksTab && ( {shouldShowTasksTab && (
<div className={`h-full ${activeTab === 'tasks' ? 'block' : 'hidden'}`}> <div className={`h-full ${activeTab === 'tasks' ? 'block' : 'hidden'}`}>