mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-03-12 01:17:48 +00:00
Compare commits
8 Commits
fix/numero
...
fix/networ
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1995a411d2 | ||
|
|
468ab599be | ||
|
|
f3b25bbbab | ||
|
|
0049ff51ee | ||
|
|
6fa552f157 | ||
|
|
621853cbfb | ||
|
|
4d8fb6e30a | ||
|
|
a77f213dd5 |
@@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
# Backend server port (Express API + WebSocket server)
|
# Backend server port (Express API + WebSocket server)
|
||||||
#API server
|
#API server
|
||||||
PORT=3001
|
SERVER_PORT=3001
|
||||||
#Frontend port
|
#Frontend port
|
||||||
VITE_PORT=5173
|
VITE_PORT=5173
|
||||||
|
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -135,3 +135,6 @@ tasks/
|
|||||||
!src/i18n/locales/en/tasks.json
|
!src/i18n/locales/en/tasks.json
|
||||||
!src/i18n/locales/ja/tasks.json
|
!src/i18n/locales/ja/tasks.json
|
||||||
!src/i18n/locales/ru/tasks.json
|
!src/i18n/locales/ru/tasks.json
|
||||||
|
|
||||||
|
# Git worktrees
|
||||||
|
.worktrees/
|
||||||
@@ -70,7 +70,7 @@
|
|||||||
npx @siteboon/claude-code-ui
|
npx @siteboon/claude-code-ui
|
||||||
```
|
```
|
||||||
|
|
||||||
サーバーが起動し、`http://localhost:3001`(または設定した PORT)でアクセスできます。
|
サーバーが起動し、`http://localhost:3001`(または設定した SERVER_PORT)でアクセスできます。
|
||||||
|
|
||||||
**再起動**: サーバーを停止した後、同じ `npx` コマンドを再度実行するだけです
|
**再起動**: サーバーを停止した後、同じ `npx` コマンドを再度実行するだけです
|
||||||
### グローバルインストール(定期的に使用する場合)
|
### グローバルインストール(定期的に使用する場合)
|
||||||
|
|||||||
@@ -70,7 +70,7 @@
|
|||||||
npx @siteboon/claude-code-ui
|
npx @siteboon/claude-code-ui
|
||||||
```
|
```
|
||||||
|
|
||||||
서버가 시작되면 `http://localhost:3001` (또는 설정한 PORT)에서 접근할 수 있습니다.
|
서버가 시작되면 `http://localhost:3001` (또는 설정한 SERVER_PORT)에서 접근할 수 있습니다.
|
||||||
|
|
||||||
**재시작**: 서버를 중지한 후 동일한 `npx` 명령을 다시 실행하면 됩니다
|
**재시작**: 서버를 중지한 후 동일한 `npx` 명령을 다시 실행하면 됩니다
|
||||||
### 전역 설치 (정기적 사용 시)
|
### 전역 설치 (정기적 사용 시)
|
||||||
|
|||||||
@@ -70,7 +70,7 @@
|
|||||||
npx @siteboon/claude-code-ui
|
npx @siteboon/claude-code-ui
|
||||||
```
|
```
|
||||||
|
|
||||||
服务器将启动并可通过 `http://localhost:3001`(或您配置的 PORT)访问。
|
服务器将启动并可通过 `http://localhost:3001`(或您配置的 SERVER_PORT)访问。
|
||||||
|
|
||||||
**重启**: 停止服务器后只需再次运行相同的 `npx` 命令
|
**重启**: 停止服务器后只需再次运行相同的 `npx` 命令
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently --kill-others \"npm run server\" \"npm run client\"",
|
"dev": "concurrently --kill-others \"npm run server\" \"npm run client\"",
|
||||||
"server": "node server/index.js",
|
"server": "node server/index.js",
|
||||||
"client": "vite --host",
|
"client": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ function showStatus() {
|
|||||||
|
|
||||||
// Environment variables
|
// Environment variables
|
||||||
console.log(`\n${c.info('[INFO]')} Configuration:`);
|
console.log(`\n${c.info('[INFO]')} Configuration:`);
|
||||||
console.log(` PORT: ${c.bright(process.env.PORT || '3001')} ${c.dim(process.env.PORT ? '' : '(default)')}`);
|
console.log(` SERVER_PORT: ${c.bright(process.env.SERVER_PORT || '3001')} ${c.dim(process.env.SERVER_PORT ? '' : '(default)')}`);
|
||||||
console.log(` DATABASE_PATH: ${c.dim(process.env.DATABASE_PATH || '(using default location)')}`);
|
console.log(` DATABASE_PATH: ${c.dim(process.env.DATABASE_PATH || '(using default location)')}`);
|
||||||
console.log(` CLAUDE_CLI_PATH: ${c.dim(process.env.CLAUDE_CLI_PATH || 'claude (default)')}`);
|
console.log(` CLAUDE_CLI_PATH: ${c.dim(process.env.CLAUDE_CLI_PATH || 'claude (default)')}`);
|
||||||
console.log(` CONTEXT_WINDOW: ${c.dim(process.env.CONTEXT_WINDOW || '160000 (default)')}`);
|
console.log(` CONTEXT_WINDOW: ${c.dim(process.env.CONTEXT_WINDOW || '160000 (default)')}`);
|
||||||
@@ -134,7 +134,7 @@ function showStatus() {
|
|||||||
console.log(` ${c.dim('>')} Use ${c.bright('cloudcli --port 8080')} to run on a custom port`);
|
console.log(` ${c.dim('>')} Use ${c.bright('cloudcli --port 8080')} to run on a custom port`);
|
||||||
console.log(` ${c.dim('>')} Use ${c.bright('cloudcli --database-path /path/to/db')} for custom database`);
|
console.log(` ${c.dim('>')} Use ${c.bright('cloudcli --database-path /path/to/db')} for custom database`);
|
||||||
console.log(` ${c.dim('>')} Run ${c.bright('cloudcli help')} for all options`);
|
console.log(` ${c.dim('>')} Run ${c.bright('cloudcli help')} for all options`);
|
||||||
console.log(` ${c.dim('>')} Access the UI at http://localhost:${process.env.PORT || '3001'}\n`);
|
console.log(` ${c.dim('>')} Access the UI at http://localhost:${process.env.SERVER_PORT || '3001'}\n`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show help
|
// Show help
|
||||||
@@ -169,7 +169,7 @@ Examples:
|
|||||||
$ cloudcli status # Show configuration
|
$ cloudcli status # Show configuration
|
||||||
|
|
||||||
Environment Variables:
|
Environment Variables:
|
||||||
PORT Set server port (default: 3001)
|
SERVER_PORT Set server port (default: 3001)
|
||||||
DATABASE_PATH Set custom database location
|
DATABASE_PATH Set custom database location
|
||||||
CLAUDE_CLI_PATH Set custom Claude CLI path
|
CLAUDE_CLI_PATH Set custom Claude CLI path
|
||||||
CONTEXT_WINDOW Set context window size (default: 160000)
|
CONTEXT_WINDOW Set context window size (default: 160000)
|
||||||
@@ -260,9 +260,9 @@ function parseArgs(args) {
|
|||||||
const arg = args[i];
|
const arg = args[i];
|
||||||
|
|
||||||
if (arg === '--port' || arg === '-p') {
|
if (arg === '--port' || arg === '-p') {
|
||||||
parsed.options.port = args[++i];
|
parsed.options.serverPort = args[++i];
|
||||||
} else if (arg.startsWith('--port=')) {
|
} else if (arg.startsWith('--port=')) {
|
||||||
parsed.options.port = arg.split('=')[1];
|
parsed.options.serverPort = arg.split('=')[1];
|
||||||
} else if (arg === '--database-path') {
|
} else if (arg === '--database-path') {
|
||||||
parsed.options.databasePath = args[++i];
|
parsed.options.databasePath = args[++i];
|
||||||
} else if (arg.startsWith('--database-path=')) {
|
} else if (arg.startsWith('--database-path=')) {
|
||||||
@@ -285,8 +285,8 @@ async function main() {
|
|||||||
const { command, options } = parseArgs(args);
|
const { command, options } = parseArgs(args);
|
||||||
|
|
||||||
// Apply CLI options to environment variables
|
// Apply CLI options to environment variables
|
||||||
if (options.port) {
|
if (options.serverPort) {
|
||||||
process.env.PORT = options.port;
|
process.env.SERVER_PORT = options.serverPort;
|
||||||
}
|
}
|
||||||
if (options.databasePath) {
|
if (options.databasePath) {
|
||||||
process.env.DATABASE_PATH = options.databasePath;
|
process.env.DATABASE_PATH = options.databasePath;
|
||||||
|
|||||||
@@ -1,20 +1,33 @@
|
|||||||
import { spawn } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
import crossSpawn from 'cross-spawn';
|
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
|
// Use cross-spawn on Windows for better command execution
|
||||||
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
|
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
|
||||||
|
|
||||||
let activeCursorProcesses = new Map(); // Track active processes by session ID
|
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) {
|
async function spawnCursor(command, options = {}, ws) {
|
||||||
return new Promise(async (resolve, reject) => {
|
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 capturedSessionId = sessionId; // Track session ID throughout the process
|
||||||
let sessionCreatedSent = false; // Track if we've already sent session-created event
|
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
|
// Use tools settings passed from frontend, or defaults
|
||||||
const settings = toolsSettings || {
|
const settings = toolsSettings || {
|
||||||
@@ -23,36 +36,56 @@ async function spawnCursor(command, options = {}, ws) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Build Cursor CLI command
|
// Build Cursor CLI command
|
||||||
const args = [];
|
const baseArgs = [];
|
||||||
|
|
||||||
// Build flags allowing both resume and prompt together (reply in existing session)
|
// Build flags allowing both resume and prompt together (reply in existing session)
|
||||||
// Treat presence of sessionId as intention to resume, regardless of resume flag
|
// Treat presence of sessionId as intention to resume, regardless of resume flag
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
args.push('--resume=' + sessionId);
|
baseArgs.push('--resume=' + sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (command && command.trim()) {
|
if (command && command.trim()) {
|
||||||
// Provide a prompt (works for both new and resumed sessions)
|
// 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)
|
// Add model flag if specified (only meaningful for new sessions; harmless on resume)
|
||||||
if (!sessionId && model) {
|
if (!sessionId && model) {
|
||||||
args.push('--model', model);
|
baseArgs.push('--model', model);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Request streaming JSON when we are providing a prompt
|
// 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
|
// Add skip permissions flag if enabled
|
||||||
if (skipPermissions || settings.skipPermissions) {
|
if (skipPermissions || settings.skipPermissions) {
|
||||||
args.push('-f');
|
baseArgs.push('-f');
|
||||||
console.log('⚠️ Using -f flag (skip permissions)');
|
console.log('Using -f flag (skip permissions)');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use cwd (actual project directory) instead of projectPath
|
// Use cwd (actual project directory) instead of projectPath
|
||||||
const workingDir = cwd || projectPath || process.cwd();
|
const workingDir = cwd || projectPath || process.cwd();
|
||||||
|
|
||||||
|
// Store process reference for potential abort
|
||||||
|
const processKey = capturedSessionId || Date.now().toString();
|
||||||
|
|
||||||
|
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('Spawning Cursor CLI:', 'cursor-agent', args.join(' '));
|
||||||
console.log('Working directory:', workingDir);
|
console.log('Working directory:', workingDir);
|
||||||
console.log('Session info - Input sessionId:', sessionId, 'Resume:', resume);
|
console.log('Session info - Input sessionId:', sessionId, 'Resume:', resume);
|
||||||
@@ -63,21 +96,28 @@ async function spawnCursor(command, options = {}, ws) {
|
|||||||
env: { ...process.env } // Inherit all environment variables
|
env: { ...process.env } // Inherit all environment variables
|
||||||
});
|
});
|
||||||
|
|
||||||
// Store process reference for potential abort
|
|
||||||
const processKey = capturedSessionId || Date.now().toString();
|
|
||||||
activeCursorProcesses.set(processKey, cursorProcess);
|
activeCursorProcesses.set(processKey, cursorProcess);
|
||||||
|
|
||||||
// Handle stdout (streaming JSON responses)
|
const shouldSuppressForTrustRetry = (text) => {
|
||||||
cursorProcess.stdout.on('data', (data) => {
|
if (hasRetriedWithTrust || args.includes('--trust')) {
|
||||||
const rawOutput = data.toString();
|
return false;
|
||||||
console.log('📤 Cursor CLI stdout:', rawOutput);
|
}
|
||||||
|
if (!isWorkspaceTrustPrompt(text)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const lines = rawOutput.split('\n').filter(line => line.trim());
|
runSawWorkspaceTrustPrompt = true;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const processCursorOutputLine = (line) => {
|
||||||
|
if (!line || !line.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
try {
|
try {
|
||||||
const response = JSON.parse(line);
|
const response = JSON.parse(line);
|
||||||
console.log('📄 Parsed JSON response:', response);
|
console.log('Parsed JSON response:', response);
|
||||||
|
|
||||||
// Handle different message types
|
// Handle different message types
|
||||||
switch (response.type) {
|
switch (response.type) {
|
||||||
@@ -86,7 +126,7 @@ async function spawnCursor(command, options = {}, ws) {
|
|||||||
// Capture session ID
|
// Capture session ID
|
||||||
if (response.session_id && !capturedSessionId) {
|
if (response.session_id && !capturedSessionId) {
|
||||||
capturedSessionId = response.session_id;
|
capturedSessionId = response.session_id;
|
||||||
console.log('📝 Captured session ID:', capturedSessionId);
|
console.log('Captured session ID:', capturedSessionId);
|
||||||
|
|
||||||
// Update process key with captured session ID
|
// Update process key with captured session ID
|
||||||
if (processKey !== capturedSessionId) {
|
if (processKey !== capturedSessionId) {
|
||||||
@@ -133,7 +173,6 @@ async function spawnCursor(command, options = {}, ws) {
|
|||||||
// Accumulate assistant message chunks
|
// Accumulate assistant message chunks
|
||||||
if (response.message && response.message.content && response.message.content.length > 0) {
|
if (response.message && response.message.content && response.message.content.length > 0) {
|
||||||
const textContent = response.message.content[0].text;
|
const textContent = response.message.content[0].text;
|
||||||
messageBuffer += textContent;
|
|
||||||
|
|
||||||
// Send as Claude-compatible format for frontend
|
// Send as Claude-compatible format for frontend
|
||||||
ws.send({
|
ws.send({
|
||||||
@@ -154,18 +193,9 @@ async function spawnCursor(command, options = {}, ws) {
|
|||||||
// Session complete
|
// Session complete
|
||||||
console.log('Cursor session result:', response);
|
console.log('Cursor session result:', response);
|
||||||
|
|
||||||
// Send final message if we have buffered content
|
// Do not emit an extra content_block_stop here.
|
||||||
if (messageBuffer) {
|
// The UI already finalizes the streaming message in cursor-result handling,
|
||||||
ws.send({
|
// and emitting both can produce duplicate assistant messages.
|
||||||
type: 'claude-response',
|
|
||||||
data: {
|
|
||||||
type: 'content_block_stop'
|
|
||||||
},
|
|
||||||
sessionId: capturedSessionId || sessionId || null
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send completion event
|
|
||||||
ws.send({
|
ws.send({
|
||||||
type: 'cursor-result',
|
type: 'cursor-result',
|
||||||
sessionId: capturedSessionId || sessionId,
|
sessionId: capturedSessionId || sessionId,
|
||||||
@@ -183,7 +213,12 @@ async function spawnCursor(command, options = {}, ws) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (parseError) {
|
} 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
|
// If not JSON, send as raw text
|
||||||
ws.send({
|
ws.send({
|
||||||
type: 'cursor-output',
|
type: 'cursor-output',
|
||||||
@@ -191,15 +226,35 @@ async function spawnCursor(command, options = {}, ws) {
|
|||||||
sessionId: capturedSessionId || sessionId || null
|
sessionId: capturedSessionId || sessionId || null
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
|
// Handle stdout (streaming JSON responses)
|
||||||
|
cursorProcess.stdout.on('data', (data) => {
|
||||||
|
const rawOutput = data.toString();
|
||||||
|
console.log('Cursor CLI stdout:', rawOutput);
|
||||||
|
|
||||||
|
// 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());
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle stderr
|
// Handle stderr
|
||||||
cursorProcess.stderr.on('data', (data) => {
|
cursorProcess.stderr.on('data', (data) => {
|
||||||
console.error('Cursor CLI stderr:', data.toString());
|
const stderrText = data.toString();
|
||||||
|
console.error('Cursor CLI stderr:', stderrText);
|
||||||
|
|
||||||
|
if (shouldSuppressForTrustRetry(stderrText)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
ws.send({
|
ws.send({
|
||||||
type: 'cursor-error',
|
type: 'cursor-error',
|
||||||
error: data.toString(),
|
error: stderrText,
|
||||||
sessionId: capturedSessionId || sessionId || null
|
sessionId: capturedSessionId || sessionId || null
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -208,10 +263,26 @@ async function spawnCursor(command, options = {}, ws) {
|
|||||||
cursorProcess.on('close', async (code) => {
|
cursorProcess.on('close', async (code) => {
|
||||||
console.log(`Cursor CLI process exited with code ${code}`);
|
console.log(`Cursor CLI process exited with code ${code}`);
|
||||||
|
|
||||||
// Clean up process reference
|
|
||||||
const finalSessionId = capturedSessionId || sessionId || processKey;
|
const finalSessionId = capturedSessionId || sessionId || processKey;
|
||||||
activeCursorProcesses.delete(finalSessionId);
|
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({
|
ws.send({
|
||||||
type: 'claude-complete',
|
type: 'claude-complete',
|
||||||
sessionId: finalSessionId,
|
sessionId: finalSessionId,
|
||||||
@@ -220,9 +291,9 @@ async function spawnCursor(command, options = {}, ws) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
resolve();
|
settleOnce(() => resolve());
|
||||||
} else {
|
} else {
|
||||||
reject(new Error(`Cursor CLI exited with code ${code}`));
|
settleOnce(() => reject(new Error(`Cursor CLI exited with code ${code}`)));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -240,18 +311,21 @@ async function spawnCursor(command, options = {}, ws) {
|
|||||||
sessionId: capturedSessionId || sessionId || null
|
sessionId: capturedSessionId || sessionId || null
|
||||||
});
|
});
|
||||||
|
|
||||||
reject(error);
|
settleOnce(() => reject(error));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Close stdin since Cursor doesn't need interactive input
|
// Close stdin since Cursor doesn't need interactive input
|
||||||
cursorProcess.stdin.end();
|
cursorProcess.stdin.end();
|
||||||
|
};
|
||||||
|
|
||||||
|
runCursorProcess(baseArgs, 'initial');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function abortCursorSession(sessionId) {
|
function abortCursorSession(sessionId) {
|
||||||
const process = activeCursorProcesses.get(sessionId);
|
const process = activeCursorProcesses.get(sessionId);
|
||||||
if (process) {
|
if (process) {
|
||||||
console.log(`🛑 Aborting Cursor session: ${sessionId}`);
|
console.log(`Aborting Cursor session: ${sessionId}`);
|
||||||
process.kill('SIGTERM');
|
process.kill('SIGTERM');
|
||||||
activeCursorProcesses.delete(sessionId);
|
activeCursorProcesses.delete(sessionId);
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ const c = {
|
|||||||
dim: (text) => `${colors.dim}${text}${colors.reset}`,
|
dim: (text) => `${colors.dim}${text}${colors.reset}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('PORT from env:', process.env.PORT);
|
console.log('SERVER_PORT from env:', process.env.SERVER_PORT);
|
||||||
|
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { WebSocketServer, WebSocket } from 'ws';
|
import { WebSocketServer, WebSocket } from 'ws';
|
||||||
@@ -69,6 +69,7 @@ import { startEnabledPluginServers, stopAllPlugins } from './utils/plugin-proces
|
|||||||
import { initializeDatabase, sessionNamesDb, applyCustomSessionNames } from './database/db.js';
|
import { initializeDatabase, sessionNamesDb, applyCustomSessionNames } from './database/db.js';
|
||||||
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
|
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
|
||||||
import { IS_PLATFORM } from './constants/config.js';
|
import { IS_PLATFORM } from './constants/config.js';
|
||||||
|
import { getConnectableHost } from '../shared/networkHosts.js';
|
||||||
|
|
||||||
const VALID_PROVIDERS = ['claude', 'codex', 'cursor', 'gemini'];
|
const VALID_PROVIDERS = ['claude', 'codex', 'cursor', 'gemini'];
|
||||||
|
|
||||||
@@ -1730,8 +1731,14 @@ function handleShellConnection(ws) {
|
|||||||
shellCommand = 'cursor-agent';
|
shellCommand = 'cursor-agent';
|
||||||
}
|
}
|
||||||
} else if (provider === 'codex') {
|
} else if (provider === 'codex') {
|
||||||
|
// Use codex command; attempt to resume and fall back to a new session when the resume fails.
|
||||||
if (hasSession && sessionId) {
|
if (hasSession && sessionId) {
|
||||||
|
if (os.platform() === 'win32') {
|
||||||
|
// PowerShell syntax for fallback
|
||||||
|
shellCommand = `codex resume "${sessionId}"; if ($LASTEXITCODE -ne 0) { codex }`;
|
||||||
|
} else {
|
||||||
shellCommand = `codex resume "${sessionId}" || codex`;
|
shellCommand = `codex resume "${sessionId}" || codex`;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
shellCommand = 'codex';
|
shellCommand = 'codex';
|
||||||
}
|
}
|
||||||
@@ -1765,7 +1772,11 @@ function handleShellConnection(ws) {
|
|||||||
// Claude (default provider)
|
// Claude (default provider)
|
||||||
const command = initialCommand || 'claude';
|
const command = initialCommand || 'claude';
|
||||||
if (hasSession && sessionId) {
|
if (hasSession && sessionId) {
|
||||||
|
if (os.platform() === 'win32') {
|
||||||
|
shellCommand = `claude --resume "${sessionId}"; if ($LASTEXITCODE -ne 0) { claude }`;
|
||||||
|
} else {
|
||||||
shellCommand = `claude --resume "${sessionId}" || claude`;
|
shellCommand = `claude --resume "${sessionId}" || claude`;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
shellCommand = command;
|
shellCommand = command;
|
||||||
}
|
}
|
||||||
@@ -2391,7 +2402,8 @@ app.get('*', (req, res) => {
|
|||||||
res.sendFile(indexPath);
|
res.sendFile(indexPath);
|
||||||
} else {
|
} else {
|
||||||
// In development, redirect to Vite dev server only if dist doesn't exist
|
// In development, redirect to Vite dev server only if dist doesn't exist
|
||||||
res.redirect(`http://localhost:${process.env.VITE_PORT || 5173}`);
|
const redirectHost = getConnectableHost(req.hostname);
|
||||||
|
res.redirect(`${req.protocol}://${redirectHost}:${VITE_PORT}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2479,10 +2491,10 @@ async function getFileTree(dirPath, maxDepth = 3, currentDepth = 0, showHidden =
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const PORT = process.env.PORT || 3001;
|
const SERVER_PORT = process.env.SERVER_PORT || 3001;
|
||||||
const HOST = process.env.HOST || '0.0.0.0';
|
const HOST = process.env.HOST || '0.0.0.0';
|
||||||
// Show localhost in URL when binding to all interfaces (0.0.0.0 isn't a connectable address)
|
const DISPLAY_HOST = getConnectableHost(HOST);
|
||||||
const DISPLAY_HOST = HOST === '0.0.0.0' ? 'localhost' : HOST;
|
const VITE_PORT = process.env.VITE_PORT || 5173;
|
||||||
|
|
||||||
// Initialize database and start server
|
// Initialize database and start server
|
||||||
async function startServer() {
|
async function startServer() {
|
||||||
@@ -2496,13 +2508,15 @@ async function startServer() {
|
|||||||
|
|
||||||
// Log Claude implementation mode
|
// Log Claude implementation mode
|
||||||
console.log(`${c.info('[INFO]')} Using Claude Agents SDK for Claude integration`);
|
console.log(`${c.info('[INFO]')} Using Claude Agents SDK for Claude integration`);
|
||||||
console.log(`${c.info('[INFO]')} Running in ${c.bright(isProduction ? 'PRODUCTION' : 'DEVELOPMENT')} mode`);
|
console.log('');
|
||||||
|
|
||||||
if (!isProduction) {
|
if (isProduction) {
|
||||||
console.log(`${c.warn('[WARN]')} Note: Requests will be proxied to Vite dev server at ${c.dim('http://localhost:' + (process.env.VITE_PORT || 5173))}`);
|
console.log(`${c.info('[INFO]')} To run in production mode, go to http://${DISPLAY_HOST}:${SERVER_PORT}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
server.listen(PORT, HOST, async () => {
|
console.log(`${c.info('[INFO]')} To run in development mode with hot-module replacement, go to http://${DISPLAY_HOST}:${VITE_PORT}`);
|
||||||
|
|
||||||
|
server.listen(SERVER_PORT, HOST, async () => {
|
||||||
const appInstallPath = path.join(__dirname, '..');
|
const appInstallPath = path.join(__dirname, '..');
|
||||||
|
|
||||||
console.log('');
|
console.log('');
|
||||||
@@ -2510,7 +2524,7 @@ async function startServer() {
|
|||||||
console.log(` ${c.bright('Claude Code UI Server - Ready')}`);
|
console.log(` ${c.bright('Claude Code UI Server - Ready')}`);
|
||||||
console.log(c.dim('═'.repeat(63)));
|
console.log(c.dim('═'.repeat(63)));
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log(`${c.info('[INFO]')} Server URL: ${c.bright('http://' + DISPLAY_HOST + ':' + PORT)}`);
|
console.log(`${c.info('[INFO]')} Server URL: ${c.bright('http://' + DISPLAY_HOST + ':' + SERVER_PORT)}`);
|
||||||
console.log(`${c.info('[INFO]')} Installed at: ${c.dim(appInstallPath)}`);
|
console.log(`${c.info('[INFO]')} Installed at: ${c.dim(appInstallPath)}`);
|
||||||
console.log(`${c.tip('[TIP]')} Run "cloudcli status" for full configuration details`);
|
console.log(`${c.tip('[TIP]')} Run "cloudcli status" for full configuration details`);
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { queryClaudeSDK } from '../claude-sdk.js';
|
|||||||
import { spawnCursor } from '../cursor-cli.js';
|
import { spawnCursor } from '../cursor-cli.js';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
const COMMIT_DIFF_CHARACTER_LIMIT = 500_000;
|
||||||
|
|
||||||
function spawnAsync(command, args, options = {}) {
|
function spawnAsync(command, args, options = {}) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -107,8 +108,7 @@ async function getActualProjectPath(projectName) {
|
|||||||
projectPath = await extractProjectDirectory(projectName);
|
projectPath = await extractProjectDirectory(projectName);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error extracting project directory for ${projectName}:`, error);
|
console.error(`Error extracting project directory for ${projectName}:`, error);
|
||||||
// Fallback to the old method
|
throw new Error(`Unable to resolve project path for "${projectName}"`);
|
||||||
projectPath = projectName.replace(/-/g, '/');
|
|
||||||
}
|
}
|
||||||
return validateProjectPath(projectPath);
|
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
|
// Get git status for a project
|
||||||
router.get('/status', async (req, res) => {
|
router.get('/status', async (req, res) => {
|
||||||
const { project } = req.query;
|
const { project } = req.query;
|
||||||
@@ -180,21 +301,8 @@ router.get('/status', async (req, res) => {
|
|||||||
// Validate git repository
|
// Validate git repository
|
||||||
await validateGitRepository(projectPath);
|
await validateGitRepository(projectPath);
|
||||||
|
|
||||||
// Get current branch - handle case where there are no commits yet
|
const branch = await getCurrentBranchName(projectPath);
|
||||||
let branch = 'main';
|
const hasCommits = await repositoryHasCommits(projectPath);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get git status
|
// Get git status
|
||||||
const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain'], { cwd: projectPath });
|
const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain'], { cwd: projectPath });
|
||||||
@@ -256,46 +364,64 @@ router.get('/diff', async (req, res) => {
|
|||||||
// Validate git repository
|
// Validate git repository
|
||||||
await validateGitRepository(projectPath);
|
await validateGitRepository(projectPath);
|
||||||
|
|
||||||
// Validate file path
|
const {
|
||||||
validateFilePath(file, projectPath);
|
repositoryRootPath,
|
||||||
|
repositoryRelativeFilePath,
|
||||||
|
} = await resolveRepositoryFilePath(projectPath, file);
|
||||||
|
|
||||||
// Check if file is untracked or deleted
|
// 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 isUntracked = statusOutput.startsWith('??');
|
||||||
const isDeleted = statusOutput.trim().startsWith('D ') || statusOutput.trim().startsWith(' D');
|
const isDeleted = statusOutput.trim().startsWith('D ') || statusOutput.trim().startsWith(' D');
|
||||||
|
|
||||||
let diff;
|
let diff;
|
||||||
if (isUntracked) {
|
if (isUntracked) {
|
||||||
// For untracked files, show the entire file content as additions
|
// 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);
|
const stats = await fs.stat(filePath);
|
||||||
|
|
||||||
if (stats.isDirectory()) {
|
if (stats.isDirectory()) {
|
||||||
// For directories, show a simple message
|
// 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 {
|
} else {
|
||||||
const fileContent = await fs.readFile(filePath, 'utf-8');
|
const fileContent = await fs.readFile(filePath, 'utf-8');
|
||||||
const lines = fileContent.split('\n');
|
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');
|
lines.map(line => `+${line}`).join('\n');
|
||||||
}
|
}
|
||||||
} else if (isDeleted) {
|
} else if (isDeleted) {
|
||||||
// For deleted files, show the entire file content from HEAD as deletions
|
// 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');
|
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');
|
lines.map(line => `-${line}`).join('\n');
|
||||||
} else {
|
} else {
|
||||||
// Get diff for tracked files
|
// Get diff for tracked files
|
||||||
// First check for unstaged changes (working tree vs index)
|
// 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) {
|
if (unstagedDiff) {
|
||||||
// Show unstaged changes if they exist
|
// Show unstaged changes if they exist
|
||||||
diff = stripDiffHeaders(unstagedDiff);
|
diff = stripDiffHeaders(unstagedDiff);
|
||||||
} else {
|
} else {
|
||||||
// If no unstaged changes, check for staged changes (index vs HEAD)
|
// 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) || '';
|
diff = stripDiffHeaders(stagedDiff) || '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -321,11 +447,17 @@ router.get('/file-with-diff', async (req, res) => {
|
|||||||
// Validate git repository
|
// Validate git repository
|
||||||
await validateGitRepository(projectPath);
|
await validateGitRepository(projectPath);
|
||||||
|
|
||||||
// Validate file path
|
const {
|
||||||
validateFilePath(file, projectPath);
|
repositoryRootPath,
|
||||||
|
repositoryRelativeFilePath,
|
||||||
|
} = await resolveRepositoryFilePath(projectPath, file);
|
||||||
|
|
||||||
// Check file status
|
// 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 isUntracked = statusOutput.startsWith('??');
|
||||||
const isDeleted = statusOutput.trim().startsWith('D ') || statusOutput.trim().startsWith(' D');
|
const isDeleted = statusOutput.trim().startsWith('D ') || statusOutput.trim().startsWith(' D');
|
||||||
|
|
||||||
@@ -334,12 +466,16 @@ router.get('/file-with-diff', async (req, res) => {
|
|||||||
|
|
||||||
if (isDeleted) {
|
if (isDeleted) {
|
||||||
// For deleted files, get content from HEAD
|
// 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;
|
oldContent = headContent;
|
||||||
currentContent = headContent; // Show the deleted content in editor
|
currentContent = headContent; // Show the deleted content in editor
|
||||||
} else {
|
} else {
|
||||||
// Get current file content
|
// Get current file content
|
||||||
const filePath = path.join(projectPath, file);
|
const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);
|
||||||
const stats = await fs.stat(filePath);
|
const stats = await fs.stat(filePath);
|
||||||
|
|
||||||
if (stats.isDirectory()) {
|
if (stats.isDirectory()) {
|
||||||
@@ -352,7 +488,11 @@ router.get('/file-with-diff', async (req, res) => {
|
|||||||
if (!isUntracked) {
|
if (!isUntracked) {
|
||||||
// Get the old content from HEAD for tracked files
|
// Get the old content from HEAD for tracked files
|
||||||
try {
|
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;
|
oldContent = headContent;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// File might be newly added to git (staged but not committed)
|
// File might be newly added to git (staged but not committed)
|
||||||
@@ -430,15 +570,16 @@ router.post('/commit', async (req, res) => {
|
|||||||
|
|
||||||
// Validate git repository
|
// Validate git repository
|
||||||
await validateGitRepository(projectPath);
|
await validateGitRepository(projectPath);
|
||||||
|
const repositoryRootPath = await getRepositoryRootPath(projectPath);
|
||||||
|
|
||||||
// Stage selected files
|
// Stage selected files
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
validateFilePath(file, projectPath);
|
const { repositoryRelativeFilePath } = await resolveRepositoryFilePath(projectPath, file);
|
||||||
await spawnAsync('git', ['add', file], { cwd: projectPath });
|
await spawnAsync('git', ['add', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Commit with message
|
// 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 });
|
res.json({ success: true, output: stdout });
|
||||||
} catch (error) {
|
} 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
|
// Get list of branches
|
||||||
router.get('/branches', async (req, res) => {
|
router.get('/branches', async (req, res) => {
|
||||||
const { project } = req.query;
|
const { project } = req.query;
|
||||||
@@ -610,7 +798,12 @@ router.get('/commit-diff', async (req, res) => {
|
|||||||
{ cwd: projectPath }
|
{ 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) {
|
} catch (error) {
|
||||||
console.error('Git commit diff error:', error);
|
console.error('Git commit diff error:', error);
|
||||||
res.json({ error: error.message });
|
res.json({ error: error.message });
|
||||||
@@ -632,18 +825,20 @@ router.post('/generate-commit-message', async (req, res) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const projectPath = await getActualProjectPath(project);
|
const projectPath = await getActualProjectPath(project);
|
||||||
|
await validateGitRepository(projectPath);
|
||||||
|
const repositoryRootPath = await getRepositoryRootPath(projectPath);
|
||||||
|
|
||||||
// Get diff for selected files
|
// Get diff for selected files
|
||||||
let diffContext = '';
|
let diffContext = '';
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
try {
|
try {
|
||||||
validateFilePath(file, projectPath);
|
const { repositoryRelativeFilePath } = await resolveRepositoryFilePath(projectPath, file);
|
||||||
const { stdout } = await spawnAsync(
|
const { stdout } = await spawnAsync(
|
||||||
'git', ['diff', 'HEAD', '--', file],
|
'git', ['diff', 'HEAD', '--', repositoryRelativeFilePath],
|
||||||
{ cwd: projectPath }
|
{ cwd: repositoryRootPath }
|
||||||
);
|
);
|
||||||
if (stdout) {
|
if (stdout) {
|
||||||
diffContext += `\n--- ${file} ---\n${stdout}`;
|
diffContext += `\n--- ${repositoryRelativeFilePath} ---\n${stdout}`;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error getting diff for ${file}:`, 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
|
// Try to get content of untracked files
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
try {
|
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);
|
const stats = await fs.stat(filePath);
|
||||||
|
|
||||||
if (!stats.isDirectory()) {
|
if (!stats.isDirectory()) {
|
||||||
const content = await fs.readFile(filePath, 'utf-8');
|
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 {
|
} else {
|
||||||
diffContext += `\n--- ${file} (new directory) ---\n`;
|
diffContext += `\n--- ${repositoryRelativeFilePath} (new directory) ---\n`;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error reading file ${file}:`, error);
|
console.error(`Error reading file ${file}:`, error);
|
||||||
@@ -831,9 +1027,30 @@ router.get('/remote-status', async (req, res) => {
|
|||||||
const projectPath = await getActualProjectPath(project);
|
const projectPath = await getActualProjectPath(project);
|
||||||
await validateGitRepository(projectPath);
|
await validateGitRepository(projectPath);
|
||||||
|
|
||||||
// Get current branch
|
const branch = await getCurrentBranchName(projectPath);
|
||||||
const { stdout: currentBranch } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath });
|
const hasCommits = await repositoryHasCommits(projectPath);
|
||||||
const branch = currentBranch.trim();
|
|
||||||
|
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)
|
// Check if there's a remote tracking branch (smart detection)
|
||||||
let trackingBranch;
|
let trackingBranch;
|
||||||
@@ -843,25 +1060,11 @@ router.get('/remote-status', async (req, res) => {
|
|||||||
trackingBranch = stdout.trim();
|
trackingBranch = stdout.trim();
|
||||||
remoteName = trackingBranch.split('/')[0]; // Extract remote name (e.g., "origin/main" -> "origin")
|
remoteName = trackingBranch.split('/')[0]; // Extract remote name (e.g., "origin/main" -> "origin")
|
||||||
} catch (error) {
|
} 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({
|
return res.json({
|
||||||
hasRemote,
|
hasRemote,
|
||||||
hasUpstream: false,
|
hasUpstream: false,
|
||||||
branch,
|
branch,
|
||||||
remoteName,
|
remoteName: fallbackRemoteName,
|
||||||
message: 'No remote tracking branch configured'
|
message: 'No remote tracking branch configured'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -903,8 +1106,7 @@ router.post('/fetch', async (req, res) => {
|
|||||||
await validateGitRepository(projectPath);
|
await validateGitRepository(projectPath);
|
||||||
|
|
||||||
// Get current branch and its upstream remote
|
// Get current branch and its upstream remote
|
||||||
const { stdout: currentBranch } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath });
|
const branch = await getCurrentBranchName(projectPath);
|
||||||
const branch = currentBranch.trim();
|
|
||||||
|
|
||||||
let remoteName = 'origin'; // fallback
|
let remoteName = 'origin'; // fallback
|
||||||
try {
|
try {
|
||||||
@@ -945,8 +1147,7 @@ router.post('/pull', async (req, res) => {
|
|||||||
await validateGitRepository(projectPath);
|
await validateGitRepository(projectPath);
|
||||||
|
|
||||||
// Get current branch and its upstream remote
|
// Get current branch and its upstream remote
|
||||||
const { stdout: currentBranch } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath });
|
const branch = await getCurrentBranchName(projectPath);
|
||||||
const branch = currentBranch.trim();
|
|
||||||
|
|
||||||
let remoteName = 'origin'; // fallback
|
let remoteName = 'origin'; // fallback
|
||||||
let remoteBranch = branch; // fallback
|
let remoteBranch = branch; // fallback
|
||||||
@@ -1014,8 +1215,7 @@ router.post('/push', async (req, res) => {
|
|||||||
await validateGitRepository(projectPath);
|
await validateGitRepository(projectPath);
|
||||||
|
|
||||||
// Get current branch and its upstream remote
|
// Get current branch and its upstream remote
|
||||||
const { stdout: currentBranch } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath });
|
const branch = await getCurrentBranchName(projectPath);
|
||||||
const branch = currentBranch.trim();
|
|
||||||
|
|
||||||
let remoteName = 'origin'; // fallback
|
let remoteName = 'origin'; // fallback
|
||||||
let remoteBranch = branch; // fallback
|
let remoteBranch = branch; // fallback
|
||||||
@@ -1089,8 +1289,7 @@ router.post('/publish', async (req, res) => {
|
|||||||
validateBranchName(branch);
|
validateBranchName(branch);
|
||||||
|
|
||||||
// Get current branch to verify it matches the requested 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 = await getCurrentBranchName(projectPath);
|
||||||
const currentBranchName = currentBranch.trim();
|
|
||||||
|
|
||||||
if (currentBranchName !== branch) {
|
if (currentBranchName !== branch) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
@@ -1164,12 +1363,17 @@ router.post('/discard', async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const projectPath = await getActualProjectPath(project);
|
const projectPath = await getActualProjectPath(project);
|
||||||
await validateGitRepository(projectPath);
|
await validateGitRepository(projectPath);
|
||||||
|
const {
|
||||||
// Validate file path
|
repositoryRootPath,
|
||||||
validateFilePath(file, projectPath);
|
repositoryRelativeFilePath,
|
||||||
|
} = await resolveRepositoryFilePath(projectPath, file);
|
||||||
|
|
||||||
// Check file status to determine correct discard command
|
// 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()) {
|
if (!statusOutput.trim()) {
|
||||||
return res.status(400).json({ error: 'No changes to discard for this file' });
|
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 === '??') {
|
if (status === '??') {
|
||||||
// Untracked file or directory - delete it
|
// Untracked file or directory - delete it
|
||||||
const filePath = path.join(projectPath, file);
|
const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);
|
||||||
const stats = await fs.stat(filePath);
|
const stats = await fs.stat(filePath);
|
||||||
|
|
||||||
if (stats.isDirectory()) {
|
if (stats.isDirectory()) {
|
||||||
@@ -1189,13 +1393,13 @@ router.post('/discard', async (req, res) => {
|
|||||||
}
|
}
|
||||||
} else if (status.includes('M') || status.includes('D')) {
|
} else if (status.includes('M') || status.includes('D')) {
|
||||||
// Modified or deleted file - restore from HEAD
|
// 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')) {
|
} else if (status.includes('A')) {
|
||||||
// Added file - unstage it
|
// 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) {
|
} catch (error) {
|
||||||
console.error('Git discard error:', error);
|
console.error('Git discard error:', error);
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
@@ -1213,12 +1417,17 @@ router.post('/delete-untracked', async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const projectPath = await getActualProjectPath(project);
|
const projectPath = await getActualProjectPath(project);
|
||||||
await validateGitRepository(projectPath);
|
await validateGitRepository(projectPath);
|
||||||
|
const {
|
||||||
// Validate file path
|
repositoryRootPath,
|
||||||
validateFilePath(file, projectPath);
|
repositoryRelativeFilePath,
|
||||||
|
} = await resolveRepositoryFilePath(projectPath, file);
|
||||||
|
|
||||||
// Check if file is actually untracked
|
// 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()) {
|
if (!statusOutput.trim()) {
|
||||||
return res.status(400).json({ error: 'File is not untracked or does not exist' });
|
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
|
// Delete the untracked file or directory
|
||||||
const filePath = path.join(projectPath, file);
|
const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);
|
||||||
const stats = await fs.stat(filePath);
|
const stats = await fs.stat(filePath);
|
||||||
|
|
||||||
if (stats.isDirectory()) {
|
if (stats.isDirectory()) {
|
||||||
// Use rm with recursive option for directories
|
// Use rm with recursive option for directories
|
||||||
await fs.rm(filePath, { recursive: true, force: true });
|
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 {
|
} else {
|
||||||
await fs.unlink(filePath);
|
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) {
|
} catch (error) {
|
||||||
console.error('Git delete untracked error:', error);
|
console.error('Git delete untracked error:', error);
|
||||||
|
|||||||
@@ -529,7 +529,7 @@ router.get('/next/:projectName', async (req, res) => {
|
|||||||
|
|
||||||
// Fallback to loading tasks and finding next one locally
|
// Fallback to loading tasks and finding next one locally
|
||||||
// Use localhost to bypass proxy for internal server-to-server calls
|
// Use localhost to bypass proxy for internal server-to-server calls
|
||||||
const tasksResponse = await fetch(`http://localhost:${process.env.PORT || 3001}/api/taskmaster/tasks/${encodeURIComponent(projectName)}`, {
|
const tasksResponse = await fetch(`http://localhost:${process.env.SERVER_PORT || 3001}/api/taskmaster/tasks/${encodeURIComponent(projectName)}`, {
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': req.headers.authorization
|
'Authorization': req.headers.authorization
|
||||||
}
|
}
|
||||||
|
|||||||
22
shared/networkHosts.js
Normal file
22
shared/networkHosts.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
export function isWildcardHost(host) {
|
||||||
|
return host === '0.0.0.0' || host === '::';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLoopbackHost(host) {
|
||||||
|
return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeLoopbackHost(host) {
|
||||||
|
if (!host) {
|
||||||
|
return host;
|
||||||
|
}
|
||||||
|
return isLoopbackHost(host) ? 'localhost' : host;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use localhost for connectable loopback and wildcard addresses in browser-facing URLs.
|
||||||
|
export function getConnectableHost(host) {
|
||||||
|
if (!host) {
|
||||||
|
return 'localhost';
|
||||||
|
}
|
||||||
|
return isWildcardHost(host) || isLoopbackHost(host) ? 'localhost' : host;
|
||||||
|
}
|
||||||
@@ -40,7 +40,7 @@ export default function AppContent() {
|
|||||||
setIsInputFocused,
|
setIsInputFocused,
|
||||||
setShowSettings,
|
setShowSettings,
|
||||||
openSettings,
|
openSettings,
|
||||||
fetchProjects,
|
refreshProjectsSilently,
|
||||||
sidebarSharedProps,
|
sidebarSharedProps,
|
||||||
} = useProjectsState({
|
} = useProjectsState({
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -51,14 +51,16 @@ export default function AppContent() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
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 () => {
|
return () => {
|
||||||
if (window.refreshProjects === fetchProjects) {
|
if (window.refreshProjects === refreshProjectsSilently) {
|
||||||
delete window.refreshProjects;
|
delete window.refreshProjects;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [fetchProjects]);
|
}, [refreshProjectsSilently]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.openSettings = openSettings;
|
window.openSettings = openSettings;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useRef, useEffect, type Dispatch, type SetStateAction } from 'react';
|
import { useState, useRef, useEffect, type Dispatch, type SetStateAction } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Folder,
|
Folder,
|
||||||
@@ -37,6 +38,7 @@ type MobileNavProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function MobileNav({ activeTab, setActiveTab, isInputFocused }: MobileNavProps) {
|
export default function MobileNav({ activeTab, setActiveTab, isInputFocused }: MobileNavProps) {
|
||||||
|
const { t } = useTranslation(['common', 'settings']);
|
||||||
const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings();
|
const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings();
|
||||||
const shouldShowTasksTab = Boolean(tasksEnabled && isTaskMasterInstalled);
|
const shouldShowTasksTab = Boolean(tasksEnabled && isTaskMasterInstalled);
|
||||||
const { plugins } = usePlugins();
|
const { plugins } = usePlugins();
|
||||||
@@ -126,8 +128,7 @@ export default function MobileNav({ activeTab, setActiveTab, isInputFocused }: M
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setMoreOpen((v) => !v);
|
setMoreOpen((v) => !v);
|
||||||
}}
|
}}
|
||||||
className={`relative flex w-full touch-manipulation flex-col items-center justify-center gap-0.5 rounded-xl px-3 py-2 transition-all duration-200 active:scale-95 ${
|
className={`relative flex w-full touch-manipulation flex-col items-center justify-center gap-0.5 rounded-xl px-3 py-2 transition-all duration-200 active:scale-95 ${isPluginActive || moreOpen
|
||||||
isPluginActive || moreOpen
|
|
||||||
? 'text-primary'
|
? 'text-primary'
|
||||||
: 'text-muted-foreground hover:text-foreground'
|
: 'text-muted-foreground hover:text-foreground'
|
||||||
}`}
|
}`}
|
||||||
@@ -142,7 +143,7 @@ export default function MobileNav({ activeTab, setActiveTab, isInputFocused }: M
|
|||||||
strokeWidth={isPluginActive ? 2.4 : 1.8}
|
strokeWidth={isPluginActive ? 2.4 : 1.8}
|
||||||
/>
|
/>
|
||||||
<span className={`relative z-10 text-[10px] font-medium transition-all duration-200 ${isPluginActive || moreOpen ? 'opacity-100' : 'opacity-60'}`}>
|
<span className={`relative z-10 text-[10px] font-medium transition-all duration-200 ${isPluginActive || moreOpen ? 'opacity-100' : 'opacity-60'}`}>
|
||||||
More
|
{t('settings:pluginSettings.morePlugins')}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -157,8 +158,7 @@ export default function MobileNav({ activeTab, setActiveTab, isInputFocused }: M
|
|||||||
<button
|
<button
|
||||||
key={p.name}
|
key={p.name}
|
||||||
onClick={() => selectPlugin(p.name)}
|
onClick={() => selectPlugin(p.name)}
|
||||||
className={`flex w-full items-center gap-2.5 px-3.5 py-2.5 text-sm transition-colors ${
|
className={`flex w-full items-center gap-2.5 px-3.5 py-2.5 text-sm transition-colors ${isActive
|
||||||
isActive
|
|
||||||
? 'bg-primary/8 text-primary'
|
? 'bg-primary/8 text-primary'
|
||||||
: 'text-foreground hover:bg-muted/60'
|
: 'text-foreground hover:bg-muted/60'
|
||||||
}`}
|
}`}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ interface UseChatRealtimeHandlersArgs {
|
|||||||
onSessionNotProcessing?: (sessionId?: string | null) => void;
|
onSessionNotProcessing?: (sessionId?: string | null) => void;
|
||||||
onReplaceTemporarySession?: (sessionId?: string | null) => void;
|
onReplaceTemporarySession?: (sessionId?: string | null) => void;
|
||||||
onNavigateToSession?: (sessionId: string) => void;
|
onNavigateToSession?: (sessionId: string) => void;
|
||||||
|
onWebSocketReconnect?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const appendStreamingChunk = (
|
const appendStreamingChunk = (
|
||||||
@@ -113,6 +114,7 @@ export function useChatRealtimeHandlers({
|
|||||||
onSessionNotProcessing,
|
onSessionNotProcessing,
|
||||||
onReplaceTemporarySession,
|
onReplaceTemporarySession,
|
||||||
onNavigateToSession,
|
onNavigateToSession,
|
||||||
|
onWebSocketReconnect,
|
||||||
}: UseChatRealtimeHandlersArgs) {
|
}: UseChatRealtimeHandlersArgs) {
|
||||||
const lastProcessedMessageRef = useRef<LatestChatMessage | null>(null);
|
const lastProcessedMessageRef = useRef<LatestChatMessage | null>(null);
|
||||||
|
|
||||||
@@ -136,7 +138,7 @@ export function useChatRealtimeHandlers({
|
|||||||
: null;
|
: null;
|
||||||
const messageType = String(latestMessage.type);
|
const messageType = String(latestMessage.type);
|
||||||
|
|
||||||
const globalMessageTypes = ['projects_updated', 'taskmaster-project-updated', 'session-created'];
|
const globalMessageTypes = ['projects_updated', 'taskmaster-project-updated', 'session-created', 'websocket-reconnected'];
|
||||||
const isGlobalMessage = globalMessageTypes.includes(messageType);
|
const isGlobalMessage = globalMessageTypes.includes(messageType);
|
||||||
const lifecycleMessageTypes = new Set([
|
const lifecycleMessageTypes = new Set([
|
||||||
'claude-complete',
|
'claude-complete',
|
||||||
@@ -300,6 +302,11 @@ export function useChatRealtimeHandlers({
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'websocket-reconnected':
|
||||||
|
// WebSocket dropped and reconnected — re-fetch session history to catch up on missed messages
|
||||||
|
onWebSocketReconnect?.();
|
||||||
|
break;
|
||||||
|
|
||||||
case 'token-budget':
|
case 'token-budget':
|
||||||
if (latestMessage.data) {
|
if (latestMessage.data) {
|
||||||
setTokenBudget(latestMessage.data);
|
setTokenBudget(latestMessage.data);
|
||||||
@@ -692,14 +699,28 @@ export function useChatRealtimeHandlers({
|
|||||||
const updated = [...previous];
|
const updated = [...previous];
|
||||||
const lastIndex = updated.length - 1;
|
const lastIndex = updated.length - 1;
|
||||||
const last = updated[lastIndex];
|
const last = updated[lastIndex];
|
||||||
|
const normalizedTextResult = textResult.trim();
|
||||||
|
|
||||||
if (last && last.type === 'assistant' && !last.isToolUse && last.isStreaming) {
|
if (last && last.type === 'assistant' && !last.isToolUse && last.isStreaming) {
|
||||||
const finalContent =
|
const finalContent =
|
||||||
textResult && textResult.trim()
|
normalizedTextResult
|
||||||
? textResult
|
? textResult
|
||||||
: `${last.content || ''}${pendingChunk || ''}`;
|
: `${last.content || ''}${pendingChunk || ''}`;
|
||||||
// Clone the message instead of mutating in place so React can reliably detect state updates.
|
// Clone the message instead of mutating in place so React can reliably detect state updates.
|
||||||
updated[lastIndex] = { ...last, content: finalContent, isStreaming: false };
|
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({
|
updated.push({
|
||||||
type: resultData.is_error ? 'error' : 'assistant',
|
type: resultData.is_error ? 'error' : 'assistant',
|
||||||
content: textResult,
|
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) => {
|
const toAbsolutePath = (projectPath: string, filePath?: string) => {
|
||||||
if (!filePath) {
|
if (!filePath) {
|
||||||
return filePath;
|
return filePath;
|
||||||
@@ -321,6 +363,10 @@ export const convertCursorSessionMessages = (blobs: CursorBlob[], projectPath: s
|
|||||||
console.log('Error parsing blob content:', error);
|
console.log('Error parsing blob content:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (role === 'user') {
|
||||||
|
text = sanitizeCursorUserMessageText(text);
|
||||||
|
}
|
||||||
|
|
||||||
if (text && text.trim()) {
|
if (text && text.trim()) {
|
||||||
const message: ChatMessage = {
|
const message: ChatMessage = {
|
||||||
type: role,
|
type: role,
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ function ChatInterface({
|
|||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
scrollToBottomAndReset,
|
scrollToBottomAndReset,
|
||||||
handleScroll,
|
handleScroll,
|
||||||
|
loadSessionMessages,
|
||||||
} = useChatSessionState({
|
} = useChatSessionState({
|
||||||
selectedProject,
|
selectedProject,
|
||||||
selectedSession,
|
selectedSession,
|
||||||
@@ -197,6 +198,23 @@ function ChatInterface({
|
|||||||
setPendingPermissionRequests,
|
setPendingPermissionRequests,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// On WebSocket reconnect, re-fetch the current session's messages from JSONL so missed
|
||||||
|
// streaming events (e.g. from long tool calls while iOS had the tab backgrounded) are shown.
|
||||||
|
// Also reset isLoading — if the server restarted or the session died mid-stream, the client
|
||||||
|
// would be stuck in "Processing..." forever without this reset.
|
||||||
|
const handleWebSocketReconnect = useCallback(async () => {
|
||||||
|
if (!selectedProject || !selectedSession) return;
|
||||||
|
const provider = (localStorage.getItem('selected-provider') as any) || 'claude';
|
||||||
|
const messages = await loadSessionMessages(selectedProject.name, selectedSession.id, false, provider);
|
||||||
|
if (messages && messages.length > 0) {
|
||||||
|
setChatMessages(messages);
|
||||||
|
}
|
||||||
|
// Reset loading state — if the session is still active, new WebSocket messages will
|
||||||
|
// set it back to true. If it died, this clears the permanent frozen state.
|
||||||
|
setIsLoading(false);
|
||||||
|
setCanAbortSession(false);
|
||||||
|
}, [selectedProject, selectedSession, loadSessionMessages, setChatMessages, setIsLoading, setCanAbortSession]);
|
||||||
|
|
||||||
useChatRealtimeHandlers({
|
useChatRealtimeHandlers({
|
||||||
latestMessage,
|
latestMessage,
|
||||||
provider,
|
provider,
|
||||||
@@ -219,6 +237,7 @@ function ChatInterface({
|
|||||||
onSessionNotProcessing,
|
onSessionNotProcessing,
|
||||||
onReplaceTemporarySession,
|
onReplaceTemporarySession,
|
||||||
onNavigateToSession,
|
onNavigateToSession,
|
||||||
|
onWebSocketReconnect: handleWebSocketReconnect,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -301,8 +301,7 @@ export default function ChatComposer({
|
|||||||
onBlur={() => onInputFocusChange?.(false)}
|
onBlur={() => onInputFocusChange?.(false)}
|
||||||
onInput={onTextareaInput}
|
onInput={onTextareaInput}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
disabled={isLoading}
|
className="chat-input-placeholder block max-h-[40vh] min-h-[50px] w-full resize-none overflow-y-auto rounded-2xl bg-transparent py-1.5 pl-12 pr-20 text-base leading-6 text-foreground placeholder-muted-foreground/50 transition-all duration-200 focus:outline-none sm:max-h-[300px] sm:min-h-[80px] sm:py-4 sm:pr-40"
|
||||||
className="chat-input-placeholder block max-h-[40vh] min-h-[50px] w-full resize-none overflow-y-auto rounded-2xl bg-transparent py-1.5 pl-12 pr-20 text-base leading-6 text-foreground placeholder-muted-foreground/50 transition-all duration-200 focus:outline-none disabled:opacity-50 sm:max-h-[300px] sm:min-h-[80px] sm:py-4 sm:pr-40"
|
|
||||||
style={{ height: '50px' }}
|
style={{ height: '50px' }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export const CONFIRMATION_TITLES: Record<ConfirmActionType, string> = {
|
|||||||
pull: 'Confirm Pull',
|
pull: 'Confirm Pull',
|
||||||
push: 'Confirm Push',
|
push: 'Confirm Push',
|
||||||
publish: 'Publish Branch',
|
publish: 'Publish Branch',
|
||||||
|
revertLocalCommit: 'Revert Local Commit',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CONFIRMATION_ACTION_LABELS: Record<ConfirmActionType, string> = {
|
export const CONFIRMATION_ACTION_LABELS: Record<ConfirmActionType, string> = {
|
||||||
@@ -40,6 +41,7 @@ export const CONFIRMATION_ACTION_LABELS: Record<ConfirmActionType, string> = {
|
|||||||
pull: 'Pull',
|
pull: 'Pull',
|
||||||
push: 'Push',
|
push: 'Push',
|
||||||
publish: 'Publish',
|
publish: 'Publish',
|
||||||
|
revertLocalCommit: 'Revert Commit',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CONFIRMATION_BUTTON_CLASSES: Record<ConfirmActionType, string> = {
|
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',
|
pull: 'bg-green-600 hover:bg-green-700',
|
||||||
push: 'bg-orange-600 hover:bg-orange-700',
|
push: 'bg-orange-600 hover:bg-orange-700',
|
||||||
publish: 'bg-purple-600 hover:bg-purple-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> = {
|
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',
|
pull: 'bg-yellow-100 dark:bg-yellow-900/30',
|
||||||
push: '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',
|
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> = {
|
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',
|
pull: 'text-yellow-600 dark:text-yellow-400',
|
||||||
push: 'text-yellow-600 dark:text-yellow-400',
|
push: 'text-yellow-600 dark:text-yellow-400',
|
||||||
publish: '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 GitPanelView = 'changes' | 'history';
|
||||||
export type FileStatusCode = 'M' | 'A' | 'D' | 'U';
|
export type FileStatusCode = 'M' | 'A' | 'D' | 'U';
|
||||||
export type GitStatusFileGroup = 'modified' | 'added' | 'deleted' | 'untracked';
|
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 = {
|
export type FileDiffInfo = {
|
||||||
old_string: string;
|
old_string: string;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import { useGitPanelController } from '../hooks/useGitPanelController';
|
import { useGitPanelController } from '../hooks/useGitPanelController';
|
||||||
|
import { useRevertLocalCommit } from '../hooks/useRevertLocalCommit';
|
||||||
import type { ConfirmationRequest, GitPanelProps, GitPanelView } from '../types/types';
|
import type { ConfirmationRequest, GitPanelProps, GitPanelView } from '../types/types';
|
||||||
import ChangesView from '../view/changes/ChangesView';
|
import ChangesView from '../view/changes/ChangesView';
|
||||||
import HistoryView from '../view/history/HistoryView';
|
import HistoryView from '../view/history/HistoryView';
|
||||||
@@ -49,6 +50,11 @@ export default function GitPanel({ selectedProject, isMobile = false, onFileOpen
|
|||||||
onFileOpen,
|
onFileOpen,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { isRevertingLocalCommit, revertLatestLocalCommit } = useRevertLocalCommit({
|
||||||
|
projectName: selectedProject?.name ?? null,
|
||||||
|
onSuccess: refreshAll,
|
||||||
|
});
|
||||||
|
|
||||||
const executeConfirmedAction = useCallback(async () => {
|
const executeConfirmedAction = useCallback(async () => {
|
||||||
if (!confirmAction) {
|
if (!confirmAction) {
|
||||||
return;
|
return;
|
||||||
@@ -85,7 +91,9 @@ export default function GitPanel({ selectedProject, isMobile = false, onFileOpen
|
|||||||
isPulling={isPulling}
|
isPulling={isPulling}
|
||||||
isPushing={isPushing}
|
isPushing={isPushing}
|
||||||
isPublishing={isPublishing}
|
isPublishing={isPublishing}
|
||||||
|
isRevertingLocalCommit={isRevertingLocalCommit}
|
||||||
onRefresh={refreshAll}
|
onRefresh={refreshAll}
|
||||||
|
onRevertLocalCommit={revertLatestLocalCommit}
|
||||||
onSwitchBranch={switchBranch}
|
onSwitchBranch={switchBranch}
|
||||||
onCreateBranch={createBranch}
|
onCreateBranch={createBranch}
|
||||||
onFetch={handleFetch}
|
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 { useEffect, useRef, useState } from 'react';
|
||||||
import type { ConfirmationRequest, GitRemoteStatus } from '../types/types';
|
import type { ConfirmationRequest, GitRemoteStatus } from '../types/types';
|
||||||
import NewBranchModal from './modals/NewBranchModal';
|
import NewBranchModal from './modals/NewBranchModal';
|
||||||
@@ -14,7 +14,9 @@ type GitPanelHeaderProps = {
|
|||||||
isPulling: boolean;
|
isPulling: boolean;
|
||||||
isPushing: boolean;
|
isPushing: boolean;
|
||||||
isPublishing: boolean;
|
isPublishing: boolean;
|
||||||
|
isRevertingLocalCommit: boolean;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
|
onRevertLocalCommit: () => Promise<void>;
|
||||||
onSwitchBranch: (branchName: string) => Promise<boolean>;
|
onSwitchBranch: (branchName: string) => Promise<boolean>;
|
||||||
onCreateBranch: (branchName: string) => Promise<boolean>;
|
onCreateBranch: (branchName: string) => Promise<boolean>;
|
||||||
onFetch: () => Promise<void>;
|
onFetch: () => Promise<void>;
|
||||||
@@ -35,7 +37,9 @@ export default function GitPanelHeader({
|
|||||||
isPulling,
|
isPulling,
|
||||||
isPushing,
|
isPushing,
|
||||||
isPublishing,
|
isPublishing,
|
||||||
|
isRevertingLocalCommit,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
|
onRevertLocalCommit,
|
||||||
onSwitchBranch,
|
onSwitchBranch,
|
||||||
onCreateBranch,
|
onCreateBranch,
|
||||||
onFetch,
|
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) => {
|
const handleSwitchBranch = async (branchName: string) => {
|
||||||
try {
|
try {
|
||||||
const success = await onSwitchBranch(branchName);
|
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
|
<button
|
||||||
onClick={onRefresh}
|
onClick={onRefresh}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { Check, Download, Trash2, Upload } from 'lucide-react';
|
import { Check, Download, RotateCcw, Trash2, Upload } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
CONFIRMATION_ACTION_LABELS,
|
CONFIRMATION_ACTION_LABELS,
|
||||||
CONFIRMATION_BUTTON_CLASSES,
|
CONFIRMATION_BUTTON_CLASSES,
|
||||||
@@ -27,6 +27,10 @@ function renderConfirmActionIcon(actionType: ConfirmationRequest['type']) {
|
|||||||
return <Download className="h-4 w-4" />;
|
return <Download className="h-4 w-4" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (actionType === 'revertLocalCommit') {
|
||||||
|
return <RotateCcw className="h-4 w-4" />;
|
||||||
|
}
|
||||||
|
|
||||||
return <Upload className="h-4 w-4" />;
|
return <Upload className="h-4 w-4" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,38 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
type GitDiffViewerProps = {
|
type GitDiffViewerProps = {
|
||||||
diff: string | null;
|
diff: string | null;
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
wrapText: 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) {
|
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) {
|
if (!diff) {
|
||||||
return (
|
return (
|
||||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||||
@@ -35,7 +63,12 @@ export default function GitDiffViewer({ diff, isMobile, wrapText }: GitDiffViewe
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="diff-viewer">
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -146,7 +146,12 @@ function MainContent({
|
|||||||
|
|
||||||
{activeTab === 'shell' && (
|
{activeTab === 'shell' && (
|
||||||
<div className="h-full w-full overflow-hidden">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Trash2, RefreshCw, GitBranch, Loader2, ServerCrash, ShieldAlert, ExternalLink, BookOpen, Download, BarChart3 } from 'lucide-react';
|
import { Trash2, RefreshCw, GitBranch, Loader2, ServerCrash, ShieldAlert, ExternalLink, BookOpen, Download, BarChart3 } from 'lucide-react';
|
||||||
import { usePlugins } from '../../../contexts/PluginsContext';
|
import { usePlugins } from '../../../contexts/PluginsContext';
|
||||||
import type { Plugin } from '../../../contexts/PluginsContext';
|
import type { Plugin } from '../../../contexts/PluginsContext';
|
||||||
@@ -32,7 +33,7 @@ function ToggleSwitch({ checked, onChange, ariaLabel }: { checked: boolean; onCh
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Server Dot ────────────────────────────────────────────────────────── */
|
/* ─── Server Dot ────────────────────────────────────────────────────────── */
|
||||||
function ServerDot({ running }: { running: boolean }) {
|
function ServerDot({ running, t }: { running: boolean; t: any }) {
|
||||||
if (!running) return null;
|
if (!running) return null;
|
||||||
return (
|
return (
|
||||||
<span className="relative flex items-center gap-1.5">
|
<span className="relative flex items-center gap-1.5">
|
||||||
@@ -41,7 +42,7 @@ function ServerDot({ running }: { running: boolean }) {
|
|||||||
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-500" />
|
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-500" />
|
||||||
</span>
|
</span>
|
||||||
<span className="font-mono text-[10px] uppercase tracking-wide text-emerald-600 dark:text-emerald-400">
|
<span className="font-mono text-[10px] uppercase tracking-wide text-emerald-600 dark:text-emerald-400">
|
||||||
running
|
{t('pluginSettings.runningStatus')}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -71,6 +72,7 @@ function PluginCard({
|
|||||||
onCancelUninstall,
|
onCancelUninstall,
|
||||||
updateError,
|
updateError,
|
||||||
}: PluginCardProps) {
|
}: PluginCardProps) {
|
||||||
|
const { t } = useTranslation('settings');
|
||||||
const accentColor = plugin.enabled
|
const accentColor = plugin.enabled
|
||||||
? 'bg-emerald-500'
|
? 'bg-emerald-500'
|
||||||
: 'bg-muted-foreground/20';
|
: 'bg-muted-foreground/20';
|
||||||
@@ -108,7 +110,7 @@ function PluginCard({
|
|||||||
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
||||||
{plugin.slot}
|
{plugin.slot}
|
||||||
</span>
|
</span>
|
||||||
<ServerDot running={!!plugin.serverRunning} />
|
<ServerDot running={!!plugin.serverRunning} t={t} />
|
||||||
</div>
|
</div>
|
||||||
{plugin.description && (
|
{plugin.description && (
|
||||||
<p className="mt-1 text-sm leading-snug text-muted-foreground">
|
<p className="mt-1 text-sm leading-snug text-muted-foreground">
|
||||||
@@ -143,8 +145,8 @@ function PluginCard({
|
|||||||
<button
|
<button
|
||||||
onClick={onUpdate}
|
onClick={onUpdate}
|
||||||
disabled={updating || !plugin.repoUrl}
|
disabled={updating || !plugin.repoUrl}
|
||||||
title={plugin.repoUrl ? 'Pull latest from git' : 'No git remote — update not available'}
|
title={plugin.repoUrl ? t('pluginSettings.pullLatest') : t('pluginSettings.noGitRemote')}
|
||||||
aria-label={`Update ${plugin.displayName}`}
|
aria-label={t('pluginSettings.pullLatest')}
|
||||||
className="rounded p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-40"
|
className="rounded p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-40"
|
||||||
>
|
>
|
||||||
{updating ? (
|
{updating ? (
|
||||||
@@ -156,10 +158,9 @@ function PluginCard({
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={onUninstall}
|
onClick={onUninstall}
|
||||||
title={confirmingUninstall ? 'Click again to confirm' : 'Uninstall plugin'}
|
title={confirmingUninstall ? t('pluginSettings.confirmUninstall') : t('pluginSettings.uninstallPlugin')}
|
||||||
aria-label={`Uninstall ${plugin.displayName}`}
|
aria-label={t('pluginSettings.uninstallPlugin')}
|
||||||
className={`rounded p-1.5 transition-colors ${
|
className={`rounded p-1.5 transition-colors ${confirmingUninstall
|
||||||
confirmingUninstall
|
|
||||||
? 'bg-red-50 text-red-500 hover:bg-red-100 dark:bg-red-900/20 dark:hover:bg-red-900/30'
|
? 'bg-red-50 text-red-500 hover:bg-red-100 dark:bg-red-900/20 dark:hover:bg-red-900/30'
|
||||||
: 'text-muted-foreground hover:bg-muted hover:text-red-500'
|
: 'text-muted-foreground hover:bg-muted hover:text-red-500'
|
||||||
}`}
|
}`}
|
||||||
@@ -167,7 +168,7 @@ function PluginCard({
|
|||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<ToggleSwitch checked={plugin.enabled} onChange={onToggle} ariaLabel={`${plugin.enabled ? 'Disable' : 'Enable'} ${plugin.displayName}`} />
|
<ToggleSwitch checked={plugin.enabled} onChange={onToggle} ariaLabel={`${plugin.enabled ? t('pluginSettings.disable') : t('pluginSettings.enable')} ${plugin.displayName}`} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -175,20 +176,20 @@ function PluginCard({
|
|||||||
{confirmingUninstall && (
|
{confirmingUninstall && (
|
||||||
<div className="mt-3 flex items-center justify-between gap-3 rounded border border-red-200 bg-red-50 px-3 py-2 dark:border-red-800/50 dark:bg-red-950/30">
|
<div className="mt-3 flex items-center justify-between gap-3 rounded border border-red-200 bg-red-50 px-3 py-2 dark:border-red-800/50 dark:bg-red-950/30">
|
||||||
<span className="text-sm text-red-600 dark:text-red-400">
|
<span className="text-sm text-red-600 dark:text-red-400">
|
||||||
Remove <span className="font-semibold">{plugin.displayName}</span>? This cannot be undone.
|
{t('pluginSettings.confirmUninstallMessage', { name: plugin.displayName })}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
<button
|
<button
|
||||||
onClick={onCancelUninstall}
|
onClick={onCancelUninstall}
|
||||||
className="rounded border border-border px-2.5 py-1 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
className="rounded border border-border px-2.5 py-1 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||||
>
|
>
|
||||||
Cancel
|
{t('pluginSettings.cancel')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onUninstall}
|
onClick={onUninstall}
|
||||||
className="rounded border border-red-300 px-2.5 py-1 text-sm font-medium text-red-600 transition-colors hover:bg-red-100 dark:border-red-700 dark:text-red-400 dark:hover:bg-red-900/30"
|
className="rounded border border-red-300 px-2.5 py-1 text-sm font-medium text-red-600 transition-colors hover:bg-red-100 dark:border-red-700 dark:text-red-400 dark:hover:bg-red-900/30"
|
||||||
>
|
>
|
||||||
Remove
|
{t('pluginSettings.remove')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -208,6 +209,8 @@ function PluginCard({
|
|||||||
|
|
||||||
/* ─── Starter Plugin Card ───────────────────────────────────────────────── */
|
/* ─── Starter Plugin Card ───────────────────────────────────────────────── */
|
||||||
function StarterPluginCard({ onInstall, installing }: { onInstall: () => void; installing: boolean }) {
|
function StarterPluginCard({ onInstall, installing }: { onInstall: () => void; installing: boolean }) {
|
||||||
|
const { t } = useTranslation('settings');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex overflow-hidden rounded-lg border border-dashed border-border bg-card transition-all duration-200 hover:border-blue-400 dark:hover:border-blue-500">
|
<div className="relative flex overflow-hidden rounded-lg border border-dashed border-border bg-card transition-all duration-200 hover:border-blue-400 dark:hover:border-blue-500">
|
||||||
<div className="w-[3px] flex-shrink-0 bg-blue-500/30" />
|
<div className="w-[3px] flex-shrink-0 bg-blue-500/30" />
|
||||||
@@ -220,17 +223,17 @@ function StarterPluginCard({ onInstall, installing }: { onInstall: () => void; i
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="text-sm font-semibold leading-none text-foreground">
|
<span className="text-sm font-semibold leading-none text-foreground">
|
||||||
Project Stats
|
{t('pluginSettings.starterPlugin.name')}
|
||||||
</span>
|
</span>
|
||||||
<span className="rounded bg-blue-50 px-1.5 py-0.5 text-[10px] font-medium text-blue-600 dark:bg-blue-950/50 dark:text-blue-400">
|
<span className="rounded bg-blue-50 px-1.5 py-0.5 text-[10px] font-medium text-blue-600 dark:bg-blue-950/50 dark:text-blue-400">
|
||||||
starter
|
{t('pluginSettings.starterPlugin.badge')}
|
||||||
</span>
|
</span>
|
||||||
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
||||||
tab
|
{t('pluginSettings.tab')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-sm leading-snug text-muted-foreground">
|
<p className="mt-1 text-sm leading-snug text-muted-foreground">
|
||||||
File counts, lines of code, file-type breakdown, and recent activity for your project.
|
{t('pluginSettings.starterPlugin.description')}
|
||||||
</p>
|
</p>
|
||||||
<a
|
<a
|
||||||
href={STARTER_PLUGIN_URL}
|
href={STARTER_PLUGIN_URL}
|
||||||
@@ -253,7 +256,7 @@ function StarterPluginCard({ onInstall, installing }: { onInstall: () => void; i
|
|||||||
) : (
|
) : (
|
||||||
<Download className="h-3.5 w-3.5" />
|
<Download className="h-3.5 w-3.5" />
|
||||||
)}
|
)}
|
||||||
{installing ? 'Installing…' : 'Install'}
|
{installing ? t('pluginSettings.installing') : t('pluginSettings.starterPlugin.install')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -263,6 +266,7 @@ function StarterPluginCard({ onInstall, installing }: { onInstall: () => void; i
|
|||||||
|
|
||||||
/* ─── Main Component ────────────────────────────────────────────────────── */
|
/* ─── Main Component ────────────────────────────────────────────────────── */
|
||||||
export default function PluginSettingsTab() {
|
export default function PluginSettingsTab() {
|
||||||
|
const { t } = useTranslation('settings');
|
||||||
const { plugins, loading, installPlugin, uninstallPlugin, updatePlugin, togglePlugin } =
|
const { plugins, loading, installPlugin, uninstallPlugin, updatePlugin, togglePlugin } =
|
||||||
usePlugins();
|
usePlugins();
|
||||||
|
|
||||||
@@ -279,7 +283,7 @@ export default function PluginSettingsTab() {
|
|||||||
setUpdateErrors((prev) => { const next = { ...prev }; delete next[name]; return next; });
|
setUpdateErrors((prev) => { const next = { ...prev }; delete next[name]; return next; });
|
||||||
const result = await updatePlugin(name);
|
const result = await updatePlugin(name);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
setUpdateErrors((prev) => ({ ...prev, [name]: result.error || 'Update failed' }));
|
setUpdateErrors((prev) => ({ ...prev, [name]: result.error || t('pluginSettings.updateFailed') }));
|
||||||
}
|
}
|
||||||
setUpdatingPlugins((prev) => { const next = new Set(prev); next.delete(name); return next; });
|
setUpdatingPlugins((prev) => { const next = new Set(prev); next.delete(name); return next; });
|
||||||
};
|
};
|
||||||
@@ -292,7 +296,7 @@ export default function PluginSettingsTab() {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
setGitUrl('');
|
setGitUrl('');
|
||||||
} else {
|
} else {
|
||||||
setInstallError(result.error || 'Installation failed');
|
setInstallError(result.error || t('pluginSettings.installFailed'));
|
||||||
}
|
}
|
||||||
setInstalling(false);
|
setInstalling(false);
|
||||||
};
|
};
|
||||||
@@ -302,7 +306,7 @@ export default function PluginSettingsTab() {
|
|||||||
setInstallError(null);
|
setInstallError(null);
|
||||||
const result = await installPlugin(STARTER_PLUGIN_URL);
|
const result = await installPlugin(STARTER_PLUGIN_URL);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
setInstallError(result.error || 'Installation failed');
|
setInstallError(result.error || t('pluginSettings.installFailed'));
|
||||||
}
|
}
|
||||||
setInstallingStarter(false);
|
setInstallingStarter(false);
|
||||||
};
|
};
|
||||||
@@ -316,7 +320,7 @@ export default function PluginSettingsTab() {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
setConfirmUninstall(null);
|
setConfirmUninstall(null);
|
||||||
} else {
|
} else {
|
||||||
setInstallError(result.error || 'Uninstall failed');
|
setInstallError(result.error || t('pluginSettings.uninstallFailed'));
|
||||||
setConfirmUninstall(null);
|
setConfirmUninstall(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -328,17 +332,10 @@ export default function PluginSettingsTab() {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="mb-1 text-base font-semibold text-foreground">
|
<h3 className="mb-1 text-base font-semibold text-foreground">
|
||||||
Plugins
|
{t('pluginSettings.title')}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Extend the interface with custom plugins. Install from{' '}
|
{t('pluginSettings.description')}
|
||||||
<code className="rounded bg-muted px-1.5 py-0.5 text-xs font-semibold">
|
|
||||||
git
|
|
||||||
</code>{' '}
|
|
||||||
or drop a folder in{' '}
|
|
||||||
<code className="rounded bg-muted px-1.5 py-0.5 text-xs font-semibold">
|
|
||||||
~/.claude-code-ui/plugins/
|
|
||||||
</code>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -354,8 +351,8 @@ export default function PluginSettingsTab() {
|
|||||||
setGitUrl(e.target.value);
|
setGitUrl(e.target.value);
|
||||||
setInstallError(null);
|
setInstallError(null);
|
||||||
}}
|
}}
|
||||||
placeholder="https://github.com/user/my-plugin"
|
placeholder={t('pluginSettings.installPlaceholder')}
|
||||||
aria-label="Plugin git repository URL"
|
aria-label={t('pluginSettings.installAriaLabel')}
|
||||||
className="flex-1 bg-transparent px-2 py-2.5 text-sm text-foreground placeholder:text-muted-foreground/40 focus:outline-none"
|
className="flex-1 bg-transparent px-2 py-2.5 text-sm text-foreground placeholder:text-muted-foreground/40 focus:outline-none"
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') void handleInstall();
|
if (e.key === 'Enter') void handleInstall();
|
||||||
@@ -369,7 +366,7 @@ export default function PluginSettingsTab() {
|
|||||||
{installing ? (
|
{installing ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
'Install'
|
t('pluginSettings.installButton')
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -381,7 +378,7 @@ export default function PluginSettingsTab() {
|
|||||||
<p className="-mt-4 flex items-start gap-1.5 text-xs leading-snug text-muted-foreground/50">
|
<p className="-mt-4 flex items-start gap-1.5 text-xs leading-snug text-muted-foreground/50">
|
||||||
<ShieldAlert className="mt-px h-3 w-3 flex-shrink-0" />
|
<ShieldAlert className="mt-px h-3 w-3 flex-shrink-0" />
|
||||||
<span>
|
<span>
|
||||||
Only install plugins whose source code you have reviewed or from authors you trust.
|
{t('pluginSettings.securityWarning')}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -394,18 +391,26 @@ export default function PluginSettingsTab() {
|
|||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center gap-2 py-10 text-sm text-muted-foreground">
|
<div className="flex items-center justify-center gap-2 py-10 text-sm text-muted-foreground">
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
Scanning plugins…
|
{t('pluginSettings.scanningPlugins')}
|
||||||
</div>
|
</div>
|
||||||
) : plugins.length === 0 ? (
|
) : plugins.length === 0 ? (
|
||||||
<p className="py-8 text-center text-sm text-muted-foreground">No plugins installed</p>
|
<p className="py-8 text-center text-sm text-muted-foreground">{t('pluginSettings.noPluginsInstalled')}</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{plugins.map((plugin, index) => (
|
{plugins.map((plugin, index) => {
|
||||||
|
const handleToggle = async (enabled: boolean) => {
|
||||||
|
const r = await togglePlugin(plugin.name, enabled);
|
||||||
|
if (!r.success) {
|
||||||
|
setInstallError(r.error || t('pluginSettings.toggleFailed'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
<PluginCard
|
<PluginCard
|
||||||
key={plugin.name}
|
key={plugin.name}
|
||||||
plugin={plugin}
|
plugin={plugin}
|
||||||
index={index}
|
index={index}
|
||||||
onToggle={(enabled) => void togglePlugin(plugin.name, enabled).then(r => { if (!r.success) setInstallError(r.error || 'Toggle failed'); })}
|
onToggle={(enabled) => void handleToggle(enabled)}
|
||||||
onUpdate={() => void handleUpdate(plugin.name)}
|
onUpdate={() => void handleUpdate(plugin.name)}
|
||||||
onUninstall={() => void handleUninstall(plugin.name)}
|
onUninstall={() => void handleUninstall(plugin.name)}
|
||||||
updating={updatingPlugins.has(plugin.name)}
|
updating={updatingPlugins.has(plugin.name)}
|
||||||
@@ -413,7 +418,8 @@ export default function PluginSettingsTab() {
|
|||||||
onCancelUninstall={() => setConfirmUninstall(null)}
|
onCancelUninstall={() => setConfirmUninstall(null)}
|
||||||
updateError={updateErrors[plugin.name] ?? null}
|
updateError={updateErrors[plugin.name] ?? null}
|
||||||
/>
|
/>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -422,7 +428,7 @@ export default function PluginSettingsTab() {
|
|||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<BookOpen className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground/40" />
|
<BookOpen className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground/40" />
|
||||||
<span className="text-xs text-muted-foreground/60">
|
<span className="text-xs text-muted-foreground/60">
|
||||||
Build your own plugin
|
{t('pluginSettings.buildYourOwn')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-shrink-0 items-center gap-3">
|
<div className="flex flex-shrink-0 items-center gap-3">
|
||||||
@@ -432,7 +438,7 @@ export default function PluginSettingsTab() {
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground/60 transition-colors hover:text-foreground"
|
className="inline-flex items-center gap-1 text-xs text-muted-foreground/60 transition-colors hover:text-foreground"
|
||||||
>
|
>
|
||||||
Starter <ExternalLink className="h-2.5 w-2.5" />
|
{t('pluginSettings.starter')} <ExternalLink className="h-2.5 w-2.5" />
|
||||||
</a>
|
</a>
|
||||||
<span className="text-muted-foreground/20">·</span>
|
<span className="text-muted-foreground/20">·</span>
|
||||||
<a
|
<a
|
||||||
@@ -441,7 +447,7 @@ export default function PluginSettingsTab() {
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground/60 transition-colors hover:text-foreground"
|
className="inline-flex items-center gap-1 text-xs text-muted-foreground/60 transition-colors hover:text-foreground"
|
||||||
>
|
>
|
||||||
Docs <ExternalLink className="h-2.5 w-2.5" />
|
{t('pluginSettings.docs')} <ExternalLink className="h-2.5 w-2.5" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
TERMINAL_OPTIONS,
|
TERMINAL_OPTIONS,
|
||||||
TERMINAL_RESIZE_DELAY_MS,
|
TERMINAL_RESIZE_DELAY_MS,
|
||||||
} from '../constants/constants';
|
} from '../constants/constants';
|
||||||
|
import { copyTextToClipboard } from '../../../utils/clipboard';
|
||||||
import { isCodexLoginCommand } from '../utils/auth';
|
import { isCodexLoginCommand } from '../utils/auth';
|
||||||
import { sendSocketMessage } from '../utils/socket';
|
import { sendSocketMessage } from '../utils/socket';
|
||||||
import { ensureXtermFocusStyles } from '../utils/terminalStyles';
|
import { ensureXtermFocusStyles } from '../utils/terminalStyles';
|
||||||
@@ -103,6 +104,37 @@ export function useShellTerminal({
|
|||||||
|
|
||||||
nextTerminal.open(terminalContainerRef.current);
|
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) => {
|
nextTerminal.attachCustomKeyEventHandler((event) => {
|
||||||
const activeAuthUrl = isCodexLoginCommand(initialCommandRef.current)
|
const activeAuthUrl = isCodexLoginCommand(initialCommandRef.current)
|
||||||
? CODEX_DEVICE_AUTH_URL
|
? CODEX_DEVICE_AUTH_URL
|
||||||
@@ -132,7 +164,7 @@ export function useShellTerminal({
|
|||||||
) {
|
) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
document.execCommand('copy');
|
void copyTerminalSelection();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,6 +243,7 @@ export function useShellTerminal({
|
|||||||
resizeObserver.observe(terminalContainerRef.current);
|
resizeObserver.observe(terminalContainerRef.current);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
terminalContainerRef.current?.removeEventListener('copy', handleTerminalCopy);
|
||||||
resizeObserver.disconnect();
|
resizeObserver.disconnect();
|
||||||
if (resizeTimeoutRef.current !== null) {
|
if (resizeTimeoutRef.current !== null) {
|
||||||
window.clearTimeout(resizeTimeoutRef.current);
|
window.clearTimeout(resizeTimeoutRef.current);
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export default function Shell({
|
|||||||
onProcessComplete = null,
|
onProcessComplete = null,
|
||||||
minimal = false,
|
minimal = false,
|
||||||
autoConnect = false,
|
autoConnect = false,
|
||||||
isActive,
|
isActive = true,
|
||||||
}: ShellProps) {
|
}: ShellProps) {
|
||||||
const { t } = useTranslation('chat');
|
const { t } = useTranslation('chat');
|
||||||
const [isRestarting, setIsRestarting] = useState(false);
|
const [isRestarting, setIsRestarting] = useState(false);
|
||||||
@@ -48,9 +48,6 @@ export default function Shell({
|
|||||||
const promptCheckTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const promptCheckTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const onOutputRef = useRef<(() => void) | null>(null);
|
const onOutputRef = useRef<(() => void) | null>(null);
|
||||||
|
|
||||||
// Keep the public API stable for existing callers that still pass `isActive`.
|
|
||||||
void isActive;
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
terminalContainerRef,
|
terminalContainerRef,
|
||||||
terminalRef,
|
terminalRef,
|
||||||
@@ -157,6 +154,24 @@ export default function Shell({
|
|||||||
}
|
}
|
||||||
}, [isConnected]);
|
}, [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(
|
const sendInput = useCallback(
|
||||||
(data: string) => {
|
(data: string) => {
|
||||||
sendSocketMessage(wsRef.current, { type: 'input', data });
|
sendSocketMessage(wsRef.current, { type: 'input', data });
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ type StandaloneShellProps = {
|
|||||||
session?: ProjectSession | null;
|
session?: ProjectSession | null;
|
||||||
command?: string | null;
|
command?: string | null;
|
||||||
isPlainShell?: boolean | null;
|
isPlainShell?: boolean | null;
|
||||||
|
isActive?: boolean;
|
||||||
autoConnect?: boolean;
|
autoConnect?: boolean;
|
||||||
onComplete?: ((exitCode: number) => void) | null;
|
onComplete?: ((exitCode: number) => void) | null;
|
||||||
onClose?: (() => void) | null;
|
onClose?: (() => void) | null;
|
||||||
@@ -24,6 +25,7 @@ export default function StandaloneShell({
|
|||||||
session = null,
|
session = null,
|
||||||
command = null,
|
command = null,
|
||||||
isPlainShell = null,
|
isPlainShell = null,
|
||||||
|
isActive = true,
|
||||||
autoConnect = true,
|
autoConnect = true,
|
||||||
onComplete = null,
|
onComplete = null,
|
||||||
onClose = null,
|
onClose = null,
|
||||||
@@ -64,6 +66,7 @@ export default function StandaloneShell({
|
|||||||
selectedSession={session}
|
selectedSession={session}
|
||||||
initialCommand={command}
|
initialCommand={command}
|
||||||
isPlainShell={shouldUsePlainShell}
|
isPlainShell={shouldUsePlainShell}
|
||||||
|
isActive={isActive}
|
||||||
onProcessComplete={handleProcessComplete}
|
onProcessComplete={handleProcessComplete}
|
||||||
minimal={minimal}
|
minimal={minimal}
|
||||||
autoConnect={minimal ? true : autoConnect}
|
autoConnect={minimal ? true : autoConnect}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const buildWebSocketUrl = (token: string | null) => {
|
|||||||
const useWebSocketProviderState = (): WebSocketContextType => {
|
const useWebSocketProviderState = (): WebSocketContextType => {
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
const unmountedRef = useRef(false); // Track if component is unmounted
|
const unmountedRef = useRef(false); // Track if component is unmounted
|
||||||
|
const hasConnectedRef = useRef(false); // Track if we've ever connected (to detect reconnects)
|
||||||
const [latestMessage, setLatestMessage] = useState<any>(null);
|
const [latestMessage, setLatestMessage] = useState<any>(null);
|
||||||
const [isConnected, setIsConnected] = useState(false);
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
@@ -61,6 +62,11 @@ const useWebSocketProviderState = (): WebSocketContextType => {
|
|||||||
websocket.onopen = () => {
|
websocket.onopen = () => {
|
||||||
setIsConnected(true);
|
setIsConnected(true);
|
||||||
wsRef.current = websocket;
|
wsRef.current = websocket;
|
||||||
|
if (hasConnectedRef.current) {
|
||||||
|
// This is a reconnect — signal so components can catch up on missed messages
|
||||||
|
setLatestMessage({ type: 'websocket-reconnected', timestamp: Date.now() });
|
||||||
|
}
|
||||||
|
hasConnectedRef.current = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
websocket.onmessage = (event) => {
|
websocket.onmessage = (event) => {
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ type UseProjectsStateArgs = {
|
|||||||
activeSessions: Set<string>;
|
activeSessions: Set<string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type FetchProjectsOptions = {
|
||||||
|
showLoadingState?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
const serialize = (value: unknown) => JSON.stringify(value ?? null);
|
const serialize = (value: unknown) => JSON.stringify(value ?? null);
|
||||||
|
|
||||||
const projectsHaveChanges = (
|
const projectsHaveChanges = (
|
||||||
@@ -152,9 +156,11 @@ export function useProjectsState({
|
|||||||
|
|
||||||
const loadingProgressTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const loadingProgressTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const fetchProjects = useCallback(async () => {
|
const fetchProjects = useCallback(async ({ showLoadingState = true }: FetchProjectsOptions = {}) => {
|
||||||
try {
|
try {
|
||||||
|
if (showLoadingState) {
|
||||||
setIsLoadingProjects(true);
|
setIsLoadingProjects(true);
|
||||||
|
}
|
||||||
const response = await api.projects();
|
const response = await api.projects();
|
||||||
const projectData = (await response.json()) as Project[];
|
const projectData = (await response.json()) as Project[];
|
||||||
|
|
||||||
@@ -170,10 +176,17 @@ export function useProjectsState({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching projects:', error);
|
console.error('Error fetching projects:', error);
|
||||||
} finally {
|
} finally {
|
||||||
|
if (showLoadingState) {
|
||||||
setIsLoadingProjects(false);
|
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') => {
|
const openSettings = useCallback((tab = 'tools') => {
|
||||||
setSettingsInitialTab(tab);
|
setSettingsInitialTab(tab);
|
||||||
setShowSettings(true);
|
setShowSettings(true);
|
||||||
@@ -547,6 +560,7 @@ export function useProjectsState({
|
|||||||
setShowSettings,
|
setShowSettings,
|
||||||
openSettings,
|
openSettings,
|
||||||
fetchProjects,
|
fetchProjects,
|
||||||
|
refreshProjectsSilently,
|
||||||
sidebarSharedProps,
|
sidebarSharedProps,
|
||||||
handleProjectSelect,
|
handleProjectSelect,
|
||||||
handleSessionSelect,
|
handleSessionSelect,
|
||||||
|
|||||||
@@ -434,5 +434,41 @@
|
|||||||
"title": "About Codex MCP",
|
"title": "About Codex MCP",
|
||||||
"description": "Codex supports stdio-based MCP servers. You can add servers that extend Codex's capabilities with additional tools and resources."
|
"description": "Codex supports stdio-based MCP servers. You can add servers that extend Codex's capabilities with additional tools and resources."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"pluginSettings": {
|
||||||
|
"title": "Plugins",
|
||||||
|
"description": "Extend the interface with custom plugins. Install from git or drop a folder in ~/.claude-code-ui/plugins/",
|
||||||
|
"installPlaceholder": "https://github.com/user/my-plugin",
|
||||||
|
"installButton": "Install",
|
||||||
|
"installing": "Installing…",
|
||||||
|
"securityWarning": "Only install plugins whose source code you have reviewed or from authors you trust.",
|
||||||
|
"scanningPlugins": "Scanning plugins…",
|
||||||
|
"noPluginsInstalled": "No plugins installed",
|
||||||
|
"pullLatest": "Pull latest from git",
|
||||||
|
"noGitRemote": "No git remote — update not available",
|
||||||
|
"uninstallPlugin": "Uninstall plugin",
|
||||||
|
"confirmUninstall": "Click again to confirm",
|
||||||
|
"confirmUninstallMessage": "Remove {{name}}? This cannot be undone.",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"remove": "Remove",
|
||||||
|
"updateFailed": "Update failed",
|
||||||
|
"installFailed": "Installation failed",
|
||||||
|
"uninstallFailed": "Uninstall failed",
|
||||||
|
"toggleFailed": "Toggle failed",
|
||||||
|
"buildYourOwn": "Build your own plugin",
|
||||||
|
"starter": "Starter",
|
||||||
|
"docs": "Docs",
|
||||||
|
"starterPlugin": {
|
||||||
|
"name": "Project Stats",
|
||||||
|
"badge": "starter",
|
||||||
|
"description": "File counts, lines of code, file-type breakdown, and recent activity for your project.",
|
||||||
|
"install": "Install"
|
||||||
|
},
|
||||||
|
"morePlugins": "More",
|
||||||
|
"enable": "Enable",
|
||||||
|
"disable": "Disable",
|
||||||
|
"installAriaLabel": "Plugin git repository URL",
|
||||||
|
"tab": "tab",
|
||||||
|
"runningStatus": "running"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -434,5 +434,41 @@
|
|||||||
"title": "Codex MCPについて",
|
"title": "Codex MCPについて",
|
||||||
"description": "Codexはstdioベースのツールサーバーをサポートしています。追加のツールやリソースでCodexの機能を拡張するサーバーを追加できます。"
|
"description": "Codexはstdioベースのツールサーバーをサポートしています。追加のツールやリソースでCodexの機能を拡張するサーバーを追加できます。"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"pluginSettings": {
|
||||||
|
"title": "プラグイン",
|
||||||
|
"description": "カスタムプラグインでインターフェースを拡張します。gitからインストールするか、~/.claude-code-ui/plugins/ にフォルダを配置してください。",
|
||||||
|
"installPlaceholder": "https://github.com/user/my-plugin",
|
||||||
|
"installButton": "インストール",
|
||||||
|
"installing": "インストール中…",
|
||||||
|
"securityWarning": "信頼できる作成者のプラグイン、またはソースコードを確認済みのプラグインのみをインストールしてください。",
|
||||||
|
"scanningPlugins": "プラグインをスキャン中…",
|
||||||
|
"noPluginsInstalled": "プラグインがインストールされていません",
|
||||||
|
"pullLatest": "gitから最新を取得",
|
||||||
|
"noGitRemote": "リモートgitリポジトリがありません — アップデート不可",
|
||||||
|
"uninstallPlugin": "プラグインを削除",
|
||||||
|
"confirmUninstall": "クリックして確定",
|
||||||
|
"confirmUninstallMessage": "{{name}} を削除しますか?この操作は取り消せません。",
|
||||||
|
"cancel": "キャンセル",
|
||||||
|
"remove": "削除",
|
||||||
|
"updateFailed": "アップデートに失敗しました",
|
||||||
|
"installFailed": "インストールに失敗しました",
|
||||||
|
"uninstallFailed": "削除に失敗しました",
|
||||||
|
"toggleFailed": "切り替えに失敗しました",
|
||||||
|
"buildYourOwn": "プラグインを自作する",
|
||||||
|
"starter": "スターター",
|
||||||
|
"docs": "ドキュメント",
|
||||||
|
"starterPlugin": {
|
||||||
|
"name": "プロジェクト統計",
|
||||||
|
"badge": "スターター",
|
||||||
|
"description": "プロジェクトのファイル数、コード行数、ファイルタイプの内訳、最近のアクティビティを表示します。",
|
||||||
|
"install": "インストール"
|
||||||
|
},
|
||||||
|
"morePlugins": "詳細",
|
||||||
|
"enable": "有効にする",
|
||||||
|
"disable": "無効にする",
|
||||||
|
"installAriaLabel": "プラグインのgitリポジトリURL",
|
||||||
|
"tab": "タブ",
|
||||||
|
"runningStatus": "実行中"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -434,5 +434,41 @@
|
|||||||
"title": "Codex MCP 정보",
|
"title": "Codex MCP 정보",
|
||||||
"description": "Codex는 stdio 기반 MCP 서버를 지원합니다. 추가 도구와 리소스로 Codex의 기능을 확장하는 서버를 추가할 수 있습니다."
|
"description": "Codex는 stdio 기반 MCP 서버를 지원합니다. 추가 도구와 리소스로 Codex의 기능을 확장하는 서버를 추가할 수 있습니다."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"pluginSettings": {
|
||||||
|
"title": "플러그인",
|
||||||
|
"description": "커스텀 플러그인으로 인터페이스를 확장하세요. git에서 설치하거나 ~/.claude-code-ui/plugins/ 폴더에 직접 추가할 수 있습니다.",
|
||||||
|
"installPlaceholder": "https://github.com/user/my-plugin",
|
||||||
|
"installButton": "설치",
|
||||||
|
"installing": "설치 중…",
|
||||||
|
"securityWarning": "소스 코드를 검토했거나 신뢰할 수 있는 작성자의 플러그인만 설치하세요.",
|
||||||
|
"scanningPlugins": "플러그인 스캔 중…",
|
||||||
|
"noPluginsInstalled": "설치된 플러그인이 없습니다",
|
||||||
|
"pullLatest": "git에서 최신 버전 가져오기",
|
||||||
|
"noGitRemote": "git 리모트가 없음 — 업데이트 불가",
|
||||||
|
"uninstallPlugin": "플러그인 삭제",
|
||||||
|
"confirmUninstall": "다시 클릭하여 확인",
|
||||||
|
"confirmUninstallMessage": "{{name}} 플러그인을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
|
||||||
|
"cancel": "취소",
|
||||||
|
"remove": "삭제",
|
||||||
|
"updateFailed": "업데이트 실패",
|
||||||
|
"installFailed": "설치 실패",
|
||||||
|
"uninstallFailed": "삭제 실패",
|
||||||
|
"toggleFailed": "토글 실패",
|
||||||
|
"buildYourOwn": "나만의 플러그인 만들기",
|
||||||
|
"starter": "스타터",
|
||||||
|
"docs": "문서",
|
||||||
|
"starterPlugin": {
|
||||||
|
"name": "프로젝트 통계",
|
||||||
|
"badge": "스타터",
|
||||||
|
"description": "프로젝트의 파일 수, 코드 라인 수, 파일 유형별 분석 및 최근 활동을 확인합니다.",
|
||||||
|
"install": "설치"
|
||||||
|
},
|
||||||
|
"morePlugins": "더 보기",
|
||||||
|
"enable": "활성화",
|
||||||
|
"disable": "비활성화",
|
||||||
|
"installAriaLabel": "플러그인 git 저장소 URL",
|
||||||
|
"tab": "탭",
|
||||||
|
"runningStatus": "실행 중"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -104,7 +104,8 @@
|
|||||||
"appearance": "Внешний вид",
|
"appearance": "Внешний вид",
|
||||||
"git": "Git",
|
"git": "Git",
|
||||||
"apiTokens": "API и токены",
|
"apiTokens": "API и токены",
|
||||||
"tasks": "Задачи"
|
"tasks": "Задачи",
|
||||||
|
"plugins": "Плагины"
|
||||||
},
|
},
|
||||||
"appearanceSettings": {
|
"appearanceSettings": {
|
||||||
"darkMode": {
|
"darkMode": {
|
||||||
@@ -433,5 +434,41 @@
|
|||||||
"title": "О Codex MCP",
|
"title": "О Codex MCP",
|
||||||
"description": "Codex поддерживает MCP серверы на основе stdio. Вы можете добавлять серверы, которые расширяют возможности Codex дополнительными инструментами и ресурсами."
|
"description": "Codex поддерживает MCP серверы на основе stdio. Вы можете добавлять серверы, которые расширяют возможности Codex дополнительными инструментами и ресурсами."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"pluginSettings": {
|
||||||
|
"title": "Плагины",
|
||||||
|
"description": "Расширяйте интерфейс с помощью кастомных плагинов. Установите из git или добавьте папку в ~/.claude-code-ui/plugins/",
|
||||||
|
"installPlaceholder": "https://github.com/user/my-plugin",
|
||||||
|
"installButton": "Установить",
|
||||||
|
"installing": "Установка…",
|
||||||
|
"securityWarning": "Устанавливайте только те плагины, исходный код которых вы проверили или от авторов, которым вы доверяете.",
|
||||||
|
"scanningPlugins": "Сканирование плагинов…",
|
||||||
|
"noPluginsInstalled": "Плагины не установлены",
|
||||||
|
"pullLatest": "Получить обновления из git",
|
||||||
|
"noGitRemote": "Нет удаленного git-репозитория — обновление недоступно",
|
||||||
|
"uninstallPlugin": "Удалить плагин",
|
||||||
|
"confirmUninstall": "Нажмите еще раз для подтверждения",
|
||||||
|
"confirmUninstallMessage": "Удалить {{name}}? Это действие нельзя отменить.",
|
||||||
|
"cancel": "Отмена",
|
||||||
|
"remove": "Удалить",
|
||||||
|
"updateFailed": "Ошибка обновления",
|
||||||
|
"installFailed": "Ошибка установки",
|
||||||
|
"uninstallFailed": "Ошибка удаления",
|
||||||
|
"toggleFailed": "Ошибка переключения",
|
||||||
|
"buildYourOwn": "Создайте свой плагин",
|
||||||
|
"starter": "Шаблон",
|
||||||
|
"docs": "Документация",
|
||||||
|
"starterPlugin": {
|
||||||
|
"name": "Статистика проекта",
|
||||||
|
"badge": "шаблон",
|
||||||
|
"description": "Количество файлов, строк кода, разбивка по типам файлов и недавняя активность в вашем проекте.",
|
||||||
|
"install": "Установить"
|
||||||
|
},
|
||||||
|
"morePlugins": "Ещё",
|
||||||
|
"enable": "Включить",
|
||||||
|
"disable": "Выключить",
|
||||||
|
"installAriaLabel": "URL git-репозитория плагина",
|
||||||
|
"tab": "вкладка",
|
||||||
|
"runningStatus": "запущен"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -434,5 +434,41 @@
|
|||||||
"title": "关于 Codex MCP",
|
"title": "关于 Codex MCP",
|
||||||
"description": "Codex 支持基于 stdio 的 MCP 服务器。您可以添加服务器,通过额外的工具和资源来扩展 Codex 的功能。"
|
"description": "Codex 支持基于 stdio 的 MCP 服务器。您可以添加服务器,通过额外的工具和资源来扩展 Codex 的功能。"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"pluginSettings": {
|
||||||
|
"title": "插件",
|
||||||
|
"description": "通过自定义插件扩展界面。从 git 安装或直接将文件夹放入 ~/.claude-code-ui/plugins/",
|
||||||
|
"installPlaceholder": "https://github.com/user/my-plugin",
|
||||||
|
"installButton": "安装",
|
||||||
|
"installing": "安装中…",
|
||||||
|
"securityWarning": "仅安装您已审查过源代码或信任作者的插件。",
|
||||||
|
"scanningPlugins": "正在扫描插件…",
|
||||||
|
"noPluginsInstalled": "未安装插件",
|
||||||
|
"pullLatest": "从 git 拉取最新内容",
|
||||||
|
"noGitRemote": "无 git 远程仓库 — 无法更新",
|
||||||
|
"uninstallPlugin": "卸载插件",
|
||||||
|
"confirmUninstall": "再次点击确认",
|
||||||
|
"confirmUninstallMessage": "移除 {{name}}?此操作无法撤销。",
|
||||||
|
"cancel": "取消",
|
||||||
|
"remove": "移除",
|
||||||
|
"updateFailed": "更新失败",
|
||||||
|
"installFailed": "安装失败",
|
||||||
|
"uninstallFailed": "卸载失败",
|
||||||
|
"toggleFailed": "切换失败",
|
||||||
|
"buildYourOwn": "构建您自己的插件",
|
||||||
|
"starter": "入门模板",
|
||||||
|
"docs": "文档",
|
||||||
|
"starterPlugin": {
|
||||||
|
"name": "项目统计",
|
||||||
|
"badge": "入门",
|
||||||
|
"description": "查看项目的文件数、代码行数、文件类型分布以及最近活动。",
|
||||||
|
"install": "安装"
|
||||||
|
},
|
||||||
|
"morePlugins": "更多",
|
||||||
|
"enable": "启用",
|
||||||
|
"disable": "禁用",
|
||||||
|
"installAriaLabel": "插件 git 仓库 URL",
|
||||||
|
"tab": "标签",
|
||||||
|
"runningStatus": "运行中"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,15 +1,20 @@
|
|||||||
import { defineConfig, loadEnv } from 'vite'
|
import { defineConfig, loadEnv } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
|
import { getConnectableHost, normalizeLoopbackHost } from './shared/networkHosts.js'
|
||||||
|
|
||||||
export default defineConfig(({ command, mode }) => {
|
export default defineConfig(({ mode }) => {
|
||||||
// Load env file based on `mode` in the current working directory.
|
// Load env file based on `mode` in the current working directory.
|
||||||
const env = loadEnv(mode, process.cwd(), '')
|
const env = loadEnv(mode, process.cwd(), '')
|
||||||
|
|
||||||
const host = env.HOST || '0.0.0.0'
|
const configuredHost = env.HOST || '0.0.0.0'
|
||||||
// When binding to all interfaces (0.0.0.0), proxy should connect to localhost
|
// if the host is not a loopback address, it should be used directly.
|
||||||
// Otherwise, proxy to the specific host the backend is bound to
|
// This allows the vite server to EXPOSE all interfaces when the host
|
||||||
const proxyHost = host === '0.0.0.0' ? 'localhost' : host
|
// is set to '0.0.0.0' or '::', while still using 'localhost' for browser
|
||||||
const port = env.PORT || 3001
|
// URLs and proxy targets.
|
||||||
|
const host = normalizeLoopbackHost(configuredHost)
|
||||||
|
|
||||||
|
const proxyHost = getConnectableHost(configuredHost)
|
||||||
|
const serverPort = env.SERVER_PORT || 3001
|
||||||
|
|
||||||
return {
|
return {
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
@@ -17,13 +22,13 @@ export default defineConfig(({ command, mode }) => {
|
|||||||
host,
|
host,
|
||||||
port: parseInt(env.VITE_PORT) || 5173,
|
port: parseInt(env.VITE_PORT) || 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': `http://${proxyHost}:${port}`,
|
'/api': `http://${proxyHost}:${serverPort}`,
|
||||||
'/ws': {
|
'/ws': {
|
||||||
target: `ws://${proxyHost}:${port}`,
|
target: `ws://${proxyHost}:${serverPort}`,
|
||||||
ws: true
|
ws: true
|
||||||
},
|
},
|
||||||
'/shell': {
|
'/shell': {
|
||||||
target: `ws://${proxyHost}:${port}`,
|
target: `ws://${proxyHost}:${serverPort}`,
|
||||||
ws: true
|
ws: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user