Merge branch 'main' into master

This commit is contained in:
viper151
2025-10-31 02:00:01 +01:00
committed by GitHub
11 changed files with 660 additions and 218 deletions

32
package-lock.json generated
View File

@@ -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",
@@ -538,6 +540,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",
@@ -2454,6 +2469,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",

View File

@@ -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",
@@ -76,9 +80,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",

View File

@@ -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');

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
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;

View File

@@ -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 = () => {

View File

@@ -314,7 +314,7 @@ const markdownComponents = {
};
// 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') ||
@@ -459,14 +459,37 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
📝 View edit diff for
<button
onClick={(e) => {
<button
onClick={async (e) => {
e.preventDefault();
e.stopPropagation();
onFileOpen && onFileOpen(input.file_path, {
old_string: input.old_string,
new_string: input.new_string
});
if (!onFileOpen) return;
try {
// Fetch the current file (after the edit)
const response = await api.readFile(selectedProject?.name, input.file_path);
const data = await response.json();
if (!response.ok || data.error) {
console.error('Failed to fetch file:', data.error);
onFileOpen(input.file_path);
return;
}
const currentContent = data.content || '';
// Reverse apply the edit: replace new_string back to old_string to get the file BEFORE the edit
const oldContent = currentContent.replace(input.new_string, input.old_string);
// Pass the full file before and after the edit
onFileOpen(input.file_path, {
old_string: oldContent,
new_string: currentContent
});
} catch (error) {
console.error('Error preparing diff:', error);
onFileOpen(input.file_path);
}
}}
className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 underline font-mono"
>
@@ -476,11 +499,35 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
<div className="mt-3">
<div className="bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<div className="flex items-center justify-between px-3 py-2 bg-gray-100 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
<button
onClick={() => onFileOpen && onFileOpen(input.file_path, {
old_string: input.old_string,
new_string: input.new_string
})}
<button
onClick={async () => {
if (!onFileOpen) return;
try {
// Fetch the current file (after the edit)
const response = await api.readFile(selectedProject?.name, input.file_path);
const data = await response.json();
if (!response.ok || data.error) {
console.error('Failed to fetch file:', data.error);
onFileOpen(input.file_path);
return;
}
const currentContent = data.content || '';
// Reverse apply the edit: replace new_string back to old_string
const oldContent = currentContent.replace(input.new_string, input.old_string);
// Pass the full file before and after the edit
onFileOpen(input.file_path, {
old_string: oldContent,
new_string: currentContent
});
} catch (error) {
console.error('Error preparing diff:', error);
onFileOpen(input.file_path);
}
}}
className="text-xs font-mono text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 truncate underline cursor-pointer"
>
{input.file_path}
@@ -560,15 +607,33 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
<svg className="w-4 h-4 transition-transform details-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
📄 Creating new file:
<button
onClick={(e) => {
📄 Creating new file:
<button
onClick={async (e) => {
e.preventDefault();
e.stopPropagation();
onFileOpen && onFileOpen(input.file_path, {
old_string: '',
new_string: input.content
});
if (!onFileOpen) return;
try {
// Fetch the written file from disk
const response = await api.readFile(selectedProject?.name, input.file_path);
const data = await response.json();
const newContent = (response.ok && !data.error) ? data.content || '' : input.content || '';
// New file: old_string is empty, new_string is the full file
onFileOpen(input.file_path, {
old_string: '',
new_string: newContent
});
} catch (error) {
console.error('Error preparing diff:', error);
// Fallback to tool input content
onFileOpen(input.file_path, {
old_string: '',
new_string: input.content || ''
});
}
}}
className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 underline font-mono"
>
@@ -578,11 +643,31 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
<div className="mt-3">
<div className="bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<div className="flex items-center justify-between px-3 py-2 bg-gray-100 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
<button
onClick={() => onFileOpen && onFileOpen(input.file_path, {
old_string: '',
new_string: input.content
})}
<button
onClick={async () => {
if (!onFileOpen) return;
try {
// Fetch the written file from disk
const response = await api.readFile(selectedProject?.name, input.file_path);
const data = await response.json();
const newContent = (response.ok && !data.error) ? data.content || '' : input.content || '';
// New file: old_string is empty, new_string is the full file
onFileOpen(input.file_path, {
old_string: '',
new_string: newContent
});
} catch (error) {
console.error('Error preparing diff:', error);
// Fallback to tool input content
onFileOpen(input.file_path, {
old_string: '',
new_string: input.content || ''
});
}
}}
className="text-xs font-mono text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 truncate underline cursor-pointer"
>
{input.file_path}
@@ -722,7 +807,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
return (
<div className="mt-2 text-sm text-blue-700 dark:text-blue-300">
Read{' '}
<button
<button
onClick={() => onFileOpen && onFileOpen(input.file_path)}
className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 underline font-mono"
>
@@ -979,8 +1064,28 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
<div className="flex items-center gap-2 mb-2">
<span className="font-medium">File updated successfully</span>
</div>
<button
onClick={() => onFileOpen && onFileOpen(fileEditMatch[1])}
<button
onClick={async () => {
if (!onFileOpen) return;
// Fetch FULL file content with diff from git
try {
const response = await authenticatedFetch(`/api/git/file-with-diff?project=${encodeURIComponent(selectedProject?.name)}&file=${encodeURIComponent(fileEditMatch[1])}`);
const data = await response.json();
if (!data.error && data.oldContent !== undefined && data.currentContent !== undefined) {
onFileOpen(fileEditMatch[1], {
old_string: data.oldContent || '',
new_string: data.currentContent || ''
});
} else {
onFileOpen(fileEditMatch[1]);
}
} catch (error) {
console.error('Error fetching file diff:', error);
onFileOpen(fileEditMatch[1]);
}
}}
className="text-xs font-mono bg-green-100 dark:bg-green-800/30 px-2 py-1 rounded text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 underline cursor-pointer"
>
{fileEditMatch[1]}
@@ -997,8 +1102,28 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
<div className="flex items-center gap-2 mb-2">
<span className="font-medium">File created successfully</span>
</div>
<button
onClick={() => onFileOpen && onFileOpen(fileCreateMatch[1])}
<button
onClick={async () => {
if (!onFileOpen) return;
// Fetch FULL file content with diff from git
try {
const response = await authenticatedFetch(`/api/git/file-with-diff?project=${encodeURIComponent(selectedProject?.name)}&file=${encodeURIComponent(fileCreateMatch[1])}`);
const data = await response.json();
if (!data.error && data.oldContent !== undefined && data.currentContent !== undefined) {
onFileOpen(fileCreateMatch[1], {
old_string: data.oldContent || '',
new_string: data.currentContent || ''
});
} else {
onFileOpen(fileCreateMatch[1]);
}
} catch (error) {
console.error('Error fetching file diff:', error);
onFileOpen(fileCreateMatch[1]);
}
}}
className="text-xs font-mono bg-green-100 dark:bg-green-800/30 px-2 py-1 rounded text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 underline cursor-pointer"
>
{fileCreateMatch[1]}
@@ -1163,7 +1288,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
return (
<div className="bg-blue-50 dark:bg-blue-900/20 border-l-2 border-blue-300 dark:border-blue-600 pl-3 py-1 mb-2 text-sm text-blue-700 dark:text-blue-300">
📖 Read{' '}
<button
<button
onClick={() => onFileOpen && onFileOpen(input.file_path)}
className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 underline font-mono"
>
@@ -2481,8 +2606,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';
@@ -2578,7 +2701,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
@@ -2998,16 +3120,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);
@@ -3315,16 +3429,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) {
@@ -4089,6 +4200,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
autoExpandTools={autoExpandTools}
showRawParameters={showRawParameters}
showThinking={showThinking}
selectedProject={selectedProject}
/>
);
})}

View File

@@ -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);

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 { javascript } from '@codemirror/lang-javascript';
import { python } from '@codemirror/lang-python';
@@ -7,8 +7,9 @@ import { css } from '@codemirror/lang-css';
import { json } from '@codemirror/lang-json';
import { markdown } from '@codemirror/lang-markdown';
import { oneDark } from '@codemirror/theme-one-dark';
import { EditorView, Decoration } from '@codemirror/view';
import { StateField, StateEffect, RangeSetBuilder } from '@codemirror/state';
import { EditorView, showPanel, ViewPlugin } from '@codemirror/view';
import { unifiedMergeView, getChunks } from '@codemirror/merge';
import { showMinimap } from '@replit/codemirror-minimap';
import { X, Save, Download, Maximize2, Minimize2, Eye, EyeOff } from 'lucide-react';
import { api } from '../utils/api';
@@ -21,90 +22,150 @@ function CodeEditor({ file, onClose, projectPath }) {
const [saveSuccess, setSaveSuccess] = useState(false);
const [showDiff, setShowDiff] = useState(!!file.diffInfo);
const [wordWrap, setWordWrap] = useState(false);
const editorRef = useRef(null);
// Create diff highlighting
const diffEffect = StateEffect.define();
const diffField = StateField.define({
create() {
return Decoration.none;
},
update(decorations, tr) {
decorations = decorations.map(tr.changes);
for (let effect of tr.effects) {
if (effect.is(diffEffect)) {
decorations = effect.value;
// Create minimap extension with chunk-based gutters
const minimapExtension = useMemo(() => {
if (!file.diffInfo || !showDiff) return [];
const gutters = {};
return [
showMinimap.compute(['doc'], (state) => {
// Get actual chunks from merge view
const chunksData = getChunks(state);
const chunks = chunksData?.chunks || [];
// Clear previous gutters
Object.keys(gutters).forEach(key => delete gutters[key]);
// Mark lines that are part of chunks
chunks.forEach(chunk => {
// Mark the lines in the B side (current document)
const fromLine = state.doc.lineAt(chunk.fromB).number;
const toLine = state.doc.lineAt(Math.min(chunk.toB, state.doc.length)).number;
for (let lineNum = fromLine; lineNum <= toLine; lineNum++) {
gutters[lineNum] = isDarkMode ? 'rgba(34, 197, 94, 0.8)' : 'rgba(34, 197, 94, 1)';
}
});
return {
create: () => ({ dom: document.createElement('div') }),
displayText: 'blocks',
showOverlay: 'always',
gutters: [gutters]
};
})
];
}, [file.diffInfo, showDiff, isDarkMode]);
// Create extension to scroll to first chunk on mount
const scrollToFirstChunkExtension = useMemo(() => {
if (!file.diffInfo || !showDiff) return [];
return [
ViewPlugin.fromClass(class {
constructor(view) {
// Delay to ensure merge view is fully initialized
setTimeout(() => {
const chunksData = getChunks(view.state);
const chunks = chunksData?.chunks || [];
if (chunks.length > 0) {
const firstChunk = chunks[0];
// Scroll to the first chunk
view.dispatch({
effects: EditorView.scrollIntoView(firstChunk.fromB, { y: 'center' })
});
}
}, 100);
}
}
return decorations;
},
provide: f => EditorView.decorations.from(f)
});
const createDiffDecorations = (content, diffInfo) => {
if (!diffInfo || !showDiff) return Decoration.none;
const builder = new RangeSetBuilder();
const lines = content.split('\n');
const oldLines = diffInfo.old_string.split('\n');
// Find the line where the old content starts
let startLineIndex = -1;
for (let i = 0; i <= lines.length - oldLines.length; i++) {
let matches = true;
for (let j = 0; j < oldLines.length; j++) {
if (lines[i + j] !== oldLines[j]) {
matches = false;
break;
}
}
if (matches) {
startLineIndex = i;
break;
}
}
update() {}
destroy() {}
})
];
}, [file.diffInfo, showDiff]);
if (startLineIndex >= 0) {
let pos = 0;
// Calculate position to start of old content
for (let i = 0; i < startLineIndex; i++) {
pos += lines[i].length + 1; // +1 for newline
}
// Highlight old lines (to be removed)
for (let i = 0; i < oldLines.length; i++) {
const lineStart = pos;
const lineEnd = pos + oldLines[i].length;
builder.add(lineStart, lineEnd, Decoration.line({
class: isDarkMode ? 'diff-removed-dark' : 'diff-removed-light'
}));
pos += oldLines[i].length + 1;
}
}
return builder.finish();
};
// Create diff navigation panel extension
const diffNavigationPanel = useMemo(() => {
if (!file.diffInfo || !showDiff) return [];
// Diff decoration theme
const diffTheme = EditorView.theme({
'.diff-removed-light': {
backgroundColor: '#fef2f2',
borderLeft: '3px solid #ef4444'
},
'.diff-removed-dark': {
backgroundColor: 'rgba(239, 68, 68, 0.1)',
borderLeft: '3px solid #ef4444'
},
'.diff-added-light': {
backgroundColor: '#f0fdf4',
borderLeft: '3px solid #22c55e'
},
'.diff-added-dark': {
backgroundColor: 'rgba(34, 197, 94, 0.1)',
borderLeft: '3px solid #22c55e'
}
});
const createPanel = (view) => {
const dom = document.createElement('div');
dom.className = 'cm-diff-navigation-panel';
let currentIndex = 0;
const updatePanel = () => {
// Use getChunks API to get ALL chunks regardless of viewport
const chunksData = getChunks(view.state);
const chunks = chunksData?.chunks || [];
const chunkCount = chunks.length;
dom.innerHTML = `
<div style="display: flex; align-items: center; gap: 8px;">
<span style="font-weight: 500;">${chunkCount > 0 ? `${currentIndex + 1}/${chunkCount}` : '0'} changes</span>
<button class="cm-diff-nav-btn cm-diff-nav-prev" title="Previous change" ${chunkCount === 0 ? 'disabled' : ''}>
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
</svg>
</button>
<button class="cm-diff-nav-btn cm-diff-nav-next" title="Next change" ${chunkCount === 0 ? 'disabled' : ''}>
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
`;
const prevBtn = dom.querySelector('.cm-diff-nav-prev');
const nextBtn = dom.querySelector('.cm-diff-nav-next');
prevBtn?.addEventListener('click', () => {
if (chunks.length === 0) return;
currentIndex = currentIndex > 0 ? currentIndex - 1 : chunks.length - 1;
// Navigate to the chunk - use fromB which is the position in the current document
const chunk = chunks[currentIndex];
if (chunk) {
// Scroll to the start of the chunk in the B side (current document)
view.dispatch({
effects: EditorView.scrollIntoView(chunk.fromB, { y: 'center' })
});
}
updatePanel();
});
nextBtn?.addEventListener('click', () => {
if (chunks.length === 0) return;
currentIndex = currentIndex < chunks.length - 1 ? currentIndex + 1 : 0;
// Navigate to the chunk - use fromB which is the position in the current document
const chunk = chunks[currentIndex];
if (chunk) {
// Scroll to the start of the chunk in the B side (current document)
view.dispatch({
effects: EditorView.scrollIntoView(chunk.fromB, { y: 'center' })
});
}
updatePanel();
});
};
updatePanel();
return {
top: true,
dom,
update: updatePanel
};
};
return [showPanel.of(createPanel)];
}, [file.diffInfo, showDiff]);
// Get language extension based on file extension
const getLanguageExtension = (filename) => {
@@ -139,13 +200,24 @@ function CodeEditor({ file, onClose, projectPath }) {
const loadFileContent = async () => {
try {
setLoading(true);
// If we have diffInfo with both old and new content, we can show the diff directly
// This handles both GitPanel (full content) and ChatInterface (full content from API)
if (file.diffInfo && file.diffInfo.new_string !== undefined && file.diffInfo.old_string !== undefined) {
// Use the new_string as the content to display
// The unifiedMergeView will compare it against old_string
setContent(file.diffInfo.new_string);
setLoading(false);
return;
}
// Otherwise, load from disk
const response = await api.readFile(file.projectName, file.path);
if (!response.ok) {
throw new Error(`Failed to load file: ${response.status} ${response.statusText}`);
}
const data = await response.json();
setContent(data.content);
} catch (error) {
@@ -159,37 +231,41 @@ function CodeEditor({ file, onClose, projectPath }) {
loadFileContent();
}, [file, projectPath]);
// Update diff decorations when content or diff info changes
const editorRef = useRef(null);
useEffect(() => {
if (editorRef.current && content && file.diffInfo && showDiff) {
const decorations = createDiffDecorations(content, file.diffInfo);
const view = editorRef.current.view;
if (view) {
view.dispatch({
effects: diffEffect.of(decorations)
});
}
}
}, [content, file.diffInfo, showDiff, isDarkMode]);
const handleSave = async () => {
setSaving(true);
try {
console.log('Saving file:', {
projectName: file.projectName,
path: file.path,
contentLength: content?.length
});
const response = await api.saveFile(file.projectName, file.path, content);
console.log('Save response:', {
status: response.status,
ok: response.ok,
contentType: response.headers.get('content-type')
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || `Save failed: ${response.status}`);
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
const errorData = await response.json();
throw new Error(errorData.error || `Save failed: ${response.status}`);
} else {
const textError = await response.text();
console.error('Non-JSON error response:', textError);
throw new Error(`Save failed: ${response.status} ${response.statusText}`);
}
}
const result = await response.json();
// Show success feedback
console.log('Save successful:', result);
setSaveSuccess(true);
setTimeout(() => setSaveSuccess(false), 2000); // Hide after 2 seconds
setTimeout(() => setSaveSuccess(false), 2000);
} catch (error) {
console.error('Error saving file:', error);
alert(`Error saving file: ${error.message}`);
@@ -258,11 +334,80 @@ function CodeEditor({ file, onClose, projectPath }) {
}
return (
<div className={`fixed inset-0 z-50 ${
// Mobile: native fullscreen, Desktop: modal with backdrop
'md:bg-black/50 md:flex md:items-center md:justify-center md:p-4'
} ${isFullscreen ? 'md:p-0' : ''}`}>
<div className={`bg-white shadow-2xl flex flex-col ${
<>
<style>
{`
/* Light background for full line changes */
.cm-deletedChunk {
background-color: ${isDarkMode ? 'rgba(239, 68, 68, 0.15)' : 'rgba(255, 235, 235, 1)'} !important;
border-left: 3px solid ${isDarkMode ? 'rgba(239, 68, 68, 0.6)' : 'rgb(239, 68, 68)'} !important;
padding-left: 4px !important;
}
.cm-insertedChunk {
background-color: ${isDarkMode ? 'rgba(34, 197, 94, 0.15)' : 'rgba(230, 255, 237, 1)'} !important;
border-left: 3px solid ${isDarkMode ? 'rgba(34, 197, 94, 0.6)' : 'rgb(34, 197, 94)'} !important;
padding-left: 4px !important;
}
/* Override linear-gradient underline and use solid darker background for partial changes */
.cm-editor.cm-merge-b .cm-changedText {
background: ${isDarkMode ? 'rgba(34, 197, 94, 0.4)' : 'rgba(34, 197, 94, 0.3)'} !important;
padding-top: 2px !important;
padding-bottom: 2px !important;
margin-top: -2px !important;
margin-bottom: -2px !important;
}
.cm-editor .cm-deletedChunk .cm-changedText {
background: ${isDarkMode ? 'rgba(239, 68, 68, 0.4)' : 'rgba(239, 68, 68, 0.3)'} !important;
padding-top: 2px !important;
padding-bottom: 2px !important;
margin-top: -2px !important;
margin-bottom: -2px !important;
}
/* Minimap gutter styling */
.cm-gutter.cm-gutter-minimap {
background-color: ${isDarkMode ? '#1e1e1e' : '#f5f5f5'};
}
/* Diff navigation panel styling */
.cm-diff-navigation-panel {
padding: 8px 12px;
background-color: ${isDarkMode ? '#1f2937' : '#ffffff'};
border-bottom: 1px solid ${isDarkMode ? '#374151' : '#e5e7eb'};
color: ${isDarkMode ? '#d1d5db' : '#374151'};
font-size: 14px;
}
.cm-diff-nav-btn {
padding: 4px;
background: transparent;
border: none;
cursor: pointer;
border-radius: 4px;
display: inline-flex;
align-items: center;
justify-content: center;
color: inherit;
}
.cm-diff-nav-btn:hover {
background-color: ${isDarkMode ? '#374151' : '#f3f4f6'};
}
.cm-diff-nav-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`}
</style>
<div className={`fixed inset-0 z-50 ${
// Mobile: native fullscreen, Desktop: modal with backdrop
'md:bg-black/50 md:flex md:items-center md:justify-center md:p-4'
} ${isFullscreen ? 'md:p-0' : ''}`}>
<div className={`bg-white shadow-2xl flex flex-col ${
// Mobile: always fullscreen, Desktop: modal sizing
'w-full h-full md:rounded-lg md:shadow-2xl' +
(isFullscreen ? ' md:w-full md:h-full md:rounded-none' : ' md:w-full md:max-w-6xl md:h-[80vh] md:max-h-[80vh]')
@@ -287,7 +432,7 @@ function CodeEditor({ file, onClose, projectPath }) {
<p className="text-sm text-gray-500 truncate">{file.path}</p>
</div>
</div>
<div className="flex items-center gap-1 md:gap-2 flex-shrink-0">
{file.diffInfo && (
<button
@@ -298,19 +443,19 @@ function CodeEditor({ file, onClose, projectPath }) {
{showDiff ? <EyeOff className="w-5 h-5 md:w-4 md:h-4" /> : <Eye className="w-5 h-5 md:w-4 md:h-4" />}
</button>
)}
<button
onClick={() => setWordWrap(!wordWrap)}
className={`p-2 md:p-2 rounded-md hover:bg-gray-100 min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center ${
wordWrap
? 'text-blue-600 bg-blue-50'
wordWrap
? 'text-blue-600 bg-blue-50'
: 'text-gray-600 hover:text-gray-900'
}`}
title={wordWrap ? 'Disable word wrap' : 'Enable word wrap'}
>
<span className="text-sm md:text-xs font-mono font-bold"></span>
</button>
<button
onClick={() => setIsDarkMode(!isDarkMode)}
className="p-2 md:p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center"
@@ -318,7 +463,7 @@ function CodeEditor({ file, onClose, projectPath }) {
>
<span className="text-lg md:text-base">{isDarkMode ? '☀️' : '🌙'}</span>
</button>
<button
onClick={handleDownload}
className="p-2 md:p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center"
@@ -326,13 +471,13 @@ function CodeEditor({ file, onClose, projectPath }) {
>
<Download className="w-5 h-5 md:w-4 md:h-4" />
</button>
<button
onClick={handleSave}
disabled={saving}
className={`px-3 py-2 text-white rounded-md disabled:opacity-50 flex items-center gap-2 transition-colors min-h-[44px] md:min-h-0 ${
saveSuccess
? 'bg-green-600 hover:bg-green-700'
saveSuccess
? 'bg-green-600 hover:bg-green-700'
: 'bg-blue-600 hover:bg-blue-700'
}`}
>
@@ -350,7 +495,7 @@ function CodeEditor({ file, onClose, projectPath }) {
</>
)}
</button>
<button
onClick={toggleFullscreen}
className="hidden md:flex p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 items-center justify-center"
@@ -358,7 +503,7 @@ function CodeEditor({ file, onClose, projectPath }) {
>
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
</button>
<button
onClick={onClose}
className="p-2 md:p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center"
@@ -377,8 +522,21 @@ function CodeEditor({ file, onClose, projectPath }) {
onChange={setContent}
extensions={[
...getLanguageExtension(file.name),
diffField,
diffTheme,
...(file.diffInfo && showDiff && file.diffInfo.old_string !== undefined
? [
unifiedMergeView({
original: file.diffInfo.old_string,
mergeControls: false,
highlightChanges: true,
syntaxHighlightDeletions: false,
gutter: true
// NOTE: NO collapseUnchanged - this shows the full file!
}),
...minimapExtension,
...scrollToFirstChunkExtension,
...diffNavigationPanel
]
: []),
...(wordWrap ? [EditorView.lineWrapping] : [])
]}
theme={isDarkMode ? oneDark : undefined}
@@ -409,14 +567,15 @@ function CodeEditor({ file, onClose, projectPath }) {
<span>Characters: {content.length}</span>
<span>Language: {file.name.split('.').pop()?.toUpperCase() || 'Text'}</span>
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
Press Ctrl+S to save Esc to close
</div>
</div>
</div>
</div>
</>
);
}
export default CodeEditor;
export default CodeEditor;

View File

@@ -4,7 +4,7 @@ import { MicButton } from './MicButton.jsx';
import { authenticatedFetch } from '../utils/api';
import DiffViewer from './DiffViewer.jsx';
function GitPanel({ selectedProject, isMobile }) {
function GitPanel({ selectedProject, isMobile, onFileOpen }) {
const [gitStatus, setGitStatus] = useState(null);
const [gitDiff, setGitDiff] = useState({});
const [isLoading, setIsLoading] = useState(false);
@@ -107,6 +107,12 @@ function GitPanel({ selectedProject, isMobile }) {
for (const file of data.added || []) {
fetchFileDiff(file);
}
for (const file of data.deleted || []) {
fetchFileDiff(file);
}
for (const file of data.untracked || []) {
fetchFileDiff(file);
}
}
} catch (error) {
console.error('Error fetching git status:', error);
@@ -402,7 +408,7 @@ function GitPanel({ selectedProject, isMobile }) {
try {
const response = await authenticatedFetch(`/api/git/diff?project=${encodeURIComponent(selectedProject.name)}&file=${encodeURIComponent(filePath)}`);
const data = await response.json();
if (!data.error && data.diff) {
setGitDiff(prev => ({
...prev,
@@ -414,6 +420,36 @@ function GitPanel({ selectedProject, isMobile }) {
}
};
const handleFileOpen = async (filePath) => {
if (!onFileOpen) return;
try {
// Fetch file content with diff information
const response = await authenticatedFetch(`/api/git/file-with-diff?project=${encodeURIComponent(selectedProject.name)}&file=${encodeURIComponent(filePath)}`);
const data = await response.json();
if (data.error) {
console.error('Error fetching file with diff:', data.error);
// Fallback: open without diff info
onFileOpen(filePath);
return;
}
// Create diffInfo object for CodeEditor
const diffInfo = {
old_string: data.oldContent || '',
new_string: data.currentContent || ''
};
// Open file with diff information
onFileOpen(filePath, diffInfo);
} catch (error) {
console.error('Error opening file:', error);
// Fallback: open without diff info
onFileOpen(filePath);
}
};
const fetchRecentCommits = async () => {
try {
const response = await authenticatedFetch(`/api/git/commits?project=${encodeURIComponent(selectedProject.name)}&limit=10`);
@@ -610,14 +646,28 @@ function GitPanel({ selectedProject, isMobile }) {
onClick={(e) => e.stopPropagation()}
className={`rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 dark:focus:ring-blue-400 dark:bg-gray-800 dark:checked:bg-blue-600 ${isMobile ? 'mr-1.5' : 'mr-2'}`}
/>
<div
className="flex items-center flex-1 cursor-pointer"
onClick={() => toggleFileExpanded(filePath)}
<div
className="flex items-center flex-1"
>
<div className={`p-0.5 hover:bg-gray-200 dark:hover:bg-gray-700 rounded ${isMobile ? 'mr-1' : 'mr-2'}`}>
<div
className={`p-0.5 hover:bg-gray-200 dark:hover:bg-gray-700 rounded cursor-pointer ${isMobile ? 'mr-1' : 'mr-2'}`}
onClick={(e) => {
e.stopPropagation();
toggleFileExpanded(filePath);
}}
>
<ChevronRight className={`w-3 h-3 transition-transform duration-200 ease-in-out ${isExpanded ? 'rotate-90' : 'rotate-0'}`} />
</div>
<span className={`flex-1 truncate ${isMobile ? 'text-xs' : 'text-sm'}`}>{filePath}</span>
<span
className={`flex-1 truncate ${isMobile ? 'text-xs' : 'text-sm'} cursor-pointer hover:text-blue-600 dark:hover:text-blue-400 hover:underline`}
onClick={(e) => {
e.stopPropagation();
handleFileOpen(filePath);
}}
title="Click to open file"
>
{filePath}
</span>
<div className="flex items-center gap-1">
{(status === 'M' || status === 'D') && (
<button

View File

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

View File

@@ -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;