feat: bolt terminal

added dedicated bolt terminal in workbench
This commit is contained in:
Dustin Loring 2025-01-18 01:56:50 -05:00
parent 3c15db95f7
commit 99666d7ab4
5 changed files with 134 additions and 16 deletions

View File

@ -60,8 +60,9 @@ export const EditorPanel = memo(
const theme = useStore(themeStore); const theme = useStore(themeStore);
const showTerminal = useStore(workbenchStore.showTerminal); const showTerminal = useStore(workbenchStore.showTerminal);
const showBoltTerminal = useStore(workbenchStore.showBoltTerminal);
const terminalRefs = useRef<Array<TerminalRef | null>>([]); const terminalRefs = useRef<(TerminalRef | null)[]>([]);
const terminalPanelRef = useRef<ImperativePanelHandle>(null); const terminalPanelRef = useRef<ImperativePanelHandle>(null);
const terminalToggledByShortcut = useRef(false); const terminalToggledByShortcut = useRef(false);
@ -105,15 +106,16 @@ export const EditorPanel = memo(
} }
const isCollapsed = terminal.isCollapsed(); const isCollapsed = terminal.isCollapsed();
const shouldShowTerminal = showTerminal || showBoltTerminal;
if (!showTerminal && !isCollapsed) { if (!shouldShowTerminal && !isCollapsed) {
terminal.collapse(); terminal.collapse();
} else if (showTerminal && isCollapsed) { } else if (shouldShowTerminal && isCollapsed) {
terminal.resize(DEFAULT_TERMINAL_SIZE); terminal.resize(DEFAULT_TERMINAL_SIZE);
} }
terminalToggledByShortcut.current = false; terminalToggledByShortcut.current = false;
}, [showTerminal]); }, [showTerminal, showBoltTerminal]);
const addTerminal = () => { const addTerminal = () => {
if (terminalCount < MAX_TERMINALS) { if (terminalCount < MAX_TERMINALS) {
@ -124,7 +126,7 @@ export const EditorPanel = memo(
return ( return (
<PanelGroup direction="vertical"> <PanelGroup direction="vertical">
<Panel defaultSize={showTerminal ? DEFAULT_EDITOR_SIZE : 100} minSize={20}> <Panel defaultSize={showTerminal || showBoltTerminal ? DEFAULT_EDITOR_SIZE : 100} minSize={20}>
<PanelGroup direction="horizontal"> <PanelGroup direction="horizontal">
<Panel defaultSize={20} minSize={10} collapsible> <Panel defaultSize={20} minSize={10} collapsible>
<div className="flex flex-col border-r border-bolt-elements-borderColor h-full"> <div className="flex flex-col border-r border-bolt-elements-borderColor h-full">
@ -182,25 +184,38 @@ export const EditorPanel = memo(
<PanelResizeHandle /> <PanelResizeHandle />
<Panel <Panel
ref={terminalPanelRef} ref={terminalPanelRef}
defaultSize={showTerminal ? DEFAULT_TERMINAL_SIZE : 0} defaultSize={DEFAULT_TERMINAL_SIZE}
minSize={10} minSize={10}
collapsible collapsible
onExpand={() => {
if (!terminalToggledByShortcut.current) {
workbenchStore.toggleTerminal(true);
}
}}
onCollapse={() => { onCollapse={() => {
if (!terminalToggledByShortcut.current) { if (!terminalToggledByShortcut.current) {
workbenchStore.toggleTerminal(false); workbenchStore.toggleTerminal(false);
workbenchStore.toggleBoltTerminal(false);
} }
}} }}
> >
<div className="h-full"> <div className="h-full">
<div className="bg-bolt-elements-terminals-background h-full flex flex-col"> <div className="bg-bolt-elements-terminals-background h-full flex flex-col">
<div className="flex items-center bg-bolt-elements-background-depth-2 border-y border-bolt-elements-borderColor gap-1.5 min-h-[34px] p-2"> <div className="flex items-center bg-bolt-elements-background-depth-2 border-y border-bolt-elements-borderColor gap-1.5 min-h-[34px] p-2">
<button
className={classNames(
'flex items-center text-sm cursor-pointer gap-1.5 px-3 py-2 h-full whitespace-nowrap rounded-full',
{
'bg-bolt-elements-terminals-buttonBackground text-bolt-elements-textPrimary': showBoltTerminal,
'bg-bolt-elements-background-depth-2 text-bolt-elements-textSecondary hover:bg-bolt-elements-terminals-buttonBackground':
!showBoltTerminal,
},
)}
onClick={() => {
workbenchStore.toggleBoltTerminal(true);
workbenchStore.toggleTerminal(false);
}}
>
<div className="i-ph:lightning-duotone text-lg" />
Bolt Terminal
</button>
{Array.from({ length: terminalCount }, (_, index) => { {Array.from({ length: terminalCount }, (_, index) => {
const isActive = activeTerminal === index; const isActive = !showBoltTerminal && activeTerminal === index;
return ( return (
<button <button
@ -213,7 +228,11 @@ export const EditorPanel = memo(
!isActive, !isActive,
}, },
)} )}
onClick={() => setActiveTerminal(index)} onClick={() => {
setActiveTerminal(index);
workbenchStore.toggleBoltTerminal(false);
workbenchStore.toggleTerminal(true);
}}
> >
<div className="i-ph:terminal-window-duotone text-lg" /> <div className="i-ph:terminal-window-duotone text-lg" />
Terminal {terminalCount > 1 && index + 1} Terminal {terminalCount > 1 && index + 1}
@ -226,11 +245,23 @@ export const EditorPanel = memo(
icon="i-ph:caret-down" icon="i-ph:caret-down"
title="Close" title="Close"
size="md" size="md"
onClick={() => workbenchStore.toggleTerminal(false)} onClick={() => {
workbenchStore.toggleTerminal(false);
workbenchStore.toggleBoltTerminal(false);
}}
/> />
</div> </div>
<Terminal
className={classNames('h-full overflow-hidden', {
hidden: !showBoltTerminal,
})}
readonly={true}
onTerminalReady={(terminal) => workbenchStore.attachBoltTerminal(terminal)}
onTerminalResize={(cols, rows) => workbenchStore.onBoltTerminalResize(cols, rows)}
theme={theme}
/>
{Array.from({ length: terminalCount }, (_, index) => { {Array.from({ length: terminalCount }, (_, index) => {
const isActive = activeTerminal === index; const isActive = !showBoltTerminal && activeTerminal === index;
return ( return (
<Terminal <Terminal
@ -239,7 +270,7 @@ export const EditorPanel = memo(
hidden: !isActive, hidden: !isActive,
})} })}
ref={(ref) => { ref={(ref) => {
terminalRefs.current.push(ref); terminalRefs.current[index] = ref;
}} }}
onTerminalReady={(terminal) => workbenchStore.attachTerminal(terminal)} onTerminalReady={(terminal) => workbenchStore.attachTerminal(terminal)}
onTerminalResize={(cols, rows) => workbenchStore.onTerminalResize(cols, rows)} onTerminalResize={(cols, rows) => workbenchStore.onTerminalResize(cols, rows)}

View File

@ -5,6 +5,7 @@ import type { ActionCallbackData } from './message-parser';
import type { BoltAction } from '~/types/actions'; import type { BoltAction } from '~/types/actions';
import { createScopedLogger } from '~/utils/logger'; import { createScopedLogger } from '~/utils/logger';
import { unreachable } from '~/utils/unreachable'; import { unreachable } from '~/utils/unreachable';
import { workbenchStore } from '~/lib/stores/workbench';
const logger = createScopedLogger('ActionRunner'); const logger = createScopedLogger('ActionRunner');
@ -128,18 +129,23 @@ export class ActionRunner {
const webcontainer = await this.#webcontainer; const webcontainer = await this.#webcontainer;
// Write the command to the Bolt terminal
workbenchStore.writeToBoltTerminal(`\x1b[1;34m$ ${action.content}\x1b[0m\n`);
const process = await webcontainer.spawn('jsh', ['-c', action.content], { const process = await webcontainer.spawn('jsh', ['-c', action.content], {
env: { npm_config_yes: true }, env: { npm_config_yes: true },
}); });
action.abortSignal.addEventListener('abort', () => { action.abortSignal.addEventListener('abort', () => {
process.kill(); process.kill();
workbenchStore.writeToBoltTerminal('\x1b[1;31mCommand aborted\x1b[0m\n');
}); });
process.output.pipeTo( process.output.pipeTo(
new WritableStream({ new WritableStream({
write(data) { write(data) {
console.log(data); console.log(data);
workbenchStore.writeToBoltTerminal(data);
}, },
}), }),
); );
@ -147,6 +153,7 @@ export class ActionRunner {
const exitCode = await process.exit; const exitCode = await process.exit;
logger.debug(`Process terminated with code ${exitCode}`); logger.debug(`Process terminated with code ${exitCode}`);
workbenchStore.writeToBoltTerminal(`\x1b[1;${exitCode === 0 ? '32' : '31'}mProcess exited with code ${exitCode}\x1b[0m\n\n`);
} }
async #runFileAction(action: ActionState) { async #runFileAction(action: ActionState) {
@ -161,20 +168,27 @@ export class ActionRunner {
// remove trailing slashes // remove trailing slashes
folder = folder.replace(/\/+$/g, ''); folder = folder.replace(/\/+$/g, '');
// Write file creation to Bolt terminal
workbenchStore.writeToBoltTerminal(`\x1b[1;34mCreating file: ${action.filePath}\x1b[0m\n`);
if (folder !== '.') { if (folder !== '.') {
try { try {
await webcontainer.fs.mkdir(folder, { recursive: true }); await webcontainer.fs.mkdir(folder, { recursive: true });
logger.debug('Created folder', folder); logger.debug('Created folder', folder);
workbenchStore.writeToBoltTerminal(`\x1b[1;32mCreated folder: ${folder}\x1b[0m\n`);
} catch (error) { } catch (error) {
logger.error('Failed to create folder\n\n', error); logger.error('Failed to create folder\n\n', error);
workbenchStore.writeToBoltTerminal(`\x1b[1;31mFailed to create folder: ${folder}\x1b[0m\n${error}\n`);
} }
} }
try { try {
await webcontainer.fs.writeFile(action.filePath, action.content); await webcontainer.fs.writeFile(action.filePath, action.content);
logger.debug(`File written ${action.filePath}`); logger.debug(`File written ${action.filePath}`);
workbenchStore.writeToBoltTerminal(`\x1b[1;32mFile created: ${action.filePath}\x1b[0m\n\n`);
} catch (error) { } catch (error) {
logger.error('Failed to write file\n\n', error); logger.error('Failed to write file\n\n', error);
workbenchStore.writeToBoltTerminal(`\x1b[1;31mFailed to write file: ${action.filePath}\x1b[0m\n${error}\n\n`);
} }
} }

View File

@ -0,0 +1,45 @@
import type { WebContainer, WebContainerProcess } from '@webcontainer/api';
import { atom, type WritableAtom } from 'nanostores';
import type { ITerminal } from '~/types/terminal';
import { newShellProcess } from '~/utils/shell';
import { coloredText } from '~/utils/terminal';
export class BoltTerminalStore {
#webcontainer: Promise<WebContainer>;
#terminal: { terminal: ITerminal; process: WebContainerProcess } | null = null;
showBoltTerminal: WritableAtom<boolean> = atom(false);
constructor(webcontainerPromise: Promise<WebContainer>) {
this.#webcontainer = webcontainerPromise;
if (import.meta.hot) {
import.meta.hot.data.showBoltTerminal = this.showBoltTerminal;
}
}
toggleBoltTerminal(value?: boolean) {
this.showBoltTerminal.set(value !== undefined ? value : !this.showBoltTerminal.get());
}
async attachTerminal(terminal: ITerminal) {
try {
const shellProcess = await newShellProcess(await this.#webcontainer, terminal);
this.#terminal = { terminal, process: shellProcess };
} catch (error: any) {
terminal.write(coloredText.red('Failed to spawn shell\n\n') + error.message);
return;
}
}
onTerminalResize(cols: number, rows: number) {
if (this.#terminal) {
this.#terminal.process.resize({ cols, rows });
}
}
writeToTerminal(data: string) {
if (this.#terminal) {
this.#terminal.terminal.write(data);
}
}
}

View File

@ -13,6 +13,7 @@ export interface Shortcut {
export interface Shortcuts { export interface Shortcuts {
toggleTerminal: Shortcut; toggleTerminal: Shortcut;
toggleBoltTerminal: Shortcut;
} }
export interface Settings { export interface Settings {
@ -25,6 +26,11 @@ export const shortcutsStore = map<Shortcuts>({
ctrlOrMetaKey: true, ctrlOrMetaKey: true,
action: () => workbenchStore.toggleTerminal(), action: () => workbenchStore.toggleTerminal(),
}, },
toggleBoltTerminal: {
key: 'k',
ctrlOrMetaKey: true,
action: () => workbenchStore.toggleBoltTerminal(),
},
}); });
export const settingsStore = map<Settings>({ export const settingsStore = map<Settings>({

View File

@ -3,6 +3,7 @@ import { EditorStore } from './editor';
import { FilesStore, type FileMap } from './files'; import { FilesStore, type FileMap } from './files';
import { PreviewsStore } from './previews'; import { PreviewsStore } from './previews';
import { TerminalStore } from './terminal'; import { TerminalStore } from './terminal';
import { BoltTerminalStore } from './bolt-terminal';
import type { EditorDocument, ScrollPosition } from '~/components/editor/codemirror/CodeMirrorEditor'; import type { EditorDocument, ScrollPosition } from '~/components/editor/codemirror/CodeMirrorEditor';
import { ActionRunner } from '~/lib/runtime/action-runner'; import { ActionRunner } from '~/lib/runtime/action-runner';
import type { ActionCallbackData, ArtifactCallbackData } from '~/lib/runtime/message-parser'; import type { ActionCallbackData, ArtifactCallbackData } from '~/lib/runtime/message-parser';
@ -28,6 +29,7 @@ export class WorkbenchStore {
#filesStore = new FilesStore(webcontainer); #filesStore = new FilesStore(webcontainer);
#editorStore = new EditorStore(this.#filesStore); #editorStore = new EditorStore(this.#filesStore);
#terminalStore = new TerminalStore(webcontainer); #terminalStore = new TerminalStore(webcontainer);
#boltTerminalStore = new BoltTerminalStore(webcontainer);
artifacts: Artifacts = import.meta.hot?.data.artifacts ?? map({}); artifacts: Artifacts = import.meta.hot?.data.artifacts ?? map({});
@ -86,6 +88,26 @@ export class WorkbenchStore {
this.#terminalStore.onTerminalResize(cols, rows); this.#terminalStore.onTerminalResize(cols, rows);
} }
get showBoltTerminal() {
return this.#boltTerminalStore.showBoltTerminal;
}
toggleBoltTerminal(value?: boolean) {
this.#boltTerminalStore.toggleBoltTerminal(value);
}
attachBoltTerminal(terminal: ITerminal) {
this.#boltTerminalStore.attachTerminal(terminal);
}
onBoltTerminalResize(cols: number, rows: number) {
this.#boltTerminalStore.onTerminalResize(cols, rows);
}
writeToBoltTerminal(data: string) {
this.#boltTerminalStore.writeToTerminal(data);
}
setDocuments(files: FileMap) { setDocuments(files: FileMap) {
this.#editorStore.setDocuments(files); this.#editorStore.setDocuments(files);