mirror of
https://github.com/siteboon/claudecodeui.git
synced 2025-12-11 23:59:47 +00:00
refactor: improve shell performance, fix bugs on the git tab and promote login to a standalone component
Implement PTY session persistence with 30-minute timeout for shell reconnection. Sessions are now keyed by project path and session ID, preserving terminal state across UI disconnections with buffered output replay. Refactor Shell component to use refs for stable prop access, removing unnecessary isActive prop and improving WebSocket connection lifecycle management. Replace conditional rendering with early returns in MainContent for better performance. Add directory handling in git operations: support discarding, diffing, and viewing directories in untracked files. Prevent errors when staging or generating commit messages for directories. Extract LoginModal into reusable component for Claude and Cursor CLI authentication. Add minimal mode to StandaloneShell for embedded use cases. Update Settings to use new LoginModal component. Improve terminal dimensions handling by passing client-provided cols and rows to PTY spawn. Add comprehensive logging for session lifecycle and API operations.
This commit is contained in:
@@ -164,6 +164,9 @@ async function setupProjectsWatcher() {
|
|||||||
const app = express();
|
const app = express();
|
||||||
const server = http.createServer(app);
|
const server = http.createServer(app);
|
||||||
|
|
||||||
|
const ptySessionsMap = new Map();
|
||||||
|
const PTY_SESSION_TIMEOUT = 30 * 60 * 1000;
|
||||||
|
|
||||||
// Single WebSocket server that handles both paths
|
// Single WebSocket server that handles both paths
|
||||||
const wss = new WebSocketServer({
|
const wss = new WebSocketServer({
|
||||||
server,
|
server,
|
||||||
@@ -397,9 +400,12 @@ app.put('/api/projects/:projectName/rename', authenticateToken, async (req, res)
|
|||||||
app.delete('/api/projects/:projectName/sessions/:sessionId', authenticateToken, async (req, res) => {
|
app.delete('/api/projects/:projectName/sessions/:sessionId', authenticateToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { projectName, sessionId } = req.params;
|
const { projectName, sessionId } = req.params;
|
||||||
|
console.log(`[API] Deleting session: ${sessionId} from project: ${projectName}`);
|
||||||
await deleteSession(projectName, sessionId);
|
await deleteSession(projectName, sessionId);
|
||||||
|
console.log(`[API] Session ${sessionId} deleted successfully`);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error(`[API] Error deleting session ${req.params.sessionId}:`, error);
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -797,6 +803,8 @@ function handleChatConnection(ws) {
|
|||||||
function handleShellConnection(ws) {
|
function handleShellConnection(ws) {
|
||||||
console.log('🐚 Shell client connected');
|
console.log('🐚 Shell client connected');
|
||||||
let shellProcess = null;
|
let shellProcess = null;
|
||||||
|
let ptySessionKey = null;
|
||||||
|
let outputBuffer = [];
|
||||||
|
|
||||||
ws.on('message', async (message) => {
|
ws.on('message', async (message) => {
|
||||||
try {
|
try {
|
||||||
@@ -804,7 +812,6 @@ function handleShellConnection(ws) {
|
|||||||
console.log('📨 Shell message received:', data.type);
|
console.log('📨 Shell message received:', data.type);
|
||||||
|
|
||||||
if (data.type === 'init') {
|
if (data.type === 'init') {
|
||||||
// Initialize shell with project path and session info
|
|
||||||
const projectPath = data.projectPath || process.cwd();
|
const projectPath = data.projectPath || process.cwd();
|
||||||
const sessionId = data.sessionId;
|
const sessionId = data.sessionId;
|
||||||
const hasSession = data.hasSession;
|
const hasSession = data.hasSession;
|
||||||
@@ -812,6 +819,35 @@ function handleShellConnection(ws) {
|
|||||||
const initialCommand = data.initialCommand;
|
const initialCommand = data.initialCommand;
|
||||||
const isPlainShell = data.isPlainShell || (!!initialCommand && !hasSession) || provider === 'plain-shell';
|
const isPlainShell = data.isPlainShell || (!!initialCommand && !hasSession) || provider === 'plain-shell';
|
||||||
|
|
||||||
|
ptySessionKey = `${projectPath}_${sessionId || 'default'}`;
|
||||||
|
|
||||||
|
const existingSession = ptySessionsMap.get(ptySessionKey);
|
||||||
|
if (existingSession) {
|
||||||
|
console.log('♻️ Reconnecting to existing PTY session:', ptySessionKey);
|
||||||
|
shellProcess = existingSession.pty;
|
||||||
|
|
||||||
|
clearTimeout(existingSession.timeoutId);
|
||||||
|
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'output',
|
||||||
|
data: `\x1b[36m[Reconnected to existing session]\x1b[0m\r\n`
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (existingSession.buffer && existingSession.buffer.length > 0) {
|
||||||
|
console.log(`📜 Sending ${existingSession.buffer.length} buffered messages`);
|
||||||
|
existingSession.buffer.forEach(bufferedData => {
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'output',
|
||||||
|
data: bufferedData
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
existingSession.ws = ws;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
console.log('[INFO] Starting shell in:', projectPath);
|
console.log('[INFO] Starting shell in:', projectPath);
|
||||||
console.log('📋 Session info:', hasSession ? `Resume session ${sessionId}` : (isPlainShell ? 'Plain shell mode' : 'New session'));
|
console.log('📋 Session info:', hasSession ? `Resume session ${sessionId}` : (isPlainShell ? 'Plain shell mode' : 'New session'));
|
||||||
console.log('🤖 Provider:', isPlainShell ? 'plain-shell' : provider);
|
console.log('🤖 Provider:', isPlainShell ? 'plain-shell' : provider);
|
||||||
@@ -885,10 +921,15 @@ function handleShellConnection(ws) {
|
|||||||
const shell = os.platform() === 'win32' ? 'powershell.exe' : 'bash';
|
const shell = os.platform() === 'win32' ? 'powershell.exe' : 'bash';
|
||||||
const shellArgs = os.platform() === 'win32' ? ['-Command', shellCommand] : ['-c', shellCommand];
|
const shellArgs = os.platform() === 'win32' ? ['-Command', shellCommand] : ['-c', shellCommand];
|
||||||
|
|
||||||
|
// Use terminal dimensions from client if provided, otherwise use defaults
|
||||||
|
const termCols = data.cols || 80;
|
||||||
|
const termRows = data.rows || 24;
|
||||||
|
console.log('📐 Using terminal dimensions:', termCols, 'x', termRows);
|
||||||
|
|
||||||
shellProcess = pty.spawn(shell, shellArgs, {
|
shellProcess = pty.spawn(shell, shellArgs, {
|
||||||
name: 'xterm-256color',
|
name: 'xterm-256color',
|
||||||
cols: 80,
|
cols: termCols,
|
||||||
rows: 24,
|
rows: termRows,
|
||||||
cwd: process.env.HOME || (os.platform() === 'win32' ? process.env.USERPROFILE : '/'),
|
cwd: process.env.HOME || (os.platform() === 'win32' ? process.env.USERPROFILE : '/'),
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
@@ -902,9 +943,28 @@ function handleShellConnection(ws) {
|
|||||||
|
|
||||||
console.log('🟢 Shell process started with PTY, PID:', shellProcess.pid);
|
console.log('🟢 Shell process started with PTY, PID:', shellProcess.pid);
|
||||||
|
|
||||||
|
ptySessionsMap.set(ptySessionKey, {
|
||||||
|
pty: shellProcess,
|
||||||
|
ws: ws,
|
||||||
|
buffer: [],
|
||||||
|
timeoutId: null,
|
||||||
|
projectPath,
|
||||||
|
sessionId
|
||||||
|
});
|
||||||
|
|
||||||
// Handle data output
|
// Handle data output
|
||||||
shellProcess.onData((data) => {
|
shellProcess.onData((data) => {
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
const session = ptySessionsMap.get(ptySessionKey);
|
||||||
|
if (!session) return;
|
||||||
|
|
||||||
|
if (session.buffer.length < 5000) {
|
||||||
|
session.buffer.push(data);
|
||||||
|
} else {
|
||||||
|
session.buffer.shift();
|
||||||
|
session.buffer.push(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.ws && session.ws.readyState === WebSocket.OPEN) {
|
||||||
let outputData = data;
|
let outputData = data;
|
||||||
|
|
||||||
// Check for various URL opening patterns
|
// Check for various URL opening patterns
|
||||||
@@ -928,7 +988,7 @@ function handleShellConnection(ws) {
|
|||||||
console.log('[DEBUG] Detected URL for opening:', url);
|
console.log('[DEBUG] Detected URL for opening:', url);
|
||||||
|
|
||||||
// Send URL opening message to client
|
// Send URL opening message to client
|
||||||
ws.send(JSON.stringify({
|
session.ws.send(JSON.stringify({
|
||||||
type: 'url_open',
|
type: 'url_open',
|
||||||
url: url
|
url: url
|
||||||
}));
|
}));
|
||||||
@@ -941,7 +1001,7 @@ function handleShellConnection(ws) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Send regular output
|
// Send regular output
|
||||||
ws.send(JSON.stringify({
|
session.ws.send(JSON.stringify({
|
||||||
type: 'output',
|
type: 'output',
|
||||||
data: outputData
|
data: outputData
|
||||||
}));
|
}));
|
||||||
@@ -951,12 +1011,17 @@ function handleShellConnection(ws) {
|
|||||||
// Handle process exit
|
// Handle process exit
|
||||||
shellProcess.onExit((exitCode) => {
|
shellProcess.onExit((exitCode) => {
|
||||||
console.log('🔚 Shell process exited with code:', exitCode.exitCode, 'signal:', exitCode.signal);
|
console.log('🔚 Shell process exited with code:', exitCode.exitCode, 'signal:', exitCode.signal);
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
const session = ptySessionsMap.get(ptySessionKey);
|
||||||
ws.send(JSON.stringify({
|
if (session && session.ws && session.ws.readyState === WebSocket.OPEN) {
|
||||||
|
session.ws.send(JSON.stringify({
|
||||||
type: 'output',
|
type: 'output',
|
||||||
data: `\r\n\x1b[33mProcess exited with code ${exitCode.exitCode}${exitCode.signal ? ` (${exitCode.signal})` : ''}\x1b[0m\r\n`
|
data: `\r\n\x1b[33mProcess exited with code ${exitCode.exitCode}${exitCode.signal ? ` (${exitCode.signal})` : ''}\x1b[0m\r\n`
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
if (session && session.timeoutId) {
|
||||||
|
clearTimeout(session.timeoutId);
|
||||||
|
}
|
||||||
|
ptySessionsMap.delete(ptySessionKey);
|
||||||
shellProcess = null;
|
shellProcess = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -999,9 +1064,21 @@ function handleShellConnection(ws) {
|
|||||||
|
|
||||||
ws.on('close', () => {
|
ws.on('close', () => {
|
||||||
console.log('🔌 Shell client disconnected');
|
console.log('🔌 Shell client disconnected');
|
||||||
if (shellProcess && shellProcess.kill) {
|
|
||||||
console.log('🔴 Killing shell process:', shellProcess.pid);
|
if (ptySessionKey) {
|
||||||
shellProcess.kill();
|
const session = ptySessionsMap.get(ptySessionKey);
|
||||||
|
if (session) {
|
||||||
|
console.log('⏳ PTY session kept alive, will timeout in 30 minutes:', ptySessionKey);
|
||||||
|
session.ws = null;
|
||||||
|
|
||||||
|
session.timeoutId = setTimeout(() => {
|
||||||
|
console.log('⏰ PTY session timeout, killing process:', ptySessionKey);
|
||||||
|
if (session.pty && session.pty.kill) {
|
||||||
|
session.pty.kill();
|
||||||
|
}
|
||||||
|
ptySessionsMap.delete(ptySessionKey);
|
||||||
|
}, PTY_SESSION_TIMEOUT);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -161,10 +161,18 @@ router.get('/diff', async (req, res) => {
|
|||||||
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 fileContent = await fs.readFile(path.join(projectPath, file), 'utf-8');
|
const filePath = path.join(projectPath, file);
|
||||||
const lines = fileContent.split('\n');
|
const stats = await fs.stat(filePath);
|
||||||
diff = `--- /dev/null\n+++ b/${file}\n@@ -0,0 +1,${lines.length} @@\n` +
|
|
||||||
lines.map(line => `+${line}`).join('\n');
|
if (stats.isDirectory()) {
|
||||||
|
// For directories, show a simple message
|
||||||
|
diff = `Directory: ${file}\n(Cannot show diff for directories)`;
|
||||||
|
} else {
|
||||||
|
const fileContent = await fs.readFile(filePath, 'utf-8');
|
||||||
|
const lines = fileContent.split('\n');
|
||||||
|
diff = `--- /dev/null\n+++ b/${file}\n@@ -0,0 +1,${lines.length} @@\n` +
|
||||||
|
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 execAsync(`git show HEAD:"${file}"`, { cwd: projectPath });
|
const { stdout: fileContent } = await execAsync(`git show HEAD:"${file}"`, { cwd: projectPath });
|
||||||
@@ -222,7 +230,15 @@ router.get('/file-with-diff', async (req, res) => {
|
|||||||
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
|
||||||
currentContent = await fs.readFile(path.join(projectPath, file), 'utf-8');
|
const filePath = path.join(projectPath, file);
|
||||||
|
const stats = await fs.stat(filePath);
|
||||||
|
|
||||||
|
if (stats.isDirectory()) {
|
||||||
|
// Cannot show content for directories
|
||||||
|
return res.status(400).json({ error: 'Cannot show diff for directories' });
|
||||||
|
}
|
||||||
|
|
||||||
|
currentContent = await fs.readFile(filePath, 'utf-8');
|
||||||
|
|
||||||
if (!isUntracked) {
|
if (!isUntracked) {
|
||||||
// Get the old content from HEAD for tracked files
|
// Get the old content from HEAD for tracked files
|
||||||
@@ -474,8 +490,14 @@ router.post('/generate-commit-message', async (req, res) => {
|
|||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
try {
|
try {
|
||||||
const filePath = path.join(projectPath, file);
|
const filePath = path.join(projectPath, file);
|
||||||
const content = await fs.readFile(filePath, 'utf-8');
|
const stats = await fs.stat(filePath);
|
||||||
diffContext += `\n--- ${file} (new file) ---\n${content.substring(0, 1000)}\n`;
|
|
||||||
|
if (!stats.isDirectory()) {
|
||||||
|
const content = await fs.readFile(filePath, 'utf-8');
|
||||||
|
diffContext += `\n--- ${file} (new file) ---\n${content.substring(0, 1000)}\n`;
|
||||||
|
} else {
|
||||||
|
diffContext += `\n--- ${file} (new directory) ---\n`;
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error reading file ${file}:`, error);
|
console.error(`Error reading file ${file}:`, error);
|
||||||
}
|
}
|
||||||
@@ -976,10 +998,17 @@ router.post('/discard', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const status = statusOutput.substring(0, 2);
|
const status = statusOutput.substring(0, 2);
|
||||||
|
|
||||||
if (status === '??') {
|
if (status === '??') {
|
||||||
// Untracked file - delete it
|
// Untracked file or directory - delete it
|
||||||
await fs.unlink(path.join(projectPath, file));
|
const filePath = path.join(projectPath, file);
|
||||||
|
const stats = await fs.stat(filePath);
|
||||||
|
|
||||||
|
if (stats.isDirectory()) {
|
||||||
|
await fs.rm(filePath, { recursive: true, force: true });
|
||||||
|
} else {
|
||||||
|
await fs.unlink(filePath);
|
||||||
|
}
|
||||||
} 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 execAsync(`git restore "${file}"`, { cwd: projectPath });
|
await execAsync(`git restore "${file}"`, { cwd: projectPath });
|
||||||
@@ -1020,10 +1049,18 @@ router.post('/delete-untracked', async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'File is not untracked. Use discard for tracked files.' });
|
return res.status(400).json({ error: 'File is not untracked. Use discard for tracked files.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete the untracked file
|
// Delete the untracked file or directory
|
||||||
await fs.unlink(path.join(projectPath, file));
|
const filePath = path.join(projectPath, file);
|
||||||
|
const stats = await fs.stat(filePath);
|
||||||
res.json({ success: true, message: `Untracked file ${file} deleted successfully` });
|
|
||||||
|
if (stats.isDirectory()) {
|
||||||
|
// Use rm with recursive option for directories
|
||||||
|
await fs.rm(filePath, { recursive: true, force: true });
|
||||||
|
res.json({ success: true, message: `Untracked directory ${file} deleted successfully` });
|
||||||
|
} else {
|
||||||
|
await fs.unlink(filePath);
|
||||||
|
res.json({ success: true, message: `Untracked file ${file} deleted successfully` });
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Git delete untracked error:', error);
|
console.error('Git delete untracked error:', error);
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
|
|||||||
86
src/components/LoginModal.jsx
Normal file
86
src/components/LoginModal.jsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { X } from 'lucide-react';
|
||||||
|
import StandaloneShell from './StandaloneShell';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reusable login modal component for Claude and Cursor CLI authentication
|
||||||
|
*
|
||||||
|
* @param {Object} props
|
||||||
|
* @param {boolean} props.isOpen - Whether the modal is visible
|
||||||
|
* @param {Function} props.onClose - Callback when modal is closed
|
||||||
|
* @param {'claude'|'cursor'} props.provider - Which CLI provider to authenticate with
|
||||||
|
* @param {Object} props.project - Project object containing name and path information
|
||||||
|
* @param {Function} props.onComplete - Callback when login process completes (receives exitCode)
|
||||||
|
* @param {string} props.customCommand - Optional custom command to override defaults
|
||||||
|
*/
|
||||||
|
function LoginModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
provider = 'claude',
|
||||||
|
project,
|
||||||
|
onComplete,
|
||||||
|
customCommand
|
||||||
|
}) {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const getCommand = () => {
|
||||||
|
if (customCommand) return customCommand;
|
||||||
|
|
||||||
|
switch (provider) {
|
||||||
|
case 'claude':
|
||||||
|
return 'claude setup-token --dangerously-skip-permissions';
|
||||||
|
case 'cursor':
|
||||||
|
return 'cursor-agent login';
|
||||||
|
default:
|
||||||
|
return 'claude setup-token --dangerously-skip-permissions';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTitle = () => {
|
||||||
|
switch (provider) {
|
||||||
|
case 'claude':
|
||||||
|
return 'Claude CLI Login';
|
||||||
|
case 'cursor':
|
||||||
|
return 'Cursor CLI Login';
|
||||||
|
default:
|
||||||
|
return 'CLI Login';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleComplete = (exitCode) => {
|
||||||
|
if (onComplete) {
|
||||||
|
onComplete(exitCode);
|
||||||
|
}
|
||||||
|
if (exitCode === 0) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[9999] max-md:items-stretch max-md:justify-stretch">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-4xl h-3/4 flex flex-col md:max-w-4xl md:h-3/4 md:rounded-lg md:m-4 max-md:max-w-none max-md:h-full max-md:rounded-none max-md:m-0">
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
{getTitle()}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||||
|
aria-label="Close login modal"
|
||||||
|
>
|
||||||
|
<X className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
<StandaloneShell
|
||||||
|
project={project}
|
||||||
|
command={getCommand()}
|
||||||
|
onComplete={handleComplete}
|
||||||
|
minimal={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LoginModal;
|
||||||
@@ -496,20 +496,25 @@ function MainContent({
|
|||||||
/>
|
/>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
<div className={`h-full overflow-hidden ${activeTab === 'files' ? 'block' : 'hidden'}`}>
|
{activeTab === 'files' && (
|
||||||
<FileTree selectedProject={selectedProject} />
|
<div className="h-full overflow-hidden">
|
||||||
</div>
|
<FileTree selectedProject={selectedProject} />
|
||||||
<div className={`h-full overflow-hidden ${activeTab === 'shell' ? 'block' : 'hidden'}`}>
|
</div>
|
||||||
<StandaloneShell
|
)}
|
||||||
project={selectedProject}
|
{activeTab === 'shell' && (
|
||||||
session={selectedSession}
|
<div className="h-full w-full overflow-hidden">
|
||||||
isActive={activeTab === 'shell'}
|
<StandaloneShell
|
||||||
showHeader={false}
|
project={selectedProject}
|
||||||
/>
|
session={selectedSession}
|
||||||
</div>
|
showHeader={false}
|
||||||
<div className={`h-full overflow-hidden ${activeTab === 'git' ? 'block' : 'hidden'}`}>
|
/>
|
||||||
<GitPanel selectedProject={selectedProject} isMobile={isMobile} onFileOpen={handleFileOpen} />
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
{activeTab === 'git' && (
|
||||||
|
<div className="h-full overflow-hidden">
|
||||||
|
<GitPanel selectedProject={selectedProject} isMobile={isMobile} onFileOpen={handleFileOpen} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{shouldShowTasksTab && (
|
{shouldShowTasksTab && (
|
||||||
<div className={`h-full ${activeTab === 'tasks' ? 'block' : 'hidden'}`}>
|
<div className={`h-full ${activeTab === 'tasks' ? 'block' : 'hidden'}`}>
|
||||||
<div className="h-full flex flex-col overflow-hidden">
|
<div className="h-full flex flex-col overflow-hidden">
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import { Badge } from './ui/badge';
|
|||||||
import { X, Plus, Settings as SettingsIcon, Shield, AlertTriangle, Moon, Sun, Server, Edit3, Trash2, Globe, Terminal, Zap, FolderOpen, LogIn, Key } from 'lucide-react';
|
import { X, Plus, Settings as SettingsIcon, Shield, AlertTriangle, Moon, Sun, Server, Edit3, Trash2, Globe, Terminal, Zap, FolderOpen, LogIn, Key } from 'lucide-react';
|
||||||
import { useTheme } from '../contexts/ThemeContext';
|
import { useTheme } from '../contexts/ThemeContext';
|
||||||
import { useTasksSettings } from '../contexts/TasksSettingsContext';
|
import { useTasksSettings } from '../contexts/TasksSettingsContext';
|
||||||
import StandaloneShell from './StandaloneShell';
|
|
||||||
import ClaudeLogo from './ClaudeLogo';
|
import ClaudeLogo from './ClaudeLogo';
|
||||||
import CursorLogo from './CursorLogo';
|
import CursorLogo from './CursorLogo';
|
||||||
import CredentialsSettings from './CredentialsSettings';
|
import CredentialsSettings from './CredentialsSettings';
|
||||||
|
import LoginModal from './LoginModal';
|
||||||
|
|
||||||
function Settings({ isOpen, onClose, projects = [], initialTab = 'tools' }) {
|
function Settings({ isOpen, onClose, projects = [], initialTab = 'tools' }) {
|
||||||
const { isDarkMode, toggleDarkMode } = useTheme();
|
const { isDarkMode, toggleDarkMode } = useTheme();
|
||||||
@@ -441,8 +441,9 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'tools' }) {
|
|||||||
const handleLoginComplete = (exitCode) => {
|
const handleLoginComplete = (exitCode) => {
|
||||||
if (exitCode === 0) {
|
if (exitCode === 0) {
|
||||||
// Login successful - could show a success message here
|
// Login successful - could show a success message here
|
||||||
|
setSaveStatus('success');
|
||||||
}
|
}
|
||||||
setShowLoginModal(false);
|
// Modal will close itself via the LoginModal component
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveSettings = () => {
|
const saveSettings = () => {
|
||||||
@@ -2207,31 +2208,13 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'tools' }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Login Modal */}
|
{/* Login Modal */}
|
||||||
{showLoginModal && (
|
<LoginModal
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 max-md:items-stretch max-md:justify-stretch">
|
isOpen={showLoginModal}
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-4xl h-3/4 flex flex-col md:max-w-4xl md:h-3/4 md:rounded-lg md:m-4 max-md:max-w-none max-md:h-full max-md:rounded-none max-md:m-0">
|
onClose={() => setShowLoginModal(false)}
|
||||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
provider={loginProvider}
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
project={selectedProject}
|
||||||
{loginProvider === 'claude' ? 'Claude CLI Login' : 'Cursor CLI Login'}
|
onComplete={handleLoginComplete}
|
||||||
</h3>
|
/>
|
||||||
<button
|
|
||||||
onClick={() => setShowLoginModal(false)}
|
|
||||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
|
||||||
>
|
|
||||||
<X className="w-6 h-6" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 overflow-hidden">
|
|
||||||
<StandaloneShell
|
|
||||||
project={selectedProject}
|
|
||||||
command={loginProvider === 'claude' ? 'claude /login' : 'cursor-agent login'}
|
|
||||||
onComplete={handleLoginComplete}
|
|
||||||
showHeader={false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react';
|
||||||
import { Terminal } from '@xterm/xterm';
|
import { Terminal } from '@xterm/xterm';
|
||||||
import { FitAddon } from '@xterm/addon-fit';
|
import { FitAddon } from '@xterm/addon-fit';
|
||||||
import { ClipboardAddon } from '@xterm/addon-clipboard';
|
import { ClipboardAddon } from '@xterm/addon-clipboard';
|
||||||
import { WebglAddon } from '@xterm/addon-webgl';
|
import { WebglAddon } from '@xterm/addon-webgl';
|
||||||
import '@xterm/xterm/css/xterm.css';
|
import '@xterm/xterm/css/xterm.css';
|
||||||
|
|
||||||
// CSS to remove xterm focus outline
|
|
||||||
const xtermStyles = `
|
const xtermStyles = `
|
||||||
.xterm .xterm-screen {
|
.xterm .xterm-screen {
|
||||||
outline: none !important;
|
outline: none !important;
|
||||||
@@ -18,7 +17,6 @@ const xtermStyles = `
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Inject styles
|
|
||||||
if (typeof document !== 'undefined') {
|
if (typeof document !== 'undefined') {
|
||||||
const styleSheet = document.createElement('style');
|
const styleSheet = document.createElement('style');
|
||||||
styleSheet.type = 'text/css';
|
styleSheet.type = 'text/css';
|
||||||
@@ -26,10 +24,7 @@ if (typeof document !== 'undefined') {
|
|||||||
document.head.appendChild(styleSheet);
|
document.head.appendChild(styleSheet);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global store for shell sessions to persist across tab switches
|
function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell = false, onProcessComplete, minimal = false, autoConnect = false }) {
|
||||||
const shellSessions = new Map();
|
|
||||||
|
|
||||||
function Shell({ selectedProject, selectedSession, isActive, initialCommand, isPlainShell = false, onProcessComplete }) {
|
|
||||||
const terminalRef = useRef(null);
|
const terminalRef = useRef(null);
|
||||||
const terminal = useRef(null);
|
const terminal = useRef(null);
|
||||||
const fitAddon = useRef(null);
|
const fitAddon = useRef(null);
|
||||||
@@ -40,177 +35,212 @@ function Shell({ selectedProject, selectedSession, isActive, initialCommand, isP
|
|||||||
const [lastSessionId, setLastSessionId] = useState(null);
|
const [lastSessionId, setLastSessionId] = useState(null);
|
||||||
const [isConnecting, setIsConnecting] = useState(false);
|
const [isConnecting, setIsConnecting] = useState(false);
|
||||||
|
|
||||||
// Connect to shell function
|
const selectedProjectRef = useRef(selectedProject);
|
||||||
const connectToShell = () => {
|
const selectedSessionRef = useRef(selectedSession);
|
||||||
if (!isInitialized || isConnected || isConnecting) return;
|
const initialCommandRef = useRef(initialCommand);
|
||||||
|
const isPlainShellRef = useRef(isPlainShell);
|
||||||
setIsConnecting(true);
|
const onProcessCompleteRef = useRef(onProcessComplete);
|
||||||
|
|
||||||
// Start the WebSocket connection
|
|
||||||
connectWebSocket();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Disconnect from shell function
|
useEffect(() => {
|
||||||
const disconnectFromShell = () => {
|
selectedProjectRef.current = selectedProject;
|
||||||
|
selectedSessionRef.current = selectedSession;
|
||||||
|
initialCommandRef.current = initialCommand;
|
||||||
|
isPlainShellRef.current = isPlainShell;
|
||||||
|
onProcessCompleteRef.current = onProcessComplete;
|
||||||
|
});
|
||||||
|
|
||||||
|
const connectWebSocket = useCallback(async () => {
|
||||||
|
if (isConnecting || isConnected) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const isPlatform = import.meta.env.VITE_IS_PLATFORM === 'true';
|
||||||
|
let wsUrl;
|
||||||
|
|
||||||
|
if (isPlatform) {
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
wsUrl = `${protocol}//${window.location.host}/shell`;
|
||||||
|
} else {
|
||||||
|
const token = localStorage.getItem('auth-token');
|
||||||
|
if (!token) {
|
||||||
|
console.error('No authentication token found for Shell WebSocket connection');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
wsUrl = `${protocol}//${window.location.host}/shell?token=${encodeURIComponent(token)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.current = new WebSocket(wsUrl);
|
||||||
|
|
||||||
|
ws.current.onopen = () => {
|
||||||
|
setIsConnected(true);
|
||||||
|
setIsConnecting(false);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (fitAddon.current && terminal.current) {
|
||||||
|
fitAddon.current.fit();
|
||||||
|
|
||||||
|
ws.current.send(JSON.stringify({
|
||||||
|
type: 'init',
|
||||||
|
projectPath: selectedProjectRef.current.fullPath || selectedProjectRef.current.path,
|
||||||
|
sessionId: isPlainShellRef.current ? null : selectedSessionRef.current?.id,
|
||||||
|
hasSession: isPlainShellRef.current ? false : !!selectedSessionRef.current,
|
||||||
|
provider: isPlainShellRef.current ? 'plain-shell' : (selectedSessionRef.current?.__provider || 'claude'),
|
||||||
|
cols: terminal.current.cols,
|
||||||
|
rows: terminal.current.rows,
|
||||||
|
initialCommand: initialCommandRef.current,
|
||||||
|
isPlainShell: isPlainShellRef.current
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.current.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
|
||||||
|
if (data.type === 'output') {
|
||||||
|
let output = data.data;
|
||||||
|
|
||||||
|
if (isPlainShellRef.current && onProcessCompleteRef.current) {
|
||||||
|
const cleanOutput = output.replace(/\x1b\[[0-9;]*m/g, '');
|
||||||
|
if (cleanOutput.includes('Process exited with code 0')) {
|
||||||
|
onProcessCompleteRef.current(0);
|
||||||
|
} else if (cleanOutput.match(/Process exited with code (\d+)/)) {
|
||||||
|
const exitCode = parseInt(cleanOutput.match(/Process exited with code (\d+)/)[1]);
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
onProcessCompleteRef.current(exitCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (terminal.current) {
|
||||||
|
terminal.current.write(output);
|
||||||
|
}
|
||||||
|
} else if (data.type === 'url_open') {
|
||||||
|
window.open(data.url, '_blank');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Shell] Error handling WebSocket message:', error, event.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.current.onclose = (event) => {
|
||||||
|
setIsConnected(false);
|
||||||
|
setIsConnecting(false);
|
||||||
|
|
||||||
|
if (terminal.current) {
|
||||||
|
terminal.current.clear();
|
||||||
|
terminal.current.write('\x1b[2J\x1b[H');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.current.onerror = (error) => {
|
||||||
|
setIsConnected(false);
|
||||||
|
setIsConnecting(false);
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
setIsConnected(false);
|
||||||
|
setIsConnecting(false);
|
||||||
|
}
|
||||||
|
}, [isConnecting, isConnected]);
|
||||||
|
|
||||||
|
const connectToShell = useCallback(() => {
|
||||||
|
if (!isInitialized || isConnected || isConnecting) return;
|
||||||
|
setIsConnecting(true);
|
||||||
|
connectWebSocket();
|
||||||
|
}, [isInitialized, isConnected, isConnecting, connectWebSocket]);
|
||||||
|
|
||||||
|
const disconnectFromShell = useCallback(() => {
|
||||||
if (ws.current) {
|
if (ws.current) {
|
||||||
ws.current.close();
|
ws.current.close();
|
||||||
ws.current = null;
|
ws.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear terminal content completely
|
|
||||||
if (terminal.current) {
|
if (terminal.current) {
|
||||||
terminal.current.clear();
|
terminal.current.clear();
|
||||||
terminal.current.write('\x1b[2J\x1b[H'); // Clear screen and move cursor to home
|
terminal.current.write('\x1b[2J\x1b[H');
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsConnected(false);
|
setIsConnected(false);
|
||||||
setIsConnecting(false);
|
setIsConnecting(false);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
|
const sessionDisplayName = useMemo(() => {
|
||||||
|
if (!selectedSession) return null;
|
||||||
|
return selectedSession.__provider === 'cursor'
|
||||||
|
? (selectedSession.name || 'Untitled Session')
|
||||||
|
: (selectedSession.summary || 'New Session');
|
||||||
|
}, [selectedSession]);
|
||||||
|
|
||||||
|
const sessionDisplayNameShort = useMemo(() => {
|
||||||
|
if (!sessionDisplayName) return null;
|
||||||
|
return sessionDisplayName.slice(0, 30);
|
||||||
|
}, [sessionDisplayName]);
|
||||||
|
|
||||||
|
const sessionDisplayNameLong = useMemo(() => {
|
||||||
|
if (!sessionDisplayName) return null;
|
||||||
|
return sessionDisplayName.slice(0, 50);
|
||||||
|
}, [sessionDisplayName]);
|
||||||
|
|
||||||
// Restart shell function
|
|
||||||
const restartShell = () => {
|
const restartShell = () => {
|
||||||
setIsRestarting(true);
|
setIsRestarting(true);
|
||||||
|
|
||||||
// Clear ALL session storage for this project to force fresh start
|
|
||||||
const sessionKeys = Array.from(shellSessions.keys()).filter(key =>
|
|
||||||
key.includes(selectedProject.name)
|
|
||||||
);
|
|
||||||
sessionKeys.forEach(key => shellSessions.delete(key));
|
|
||||||
|
|
||||||
|
|
||||||
// Close existing WebSocket
|
|
||||||
if (ws.current) {
|
if (ws.current) {
|
||||||
ws.current.close();
|
ws.current.close();
|
||||||
ws.current = null;
|
ws.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear and dispose existing terminal
|
|
||||||
if (terminal.current) {
|
if (terminal.current) {
|
||||||
|
|
||||||
// Dispose terminal immediately without writing text
|
|
||||||
terminal.current.dispose();
|
terminal.current.dispose();
|
||||||
terminal.current = null;
|
terminal.current = null;
|
||||||
fitAddon.current = null;
|
fitAddon.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset states
|
|
||||||
setIsConnected(false);
|
setIsConnected(false);
|
||||||
setIsInitialized(false);
|
setIsInitialized(false);
|
||||||
|
|
||||||
|
|
||||||
// Force re-initialization after cleanup
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setIsRestarting(false);
|
setIsRestarting(false);
|
||||||
}, 200);
|
}, 200);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Watch for session changes and restart shell
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const currentSessionId = selectedSession?.id || null;
|
const currentSessionId = selectedSession?.id || null;
|
||||||
|
|
||||||
|
|
||||||
// Disconnect when session changes (user will need to manually reconnect)
|
|
||||||
if (lastSessionId !== null && lastSessionId !== currentSessionId && isInitialized) {
|
if (lastSessionId !== null && lastSessionId !== currentSessionId && isInitialized) {
|
||||||
|
|
||||||
// Disconnect from current shell
|
|
||||||
disconnectFromShell();
|
disconnectFromShell();
|
||||||
|
|
||||||
// Clear stored sessions for this project
|
|
||||||
const allKeys = Array.from(shellSessions.keys());
|
|
||||||
allKeys.forEach(key => {
|
|
||||||
if (key.includes(selectedProject.name)) {
|
|
||||||
shellSessions.delete(key);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setLastSessionId(currentSessionId);
|
setLastSessionId(currentSessionId);
|
||||||
}, [selectedSession?.id, isInitialized]);
|
}, [selectedSession?.id, isInitialized, disconnectFromShell]);
|
||||||
|
|
||||||
// Initialize terminal when component mounts
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!terminalRef.current || !selectedProject || isRestarting || terminal.current) {
|
||||||
if (!terminalRef.current || !selectedProject || isRestarting) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create session key for this project/session combination
|
console.log('[Shell] Terminal initializing, mounting component');
|
||||||
const sessionKey = selectedSession?.id || `project-${selectedProject.name}`;
|
|
||||||
|
|
||||||
// Check if we have an existing session
|
|
||||||
const existingSession = shellSessions.get(sessionKey);
|
|
||||||
if (existingSession && !terminal.current) {
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Reuse existing terminal
|
|
||||||
terminal.current = existingSession.terminal;
|
|
||||||
fitAddon.current = existingSession.fitAddon;
|
|
||||||
ws.current = existingSession.ws;
|
|
||||||
setIsConnected(existingSession.isConnected);
|
|
||||||
|
|
||||||
// Reattach to DOM - dispose existing element first if needed
|
|
||||||
if (terminal.current.element && terminal.current.element.parentNode) {
|
|
||||||
terminal.current.element.parentNode.removeChild(terminal.current.element);
|
|
||||||
}
|
|
||||||
|
|
||||||
terminal.current.open(terminalRef.current);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
if (fitAddon.current) {
|
|
||||||
fitAddon.current.fit();
|
|
||||||
// Send terminal size to backend after reattaching
|
|
||||||
if (ws.current && ws.current.readyState === WebSocket.OPEN) {
|
|
||||||
ws.current.send(JSON.stringify({
|
|
||||||
type: 'resize',
|
|
||||||
cols: terminal.current.cols,
|
|
||||||
rows: terminal.current.rows
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
|
|
||||||
setIsInitialized(true);
|
|
||||||
return;
|
|
||||||
} catch (error) {
|
|
||||||
// Clear the broken session and continue to create a new one
|
|
||||||
shellSessions.delete(sessionKey);
|
|
||||||
terminal.current = null;
|
|
||||||
fitAddon.current = null;
|
|
||||||
ws.current = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (terminal.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Initialize new terminal
|
|
||||||
terminal.current = new Terminal({
|
terminal.current = new Terminal({
|
||||||
cursorBlink: true,
|
cursorBlink: true,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||||
allowProposedApi: true, // Required for clipboard addon
|
allowProposedApi: true,
|
||||||
allowTransparency: false,
|
allowTransparency: false,
|
||||||
convertEol: true,
|
convertEol: true,
|
||||||
scrollback: 10000,
|
scrollback: 10000,
|
||||||
tabStopWidth: 4,
|
tabStopWidth: 4,
|
||||||
// Enable full color support
|
|
||||||
windowsMode: false,
|
windowsMode: false,
|
||||||
macOptionIsMeta: true,
|
macOptionIsMeta: true,
|
||||||
macOptionClickForcesSelection: false,
|
macOptionClickForcesSelection: false,
|
||||||
// Enhanced theme with full 16-color ANSI support + true colors
|
|
||||||
theme: {
|
theme: {
|
||||||
// Basic colors
|
|
||||||
background: '#1e1e1e',
|
background: '#1e1e1e',
|
||||||
foreground: '#d4d4d4',
|
foreground: '#d4d4d4',
|
||||||
cursor: '#ffffff',
|
cursor: '#ffffff',
|
||||||
cursorAccent: '#1e1e1e',
|
cursorAccent: '#1e1e1e',
|
||||||
selection: '#264f78',
|
selection: '#264f78',
|
||||||
selectionForeground: '#ffffff',
|
selectionForeground: '#ffffff',
|
||||||
|
|
||||||
// Standard ANSI colors (0-7)
|
|
||||||
black: '#000000',
|
black: '#000000',
|
||||||
red: '#cd3131',
|
red: '#cd3131',
|
||||||
green: '#0dbc79',
|
green: '#0dbc79',
|
||||||
@@ -219,8 +249,6 @@ function Shell({ selectedProject, selectedSession, isActive, initialCommand, isP
|
|||||||
magenta: '#bc3fbc',
|
magenta: '#bc3fbc',
|
||||||
cyan: '#11a8cd',
|
cyan: '#11a8cd',
|
||||||
white: '#e5e5e5',
|
white: '#e5e5e5',
|
||||||
|
|
||||||
// Bright ANSI colors (8-15)
|
|
||||||
brightBlack: '#666666',
|
brightBlack: '#666666',
|
||||||
brightRed: '#f14c4c',
|
brightRed: '#f14c4c',
|
||||||
brightGreen: '#23d18b',
|
brightGreen: '#23d18b',
|
||||||
@@ -229,10 +257,7 @@ function Shell({ selectedProject, selectedSession, isActive, initialCommand, isP
|
|||||||
brightMagenta: '#d670d6',
|
brightMagenta: '#d670d6',
|
||||||
brightCyan: '#29b8db',
|
brightCyan: '#29b8db',
|
||||||
brightWhite: '#ffffff',
|
brightWhite: '#ffffff',
|
||||||
|
|
||||||
// Extended colors for better Claude output
|
|
||||||
extendedAnsi: [
|
extendedAnsi: [
|
||||||
// 16-color palette extension for 256-color support
|
|
||||||
'#000000', '#800000', '#008000', '#808000',
|
'#000000', '#800000', '#008000', '#808000',
|
||||||
'#000080', '#800080', '#008080', '#c0c0c0',
|
'#000080', '#800080', '#008080', '#c0c0c0',
|
||||||
'#808080', '#ff0000', '#00ff00', '#ffff00',
|
'#808080', '#ff0000', '#00ff00', '#ffff00',
|
||||||
@@ -247,30 +272,21 @@ function Shell({ selectedProject, selectedSession, isActive, initialCommand, isP
|
|||||||
|
|
||||||
terminal.current.loadAddon(fitAddon.current);
|
terminal.current.loadAddon(fitAddon.current);
|
||||||
terminal.current.loadAddon(clipboardAddon);
|
terminal.current.loadAddon(clipboardAddon);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
terminal.current.loadAddon(webglAddon);
|
terminal.current.loadAddon(webglAddon);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.warn('[Shell] WebGL renderer unavailable, using Canvas fallback');
|
||||||
}
|
}
|
||||||
|
|
||||||
terminal.current.open(terminalRef.current);
|
terminal.current.open(terminalRef.current);
|
||||||
|
|
||||||
// Wait for terminal to be fully rendered, then fit
|
|
||||||
setTimeout(() => {
|
|
||||||
if (fitAddon.current) {
|
|
||||||
fitAddon.current.fit();
|
|
||||||
}
|
|
||||||
}, 50);
|
|
||||||
|
|
||||||
// Add keyboard shortcuts for copy/paste
|
|
||||||
terminal.current.attachCustomKeyEventHandler((event) => {
|
terminal.current.attachCustomKeyEventHandler((event) => {
|
||||||
// Ctrl+C or Cmd+C for copy (when text is selected)
|
|
||||||
if ((event.ctrlKey || event.metaKey) && event.key === 'c' && terminal.current.hasSelection()) {
|
if ((event.ctrlKey || event.metaKey) && event.key === 'c' && terminal.current.hasSelection()) {
|
||||||
document.execCommand('copy');
|
document.execCommand('copy');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ctrl+V or Cmd+V for paste
|
|
||||||
if ((event.ctrlKey || event.metaKey) && event.key === 'v') {
|
if ((event.ctrlKey || event.metaKey) && event.key === 'v') {
|
||||||
navigator.clipboard.readText().then(text => {
|
navigator.clipboard.readText().then(text => {
|
||||||
if (ws.current && ws.current.readyState === WebSocket.OPEN) {
|
if (ws.current && ws.current.readyState === WebSocket.OPEN) {
|
||||||
@@ -279,20 +295,16 @@ function Shell({ selectedProject, selectedSession, isActive, initialCommand, isP
|
|||||||
data: text
|
data: text
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}).catch(err => {
|
}).catch(() => {});
|
||||||
// Failed to read clipboard
|
|
||||||
});
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Ensure terminal takes full space and notify backend of size
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (fitAddon.current) {
|
if (fitAddon.current) {
|
||||||
fitAddon.current.fit();
|
fitAddon.current.fit();
|
||||||
// Send terminal size to backend after fitting
|
|
||||||
if (terminal.current && ws.current && ws.current.readyState === WebSocket.OPEN) {
|
if (terminal.current && ws.current && ws.current.readyState === WebSocket.OPEN) {
|
||||||
ws.current.send(JSON.stringify({
|
ws.current.send(JSON.stringify({
|
||||||
type: 'resize',
|
type: 'resize',
|
||||||
@@ -302,10 +314,8 @@ function Shell({ selectedProject, selectedSession, isActive, initialCommand, isP
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
setIsInitialized(true);
|
|
||||||
|
|
||||||
// Handle terminal input
|
setIsInitialized(true);
|
||||||
terminal.current.onData((data) => {
|
terminal.current.onData((data) => {
|
||||||
if (ws.current && ws.current.readyState === WebSocket.OPEN) {
|
if (ws.current && ws.current.readyState === WebSocket.OPEN) {
|
||||||
ws.current.send(JSON.stringify({
|
ws.current.send(JSON.stringify({
|
||||||
@@ -315,12 +325,10 @@ function Shell({ selectedProject, selectedSession, isActive, initialCommand, isP
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add resize observer to handle container size changes
|
|
||||||
const resizeObserver = new ResizeObserver(() => {
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
if (fitAddon.current && terminal.current) {
|
if (fitAddon.current && terminal.current) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
fitAddon.current.fit();
|
fitAddon.current.fit();
|
||||||
// Send updated terminal size to backend after resize
|
|
||||||
if (ws.current && ws.current.readyState === WebSocket.OPEN) {
|
if (ws.current && ws.current.readyState === WebSocket.OPEN) {
|
||||||
ws.current.send(JSON.stringify({
|
ws.current.send(JSON.stringify({
|
||||||
type: 'resize',
|
type: 'resize',
|
||||||
@@ -337,178 +345,25 @@ function Shell({ selectedProject, selectedSession, isActive, initialCommand, isP
|
|||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
console.log('[Shell] Terminal cleanup, unmounting component');
|
||||||
resizeObserver.disconnect();
|
resizeObserver.disconnect();
|
||||||
|
|
||||||
// Store session for reuse instead of disposing
|
if (ws.current && (ws.current.readyState === WebSocket.OPEN || ws.current.readyState === WebSocket.CONNECTING)) {
|
||||||
if (terminal.current && selectedProject) {
|
ws.current.close();
|
||||||
const sessionKey = selectedSession?.id || `project-${selectedProject.name}`;
|
}
|
||||||
|
ws.current = null;
|
||||||
try {
|
|
||||||
shellSessions.set(sessionKey, {
|
if (terminal.current) {
|
||||||
terminal: terminal.current,
|
terminal.current.dispose();
|
||||||
fitAddon: fitAddon.current,
|
terminal.current = null;
|
||||||
ws: ws.current,
|
|
||||||
isConnected: isConnected
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [terminalRef.current, selectedProject, selectedSession, isRestarting]);
|
}, [selectedProject?.path || selectedProject?.fullPath, isRestarting]);
|
||||||
|
|
||||||
// Fit terminal when tab becomes active
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isActive || !isInitialized) return;
|
if (!autoConnect || !isInitialized || isConnecting || isConnected) return;
|
||||||
|
connectToShell();
|
||||||
// Fit terminal when tab becomes active and notify backend
|
}, [autoConnect, isInitialized, isConnecting, isConnected, connectToShell]);
|
||||||
setTimeout(() => {
|
|
||||||
if (fitAddon.current) {
|
|
||||||
fitAddon.current.fit();
|
|
||||||
// Send terminal size to backend after tab activation
|
|
||||||
if (terminal.current && ws.current && ws.current.readyState === WebSocket.OPEN) {
|
|
||||||
ws.current.send(JSON.stringify({
|
|
||||||
type: 'resize',
|
|
||||||
cols: terminal.current.cols,
|
|
||||||
rows: terminal.current.rows
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
}, [isActive, isInitialized]);
|
|
||||||
|
|
||||||
// WebSocket connection function (called manually)
|
|
||||||
const connectWebSocket = async () => {
|
|
||||||
if (isConnecting || isConnected) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const isPlatform = import.meta.env.VITE_IS_PLATFORM === 'true';
|
|
||||||
|
|
||||||
// Construct WebSocket URL
|
|
||||||
let wsUrl;
|
|
||||||
|
|
||||||
if (isPlatform) {
|
|
||||||
// Platform mode: Use same domain as the page (goes through proxy)
|
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
||||||
wsUrl = `${protocol}//${window.location.host}/shell`;
|
|
||||||
} else {
|
|
||||||
// OSS mode: Connect to same host:port that served the page
|
|
||||||
const token = localStorage.getItem('auth-token');
|
|
||||||
if (!token) {
|
|
||||||
console.error('No authentication token found for Shell WebSocket connection');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
||||||
wsUrl = `${protocol}//${window.location.host}/shell?token=${encodeURIComponent(token)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
ws.current = new WebSocket(wsUrl);
|
|
||||||
|
|
||||||
ws.current.onopen = () => {
|
|
||||||
setIsConnected(true);
|
|
||||||
setIsConnecting(false);
|
|
||||||
|
|
||||||
// Wait for terminal to be ready, then fit and send dimensions
|
|
||||||
setTimeout(() => {
|
|
||||||
if (fitAddon.current && terminal.current) {
|
|
||||||
// Force a fit to ensure proper dimensions
|
|
||||||
fitAddon.current.fit();
|
|
||||||
|
|
||||||
// Wait a bit more for fit to complete, then send dimensions
|
|
||||||
setTimeout(() => {
|
|
||||||
const initPayload = {
|
|
||||||
type: 'init',
|
|
||||||
projectPath: selectedProject.fullPath || selectedProject.path,
|
|
||||||
sessionId: isPlainShell ? null : selectedSession?.id,
|
|
||||||
hasSession: isPlainShell ? false : !!selectedSession,
|
|
||||||
provider: isPlainShell ? 'plain-shell' : (selectedSession?.__provider || 'claude'),
|
|
||||||
cols: terminal.current.cols,
|
|
||||||
rows: terminal.current.rows,
|
|
||||||
initialCommand: initialCommand,
|
|
||||||
isPlainShell: isPlainShell
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log('Shell init payload:', initPayload);
|
|
||||||
|
|
||||||
ws.current.send(JSON.stringify(initPayload));
|
|
||||||
|
|
||||||
// Also send resize message immediately after init
|
|
||||||
setTimeout(() => {
|
|
||||||
if (terminal.current && ws.current && ws.current.readyState === WebSocket.OPEN) {
|
|
||||||
ws.current.send(JSON.stringify({
|
|
||||||
type: 'resize',
|
|
||||||
cols: terminal.current.cols,
|
|
||||||
rows: terminal.current.rows
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
}, 50);
|
|
||||||
}
|
|
||||||
}, 200);
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.current.onmessage = (event) => {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(event.data);
|
|
||||||
if (data.type === 'output') {
|
|
||||||
// Check for URLs in the output and make them clickable
|
|
||||||
const urlRegex = /(https?:\/\/[^\s\x1b\x07]+)/g;
|
|
||||||
let output = data.data;
|
|
||||||
|
|
||||||
// Find URLs in the text (excluding ANSI escape sequences)
|
|
||||||
const urls = [];
|
|
||||||
let match;
|
|
||||||
while ((match = urlRegex.exec(output.replace(/\x1b\[[0-9;]*m/g, ''))) !== null) {
|
|
||||||
urls.push(match[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isPlainShell && onProcessComplete) {
|
|
||||||
const cleanOutput = output.replace(/\x1b\[[0-9;]*m/g, ''); // Remove ANSI codes
|
|
||||||
if (cleanOutput.includes('Process exited with code 0')) {
|
|
||||||
onProcessComplete(0); // Success
|
|
||||||
} else if (cleanOutput.match(/Process exited with code (\d+)/)) {
|
|
||||||
const exitCode = parseInt(cleanOutput.match(/Process exited with code (\d+)/)[1]);
|
|
||||||
if (exitCode !== 0) {
|
|
||||||
onProcessComplete(exitCode); // Error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If URLs found, log them for potential opening
|
|
||||||
|
|
||||||
terminal.current.write(output);
|
|
||||||
} else if (data.type === 'url_open') {
|
|
||||||
// Handle explicit URL opening requests from server
|
|
||||||
window.open(data.url, '_blank');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.current.onclose = (event) => {
|
|
||||||
setIsConnected(false);
|
|
||||||
setIsConnecting(false);
|
|
||||||
|
|
||||||
// Clear terminal content when connection closes
|
|
||||||
if (terminal.current) {
|
|
||||||
terminal.current.clear();
|
|
||||||
terminal.current.write('\x1b[2J\x1b[H'); // Clear screen and move cursor to home
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't auto-reconnect anymore - user must manually connect
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.current.onerror = (error) => {
|
|
||||||
setIsConnected(false);
|
|
||||||
setIsConnecting(false);
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
setIsConnected(false);
|
|
||||||
setIsConnecting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
if (!selectedProject) {
|
if (!selectedProject) {
|
||||||
return (
|
return (
|
||||||
@@ -526,23 +381,25 @@ function Shell({ selectedProject, selectedSession, isActive, initialCommand, isP
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (minimal) {
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full bg-gray-900">
|
||||||
|
<div ref={terminalRef} className="h-full w-full focus:outline-none" style={{ outline: 'none' }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col bg-gray-900 w-full">
|
<div className="h-full flex flex-col bg-gray-900 w-full">
|
||||||
{/* Header */}
|
|
||||||
<div className="flex-shrink-0 bg-gray-800 border-b border-gray-700 px-4 py-2">
|
<div className="flex-shrink-0 bg-gray-800 border-b border-gray-700 px-4 py-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<div className={`w-2 h-2 rounded-full ${isConnected ? 'bg-green-500' : 'bg-red-500'}`} />
|
<div className={`w-2 h-2 rounded-full ${isConnected ? 'bg-green-500' : 'bg-red-500'}`} />
|
||||||
{selectedSession && (() => {
|
{selectedSession && (
|
||||||
const displaySessionName = selectedSession.__provider === 'cursor'
|
<span className="text-xs text-blue-300">
|
||||||
? (selectedSession.name || 'Untitled Session')
|
({sessionDisplayNameShort}...)
|
||||||
: (selectedSession.summary || 'New Session');
|
</span>
|
||||||
return (
|
)}
|
||||||
<span className="text-xs text-blue-300">
|
|
||||||
({displaySessionName.slice(0, 30)}...)
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
{!selectedSession && (
|
{!selectedSession && (
|
||||||
<span className="text-xs text-gray-400">(New Session)</span>
|
<span className="text-xs text-gray-400">(New Session)</span>
|
||||||
)}
|
)}
|
||||||
@@ -566,7 +423,7 @@ function Shell({ selectedProject, selectedSession, isActive, initialCommand, isP
|
|||||||
<span>Disconnect</span>
|
<span>Disconnect</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={restartShell}
|
onClick={restartShell}
|
||||||
disabled={isRestarting || isConnected}
|
disabled={isRestarting || isConnected}
|
||||||
@@ -582,18 +439,15 @@ function Shell({ selectedProject, selectedSession, isActive, initialCommand, isP
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Terminal */}
|
|
||||||
<div className="flex-1 p-2 overflow-hidden relative">
|
<div className="flex-1 p-2 overflow-hidden relative">
|
||||||
<div ref={terminalRef} className="h-full w-full focus:outline-none" style={{ outline: 'none' }} />
|
<div ref={terminalRef} className="h-full w-full focus:outline-none" style={{ outline: 'none' }} />
|
||||||
|
|
||||||
{/* Loading state */}
|
|
||||||
{!isInitialized && (
|
{!isInitialized && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90">
|
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90">
|
||||||
<div className="text-white">Loading terminal...</div>
|
<div className="text-white">Loading terminal...</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Connect button when not connected */}
|
|
||||||
{isInitialized && !isConnected && !isConnecting && (
|
{isInitialized && !isConnected && !isConnecting && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90 p-4">
|
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90 p-4">
|
||||||
<div className="text-center max-w-sm w-full">
|
<div className="text-center max-w-sm w-full">
|
||||||
@@ -608,23 +462,17 @@ function Shell({ selectedProject, selectedSession, isActive, initialCommand, isP
|
|||||||
<span>Continue in Shell</span>
|
<span>Continue in Shell</span>
|
||||||
</button>
|
</button>
|
||||||
<p className="text-gray-400 text-sm mt-3 px-2">
|
<p className="text-gray-400 text-sm mt-3 px-2">
|
||||||
{isPlainShell ?
|
{isPlainShell ?
|
||||||
`Run ${initialCommand || 'command'} in ${selectedProject.displayName}` :
|
`Run ${initialCommand || 'command'} in ${selectedProject.displayName}` :
|
||||||
selectedSession ?
|
selectedSession ?
|
||||||
(() => {
|
`Resume session: ${sessionDisplayNameLong}...` :
|
||||||
const displaySessionName = selectedSession.__provider === 'cursor'
|
|
||||||
? (selectedSession.name || 'Untitled Session')
|
|
||||||
: (selectedSession.summary || 'New Session');
|
|
||||||
return `Resume session: ${displaySessionName.slice(0, 50)}...`;
|
|
||||||
})() :
|
|
||||||
'Start a new Claude session'
|
'Start a new Claude session'
|
||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Connecting state */}
|
|
||||||
{isConnecting && (
|
{isConnecting && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90 p-4">
|
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90 p-4">
|
||||||
<div className="text-center max-w-sm w-full">
|
<div className="text-center max-w-sm w-full">
|
||||||
@@ -633,7 +481,7 @@ function Shell({ selectedProject, selectedSession, isActive, initialCommand, isP
|
|||||||
<span className="text-base font-medium">Connecting to shell...</span>
|
<span className="text-base font-medium">Connecting to shell...</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-gray-400 text-sm mt-3 px-2">
|
<p className="text-gray-400 text-sm mt-3 px-2">
|
||||||
{isPlainShell ?
|
{isPlainShell ?
|
||||||
`Running ${initialCommand || 'command'} in ${selectedProject.displayName}` :
|
`Running ${initialCommand || 'command'} in ${selectedProject.displayName}` :
|
||||||
`Starting Claude CLI in ${selectedProject.displayName}`
|
`Starting Claude CLI in ${selectedProject.displayName}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -303,19 +303,25 @@ function Sidebar({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
console.log('[Sidebar] Deleting session:', { projectName, sessionId });
|
||||||
const response = await api.deleteSession(projectName, sessionId);
|
const response = await api.deleteSession(projectName, sessionId);
|
||||||
|
console.log('[Sidebar] Delete response:', { ok: response.ok, status: response.status });
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
|
console.log('[Sidebar] Session deleted successfully, calling callback');
|
||||||
// Call parent callback if provided
|
// Call parent callback if provided
|
||||||
if (onSessionDelete) {
|
if (onSessionDelete) {
|
||||||
onSessionDelete(sessionId);
|
onSessionDelete(sessionId);
|
||||||
|
} else {
|
||||||
|
console.warn('[Sidebar] No onSessionDelete callback provided');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.error('Failed to delete session');
|
const errorText = await response.text();
|
||||||
|
console.error('[Sidebar] Failed to delete session:', { status: response.status, error: errorText });
|
||||||
alert('Failed to delete session. Please try again.');
|
alert('Failed to delete session. Please try again.');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting session:', error);
|
console.error('[Sidebar] Error deleting session:', error);
|
||||||
alert('Error deleting session. Please try again.');
|
alert('Error deleting session. Please try again.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useCallback } from 'react';
|
||||||
import Shell from './Shell.jsx';
|
import Shell from './Shell.jsx';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generic Shell wrapper that can be used in tabs, modals, and other contexts.
|
* Generic Shell wrapper that can be used in tabs, modals, and other contexts.
|
||||||
* Provides a flexible API for both standalone and session-based usage.
|
* Provides a flexible API for both standalone and session-based usage.
|
||||||
*
|
*
|
||||||
* @param {Object} project - Project object with name, fullPath/path, displayName
|
* @param {Object} project - Project object with name, fullPath/path, displayName
|
||||||
* @param {Object} session - Session object (optional, for tab usage)
|
* @param {Object} session - Session object (optional, for tab usage)
|
||||||
* @param {string} command - Initial command to run (optional)
|
* @param {string} command - Initial command to run (optional)
|
||||||
* @param {boolean} isActive - Whether the shell is active (for tab usage, default: true)
|
|
||||||
* @param {boolean} isPlainShell - Use plain shell mode vs Claude CLI (default: auto-detect)
|
* @param {boolean} isPlainShell - Use plain shell mode vs Claude CLI (default: auto-detect)
|
||||||
* @param {boolean} autoConnect - Whether to auto-connect when mounted (default: true)
|
* @param {boolean} autoConnect - Whether to auto-connect when mounted (default: true)
|
||||||
* @param {function} onComplete - Callback when process completes (receives exitCode)
|
* @param {function} onComplete - Callback when process completes (receives exitCode)
|
||||||
@@ -17,33 +16,32 @@ import Shell from './Shell.jsx';
|
|||||||
* @param {string} className - Additional CSS classes
|
* @param {string} className - Additional CSS classes
|
||||||
* @param {boolean} showHeader - Whether to show custom header (default: true)
|
* @param {boolean} showHeader - Whether to show custom header (default: true)
|
||||||
* @param {boolean} compact - Use compact layout (default: false)
|
* @param {boolean} compact - Use compact layout (default: false)
|
||||||
|
* @param {boolean} minimal - Use minimal mode: no header, no overlays, auto-connect (default: false)
|
||||||
*/
|
*/
|
||||||
function StandaloneShell({
|
function StandaloneShell({
|
||||||
project,
|
project,
|
||||||
session = null,
|
session = null,
|
||||||
command = null,
|
command = null,
|
||||||
isActive = true,
|
isPlainShell = null,
|
||||||
isPlainShell = null, // Auto-detect: true if command provided, false if session provided
|
|
||||||
autoConnect = true,
|
autoConnect = true,
|
||||||
onComplete = null,
|
onComplete = null,
|
||||||
onClose = null,
|
onClose = null,
|
||||||
title = null,
|
title = null,
|
||||||
className = "",
|
className = "",
|
||||||
showHeader = true,
|
showHeader = true,
|
||||||
compact = false
|
compact = false,
|
||||||
|
minimal = false
|
||||||
}) {
|
}) {
|
||||||
const [isCompleted, setIsCompleted] = useState(false);
|
const [isCompleted, setIsCompleted] = useState(false);
|
||||||
|
|
||||||
// Auto-detect isPlainShell based on props
|
|
||||||
const shouldUsePlainShell = isPlainShell !== null ? isPlainShell : (command !== null);
|
const shouldUsePlainShell = isPlainShell !== null ? isPlainShell : (command !== null);
|
||||||
|
|
||||||
// Handle process completion
|
const handleProcessComplete = useCallback((exitCode) => {
|
||||||
const handleProcessComplete = (exitCode) => {
|
|
||||||
setIsCompleted(true);
|
setIsCompleted(true);
|
||||||
if (onComplete) {
|
if (onComplete) {
|
||||||
onComplete(exitCode);
|
onComplete(exitCode);
|
||||||
}
|
}
|
||||||
};
|
}, [onComplete]);
|
||||||
|
|
||||||
if (!project) {
|
if (!project) {
|
||||||
return (
|
return (
|
||||||
@@ -62,9 +60,9 @@ function StandaloneShell({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`h-full flex flex-col ${className}`}>
|
<div className={`h-full w-full flex flex-col ${className}`}>
|
||||||
{/* Optional custom header */}
|
{/* Optional custom header */}
|
||||||
{showHeader && title && (
|
{!minimal && showHeader && title && (
|
||||||
<div className="flex-shrink-0 bg-gray-800 border-b border-gray-700 px-4 py-2">
|
<div className="flex-shrink-0 bg-gray-800 border-b border-gray-700 px-4 py-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
@@ -89,14 +87,15 @@ function StandaloneShell({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Shell component wrapper */}
|
{/* Shell component wrapper */}
|
||||||
<div className="flex-1">
|
<div className="flex-1 w-full min-h-0">
|
||||||
<Shell
|
<Shell
|
||||||
selectedProject={project}
|
selectedProject={project}
|
||||||
selectedSession={session}
|
selectedSession={session}
|
||||||
isActive={isActive}
|
|
||||||
initialCommand={command}
|
initialCommand={command}
|
||||||
isPlainShell={shouldUsePlainShell}
|
isPlainShell={shouldUsePlainShell}
|
||||||
onProcessComplete={handleProcessComplete}
|
onProcessComplete={handleProcessComplete}
|
||||||
|
minimal={minimal}
|
||||||
|
autoConnect={minimal ? true : autoConnect}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user