Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 5 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
22 changes: 10 additions & 12 deletions server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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' });
Expand Down Expand Up @@ -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');

Expand Down
105 changes: 98 additions & 7 deletions server/routes/git.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -124,39 +153,101 @@ 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);
res.json({ error: error.message });
}
});

// 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;
Expand Down
26 changes: 11 additions & 15 deletions src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
}
}
}
Expand Down Expand Up @@ -482,47 +478,47 @@ 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);
newSet.delete(sessionId);
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);
newSet.delete(sessionId);
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();
Expand All @@ -536,7 +532,7 @@ function AppContent() {
return newSet;
});
}
};
}, []);

// Version Upgrade Modal Component
const VersionUpgradeModal = () => {
Expand Down
Loading