From 37701352be50a12f9a759a90a07f302ef7c41ee9 Mon Sep 17 00:00:00 2001 From: Dustin <155417613+vgcman16@users.noreply.github.com> Date: Thu, 24 Oct 2024 23:13:11 -0500 Subject: [PATCH] feat: improve terminal feedback and interactivity Add checkmark indicator when dev server is ready Enable interactive terminal input with command execution Show proper completion states for all commands Add support for basic terminal features (backspace, Ctrl+C) Fix status updates in both terminal and UI Improve command feedback with clear success/failure indicators --- .../workbench/terminal/Terminal.tsx | 36 +++++++ app/lib/runtime/action-runner.ts | 93 +++++++++++++++++-- app/lib/stores/workbench.ts | 10 ++ 3 files changed, 130 insertions(+), 9 deletions(-) diff --git a/app/components/workbench/terminal/Terminal.tsx b/app/components/workbench/terminal/Terminal.tsx index 2d56c980..fe10bc85 100644 --- a/app/components/workbench/terminal/Terminal.tsx +++ b/app/components/workbench/terminal/Terminal.tsx @@ -5,6 +5,7 @@ import { forwardRef, memo, useEffect, useImperativeHandle, useRef } from 'react' import type { Theme } from '~/lib/stores/theme'; import { createScopedLogger } from '~/utils/logger'; import { getTerminalTheme } from './theme'; +import { workbenchStore } from '~/lib/stores/workbench'; const logger = createScopedLogger('Terminal'); @@ -25,6 +26,7 @@ export const Terminal = memo( const terminalElementRef = useRef(null); const terminalRef = useRef(); const fitAddonRef = useRef(); + const inputBufferRef = useRef(''); useEffect(() => { const element = terminalElementRef.current!; @@ -52,6 +54,40 @@ export const Terminal = memo( terminal.loadAddon(webLinksAddon); terminal.open(element); + // Handle user input + terminal.onData((data) => { + if (readonly) return; + + // Handle special keys + if (data === '\r') { // Enter key + const command = inputBufferRef.current.trim(); + if (command) { + terminal.write('\r\n'); + workbenchStore.handleTerminalInput(command); + } + inputBufferRef.current = ''; + return; + } + + if (data === '\u007f') { // Backspace + if (inputBufferRef.current.length > 0) { + inputBufferRef.current = inputBufferRef.current.slice(0, -1); + terminal.write('\b \b'); + } + return; + } + + if (data === '\u0003') { // Ctrl+C + terminal.write('^C\r\n$ '); + inputBufferRef.current = ''; + return; + } + + // Regular input + inputBufferRef.current += data; + terminal.write(data); + }); + // Initial fit setTimeout(() => { fitAddon.fit(); diff --git a/app/lib/runtime/action-runner.ts b/app/lib/runtime/action-runner.ts index 3b007e6a..67b9b3d7 100644 --- a/app/lib/runtime/action-runner.ts +++ b/app/lib/runtime/action-runner.ts @@ -38,6 +38,8 @@ type ActionsMap = MapStore>; export class ActionRunner { #webcontainer: Promise; #currentExecutionPromise: Promise = Promise.resolve(); + #currentProcess: any = null; + #currentActionId: string | null = null; actions: ActionsMap = map({}); @@ -97,13 +99,14 @@ export class ActionRunner { const terminal = workbenchStore.terminal; if (terminal) { const errorMessage = error instanceof Error ? error.message : String(error); - terminal.write(`\r\nError: ${errorMessage}\r\n`); + terminal.write(`\r\nError: ${errorMessage}\r\n$ `); } }); } async #executeAction(actionId: string) { const action = this.actions.get()[actionId]; + this.#currentActionId = actionId; this.#updateAction(actionId, { status: 'running' }); @@ -119,7 +122,10 @@ export class ActionRunner { } } - this.#updateAction(actionId, { status: action.abortSignal.aborted ? 'aborted' : 'complete' }); + // Don't update status for npm run dev - it will be updated when server is ready + if (!(action.type === 'shell' && action.content.includes('npm run dev'))) { + this.#updateAction(actionId, { status: action.abortSignal.aborted ? 'aborted' : 'complete' }); + } } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : String(error); this.#updateAction(actionId, { status: 'failed', error: errorMessage }); @@ -153,31 +159,55 @@ export class ActionRunner { }); // Display the command being executed - terminal.write(`\r\n$ ${action.content}\r\n`); + terminal.write(`\r\n$ ${action.content}`); const process = await webcontainer.spawn('jsh', ['-c', action.content], { env: { npm_config_yes: true }, }); + this.#currentProcess = process; + action.abortSignal.addEventListener('abort', () => { process.kill(); }); + // Set up a flag to track if we've shown the ready message + let readyMessageShown = false; + process.output.pipeTo( new WritableStream({ - write(data) { + write: (data) => { console.log(data); terminal.write(data); + + // Check for dev server ready message + if (!readyMessageShown && + (data.includes('Local:') || data.includes('localhost:')) && + action.content.includes('npm run dev')) { + terminal.write('\r\n✓ Development server is ready\r\n'); + readyMessageShown = true; + // Update the action status to complete when dev server is ready + if (this.#currentActionId) { + this.#updateAction(this.#currentActionId, { status: 'complete' }); + } + } }, }), ); const exitCode = await process.exit; + this.#currentProcess = null; if (exitCode !== 0) { + terminal.write('\r\n❌ Command failed\r\n$ '); throw new Error(`Command failed with exit code ${exitCode}`); } + // Only show completion message for non-dev-server commands + if (!action.content.includes('npm run dev')) { + terminal.write('\r\n✓ Command completed\r\n$ '); + } + logger.debug(`Process terminated with code ${exitCode}`); } @@ -212,31 +242,76 @@ export class ActionRunner { if (folder !== '.') { try { await webcontainer.fs.mkdir(folder, { recursive: true }); - terminal.write(`\r\nCreated folder: ${folder}\r\n`); + terminal.write(`\r\nCreated folder: ${folder}`); logger.debug('Created folder', folder); } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : String(error); logger.error('Failed to create folder\n\n', error); - terminal.write(`\r\nError creating folder: ${errorMessage}\r\n`); + terminal.write(`\r\nError creating folder: ${errorMessage}\r\n$ `); throw error; } } try { await webcontainer.fs.writeFile(action.filePath, action.content); - terminal.write(`\r\nCreated file: ${action.filePath}\r\n`); + terminal.write(`\r\nCreated file: ${action.filePath}\r\n✓ File operation completed\r\n$ `); logger.debug(`File written ${action.filePath}`); } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : String(error); logger.error('Failed to write file\n\n', error); - terminal.write(`\r\nError creating file: ${errorMessage}\r\n`); + terminal.write(`\r\nError creating file: ${errorMessage}\r\n$ `); throw error; } } #updateAction(id: string, newState: ActionStateUpdate) { const actions = this.actions.get(); - this.actions.setKey(id, { ...actions[id], ...newState }); } + + // Handle user input from terminal + async handleTerminalInput(command: string) { + if (!command) return; + + const webcontainer = await this.#webcontainer; + const terminal = workbenchStore.terminal; + + if (!terminal) return; + + const process = await webcontainer.spawn('jsh', ['-c', command], { + env: { npm_config_yes: true }, + }); + + this.#currentProcess = process; + + // Set up a flag to track if we've shown the ready message + let readyMessageShown = false; + + process.output.pipeTo( + new WritableStream({ + write(data) { + console.log(data); + terminal.write(data); + + // Check for dev server ready message + if (!readyMessageShown && + (data.includes('Local:') || data.includes('localhost:')) && + command.includes('npm run dev')) { + terminal.write('\r\n✓ Development server is ready\r\n'); + readyMessageShown = true; + } + }, + }), + ); + + const exitCode = await process.exit; + this.#currentProcess = null; + + if (exitCode !== 0) { + terminal.write('\r\n❌ Command failed\r\n$ '); + } else if (!command.includes('npm run dev')) { + // Only show completion message for non-dev-server commands + terminal.write('\r\n✓ Command completed\r\n$ '); + } + } } diff --git a/app/lib/stores/workbench.ts b/app/lib/stores/workbench.ts index b492a6c4..d6f8bfe3 100644 --- a/app/lib/stores/workbench.ts +++ b/app/lib/stores/workbench.ts @@ -33,6 +33,7 @@ export class WorkbenchStore { #editorStore = new EditorStore(this.#filesStore); #terminalStore = new TerminalStore(webcontainer); #currentTerminal: XTerm | null = null; + #currentRunner: ActionRunner | null = null; artifacts: Artifacts = import.meta.hot?.data.artifacts ?? map({}); @@ -49,6 +50,8 @@ export class WorkbenchStore { import.meta.hot.data.showWorkbench = this.showWorkbench; import.meta.hot.data.currentView = this.currentView; } + // Initialize a default action runner for terminal commands + this.#currentRunner = new ActionRunner(webcontainer); } get terminal() { @@ -97,6 +100,13 @@ export class WorkbenchStore { this.toggleTerminal(true); } + // Handle terminal input + async handleTerminalInput(command: string) { + if (this.#currentRunner) { + await this.#currentRunner.handleTerminalInput(command); + } + } + onTerminalResize(cols: number, rows: number) { this.#terminalStore.onTerminalResize(cols, rows); }