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 - + + + `; + + 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 && ( )} - + - + - + - + - + - +
- +
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;