mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-03-12 01:17:48 +00:00
Compare commits
11 Commits
main
...
fix/numero
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd2e4da524 | ||
|
|
53af032d88 | ||
|
|
a780cb6523 | ||
|
|
68787e01fc | ||
|
|
ed69d2fdc9 | ||
|
|
3e617394b7 | ||
|
|
52d4671504 | ||
|
|
d508b1a4fd | ||
|
|
0073c5b550 | ||
|
|
3a80be9492 | ||
|
|
78601e3bce |
@@ -1,84 +1,124 @@
|
||||
import { spawn } from 'child_process';
|
||||
import crossSpawn from 'cross-spawn';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
// Use cross-spawn on Windows for better command execution
|
||||
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
|
||||
|
||||
let activeCursorProcesses = new Map(); // Track active processes by session ID
|
||||
|
||||
const WORKSPACE_TRUST_PATTERNS = [
|
||||
/workspace trust required/i,
|
||||
/do you trust the contents of this directory/i,
|
||||
/working with untrusted contents/i,
|
||||
/pass --trust,\s*--yolo,\s*or -f/i
|
||||
];
|
||||
|
||||
function isWorkspaceTrustPrompt(text = '') {
|
||||
if (!text || typeof text !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return WORKSPACE_TRUST_PATTERNS.some((pattern) => pattern.test(text));
|
||||
}
|
||||
|
||||
async function spawnCursor(command, options = {}, ws) {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
const { sessionId, projectPath, cwd, resume, toolsSettings, skipPermissions, model, images } = options;
|
||||
const { sessionId, projectPath, cwd, resume, toolsSettings, skipPermissions, model } = options;
|
||||
let capturedSessionId = sessionId; // Track session ID throughout the process
|
||||
let sessionCreatedSent = false; // Track if we've already sent session-created event
|
||||
let messageBuffer = ''; // Buffer for accumulating assistant messages
|
||||
|
||||
let hasRetriedWithTrust = false;
|
||||
let settled = false;
|
||||
|
||||
// Use tools settings passed from frontend, or defaults
|
||||
const settings = toolsSettings || {
|
||||
allowedShellCommands: [],
|
||||
skipPermissions: false
|
||||
};
|
||||
|
||||
|
||||
// Build Cursor CLI command
|
||||
const args = [];
|
||||
|
||||
const baseArgs = [];
|
||||
|
||||
// Build flags allowing both resume and prompt together (reply in existing session)
|
||||
// Treat presence of sessionId as intention to resume, regardless of resume flag
|
||||
if (sessionId) {
|
||||
args.push('--resume=' + sessionId);
|
||||
baseArgs.push('--resume=' + sessionId);
|
||||
}
|
||||
|
||||
if (command && command.trim()) {
|
||||
// Provide a prompt (works for both new and resumed sessions)
|
||||
args.push('-p', command);
|
||||
baseArgs.push('-p', command);
|
||||
|
||||
// Add model flag if specified (only meaningful for new sessions; harmless on resume)
|
||||
if (!sessionId && model) {
|
||||
args.push('--model', model);
|
||||
baseArgs.push('--model', model);
|
||||
}
|
||||
|
||||
// Request streaming JSON when we are providing a prompt
|
||||
args.push('--output-format', 'stream-json');
|
||||
baseArgs.push('--output-format', 'stream-json');
|
||||
}
|
||||
|
||||
|
||||
// Add skip permissions flag if enabled
|
||||
if (skipPermissions || settings.skipPermissions) {
|
||||
args.push('-f');
|
||||
console.log('⚠️ Using -f flag (skip permissions)');
|
||||
baseArgs.push('-f');
|
||||
console.log('Using -f flag (skip permissions)');
|
||||
}
|
||||
|
||||
|
||||
// Use cwd (actual project directory) instead of projectPath
|
||||
const workingDir = cwd || projectPath || process.cwd();
|
||||
|
||||
console.log('Spawning Cursor CLI:', 'cursor-agent', args.join(' '));
|
||||
console.log('Working directory:', workingDir);
|
||||
console.log('Session info - Input sessionId:', sessionId, 'Resume:', resume);
|
||||
|
||||
const cursorProcess = spawnFunction('cursor-agent', args, {
|
||||
cwd: workingDir,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: { ...process.env } // Inherit all environment variables
|
||||
});
|
||||
|
||||
|
||||
// Store process reference for potential abort
|
||||
const processKey = capturedSessionId || Date.now().toString();
|
||||
activeCursorProcesses.set(processKey, cursorProcess);
|
||||
|
||||
// Handle stdout (streaming JSON responses)
|
||||
cursorProcess.stdout.on('data', (data) => {
|
||||
const rawOutput = data.toString();
|
||||
console.log('📤 Cursor CLI stdout:', rawOutput);
|
||||
|
||||
const lines = rawOutput.split('\n').filter(line => line.trim());
|
||||
|
||||
for (const line of lines) {
|
||||
|
||||
const settleOnce = (callback) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
callback();
|
||||
};
|
||||
|
||||
const runCursorProcess = (args, runReason = 'initial') => {
|
||||
const isTrustRetry = runReason === 'trust-retry';
|
||||
let runSawWorkspaceTrustPrompt = false;
|
||||
let stdoutLineBuffer = '';
|
||||
|
||||
if (isTrustRetry) {
|
||||
console.log('Retrying Cursor CLI with --trust after workspace trust prompt');
|
||||
}
|
||||
|
||||
console.log('Spawning Cursor CLI:', 'cursor-agent', args.join(' '));
|
||||
console.log('Working directory:', workingDir);
|
||||
console.log('Session info - Input sessionId:', sessionId, 'Resume:', resume);
|
||||
|
||||
const cursorProcess = spawnFunction('cursor-agent', args, {
|
||||
cwd: workingDir,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: { ...process.env } // Inherit all environment variables
|
||||
});
|
||||
|
||||
activeCursorProcesses.set(processKey, cursorProcess);
|
||||
|
||||
const shouldSuppressForTrustRetry = (text) => {
|
||||
if (hasRetriedWithTrust || args.includes('--trust')) {
|
||||
return false;
|
||||
}
|
||||
if (!isWorkspaceTrustPrompt(text)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
runSawWorkspaceTrustPrompt = true;
|
||||
return true;
|
||||
};
|
||||
|
||||
const processCursorOutputLine = (line) => {
|
||||
if (!line || !line.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = JSON.parse(line);
|
||||
console.log('📄 Parsed JSON response:', response);
|
||||
|
||||
console.log('Parsed JSON response:', response);
|
||||
|
||||
// Handle different message types
|
||||
switch (response.type) {
|
||||
case 'system':
|
||||
@@ -86,14 +126,14 @@ async function spawnCursor(command, options = {}, ws) {
|
||||
// Capture session ID
|
||||
if (response.session_id && !capturedSessionId) {
|
||||
capturedSessionId = response.session_id;
|
||||
console.log('📝 Captured session ID:', capturedSessionId);
|
||||
|
||||
console.log('Captured session ID:', capturedSessionId);
|
||||
|
||||
// Update process key with captured session ID
|
||||
if (processKey !== capturedSessionId) {
|
||||
activeCursorProcesses.delete(processKey);
|
||||
activeCursorProcesses.set(capturedSessionId, cursorProcess);
|
||||
}
|
||||
|
||||
|
||||
// Set session ID on writer (for API endpoint compatibility)
|
||||
if (ws.setSessionId && typeof ws.setSessionId === 'function') {
|
||||
ws.setSessionId(capturedSessionId);
|
||||
@@ -110,7 +150,7 @@ async function spawnCursor(command, options = {}, ws) {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Send system info to frontend
|
||||
ws.send({
|
||||
type: 'cursor-system',
|
||||
@@ -119,7 +159,7 @@ async function spawnCursor(command, options = {}, ws) {
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
|
||||
case 'user':
|
||||
// Forward user message
|
||||
ws.send({
|
||||
@@ -128,13 +168,12 @@ async function spawnCursor(command, options = {}, ws) {
|
||||
sessionId: capturedSessionId || sessionId || null
|
||||
});
|
||||
break;
|
||||
|
||||
|
||||
case 'assistant':
|
||||
// Accumulate assistant message chunks
|
||||
if (response.message && response.message.content && response.message.content.length > 0) {
|
||||
const textContent = response.message.content[0].text;
|
||||
messageBuffer += textContent;
|
||||
|
||||
|
||||
// Send as Claude-compatible format for frontend
|
||||
ws.send({
|
||||
type: 'claude-response',
|
||||
@@ -149,23 +188,14 @@ async function spawnCursor(command, options = {}, ws) {
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
|
||||
case 'result':
|
||||
// Session complete
|
||||
console.log('Cursor session result:', response);
|
||||
|
||||
// Send final message if we have buffered content
|
||||
if (messageBuffer) {
|
||||
ws.send({
|
||||
type: 'claude-response',
|
||||
data: {
|
||||
type: 'content_block_stop'
|
||||
},
|
||||
sessionId: capturedSessionId || sessionId || null
|
||||
});
|
||||
}
|
||||
|
||||
// Send completion event
|
||||
|
||||
// Do not emit an extra content_block_stop here.
|
||||
// The UI already finalizes the streaming message in cursor-result handling,
|
||||
// and emitting both can produce duplicate assistant messages.
|
||||
ws.send({
|
||||
type: 'cursor-result',
|
||||
sessionId: capturedSessionId || sessionId,
|
||||
@@ -173,7 +203,7 @@ async function spawnCursor(command, options = {}, ws) {
|
||||
success: response.subtype === 'success'
|
||||
});
|
||||
break;
|
||||
|
||||
|
||||
default:
|
||||
// Forward any other message types
|
||||
ws.send({
|
||||
@@ -183,7 +213,12 @@ async function spawnCursor(command, options = {}, ws) {
|
||||
});
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.log('📄 Non-JSON response:', line);
|
||||
console.log('Non-JSON response:', line);
|
||||
|
||||
if (shouldSuppressForTrustRetry(line)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If not JSON, send as raw text
|
||||
ws.send({
|
||||
type: 'cursor-output',
|
||||
@@ -191,67 +226,106 @@ async function spawnCursor(command, options = {}, ws) {
|
||||
sessionId: capturedSessionId || sessionId || null
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle stderr
|
||||
cursorProcess.stderr.on('data', (data) => {
|
||||
console.error('Cursor CLI stderr:', data.toString());
|
||||
ws.send({
|
||||
type: 'cursor-error',
|
||||
error: data.toString(),
|
||||
sessionId: capturedSessionId || sessionId || null
|
||||
});
|
||||
});
|
||||
|
||||
// Handle process completion
|
||||
cursorProcess.on('close', async (code) => {
|
||||
console.log(`Cursor CLI process exited with code ${code}`);
|
||||
|
||||
// Clean up process reference
|
||||
const finalSessionId = capturedSessionId || sessionId || processKey;
|
||||
activeCursorProcesses.delete(finalSessionId);
|
||||
};
|
||||
|
||||
ws.send({
|
||||
type: 'claude-complete',
|
||||
sessionId: finalSessionId,
|
||||
exitCode: code,
|
||||
isNewSession: !sessionId && !!command // Flag to indicate this was a new session
|
||||
});
|
||||
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Cursor CLI exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
|
||||
// Handle process errors
|
||||
cursorProcess.on('error', (error) => {
|
||||
console.error('Cursor CLI process error:', error);
|
||||
|
||||
// Clean up process reference on error
|
||||
const finalSessionId = capturedSessionId || sessionId || processKey;
|
||||
activeCursorProcesses.delete(finalSessionId);
|
||||
// Handle stdout (streaming JSON responses)
|
||||
cursorProcess.stdout.on('data', (data) => {
|
||||
const rawOutput = data.toString();
|
||||
console.log('Cursor CLI stdout:', rawOutput);
|
||||
|
||||
ws.send({
|
||||
type: 'cursor-error',
|
||||
error: error.message,
|
||||
sessionId: capturedSessionId || sessionId || null
|
||||
// Stream chunks can split JSON objects across packets; keep trailing partial line.
|
||||
stdoutLineBuffer += rawOutput;
|
||||
const completeLines = stdoutLineBuffer.split(/\r?\n/);
|
||||
stdoutLineBuffer = completeLines.pop() || '';
|
||||
|
||||
completeLines.forEach((line) => {
|
||||
processCursorOutputLine(line.trim());
|
||||
});
|
||||
});
|
||||
|
||||
reject(error);
|
||||
});
|
||||
|
||||
// Close stdin since Cursor doesn't need interactive input
|
||||
cursorProcess.stdin.end();
|
||||
// Handle stderr
|
||||
cursorProcess.stderr.on('data', (data) => {
|
||||
const stderrText = data.toString();
|
||||
console.error('Cursor CLI stderr:', stderrText);
|
||||
|
||||
if (shouldSuppressForTrustRetry(stderrText)) {
|
||||
return;
|
||||
}
|
||||
|
||||
ws.send({
|
||||
type: 'cursor-error',
|
||||
error: stderrText,
|
||||
sessionId: capturedSessionId || sessionId || null
|
||||
});
|
||||
});
|
||||
|
||||
// Handle process completion
|
||||
cursorProcess.on('close', async (code) => {
|
||||
console.log(`Cursor CLI process exited with code ${code}`);
|
||||
|
||||
const finalSessionId = capturedSessionId || sessionId || processKey;
|
||||
activeCursorProcesses.delete(finalSessionId);
|
||||
|
||||
// Flush any final unterminated stdout line before completion handling.
|
||||
if (stdoutLineBuffer.trim()) {
|
||||
processCursorOutputLine(stdoutLineBuffer.trim());
|
||||
stdoutLineBuffer = '';
|
||||
}
|
||||
|
||||
if (
|
||||
runSawWorkspaceTrustPrompt &&
|
||||
code !== 0 &&
|
||||
!hasRetriedWithTrust &&
|
||||
!args.includes('--trust')
|
||||
) {
|
||||
hasRetriedWithTrust = true;
|
||||
runCursorProcess([...args, '--trust'], 'trust-retry');
|
||||
return;
|
||||
}
|
||||
|
||||
ws.send({
|
||||
type: 'claude-complete',
|
||||
sessionId: finalSessionId,
|
||||
exitCode: code,
|
||||
isNewSession: !sessionId && !!command // Flag to indicate this was a new session
|
||||
});
|
||||
|
||||
if (code === 0) {
|
||||
settleOnce(() => resolve());
|
||||
} else {
|
||||
settleOnce(() => reject(new Error(`Cursor CLI exited with code ${code}`)));
|
||||
}
|
||||
});
|
||||
|
||||
// Handle process errors
|
||||
cursorProcess.on('error', (error) => {
|
||||
console.error('Cursor CLI process error:', error);
|
||||
|
||||
// Clean up process reference on error
|
||||
const finalSessionId = capturedSessionId || sessionId || processKey;
|
||||
activeCursorProcesses.delete(finalSessionId);
|
||||
|
||||
ws.send({
|
||||
type: 'cursor-error',
|
||||
error: error.message,
|
||||
sessionId: capturedSessionId || sessionId || null
|
||||
});
|
||||
|
||||
settleOnce(() => reject(error));
|
||||
});
|
||||
|
||||
// Close stdin since Cursor doesn't need interactive input
|
||||
cursorProcess.stdin.end();
|
||||
};
|
||||
|
||||
runCursorProcess(baseArgs, 'initial');
|
||||
});
|
||||
}
|
||||
|
||||
function abortCursorSession(sessionId) {
|
||||
const process = activeCursorProcesses.get(sessionId);
|
||||
if (process) {
|
||||
console.log(`🛑 Aborting Cursor session: ${sessionId}`);
|
||||
console.log(`Aborting Cursor session: ${sessionId}`);
|
||||
process.kill('SIGTERM');
|
||||
activeCursorProcesses.delete(sessionId);
|
||||
return true;
|
||||
|
||||
@@ -1730,8 +1730,14 @@ function handleShellConnection(ws) {
|
||||
shellCommand = 'cursor-agent';
|
||||
}
|
||||
} else if (provider === 'codex') {
|
||||
// Use codex command; attempt to resume and fall back to a new session when the resume fails.
|
||||
if (hasSession && sessionId) {
|
||||
shellCommand = `codex resume "${sessionId}" || codex`;
|
||||
if (os.platform() === 'win32') {
|
||||
// PowerShell syntax for fallback
|
||||
shellCommand = `codex resume "${sessionId}"; if ($LASTEXITCODE -ne 0) { codex }`;
|
||||
} else {
|
||||
shellCommand = `codex resume "${sessionId}" || codex`;
|
||||
}
|
||||
} else {
|
||||
shellCommand = 'codex';
|
||||
}
|
||||
@@ -1765,7 +1771,11 @@ function handleShellConnection(ws) {
|
||||
// Claude (default provider)
|
||||
const command = initialCommand || 'claude';
|
||||
if (hasSession && sessionId) {
|
||||
shellCommand = `claude --resume "${sessionId}" || claude`;
|
||||
if (os.platform() === 'win32') {
|
||||
shellCommand = `claude --resume "${sessionId}"; if ($LASTEXITCODE -ne 0) { claude }`;
|
||||
} else {
|
||||
shellCommand = `claude --resume "${sessionId}" || claude`;
|
||||
}
|
||||
} else {
|
||||
shellCommand = command;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { queryClaudeSDK } from '../claude-sdk.js';
|
||||
import { spawnCursor } from '../cursor-cli.js';
|
||||
|
||||
const router = express.Router();
|
||||
const COMMIT_DIFF_CHARACTER_LIMIT = 500_000;
|
||||
|
||||
function spawnAsync(command, args, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -107,8 +108,7 @@ async function getActualProjectPath(projectName) {
|
||||
projectPath = await extractProjectDirectory(projectName);
|
||||
} catch (error) {
|
||||
console.error(`Error extracting project directory for ${projectName}:`, error);
|
||||
// Fallback to the old method
|
||||
projectPath = projectName.replace(/-/g, '/');
|
||||
throw new Error(`Unable to resolve project path for "${projectName}"`);
|
||||
}
|
||||
return validateProjectPath(projectPath);
|
||||
}
|
||||
@@ -166,6 +166,127 @@ async function validateGitRepository(projectPath) {
|
||||
}
|
||||
}
|
||||
|
||||
function getGitErrorDetails(error) {
|
||||
return `${error?.message || ''} ${error?.stderr || ''} ${error?.stdout || ''}`;
|
||||
}
|
||||
|
||||
function isMissingHeadRevisionError(error) {
|
||||
const errorDetails = getGitErrorDetails(error).toLowerCase();
|
||||
return errorDetails.includes('unknown revision')
|
||||
|| errorDetails.includes('ambiguous argument')
|
||||
|| errorDetails.includes('needed a single revision')
|
||||
|| errorDetails.includes('bad revision');
|
||||
}
|
||||
|
||||
async function getCurrentBranchName(projectPath) {
|
||||
try {
|
||||
// symbolic-ref works even when the repository has no commits.
|
||||
const { stdout } = await spawnAsync('git', ['symbolic-ref', '--short', 'HEAD'], { cwd: projectPath });
|
||||
const branchName = stdout.trim();
|
||||
if (branchName) {
|
||||
return branchName;
|
||||
}
|
||||
} catch (error) {
|
||||
// Fall back to rev-parse for detached HEAD and older git edge cases.
|
||||
}
|
||||
|
||||
const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath });
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
async function repositoryHasCommits(projectPath) {
|
||||
try {
|
||||
await spawnAsync('git', ['rev-parse', '--verify', 'HEAD'], { cwd: projectPath });
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isMissingHeadRevisionError(error)) {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function getRepositoryRootPath(projectPath) {
|
||||
const { stdout } = await spawnAsync('git', ['rev-parse', '--show-toplevel'], { cwd: projectPath });
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
function normalizeRepositoryRelativeFilePath(filePath) {
|
||||
return String(filePath)
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/^\.\/+/, '')
|
||||
.replace(/^\/+/, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function parseStatusFilePaths(statusOutput) {
|
||||
return statusOutput
|
||||
.split('\n')
|
||||
.map((line) => line.trimEnd())
|
||||
.filter((line) => line.trim())
|
||||
.map((line) => {
|
||||
const statusPath = line.substring(3);
|
||||
const renamedFilePath = statusPath.split(' -> ')[1];
|
||||
return normalizeRepositoryRelativeFilePath(renamedFilePath || statusPath);
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function buildFilePathCandidates(projectPath, repositoryRootPath, filePath) {
|
||||
const normalizedFilePath = normalizeRepositoryRelativeFilePath(filePath);
|
||||
const projectRelativePath = normalizeRepositoryRelativeFilePath(path.relative(repositoryRootPath, projectPath));
|
||||
const candidates = [normalizedFilePath];
|
||||
|
||||
if (
|
||||
projectRelativePath
|
||||
&& projectRelativePath !== '.'
|
||||
&& !normalizedFilePath.startsWith(`${projectRelativePath}/`)
|
||||
) {
|
||||
candidates.push(`${projectRelativePath}/${normalizedFilePath}`);
|
||||
}
|
||||
|
||||
return Array.from(new Set(candidates.filter(Boolean)));
|
||||
}
|
||||
|
||||
async function resolveRepositoryFilePath(projectPath, filePath) {
|
||||
validateFilePath(filePath);
|
||||
|
||||
const repositoryRootPath = await getRepositoryRootPath(projectPath);
|
||||
const candidateFilePaths = buildFilePathCandidates(projectPath, repositoryRootPath, filePath);
|
||||
|
||||
for (const candidateFilePath of candidateFilePaths) {
|
||||
const { stdout } = await spawnAsync('git', ['status', '--porcelain', '--', candidateFilePath], { cwd: repositoryRootPath });
|
||||
if (stdout.trim()) {
|
||||
return {
|
||||
repositoryRootPath,
|
||||
repositoryRelativeFilePath: candidateFilePath,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// If the caller sent a bare filename (e.g. "hello.ts"), recover it from changed files.
|
||||
const normalizedFilePath = normalizeRepositoryRelativeFilePath(filePath);
|
||||
if (!normalizedFilePath.includes('/')) {
|
||||
const { stdout: repositoryStatusOutput } = await spawnAsync('git', ['status', '--porcelain'], { cwd: repositoryRootPath });
|
||||
const changedFilePaths = parseStatusFilePaths(repositoryStatusOutput);
|
||||
const suffixMatches = changedFilePaths.filter(
|
||||
(changedFilePath) => changedFilePath === normalizedFilePath || changedFilePath.endsWith(`/${normalizedFilePath}`),
|
||||
);
|
||||
|
||||
if (suffixMatches.length === 1) {
|
||||
return {
|
||||
repositoryRootPath,
|
||||
repositoryRelativeFilePath: suffixMatches[0],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
repositoryRootPath,
|
||||
repositoryRelativeFilePath: candidateFilePaths[0],
|
||||
};
|
||||
}
|
||||
|
||||
// Get git status for a project
|
||||
router.get('/status', async (req, res) => {
|
||||
const { project } = req.query;
|
||||
@@ -180,21 +301,8 @@ router.get('/status', async (req, res) => {
|
||||
// Validate git repository
|
||||
await validateGitRepository(projectPath);
|
||||
|
||||
// Get current branch - handle case where there are no commits yet
|
||||
let branch = 'main';
|
||||
let hasCommits = true;
|
||||
try {
|
||||
const { stdout: branchOutput } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath });
|
||||
branch = branchOutput.trim();
|
||||
} catch (error) {
|
||||
// No HEAD exists - repository has no commits yet
|
||||
if (error.message.includes('unknown revision') || error.message.includes('ambiguous argument')) {
|
||||
hasCommits = false;
|
||||
branch = 'main';
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const branch = await getCurrentBranchName(projectPath);
|
||||
const hasCommits = await repositoryHasCommits(projectPath);
|
||||
|
||||
// Get git status
|
||||
const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain'], { cwd: projectPath });
|
||||
@@ -255,47 +363,65 @@ router.get('/diff', async (req, res) => {
|
||||
|
||||
// Validate git repository
|
||||
await validateGitRepository(projectPath);
|
||||
|
||||
// Validate file path
|
||||
validateFilePath(file, projectPath);
|
||||
|
||||
const {
|
||||
repositoryRootPath,
|
||||
repositoryRelativeFilePath,
|
||||
} = await resolveRepositoryFilePath(projectPath, file);
|
||||
|
||||
// Check if file is untracked or deleted
|
||||
const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain', file], { cwd: projectPath });
|
||||
const { stdout: statusOutput } = await spawnAsync(
|
||||
'git',
|
||||
['status', '--porcelain', '--', repositoryRelativeFilePath],
|
||||
{ cwd: repositoryRootPath },
|
||||
);
|
||||
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 filePath = path.join(projectPath, file);
|
||||
const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);
|
||||
const stats = await fs.stat(filePath);
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
// For directories, show a simple message
|
||||
diff = `Directory: ${file}\n(Cannot show diff for directories)`;
|
||||
diff = `Directory: ${repositoryRelativeFilePath}\n(Cannot show diff for directories)`;
|
||||
} else {
|
||||
const fileContent = await fs.readFile(filePath, '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/${repositoryRelativeFilePath}\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 spawnAsync('git', ['show', `HEAD:${file}`], { cwd: projectPath });
|
||||
const { stdout: fileContent } = await spawnAsync(
|
||||
'git',
|
||||
['show', `HEAD:${repositoryRelativeFilePath}`],
|
||||
{ cwd: repositoryRootPath },
|
||||
);
|
||||
const lines = fileContent.split('\n');
|
||||
diff = `--- a/${file}\n+++ /dev/null\n@@ -1,${lines.length} +0,0 @@\n` +
|
||||
diff = `--- a/${repositoryRelativeFilePath}\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 spawnAsync('git', ['diff', '--', file], { cwd: projectPath });
|
||||
const { stdout: unstagedDiff } = await spawnAsync(
|
||||
'git',
|
||||
['diff', '--', repositoryRelativeFilePath],
|
||||
{ cwd: repositoryRootPath },
|
||||
);
|
||||
|
||||
if (unstagedDiff) {
|
||||
// Show unstaged changes if they exist
|
||||
diff = stripDiffHeaders(unstagedDiff);
|
||||
} else {
|
||||
// If no unstaged changes, check for staged changes (index vs HEAD)
|
||||
const { stdout: stagedDiff } = await spawnAsync('git', ['diff', '--cached', '--', file], { cwd: projectPath });
|
||||
const { stdout: stagedDiff } = await spawnAsync(
|
||||
'git',
|
||||
['diff', '--cached', '--', repositoryRelativeFilePath],
|
||||
{ cwd: repositoryRootPath },
|
||||
);
|
||||
diff = stripDiffHeaders(stagedDiff) || '';
|
||||
}
|
||||
}
|
||||
@@ -321,11 +447,17 @@ router.get('/file-with-diff', async (req, res) => {
|
||||
// Validate git repository
|
||||
await validateGitRepository(projectPath);
|
||||
|
||||
// Validate file path
|
||||
validateFilePath(file, projectPath);
|
||||
const {
|
||||
repositoryRootPath,
|
||||
repositoryRelativeFilePath,
|
||||
} = await resolveRepositoryFilePath(projectPath, file);
|
||||
|
||||
// Check file status
|
||||
const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain', file], { cwd: projectPath });
|
||||
const { stdout: statusOutput } = await spawnAsync(
|
||||
'git',
|
||||
['status', '--porcelain', '--', repositoryRelativeFilePath],
|
||||
{ cwd: repositoryRootPath },
|
||||
);
|
||||
const isUntracked = statusOutput.startsWith('??');
|
||||
const isDeleted = statusOutput.trim().startsWith('D ') || statusOutput.trim().startsWith(' D');
|
||||
|
||||
@@ -334,12 +466,16 @@ router.get('/file-with-diff', async (req, res) => {
|
||||
|
||||
if (isDeleted) {
|
||||
// For deleted files, get content from HEAD
|
||||
const { stdout: headContent } = await spawnAsync('git', ['show', `HEAD:${file}`], { cwd: projectPath });
|
||||
const { stdout: headContent } = await spawnAsync(
|
||||
'git',
|
||||
['show', `HEAD:${repositoryRelativeFilePath}`],
|
||||
{ cwd: repositoryRootPath },
|
||||
);
|
||||
oldContent = headContent;
|
||||
currentContent = headContent; // Show the deleted content in editor
|
||||
} else {
|
||||
// Get current file content
|
||||
const filePath = path.join(projectPath, file);
|
||||
const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);
|
||||
const stats = await fs.stat(filePath);
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
@@ -352,7 +488,11 @@ router.get('/file-with-diff', async (req, res) => {
|
||||
if (!isUntracked) {
|
||||
// Get the old content from HEAD for tracked files
|
||||
try {
|
||||
const { stdout: headContent } = await spawnAsync('git', ['show', `HEAD:${file}`], { cwd: projectPath });
|
||||
const { stdout: headContent } = await spawnAsync(
|
||||
'git',
|
||||
['show', `HEAD:${repositoryRelativeFilePath}`],
|
||||
{ cwd: repositoryRootPath },
|
||||
);
|
||||
oldContent = headContent;
|
||||
} catch (error) {
|
||||
// File might be newly added to git (staged but not committed)
|
||||
@@ -430,15 +570,16 @@ router.post('/commit', async (req, res) => {
|
||||
|
||||
// Validate git repository
|
||||
await validateGitRepository(projectPath);
|
||||
const repositoryRootPath = await getRepositoryRootPath(projectPath);
|
||||
|
||||
// Stage selected files
|
||||
for (const file of files) {
|
||||
validateFilePath(file, projectPath);
|
||||
await spawnAsync('git', ['add', file], { cwd: projectPath });
|
||||
const { repositoryRelativeFilePath } = await resolveRepositoryFilePath(projectPath, file);
|
||||
await spawnAsync('git', ['add', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath });
|
||||
}
|
||||
|
||||
// Commit with message
|
||||
const { stdout } = await spawnAsync('git', ['commit', '-m', message], { cwd: projectPath });
|
||||
const { stdout } = await spawnAsync('git', ['commit', '-m', message], { cwd: repositoryRootPath });
|
||||
|
||||
res.json({ success: true, output: stdout });
|
||||
} catch (error) {
|
||||
@@ -447,6 +588,53 @@ router.post('/commit', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Revert latest local commit (keeps changes staged)
|
||||
router.post('/revert-local-commit', async (req, res) => {
|
||||
const { project } = req.body;
|
||||
|
||||
if (!project) {
|
||||
return res.status(400).json({ error: 'Project name is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const projectPath = await getActualProjectPath(project);
|
||||
await validateGitRepository(projectPath);
|
||||
|
||||
try {
|
||||
await spawnAsync('git', ['rev-parse', '--verify', 'HEAD'], { cwd: projectPath });
|
||||
} catch (error) {
|
||||
return res.status(400).json({
|
||||
error: 'No local commit to revert',
|
||||
details: 'This repository has no commit yet.',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Soft reset rewinds one commit while preserving all file changes in the index.
|
||||
await spawnAsync('git', ['reset', '--soft', 'HEAD~1'], { cwd: projectPath });
|
||||
} catch (error) {
|
||||
const errorDetails = `${error.stderr || ''} ${error.message || ''}`;
|
||||
const isInitialCommit = errorDetails.includes('HEAD~1') &&
|
||||
(errorDetails.includes('unknown revision') || errorDetails.includes('ambiguous argument'));
|
||||
|
||||
if (!isInitialCommit) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Initial commit has no parent; deleting HEAD uncommits it and keeps files staged.
|
||||
await spawnAsync('git', ['update-ref', '-d', 'HEAD'], { cwd: projectPath });
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
output: 'Latest local commit reverted successfully. Changes were kept staged.',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Git revert local commit error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get list of branches
|
||||
router.get('/branches', async (req, res) => {
|
||||
const { project } = req.query;
|
||||
@@ -609,8 +797,13 @@ router.get('/commit-diff', async (req, res) => {
|
||||
'git', ['show', commit],
|
||||
{ cwd: projectPath }
|
||||
);
|
||||
|
||||
res.json({ diff: stdout });
|
||||
|
||||
const isTruncated = stdout.length > COMMIT_DIFF_CHARACTER_LIMIT;
|
||||
const diff = isTruncated
|
||||
? `${stdout.slice(0, COMMIT_DIFF_CHARACTER_LIMIT)}\n\n... Diff truncated to keep the UI responsive ...`
|
||||
: stdout;
|
||||
|
||||
res.json({ diff, isTruncated });
|
||||
} catch (error) {
|
||||
console.error('Git commit diff error:', error);
|
||||
res.json({ error: error.message });
|
||||
@@ -632,18 +825,20 @@ router.post('/generate-commit-message', async (req, res) => {
|
||||
|
||||
try {
|
||||
const projectPath = await getActualProjectPath(project);
|
||||
await validateGitRepository(projectPath);
|
||||
const repositoryRootPath = await getRepositoryRootPath(projectPath);
|
||||
|
||||
// Get diff for selected files
|
||||
let diffContext = '';
|
||||
for (const file of files) {
|
||||
try {
|
||||
validateFilePath(file, projectPath);
|
||||
const { repositoryRelativeFilePath } = await resolveRepositoryFilePath(projectPath, file);
|
||||
const { stdout } = await spawnAsync(
|
||||
'git', ['diff', 'HEAD', '--', file],
|
||||
{ cwd: projectPath }
|
||||
'git', ['diff', 'HEAD', '--', repositoryRelativeFilePath],
|
||||
{ cwd: repositoryRootPath }
|
||||
);
|
||||
if (stdout) {
|
||||
diffContext += `\n--- ${file} ---\n${stdout}`;
|
||||
diffContext += `\n--- ${repositoryRelativeFilePath} ---\n${stdout}`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error getting diff for ${file}:`, error);
|
||||
@@ -655,14 +850,15 @@ router.post('/generate-commit-message', async (req, res) => {
|
||||
// Try to get content of untracked files
|
||||
for (const file of files) {
|
||||
try {
|
||||
const filePath = path.join(projectPath, file);
|
||||
const { repositoryRelativeFilePath } = await resolveRepositoryFilePath(projectPath, file);
|
||||
const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);
|
||||
const stats = await fs.stat(filePath);
|
||||
|
||||
if (!stats.isDirectory()) {
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
diffContext += `\n--- ${file} (new file) ---\n${content.substring(0, 1000)}\n`;
|
||||
diffContext += `\n--- ${repositoryRelativeFilePath} (new file) ---\n${content.substring(0, 1000)}\n`;
|
||||
} else {
|
||||
diffContext += `\n--- ${file} (new directory) ---\n`;
|
||||
diffContext += `\n--- ${repositoryRelativeFilePath} (new directory) ---\n`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error reading file ${file}:`, error);
|
||||
@@ -831,9 +1027,30 @@ router.get('/remote-status', async (req, res) => {
|
||||
const projectPath = await getActualProjectPath(project);
|
||||
await validateGitRepository(projectPath);
|
||||
|
||||
// Get current branch
|
||||
const { stdout: currentBranch } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath });
|
||||
const branch = currentBranch.trim();
|
||||
const branch = await getCurrentBranchName(projectPath);
|
||||
const hasCommits = await repositoryHasCommits(projectPath);
|
||||
|
||||
const { stdout: remoteOutput } = await spawnAsync('git', ['remote'], { cwd: projectPath });
|
||||
const remotes = remoteOutput.trim().split('\n').filter(r => r.trim());
|
||||
const hasRemote = remotes.length > 0;
|
||||
const fallbackRemoteName = hasRemote
|
||||
? (remotes.includes('origin') ? 'origin' : remotes[0])
|
||||
: null;
|
||||
|
||||
// Repositories initialized with `git init` can have a branch but no commits.
|
||||
// Return a non-error state so the UI can show the initial-commit workflow.
|
||||
if (!hasCommits) {
|
||||
return res.json({
|
||||
hasRemote,
|
||||
hasUpstream: false,
|
||||
branch,
|
||||
remoteName: fallbackRemoteName,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
isUpToDate: false,
|
||||
message: 'Repository has no commits yet'
|
||||
});
|
||||
}
|
||||
|
||||
// Check if there's a remote tracking branch (smart detection)
|
||||
let trackingBranch;
|
||||
@@ -843,25 +1060,11 @@ router.get('/remote-status', async (req, res) => {
|
||||
trackingBranch = stdout.trim();
|
||||
remoteName = trackingBranch.split('/')[0]; // Extract remote name (e.g., "origin/main" -> "origin")
|
||||
} catch (error) {
|
||||
// No upstream branch configured - but check if we have remotes
|
||||
let hasRemote = false;
|
||||
let remoteName = null;
|
||||
try {
|
||||
const { stdout } = await spawnAsync('git', ['remote'], { cwd: projectPath });
|
||||
const remotes = stdout.trim().split('\n').filter(r => r.trim());
|
||||
if (remotes.length > 0) {
|
||||
hasRemote = true;
|
||||
remoteName = remotes.includes('origin') ? 'origin' : remotes[0];
|
||||
}
|
||||
} catch (remoteError) {
|
||||
// No remotes configured
|
||||
}
|
||||
|
||||
return res.json({
|
||||
hasRemote,
|
||||
hasUpstream: false,
|
||||
branch,
|
||||
remoteName,
|
||||
remoteName: fallbackRemoteName,
|
||||
message: 'No remote tracking branch configured'
|
||||
});
|
||||
}
|
||||
@@ -903,8 +1106,7 @@ router.post('/fetch', async (req, res) => {
|
||||
await validateGitRepository(projectPath);
|
||||
|
||||
// Get current branch and its upstream remote
|
||||
const { stdout: currentBranch } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath });
|
||||
const branch = currentBranch.trim();
|
||||
const branch = await getCurrentBranchName(projectPath);
|
||||
|
||||
let remoteName = 'origin'; // fallback
|
||||
try {
|
||||
@@ -945,8 +1147,7 @@ router.post('/pull', async (req, res) => {
|
||||
await validateGitRepository(projectPath);
|
||||
|
||||
// Get current branch and its upstream remote
|
||||
const { stdout: currentBranch } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath });
|
||||
const branch = currentBranch.trim();
|
||||
const branch = await getCurrentBranchName(projectPath);
|
||||
|
||||
let remoteName = 'origin'; // fallback
|
||||
let remoteBranch = branch; // fallback
|
||||
@@ -1014,8 +1215,7 @@ router.post('/push', async (req, res) => {
|
||||
await validateGitRepository(projectPath);
|
||||
|
||||
// Get current branch and its upstream remote
|
||||
const { stdout: currentBranch } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath });
|
||||
const branch = currentBranch.trim();
|
||||
const branch = await getCurrentBranchName(projectPath);
|
||||
|
||||
let remoteName = 'origin'; // fallback
|
||||
let remoteBranch = branch; // fallback
|
||||
@@ -1089,8 +1289,7 @@ router.post('/publish', async (req, res) => {
|
||||
validateBranchName(branch);
|
||||
|
||||
// Get current branch to verify it matches the requested branch
|
||||
const { stdout: currentBranch } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath });
|
||||
const currentBranchName = currentBranch.trim();
|
||||
const currentBranchName = await getCurrentBranchName(projectPath);
|
||||
|
||||
if (currentBranchName !== branch) {
|
||||
return res.status(400).json({
|
||||
@@ -1164,12 +1363,17 @@ router.post('/discard', async (req, res) => {
|
||||
try {
|
||||
const projectPath = await getActualProjectPath(project);
|
||||
await validateGitRepository(projectPath);
|
||||
|
||||
// Validate file path
|
||||
validateFilePath(file, projectPath);
|
||||
const {
|
||||
repositoryRootPath,
|
||||
repositoryRelativeFilePath,
|
||||
} = await resolveRepositoryFilePath(projectPath, file);
|
||||
|
||||
// Check file status to determine correct discard command
|
||||
const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain', file], { cwd: projectPath });
|
||||
const { stdout: statusOutput } = await spawnAsync(
|
||||
'git',
|
||||
['status', '--porcelain', '--', repositoryRelativeFilePath],
|
||||
{ cwd: repositoryRootPath },
|
||||
);
|
||||
|
||||
if (!statusOutput.trim()) {
|
||||
return res.status(400).json({ error: 'No changes to discard for this file' });
|
||||
@@ -1179,7 +1383,7 @@ router.post('/discard', async (req, res) => {
|
||||
|
||||
if (status === '??') {
|
||||
// Untracked file or directory - delete it
|
||||
const filePath = path.join(projectPath, file);
|
||||
const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);
|
||||
const stats = await fs.stat(filePath);
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
@@ -1189,13 +1393,13 @@ router.post('/discard', async (req, res) => {
|
||||
}
|
||||
} else if (status.includes('M') || status.includes('D')) {
|
||||
// Modified or deleted file - restore from HEAD
|
||||
await spawnAsync('git', ['restore', file], { cwd: projectPath });
|
||||
await spawnAsync('git', ['restore', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath });
|
||||
} else if (status.includes('A')) {
|
||||
// Added file - unstage it
|
||||
await spawnAsync('git', ['reset', 'HEAD', file], { cwd: projectPath });
|
||||
await spawnAsync('git', ['reset', 'HEAD', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath });
|
||||
}
|
||||
|
||||
res.json({ success: true, message: `Changes discarded for ${file}` });
|
||||
res.json({ success: true, message: `Changes discarded for ${repositoryRelativeFilePath}` });
|
||||
} catch (error) {
|
||||
console.error('Git discard error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
@@ -1213,12 +1417,17 @@ router.post('/delete-untracked', async (req, res) => {
|
||||
try {
|
||||
const projectPath = await getActualProjectPath(project);
|
||||
await validateGitRepository(projectPath);
|
||||
|
||||
// Validate file path
|
||||
validateFilePath(file, projectPath);
|
||||
const {
|
||||
repositoryRootPath,
|
||||
repositoryRelativeFilePath,
|
||||
} = await resolveRepositoryFilePath(projectPath, file);
|
||||
|
||||
// Check if file is actually untracked
|
||||
const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain', file], { cwd: projectPath });
|
||||
const { stdout: statusOutput } = await spawnAsync(
|
||||
'git',
|
||||
['status', '--porcelain', '--', repositoryRelativeFilePath],
|
||||
{ cwd: repositoryRootPath },
|
||||
);
|
||||
|
||||
if (!statusOutput.trim()) {
|
||||
return res.status(400).json({ error: 'File is not untracked or does not exist' });
|
||||
@@ -1231,16 +1440,16 @@ router.post('/delete-untracked', async (req, res) => {
|
||||
}
|
||||
|
||||
// Delete the untracked file or directory
|
||||
const filePath = path.join(projectPath, file);
|
||||
const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);
|
||||
const stats = await fs.stat(filePath);
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
// Use rm with recursive option for directories
|
||||
await fs.rm(filePath, { recursive: true, force: true });
|
||||
res.json({ success: true, message: `Untracked directory ${file} deleted successfully` });
|
||||
res.json({ success: true, message: `Untracked directory ${repositoryRelativeFilePath} deleted successfully` });
|
||||
} else {
|
||||
await fs.unlink(filePath);
|
||||
res.json({ success: true, message: `Untracked file ${file} deleted successfully` });
|
||||
res.json({ success: true, message: `Untracked file ${repositoryRelativeFilePath} deleted successfully` });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Git delete untracked error:', error);
|
||||
|
||||
@@ -40,7 +40,7 @@ export default function AppContent() {
|
||||
setIsInputFocused,
|
||||
setShowSettings,
|
||||
openSettings,
|
||||
fetchProjects,
|
||||
refreshProjectsSilently,
|
||||
sidebarSharedProps,
|
||||
} = useProjectsState({
|
||||
sessionId,
|
||||
@@ -51,14 +51,16 @@ export default function AppContent() {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
window.refreshProjects = fetchProjects;
|
||||
// Expose a non-blocking refresh for chat/session flows.
|
||||
// Full loading refreshes are still available through direct fetchProjects calls.
|
||||
window.refreshProjects = refreshProjectsSilently;
|
||||
|
||||
return () => {
|
||||
if (window.refreshProjects === fetchProjects) {
|
||||
if (window.refreshProjects === refreshProjectsSilently) {
|
||||
delete window.refreshProjects;
|
||||
}
|
||||
};
|
||||
}, [fetchProjects]);
|
||||
}, [refreshProjectsSilently]);
|
||||
|
||||
useEffect(() => {
|
||||
window.openSettings = openSettings;
|
||||
|
||||
@@ -692,14 +692,28 @@ export function useChatRealtimeHandlers({
|
||||
const updated = [...previous];
|
||||
const lastIndex = updated.length - 1;
|
||||
const last = updated[lastIndex];
|
||||
const normalizedTextResult = textResult.trim();
|
||||
|
||||
if (last && last.type === 'assistant' && !last.isToolUse && last.isStreaming) {
|
||||
const finalContent =
|
||||
textResult && textResult.trim()
|
||||
normalizedTextResult
|
||||
? textResult
|
||||
: `${last.content || ''}${pendingChunk || ''}`;
|
||||
// Clone the message instead of mutating in place so React can reliably detect state updates.
|
||||
updated[lastIndex] = { ...last, content: finalContent, isStreaming: false };
|
||||
} else if (textResult && textResult.trim()) {
|
||||
} else if (normalizedTextResult) {
|
||||
const lastAssistantText =
|
||||
last && last.type === 'assistant' && !last.isToolUse
|
||||
? String(last.content || '').trim()
|
||||
: '';
|
||||
|
||||
// Cursor can emit the same final text through both streaming and result payloads.
|
||||
// Skip adding a second assistant bubble when the final text is unchanged.
|
||||
const isDuplicateFinalText = lastAssistantText === normalizedTextResult;
|
||||
if (isDuplicateFinalText) {
|
||||
return updated;
|
||||
}
|
||||
|
||||
updated.push({
|
||||
type: resultData.is_error ? 'error' : 'assistant',
|
||||
content: textResult,
|
||||
|
||||
@@ -34,6 +34,48 @@ const normalizeToolInput = (value: unknown): string => {
|
||||
}
|
||||
};
|
||||
|
||||
const CURSOR_INTERNAL_USER_BLOCK_PATTERNS = [
|
||||
/<user_info>[\s\S]*?<\/user_info>/gi,
|
||||
/<agent_skills>[\s\S]*?<\/agent_skills>/gi,
|
||||
/<available_skills>[\s\S]*?<\/available_skills>/gi,
|
||||
/<environment_context>[\s\S]*?<\/environment_context>/gi,
|
||||
/<environment_info>[\s\S]*?<\/environment_info>/gi,
|
||||
];
|
||||
|
||||
const extractCursorUserQuery = (rawText: string): string => {
|
||||
const userQueryMatches = [...rawText.matchAll(/<user_query>([\s\S]*?)<\/user_query>/gi)];
|
||||
if (userQueryMatches.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return userQueryMatches
|
||||
.map((match) => (match[1] || '').trim())
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
.trim();
|
||||
};
|
||||
|
||||
const sanitizeCursorUserMessageText = (rawText: string): string => {
|
||||
const decodedText = decodeHtmlEntities(rawText || '').trim();
|
||||
if (!decodedText) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Cursor stores user-visible text inside <user_query> and prepends hidden context blocks
|
||||
// (<user_info>, <agent_skills>, etc). We only render the actual query in chat history.
|
||||
const extractedUserQuery = extractCursorUserQuery(decodedText);
|
||||
if (extractedUserQuery) {
|
||||
return extractedUserQuery;
|
||||
}
|
||||
|
||||
let sanitizedText = decodedText;
|
||||
CURSOR_INTERNAL_USER_BLOCK_PATTERNS.forEach((pattern) => {
|
||||
sanitizedText = sanitizedText.replace(pattern, '');
|
||||
});
|
||||
|
||||
return sanitizedText.trim();
|
||||
};
|
||||
|
||||
const toAbsolutePath = (projectPath: string, filePath?: string) => {
|
||||
if (!filePath) {
|
||||
return filePath;
|
||||
@@ -321,6 +363,10 @@ export const convertCursorSessionMessages = (blobs: CursorBlob[], projectPath: s
|
||||
console.log('Error parsing blob content:', error);
|
||||
}
|
||||
|
||||
if (role === 'user') {
|
||||
text = sanitizeCursorUserMessageText(text);
|
||||
}
|
||||
|
||||
if (text && text.trim()) {
|
||||
const message: ChatMessage = {
|
||||
type: role,
|
||||
|
||||
@@ -31,6 +31,7 @@ export const CONFIRMATION_TITLES: Record<ConfirmActionType, string> = {
|
||||
pull: 'Confirm Pull',
|
||||
push: 'Confirm Push',
|
||||
publish: 'Publish Branch',
|
||||
revertLocalCommit: 'Revert Local Commit',
|
||||
};
|
||||
|
||||
export const CONFIRMATION_ACTION_LABELS: Record<ConfirmActionType, string> = {
|
||||
@@ -40,6 +41,7 @@ export const CONFIRMATION_ACTION_LABELS: Record<ConfirmActionType, string> = {
|
||||
pull: 'Pull',
|
||||
push: 'Push',
|
||||
publish: 'Publish',
|
||||
revertLocalCommit: 'Revert Commit',
|
||||
};
|
||||
|
||||
export const CONFIRMATION_BUTTON_CLASSES: Record<ConfirmActionType, string> = {
|
||||
@@ -49,6 +51,7 @@ export const CONFIRMATION_BUTTON_CLASSES: Record<ConfirmActionType, string> = {
|
||||
pull: 'bg-green-600 hover:bg-green-700',
|
||||
push: 'bg-orange-600 hover:bg-orange-700',
|
||||
publish: 'bg-purple-600 hover:bg-purple-700',
|
||||
revertLocalCommit: 'bg-yellow-600 hover:bg-yellow-700',
|
||||
};
|
||||
|
||||
export const CONFIRMATION_ICON_CONTAINER_CLASSES: Record<ConfirmActionType, string> = {
|
||||
@@ -58,6 +61,7 @@ export const CONFIRMATION_ICON_CONTAINER_CLASSES: Record<ConfirmActionType, stri
|
||||
pull: 'bg-yellow-100 dark:bg-yellow-900/30',
|
||||
push: 'bg-yellow-100 dark:bg-yellow-900/30',
|
||||
publish: 'bg-yellow-100 dark:bg-yellow-900/30',
|
||||
revertLocalCommit: 'bg-yellow-100 dark:bg-yellow-900/30',
|
||||
};
|
||||
|
||||
export const CONFIRMATION_ICON_CLASSES: Record<ConfirmActionType, string> = {
|
||||
@@ -67,4 +71,5 @@ export const CONFIRMATION_ICON_CLASSES: Record<ConfirmActionType, string> = {
|
||||
pull: 'text-yellow-600 dark:text-yellow-400',
|
||||
push: 'text-yellow-600 dark:text-yellow-400',
|
||||
publish: 'text-yellow-600 dark:text-yellow-400',
|
||||
revertLocalCommit: 'text-yellow-600 dark:text-yellow-400',
|
||||
};
|
||||
|
||||
48
src/components/git-panel/hooks/useRevertLocalCommit.ts
Normal file
48
src/components/git-panel/hooks/useRevertLocalCommit.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { authenticatedFetch } from '../../../utils/api';
|
||||
import type { GitOperationResponse } from '../types/types';
|
||||
|
||||
type UseRevertLocalCommitOptions = {
|
||||
projectName: string | null;
|
||||
onSuccess?: () => void;
|
||||
};
|
||||
|
||||
async function readJson<T>(response: Response): Promise<T> {
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
export function useRevertLocalCommit({ projectName, onSuccess }: UseRevertLocalCommitOptions) {
|
||||
const [isRevertingLocalCommit, setIsRevertingLocalCommit] = useState(false);
|
||||
|
||||
const revertLatestLocalCommit = useCallback(async () => {
|
||||
if (!projectName) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRevertingLocalCommit(true);
|
||||
try {
|
||||
const response = await authenticatedFetch('/api/git/revert-local-commit', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ project: projectName }),
|
||||
});
|
||||
const data = await readJson<GitOperationResponse>(response);
|
||||
|
||||
if (!data.success) {
|
||||
console.error('Revert local commit failed:', data.error || data.details || 'Unknown error');
|
||||
return;
|
||||
}
|
||||
|
||||
onSuccess?.();
|
||||
} catch (error) {
|
||||
console.error('Error reverting local commit:', error);
|
||||
} finally {
|
||||
setIsRevertingLocalCommit(false);
|
||||
}
|
||||
}, [onSuccess, projectName]);
|
||||
|
||||
return {
|
||||
isRevertingLocalCommit,
|
||||
revertLatestLocalCommit,
|
||||
};
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import type { Project } from '../../../types/app';
|
||||
export type GitPanelView = 'changes' | 'history';
|
||||
export type FileStatusCode = 'M' | 'A' | 'D' | 'U';
|
||||
export type GitStatusFileGroup = 'modified' | 'added' | 'deleted' | 'untracked';
|
||||
export type ConfirmActionType = 'discard' | 'delete' | 'commit' | 'pull' | 'push' | 'publish';
|
||||
export type ConfirmActionType = 'discard' | 'delete' | 'commit' | 'pull' | 'push' | 'publish' | 'revertLocalCommit';
|
||||
|
||||
export type FileDiffInfo = {
|
||||
old_string: string;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useGitPanelController } from '../hooks/useGitPanelController';
|
||||
import { useRevertLocalCommit } from '../hooks/useRevertLocalCommit';
|
||||
import type { ConfirmationRequest, GitPanelProps, GitPanelView } from '../types/types';
|
||||
import ChangesView from '../view/changes/ChangesView';
|
||||
import HistoryView from '../view/history/HistoryView';
|
||||
@@ -49,6 +50,11 @@ export default function GitPanel({ selectedProject, isMobile = false, onFileOpen
|
||||
onFileOpen,
|
||||
});
|
||||
|
||||
const { isRevertingLocalCommit, revertLatestLocalCommit } = useRevertLocalCommit({
|
||||
projectName: selectedProject?.name ?? null,
|
||||
onSuccess: refreshAll,
|
||||
});
|
||||
|
||||
const executeConfirmedAction = useCallback(async () => {
|
||||
if (!confirmAction) {
|
||||
return;
|
||||
@@ -85,7 +91,9 @@ export default function GitPanel({ selectedProject, isMobile = false, onFileOpen
|
||||
isPulling={isPulling}
|
||||
isPushing={isPushing}
|
||||
isPublishing={isPublishing}
|
||||
isRevertingLocalCommit={isRevertingLocalCommit}
|
||||
onRefresh={refreshAll}
|
||||
onRevertLocalCommit={revertLatestLocalCommit}
|
||||
onSwitchBranch={switchBranch}
|
||||
onCreateBranch={createBranch}
|
||||
onFetch={handleFetch}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Check, ChevronDown, Download, GitBranch, Plus, RefreshCw, Upload } from 'lucide-react';
|
||||
import { Check, ChevronDown, Download, GitBranch, Plus, RefreshCw, RotateCcw, Upload } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { ConfirmationRequest, GitRemoteStatus } from '../types/types';
|
||||
import NewBranchModal from './modals/NewBranchModal';
|
||||
@@ -14,7 +14,9 @@ type GitPanelHeaderProps = {
|
||||
isPulling: boolean;
|
||||
isPushing: boolean;
|
||||
isPublishing: boolean;
|
||||
isRevertingLocalCommit: boolean;
|
||||
onRefresh: () => void;
|
||||
onRevertLocalCommit: () => Promise<void>;
|
||||
onSwitchBranch: (branchName: string) => Promise<boolean>;
|
||||
onCreateBranch: (branchName: string) => Promise<boolean>;
|
||||
onFetch: () => Promise<void>;
|
||||
@@ -35,7 +37,9 @@ export default function GitPanelHeader({
|
||||
isPulling,
|
||||
isPushing,
|
||||
isPublishing,
|
||||
isRevertingLocalCommit,
|
||||
onRefresh,
|
||||
onRevertLocalCommit,
|
||||
onSwitchBranch,
|
||||
onCreateBranch,
|
||||
onFetch,
|
||||
@@ -88,6 +92,14 @@ export default function GitPanelHeader({
|
||||
});
|
||||
};
|
||||
|
||||
const requestRevertLocalCommitConfirmation = () => {
|
||||
onRequestConfirmation({
|
||||
type: 'revertLocalCommit',
|
||||
message: 'Revert the latest local commit? This removes the commit but keeps its changes staged.',
|
||||
onConfirm: onRevertLocalCommit,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSwitchBranch = async (branchName: string) => {
|
||||
try {
|
||||
const success = await onSwitchBranch(branchName);
|
||||
@@ -240,6 +252,17 @@ export default function GitPanelHeader({
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={requestRevertLocalCommitConfirmation}
|
||||
disabled={isRevertingLocalCommit}
|
||||
className={`rounded-lg transition-colors hover:bg-accent disabled:opacity-50 ${isMobile ? 'p-1' : 'p-1.5'}`}
|
||||
title="Revert latest local commit"
|
||||
>
|
||||
<RotateCcw
|
||||
className={`text-muted-foreground ${isRevertingLocalCommit ? 'animate-pulse' : ''} ${isMobile ? 'h-3 w-3' : 'h-4 w-4'}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
disabled={isLoading}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Check, Download, Trash2, Upload } from 'lucide-react';
|
||||
import { Check, Download, RotateCcw, Trash2, Upload } from 'lucide-react';
|
||||
import {
|
||||
CONFIRMATION_ACTION_LABELS,
|
||||
CONFIRMATION_BUTTON_CLASSES,
|
||||
@@ -27,6 +27,10 @@ function renderConfirmActionIcon(actionType: ConfirmationRequest['type']) {
|
||||
return <Download className="h-4 w-4" />;
|
||||
}
|
||||
|
||||
if (actionType === 'revertLocalCommit') {
|
||||
return <RotateCcw className="h-4 w-4" />;
|
||||
}
|
||||
|
||||
return <Upload className="h-4 w-4" />;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,38 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
type GitDiffViewerProps = {
|
||||
diff: string | null;
|
||||
isMobile: boolean;
|
||||
wrapText: boolean;
|
||||
};
|
||||
|
||||
const PREVIEW_CHARACTER_LIMIT = 200_000;
|
||||
const PREVIEW_LINE_LIMIT = 1_500;
|
||||
|
||||
type DiffPreview = {
|
||||
lines: string[];
|
||||
isCharacterTruncated: boolean;
|
||||
isLineTruncated: boolean;
|
||||
};
|
||||
|
||||
function buildDiffPreview(diff: string): DiffPreview {
|
||||
const isCharacterTruncated = diff.length > PREVIEW_CHARACTER_LIMIT;
|
||||
const previewText = isCharacterTruncated ? diff.slice(0, PREVIEW_CHARACTER_LIMIT) : diff;
|
||||
const previewLines = previewText.split('\n');
|
||||
const isLineTruncated = previewLines.length > PREVIEW_LINE_LIMIT;
|
||||
|
||||
return {
|
||||
lines: isLineTruncated ? previewLines.slice(0, PREVIEW_LINE_LIMIT) : previewLines,
|
||||
isCharacterTruncated,
|
||||
isLineTruncated,
|
||||
};
|
||||
}
|
||||
|
||||
export default function GitDiffViewer({ diff, isMobile, wrapText }: GitDiffViewerProps) {
|
||||
// Render a bounded preview to keep huge commit diffs from freezing the UI thread.
|
||||
const preview = useMemo(() => buildDiffPreview(diff || ''), [diff]);
|
||||
const isPreviewTruncated = preview.isCharacterTruncated || preview.isLineTruncated;
|
||||
|
||||
if (!diff) {
|
||||
return (
|
||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||
@@ -35,7 +63,12 @@ export default function GitDiffViewer({ diff, isMobile, wrapText }: GitDiffViewe
|
||||
|
||||
return (
|
||||
<div className="diff-viewer">
|
||||
{diff.split('\n').map((line, index) => renderDiffLine(line, index))}
|
||||
{isPreviewTruncated && (
|
||||
<div className="mb-2 rounded-md border border-border bg-card px-3 py-2 text-xs text-muted-foreground">
|
||||
Large diff preview: rendering is limited to keep the tab responsive.
|
||||
</div>
|
||||
)}
|
||||
{preview.lines.map((line, index) => renderDiffLine(line, index))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,7 +146,12 @@ function MainContent({
|
||||
|
||||
{activeTab === 'shell' && (
|
||||
<div className="h-full w-full overflow-hidden">
|
||||
<StandaloneShell project={selectedProject} session={selectedSession} showHeader={false} />
|
||||
<StandaloneShell
|
||||
project={selectedProject}
|
||||
session={selectedSession}
|
||||
showHeader={false}
|
||||
isActive={activeTab === 'shell'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
TERMINAL_OPTIONS,
|
||||
TERMINAL_RESIZE_DELAY_MS,
|
||||
} from '../constants/constants';
|
||||
import { copyTextToClipboard } from '../../../utils/clipboard';
|
||||
import { isCodexLoginCommand } from '../utils/auth';
|
||||
import { sendSocketMessage } from '../utils/socket';
|
||||
import { ensureXtermFocusStyles } from '../utils/terminalStyles';
|
||||
@@ -103,6 +104,37 @@ export function useShellTerminal({
|
||||
|
||||
nextTerminal.open(terminalContainerRef.current);
|
||||
|
||||
const copyTerminalSelection = async () => {
|
||||
const selection = nextTerminal.getSelection();
|
||||
if (!selection) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return copyTextToClipboard(selection);
|
||||
};
|
||||
|
||||
const handleTerminalCopy = (event: ClipboardEvent) => {
|
||||
if (!nextTerminal.hasSelection()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selection = nextTerminal.getSelection();
|
||||
if (!selection) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
if (event.clipboardData) {
|
||||
event.clipboardData.setData('text/plain', selection);
|
||||
return;
|
||||
}
|
||||
|
||||
void copyTextToClipboard(selection);
|
||||
};
|
||||
|
||||
terminalContainerRef.current.addEventListener('copy', handleTerminalCopy);
|
||||
|
||||
nextTerminal.attachCustomKeyEventHandler((event) => {
|
||||
const activeAuthUrl = isCodexLoginCommand(initialCommandRef.current)
|
||||
? CODEX_DEVICE_AUTH_URL
|
||||
@@ -132,7 +164,7 @@ export function useShellTerminal({
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
document.execCommand('copy');
|
||||
void copyTerminalSelection();
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -211,6 +243,7 @@ export function useShellTerminal({
|
||||
resizeObserver.observe(terminalContainerRef.current);
|
||||
|
||||
return () => {
|
||||
terminalContainerRef.current?.removeEventListener('copy', handleTerminalCopy);
|
||||
resizeObserver.disconnect();
|
||||
if (resizeTimeoutRef.current !== null) {
|
||||
window.clearTimeout(resizeTimeoutRef.current);
|
||||
|
||||
@@ -40,7 +40,7 @@ export default function Shell({
|
||||
onProcessComplete = null,
|
||||
minimal = false,
|
||||
autoConnect = false,
|
||||
isActive,
|
||||
isActive = true,
|
||||
}: ShellProps) {
|
||||
const { t } = useTranslation('chat');
|
||||
const [isRestarting, setIsRestarting] = useState(false);
|
||||
@@ -48,9 +48,6 @@ export default function Shell({
|
||||
const promptCheckTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const onOutputRef = useRef<(() => void) | null>(null);
|
||||
|
||||
// Keep the public API stable for existing callers that still pass `isActive`.
|
||||
void isActive;
|
||||
|
||||
const {
|
||||
terminalContainerRef,
|
||||
terminalRef,
|
||||
@@ -157,6 +154,24 @@ export default function Shell({
|
||||
}
|
||||
}, [isConnected]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive || !isInitialized || !isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
const focusTerminal = () => {
|
||||
terminalRef.current?.focus();
|
||||
};
|
||||
|
||||
const animationFrameId = window.requestAnimationFrame(focusTerminal);
|
||||
const timeoutId = window.setTimeout(focusTerminal, 0);
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(animationFrameId);
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [isActive, isConnected, isInitialized, terminalRef]);
|
||||
|
||||
const sendInput = useCallback(
|
||||
(data: string) => {
|
||||
sendSocketMessage(wsRef.current, { type: 'input', data });
|
||||
|
||||
@@ -9,6 +9,7 @@ type StandaloneShellProps = {
|
||||
session?: ProjectSession | null;
|
||||
command?: string | null;
|
||||
isPlainShell?: boolean | null;
|
||||
isActive?: boolean;
|
||||
autoConnect?: boolean;
|
||||
onComplete?: ((exitCode: number) => void) | null;
|
||||
onClose?: (() => void) | null;
|
||||
@@ -24,6 +25,7 @@ export default function StandaloneShell({
|
||||
session = null,
|
||||
command = null,
|
||||
isPlainShell = null,
|
||||
isActive = true,
|
||||
autoConnect = true,
|
||||
onComplete = null,
|
||||
onClose = null,
|
||||
@@ -64,6 +66,7 @@ export default function StandaloneShell({
|
||||
selectedSession={session}
|
||||
initialCommand={command}
|
||||
isPlainShell={shouldUsePlainShell}
|
||||
isActive={isActive}
|
||||
onProcessComplete={handleProcessComplete}
|
||||
minimal={minimal}
|
||||
autoConnect={minimal ? true : autoConnect}
|
||||
|
||||
@@ -18,6 +18,10 @@ type UseProjectsStateArgs = {
|
||||
activeSessions: Set<string>;
|
||||
};
|
||||
|
||||
type FetchProjectsOptions = {
|
||||
showLoadingState?: boolean;
|
||||
};
|
||||
|
||||
const serialize = (value: unknown) => JSON.stringify(value ?? null);
|
||||
|
||||
const projectsHaveChanges = (
|
||||
@@ -152,9 +156,11 @@ export function useProjectsState({
|
||||
|
||||
const loadingProgressTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const fetchProjects = useCallback(async () => {
|
||||
const fetchProjects = useCallback(async ({ showLoadingState = true }: FetchProjectsOptions = {}) => {
|
||||
try {
|
||||
setIsLoadingProjects(true);
|
||||
if (showLoadingState) {
|
||||
setIsLoadingProjects(true);
|
||||
}
|
||||
const response = await api.projects();
|
||||
const projectData = (await response.json()) as Project[];
|
||||
|
||||
@@ -170,10 +176,17 @@ export function useProjectsState({
|
||||
} catch (error) {
|
||||
console.error('Error fetching projects:', error);
|
||||
} finally {
|
||||
setIsLoadingProjects(false);
|
||||
if (showLoadingState) {
|
||||
setIsLoadingProjects(false);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshProjectsSilently = useCallback(async () => {
|
||||
// Keep chat view stable while still syncing sidebar/session metadata in background.
|
||||
await fetchProjects({ showLoadingState: false });
|
||||
}, [fetchProjects]);
|
||||
|
||||
const openSettings = useCallback((tab = 'tools') => {
|
||||
setSettingsInitialTab(tab);
|
||||
setShowSettings(true);
|
||||
@@ -547,6 +560,7 @@ export function useProjectsState({
|
||||
setShowSettings,
|
||||
openSettings,
|
||||
fetchProjects,
|
||||
refreshProjectsSilently,
|
||||
sidebarSharedProps,
|
||||
handleProjectSelect,
|
||||
handleSessionSelect,
|
||||
|
||||
Reference in New Issue
Block a user