diff --git a/package-lock.json b/package-lock.json index 8c3264d..993b268 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,9 @@ "@codemirror/lang-json": "^6.0.1", "@codemirror/lang-markdown": "^6.3.3", "@codemirror/lang-python": "^6.2.1", + "@codemirror/merge": "^6.11.1", "@codemirror/theme-one-dark": "^6.1.2", + "@replit/codemirror-minimap": "^0.5.2", "@tailwindcss/typography": "^0.5.16", "@uiw/react-codemirror": "^4.23.13", "@xterm/addon-clipboard": "^0.1.0", @@ -536,6 +538,19 @@ "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": { "version": "6.5.11", "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.11.tgz", @@ -2450,6 +2465,23 @@ "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": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", diff --git a/package.json b/package.json index 24c3f70..acfbe3a 100644 --- a/package.json +++ b/package.json @@ -46,11 +46,15 @@ "@codemirror/lang-json": "^6.0.1", "@codemirror/lang-markdown": "^6.3.3", "@codemirror/lang-python": "^6.2.1", + "@codemirror/merge": "^6.11.1", "@codemirror/theme-one-dark": "^6.1.2", + "@replit/codemirror-minimap": "^0.5.2", "@tailwindcss/typography": "^0.5.16", "@uiw/react-codemirror": "^4.23.13", "@xterm/addon-clipboard": "^0.1.0", + "@xterm/addon-fit": "^0.10.0", "@xterm/addon-webgl": "^0.18.0", + "@xterm/xterm": "^5.5.0", "bcrypt": "^6.0.0", "better-sqlite3": "^12.2.0", "chokidar": "^4.0.3", @@ -75,9 +79,7 @@ "sqlite": "^5.1.1", "sqlite3": "^5.1.7", "tailwind-merge": "^3.3.1", - "ws": "^8.14.2", - "@xterm/xterm": "^5.5.0", - "@xterm/addon-fit": "^0.10.0" + "ws": "^8.14.2" }, "devDependencies": { "@types/react": "^18.2.43", diff --git a/server/index.js b/server/index.js index dfe7c81..8ab26d7 100755 --- a/server/index.js +++ b/server/index.js @@ -172,7 +172,8 @@ const wss = new WebSocketServer({ app.locals.wss = wss; 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) 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' }); } - 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; if (!resolved.startsWith(normalizedRoot)) { 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' }); } - 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; if (!resolved.startsWith(normalizedRoot)) { 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 await fsPromises.writeFile(resolved, content, 'utf8'); diff --git a/server/routes/git.js b/server/routes/git.js index 3294c92..da917d0 100755 --- a/server/routes/git.js +++ b/server/routes/git.js @@ -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 async function validateGitRepository(projectPath) { try { @@ -124,32 +153,39 @@ router.get('/diff', async (req, res) => { // Validate git repository 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 isUntracked = statusOutput.startsWith('??'); - + const isDeleted = statusOutput.trim().startsWith('D ') || statusOutput.trim().startsWith(' D'); + let diff; if (isUntracked) { // For untracked files, show the entire file content as additions const fileContent = await fs.readFile(path.join(projectPath, file), 'utf-8'); 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'); + } 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 { // Get diff for tracked files // First check for unstaged changes (working tree vs index) const { stdout: unstagedDiff } = await execAsync(`git diff -- "${file}"`, { cwd: projectPath }); - + if (unstagedDiff) { // Show unstaged changes if they exist - diff = unstagedDiff; + diff = stripDiffHeaders(unstagedDiff); } else { // If no unstaged changes, check for staged changes (index vs HEAD) const { stdout: stagedDiff } = await execAsync(`git diff --cached -- "${file}"`, { cwd: projectPath }); - diff = stagedDiff || ''; + diff = stripDiffHeaders(stagedDiff) || ''; } } - + res.json({ diff }); } catch (error) { console.error('Git diff error:', error); @@ -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 router.post('/commit', async (req, res) => { const { project, message, files } = req.body; diff --git a/src/App.jsx b/src/App.jsx index fe01254..d6aac09 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -18,7 +18,7 @@ * Handles both existing sessions (with real IDs) and new sessions (with temporary IDs). */ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { BrowserRouter as Router, Routes, Route, useNavigate, useParams } from 'react-router-dom'; import Sidebar from './components/Sidebar'; import MainContent from './components/MainContent'; @@ -184,11 +184,7 @@ function AppContent() { if (!isSessionActive) { // Session is not active - safe to reload messages - console.log('π External CLI update detected for current session:', changedSessionId); setExternalMessageUpdate(prev => prev + 1); - } else { - // Session is active - skip reload to avoid interrupting user - console.log('βΈοΈ External update paused - session is active:', changedSessionId); } } } @@ -482,14 +478,14 @@ function AppContent() { // markSessionAsActive: Called when user sends a message to mark session as protected // This includes both real session IDs and temporary "new-session-*" identifiers - const markSessionAsActive = (sessionId) => { + const markSessionAsActive = useCallback((sessionId) => { if (sessionId) { setActiveSessions(prev => new Set([...prev, sessionId])); } - }; + }, []); // markSessionAsInactive: Called when conversation completes/aborts to re-enable project updates - const markSessionAsInactive = (sessionId) => { + const markSessionAsInactive = useCallback((sessionId) => { if (sessionId) { setActiveSessions(prev => { const newSet = new Set(prev); @@ -497,19 +493,19 @@ function AppContent() { return newSet; }); } - }; + }, []); // Processing Session Functions: Track which sessions are currently thinking/processing // markSessionAsProcessing: Called when Claude starts thinking/processing - const markSessionAsProcessing = (sessionId) => { + const markSessionAsProcessing = useCallback((sessionId) => { if (sessionId) { setProcessingSessions(prev => new Set([...prev, sessionId])); } - }; + }, []); // markSessionAsNotProcessing: Called when Claude finishes thinking/processing - const markSessionAsNotProcessing = (sessionId) => { + const markSessionAsNotProcessing = useCallback((sessionId) => { if (sessionId) { setProcessingSessions(prev => { const newSet = new Set(prev); @@ -517,12 +513,12 @@ function AppContent() { return newSet; }); } - }; + }, []); // replaceTemporarySession: Called when WebSocket provides real session ID for new sessions // Removes temporary "new-session-*" identifiers and adds the real session ID // This maintains protection continuity during the transition from temporary to real session - const replaceTemporarySession = (realSessionId) => { + const replaceTemporarySession = useCallback((realSessionId) => { if (realSessionId) { setActiveSessions(prev => { const newSet = new Set(); @@ -536,7 +532,7 @@ function AppContent() { return newSet; }); } - }; + }, []); // Version Upgrade Modal Component const VersionUpgradeModal = () => { diff --git a/src/components/ChatInterface.jsx b/src/components/ChatInterface.jsx index 0a217d9..d99120d 100644 --- a/src/components/ChatInterface.jsx +++ b/src/components/ChatInterface.jsx @@ -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 π View edit diff for - { + { 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 - 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 + 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 - π Creating new file: - { + π Creating new file: + { 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 - 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-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 ( Read{' '} - 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 File updated successfully - onFileOpen && onFileOpen(fileEditMatch[1])} + { + 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 File created successfully - onFileOpen && onFileOpen(fileCreateMatch[1])} + { + 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 ( π Read{' '} - 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} /> ); })} diff --git a/src/components/ClaudeStatus.jsx b/src/components/ClaudeStatus.jsx index 05eaac2..2fb54ad 100644 --- a/src/components/ClaudeStatus.jsx +++ b/src/components/ClaudeStatus.jsx @@ -15,11 +15,14 @@ function ClaudeStatus({ status, onAbort, isLoading, provider = 'claude' }) { } const startTime = Date.now(); + // Calculate random token rate once (30-50 tokens per second) + const tokenRate = 30 + Math.random() * 20; + const timer = setInterval(() => { const elapsed = Math.floor((Date.now() - startTime) / 1000); setElapsedTime(elapsed); - // Simulate token count increasing over time (roughly 30-50 tokens per second) - setFakeTokens(Math.floor(elapsed * (30 + Math.random() * 20))); + // Simulate token count increasing over time + setFakeTokens(Math.floor(elapsed * tokenRate)); }, 1000); return () => clearInterval(timer); diff --git a/src/components/CodeEditor.jsx b/src/components/CodeEditor.jsx index d45ab0b..abdce7d 100644 --- a/src/components/CodeEditor.jsx +++ b/src/components/CodeEditor.jsx @@ -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 = ` + + ${chunkCount > 0 ? `${currentIndex + 1}/${chunkCount}` : '0'} changes + + + + + + + + + + + + `; + + 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 ( - - + + + {file.path} - + {file.diffInfo && ( : } )} - + 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'} > β΅ - + 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 }) { > {isDarkMode ? 'βοΈ' : 'π'} - + - + @@ -350,7 +495,7 @@ function CodeEditor({ file, onClose, projectPath }) { > )} - + {isFullscreen ? : } - + Characters: {content.length} Language: {file.name.split('.').pop()?.toUpperCase() || 'Text'} - + Press Ctrl+S to save β’ Esc to close + > ); } -export default CodeEditor; \ No newline at end of file +export default CodeEditor; diff --git a/src/components/GitPanel.jsx b/src/components/GitPanel.jsx index a7459f7..5b97604 100644 --- a/src/components/GitPanel.jsx +++ b/src/components/GitPanel.jsx @@ -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'}`} /> - toggleFileExpanded(filePath)} + - + { + e.stopPropagation(); + toggleFileExpanded(filePath); + }} + > - {filePath} + { + e.stopPropagation(); + handleFileOpen(filePath); + }} + title="Click to open file" + > + {filePath} + {(status === 'M' || status === 'D') && ( - + {shouldShowTasksTab && ( diff --git a/src/utils/websocket.js b/src/utils/websocket.js index 6c813ce..3f36e2a 100755 --- a/src/utils/websocket.js +++ b/src/utils/websocket.js @@ -41,7 +41,6 @@ export function useWebSocket() { // If the config returns localhost but we're not on localhost, use current host but with API server port if (wsBaseUrl.includes('localhost') && !window.location.hostname.includes('localhost')) { - console.warn('Config returned localhost, using current host with API server port instead'); const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; // For development, API server is typically on port 3002 when Vite is on 3001 const apiPort = window.location.port === '3001' ? '3002' : window.location.port;