diff --git a/backend/src/index.ts b/backend/src/index.ts index 4763a8d6..dc21b996 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -25,6 +25,9 @@ const configService = new ConfigService(); // FileSystemService for stack management const fileSystemService = new FileSystemService(); +// ComposeService for stack operations +const composeService = new ComposeService(); + // Cookie settings const COOKIE_NAME = 'sencho_token'; @@ -219,48 +222,12 @@ server.on('upgrade', async (req, socket, head) => { const logsMatch = url.match(/^\/api\/stacks\/([^/]+)\/logs$/); if (logsMatch) { - // Dedicated stack logs WebSocket - uses raw Docker CLI to support legacy containers + // Dedicated stack logs WebSocket - uses Supervisor loop for persistent logs const logsWss = new WebSocket.Server({ noServer: true }); - logsWss.handleUpgrade(req, socket, head, async (ws) => { + logsWss.handleUpgrade(req, socket, head, (ws) => { const stackName = decodeURIComponent(logsMatch[1]); try { - const dockerController = DockerController.getInstance(); - const containers = await dockerController.getContainersByStack(stackName); - if (!containers || containers.length === 0) { - ws.send('No containers running to log.\n'); - return; - } - // Stream logs from all containers using raw docker logs CLI - const childProcesses: ReturnType[] = []; - for (const container of containers) { - const containerName = container.Names?.[0]?.replace(/^\//, '') || container.Id; - const logProcess = spawn('docker', ['logs', '-f', '--tail', '100', containerName], { - env: { - ...process.env, - PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin' - } - }); - childProcesses.push(logProcess); - logProcess.stdout.on('data', (data: Buffer) => { - if (ws.readyState === WebSocket.OPEN) { - ws.send(data.toString()); - } - }); - logProcess.stderr.on('data', (data: Buffer) => { - if (ws.readyState === WebSocket.OPEN) { - ws.send(data.toString()); - } - }); - logProcess.on('error', (error: Error) => { - console.error(`Docker logs error for ${containerName}:`, error.message); - }); - } - // Kill all log processes when WebSocket closes - ws.on('close', () => { - for (const proc of childProcesses) { - try { proc.kill(); } catch { /* ignore */ } - } - }); + composeService.streamLogs(stackName, ws); } catch (error) { console.error('Failed to stream logs:', error); if (ws.readyState === WebSocket.OPEN) { @@ -481,8 +448,7 @@ app.post('/api/containers/:id/restart', async (req: Request, res: Response) => { } }); -const composeService = new ComposeService(); - +// End of legacy container routes app.post('/api/stacks/:stackName/up', async (req: Request, res: Response) => { try { const stackName = req.params.stackName as string; diff --git a/backend/src/services/ComposeService.ts b/backend/src/services/ComposeService.ts index 165569a6..04c81c62 100644 --- a/backend/src/services/ComposeService.ts +++ b/backend/src/services/ComposeService.ts @@ -1,6 +1,7 @@ import { spawn } from 'child_process'; import path from 'path'; import WebSocket from 'ws'; +import DockerController from './DockerController'; export class ComposeService { private baseDir: string; @@ -52,54 +53,115 @@ export class ComposeService { } /** - * Stream docker compose logs for a stack via WebSocket. - * Spawns `docker compose logs -f --tail 100` and pipes stdout/stderr to ws.send(). - * Kills the child process when the WebSocket closes. + * Stream docker logs for a stack via WebSocket. + * Supervisors: Fetches containers via DockerController and spawns `docker logs -f` for each. + * Automatically re-spawns on process exit if WebSocket is still OPEN, creating a persistent stream. + * Kills the child processes when the WebSocket closes. */ streamLogs(stackName: string, ws: WebSocket) { - const stackDir = path.join(this.baseDir, stackName); + let isClosed = false; + let isFirstRun = true; + let isWaitingForActivity = false; - const child = spawn('docker', ['compose', 'logs', '-f', '--tail', '100'], { - cwd: stackDir, - env: { - ...process.env, - PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin' - } - }); - - child.stdout.on('data', (data: Buffer) => { - if (ws.readyState === WebSocket.OPEN) { - ws.send(data.toString()); - } - }); - - child.stderr.on('data', (data: Buffer) => { - if (ws.readyState === WebSocket.OPEN) { - ws.send(data.toString()); - } - }); - - child.on('error', (error: Error) => { - console.error(`Docker Compose Logs Error for ${stackName}:`, error.message); - if (ws.readyState === WebSocket.OPEN) { - ws.send(`Error: ${error.message}\n`); - } - }); - - child.on('close', (code: number | null) => { - if (ws.readyState === WebSocket.OPEN) { - ws.send(`\r\nLog stream ended (code ${code})\r\n`); - } - }); - - // Kill the logs process when the WebSocket is closed ws.on('close', () => { - try { - child.kill(); - } catch { - // Ignore kill errors - } + isClosed = true; }); + + const startStream = async () => { + if (isClosed || ws.readyState !== WebSocket.OPEN) return; + + try { + const dockerController = DockerController.getInstance(); + const containers = await dockerController.getContainersByStack(stackName); + + if (!containers || containers.length === 0) { + if (!isWaitingForActivity) { + ws.send(`\r\n\x1b[33m[Sencho] No containers found. Waiting for activity...\x1b[0m\r\n`); + isWaitingForActivity = true; + } + setTimeout(startStream, 2000); + return; + } + + const runningContainers = containers.filter(c => c.State === 'running'); + + // If not first run and no containers are running, we poll to wait for activity + if (!isFirstRun && runningContainers.length === 0) { + if (!isWaitingForActivity) { + ws.send(`\r\n\x1b[33m[Sencho] Log stream ended. Waiting for container activity...\x1b[0m\r\n`); + isWaitingForActivity = true; + } + setTimeout(startStream, 2000); + return; + } + + // On first run, we stream all containers to dump history. + // On subsequent runs, we only attach to running containers to avoid immediate exit loop. + const containersToLog = isFirstRun ? containers : runningContainers; + isFirstRun = false; + isWaitingForActivity = false; // Reset since we are tracking active elements + + let activeProcesses = 0; + let streamEndedHandled = false; + const childProcesses: ReturnType[] = []; + + const onWsClose = () => { + childProcesses.forEach(cp => { + try { cp.kill(); } catch { /* ignore */ } + }); + }; + + ws.on('close', onWsClose); + + const handleProcessEnd = () => { + activeProcesses--; + if (activeProcesses <= 0 && !streamEndedHandled) { + streamEndedHandled = true; + ws.removeListener('close', onWsClose); + + if (!isClosed && ws.readyState === WebSocket.OPEN) { + setTimeout(startStream, 1000); + } + } + }; + + for (const container of containersToLog) { + const containerName = container.Names?.[0]?.replace(/^\//, '') || container.Id; + const child = spawn('docker', ['logs', '-f', '--tail', '100', containerName], { + env: { + ...process.env, + PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin' + } + }); + + activeProcesses++; + childProcesses.push(child); + + const sendOutput = (data: Buffer) => { + if (ws.readyState === WebSocket.OPEN) { + const text = data.toString().replace(/\r?\n/g, '\r\n'); + ws.send(text); + } + }; + + child.stdout.on('data', sendOutput); + child.stderr.on('data', sendOutput); + child.on('error', handleProcessEnd); + child.on('close', handleProcessEnd); + } + + } catch (err) { + if (!isClosed && ws.readyState === WebSocket.OPEN) { + if (!isWaitingForActivity) { + ws.send(`\r\n\x1b[31m[Sencho] Error tracking containers. Retrying...\x1b[0m\r\n`); + isWaitingForActivity = true; + } + setTimeout(startStream, 2000); + } + } + }; + + startStream(); } /** diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0a30ff8f..2921d77a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,12 +13,15 @@ "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-tooltip": "^1.2.8", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "lucide-react": "^0.575.0", "next-themes": "^0.4.6", "react": "^19.2.0", @@ -926,6 +929,44 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz", + "integrity": "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.5.tgz", + "integrity": "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.4", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.7.tgz", + "integrity": "sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.5" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1109,6 +1150,29 @@ } } }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-collection": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", @@ -1383,6 +1447,38 @@ } } }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-portal": { "version": "1.1.9", "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", @@ -1534,6 +1630,52 @@ } } }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz", + "integrity": "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-slot": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", @@ -1582,6 +1724,58 @@ } } }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", + "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", @@ -1667,6 +1861,71 @@ } } }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "license": "MIT" + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.3", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", @@ -2925,6 +3184,22 @@ "node": ">=6" } }, + "node_modules/cmdk": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz", + "integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-id": "^1.1.0", + "@radix-ui/react-primitive": "^2.0.2" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 89271c85..75297d01 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,12 +15,15 @@ "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-tooltip": "^1.2.8", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "lucide-react": "^0.575.0", "next-themes": "^0.4.6", "react": "^19.2.0", diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index ee7b1480..330d3af0 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -12,11 +12,15 @@ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, import { Tabs, TabsList, TabsTrigger } from './ui/tabs'; import { Card, CardContent, CardHeader, CardTitle } from './ui/card'; import { Badge } from './ui/badge'; -import { Plus, Trash2, Play, Square, Save, Terminal, Sun, Moon, RotateCw, CloudDownload, Pencil, X, Search, Home, LogOut, Brush } from 'lucide-react'; +import { Plus, Trash2, Play, Square, Save, Terminal, Sun, Moon, RotateCw, CloudDownload, Pencil, X, Home, LogOut, Brush } from 'lucide-react'; import { useAuth } from '@/context/AuthContext'; import { apiFetch } from '@/lib/api'; import { toast } from 'sonner'; import { Label } from './ui/label'; +import { Command, CommandInput, CommandList, CommandItem } from './ui/command'; +import { ScrollArea } from './ui/scroll-area'; +import { Skeleton } from './ui/skeleton'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip'; interface ContainerInfo { Id: string; @@ -447,15 +451,21 @@ export default function EditorLayout() { {/* Branding Header */}

Sencho

- + + + + + + Logout + +
{/* Create Stack Button */} @@ -487,44 +497,47 @@ export default function EditorLayout() { - {/* Search Input */} -
-
- - +
+ setSearchQuery(e.target.value)} - className="pl-8 rounded-lg" + onValueChange={setSearchQuery} + className="h-9" />
-
- - {/* Stack List */} -
-

STACKS

- {isLoading ? ( -
Loading...
- ) : ( - (filteredFiles || []).map(file => ( - - )) - )} -
+

STACKS

+ + + {isLoading ? ( +
+ + + +
+ ) : ( + (filteredFiles || []).map(file => ( + loadFile(file)} + className={`justify-start rounded-lg mb-1 cursor-pointer ${selectedFile === file ? 'bg-accent text-accent-foreground' : ''}`} + > +
+
+ {getDisplayName(file)} +
+ + )) + )} + + +
{/* Main Content Area */} @@ -675,17 +688,22 @@ export default function EditorLayout() {
- + + + + + + Open Bash Terminal + +
))} diff --git a/frontend/src/components/Terminal.tsx b/frontend/src/components/Terminal.tsx index 99cbb64e..cce69a7d 100644 --- a/frontend/src/components/Terminal.tsx +++ b/frontend/src/components/Terminal.tsx @@ -51,6 +51,7 @@ export default function TerminalComponent({ stackName }: TerminalComponentProps) }, fontFamily: 'Consolas, Monaco, monospace', fontSize: 13, + scrollback: 10000, }); const fitAddon = new FitAddon(); @@ -112,14 +113,18 @@ export default function TerminalComponent({ stackName }: TerminalComponentProps) const timeoutId = setTimeout(initTerminal, 50); // Attach ResizeObserver to the terminal's parent container + let resizeTimeout: number | undefined; const resizeObserver = new ResizeObserver(() => { - if (fitAddonRef.current && terminalRef.current && mounted) { - try { - fitAddonRef.current.fit(); - } catch { - // Ignore fit errors during resize + if (resizeTimeout) clearTimeout(resizeTimeout); + resizeTimeout = setTimeout(() => { + if (fitAddonRef.current && terminalRef.current && mounted) { + try { + fitAddonRef.current.fit(); + } catch { + // Ignore fit errors during resize + } } - } + }, 50); }); if (terminalRef.current.parentElement) { diff --git a/frontend/src/components/ui/badge.tsx b/frontend/src/components/ui/badge.tsx index 12daad7d..e87d62bf 100644 --- a/frontend/src/components/ui/badge.tsx +++ b/frontend/src/components/ui/badge.tsx @@ -4,16 +4,16 @@ import { cva, type VariantProps } from "class-variance-authority" import { cn } from "@/lib/utils" const badgeVariants = cva( - "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", { variants: { variant: { default: - "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", + "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", destructive: - "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", + "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", outline: "text-foreground", }, }, @@ -33,4 +33,4 @@ function Badge({ className, variant, ...props }: BadgeProps) { ) } -export { Badge, badgeVariants } \ No newline at end of file +export { Badge, badgeVariants } diff --git a/frontend/src/components/ui/command.tsx b/frontend/src/components/ui/command.tsx new file mode 100644 index 00000000..0db642a6 --- /dev/null +++ b/frontend/src/components/ui/command.tsx @@ -0,0 +1,151 @@ +import * as React from "react" +import { type DialogProps } from "@radix-ui/react-dialog" +import { Command as CommandPrimitive } from "cmdk" +import { Search } from "lucide-react" + +import { cn } from "@/lib/utils" +import { Dialog, DialogContent } from "@/components/ui/dialog" + +const Command = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Command.displayName = CommandPrimitive.displayName + +const CommandDialog = ({ children, ...props }: DialogProps) => { + return ( + + + + {children} + + + + ) +} + +const CommandInput = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( +
+ + +
+)) + +CommandInput.displayName = CommandPrimitive.Input.displayName + +const CommandList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandList.displayName = CommandPrimitive.List.displayName + +const CommandEmpty = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>((props, ref) => ( + +)) + +CommandEmpty.displayName = CommandPrimitive.Empty.displayName + +const CommandGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandGroup.displayName = CommandPrimitive.Group.displayName + +const CommandSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +CommandSeparator.displayName = CommandPrimitive.Separator.displayName + +const CommandItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandItem.displayName = CommandPrimitive.Item.displayName + +const CommandShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ) +} +CommandShortcut.displayName = "CommandShortcut" + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +} diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx index 4327940c..1647513e 100644 --- a/frontend/src/components/ui/dialog.tsx +++ b/frontend/src/components/ui/dialog.tsx @@ -112,11 +112,11 @@ export { Dialog, DialogPortal, DialogOverlay, - DialogClose, DialogTrigger, + DialogClose, DialogContent, DialogHeader, DialogFooter, DialogTitle, DialogDescription, -} \ No newline at end of file +} diff --git a/frontend/src/components/ui/scroll-area.tsx b/frontend/src/components/ui/scroll-area.tsx index 410a75d8..cf253cf1 100644 --- a/frontend/src/components/ui/scroll-area.tsx +++ b/frontend/src/components/ui/scroll-area.tsx @@ -1,5 +1,3 @@ -"use client" - import * as React from "react" import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" @@ -33,9 +31,9 @@ const ScrollBar = React.forwardRef< className={cn( "flex touch-none select-none transition-colors", orientation === "vertical" && - "h-full w-2.5 border-l border-l-transparent p-px", + "h-full w-2.5 border-l border-l-transparent p-[1px]", orientation === "horizontal" && - "h-2.5 flex-col border-t border-t-transparent p-px", + "h-2.5 flex-col border-t border-t-transparent p-[1px]", className )} {...props} @@ -45,4 +43,4 @@ const ScrollBar = React.forwardRef< )) ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName -export { ScrollArea, ScrollBar } \ No newline at end of file +export { ScrollArea, ScrollBar } diff --git a/frontend/src/components/ui/separator.tsx b/frontend/src/components/ui/separator.tsx new file mode 100644 index 00000000..12d81c4a --- /dev/null +++ b/frontend/src/components/ui/separator.tsx @@ -0,0 +1,31 @@ +"use client" + +import * as React from "react" +import * as SeparatorPrimitive from "@radix-ui/react-separator" + +import { cn } from "@/lib/utils" + +const Separator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>( + ( + { className, orientation = "horizontal", decorative = true, ...props }, + ref + ) => ( + + ) +) +Separator.displayName = SeparatorPrimitive.Root.displayName + +export { Separator } diff --git a/frontend/src/components/ui/skeleton.tsx b/frontend/src/components/ui/skeleton.tsx new file mode 100644 index 00000000..d7e45f7b --- /dev/null +++ b/frontend/src/components/ui/skeleton.tsx @@ -0,0 +1,15 @@ +import { cn } from "@/lib/utils" + +function Skeleton({ + className, + ...props +}: React.HTMLAttributes) { + return ( +
+ ) +} + +export { Skeleton } diff --git a/frontend/src/components/ui/tabs.tsx b/frontend/src/components/ui/tabs.tsx index 8873b851..0f4caebb 100644 --- a/frontend/src/components/ui/tabs.tsx +++ b/frontend/src/components/ui/tabs.tsx @@ -14,7 +14,7 @@ const TabsList = React.forwardRef< , + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)) +TooltipContent.displayName = TooltipPrimitive.Content.displayName + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }