feat: bolt terminal

added dedicated bolt terminal in workbench
This commit is contained in:
Dustin Loring 2025-01-18 01:59:37 -05:00 committed by GitHub
commit 47b7abb769
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 134 additions and 16 deletions

View File

@ -60,8 +60,9 @@ export const EditorPanel = memo(
const theme = useStore(themeStore);
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 terminalToggledByShortcut = useRef(false);
@ -105,15 +106,16 @@ export const EditorPanel = memo(
}
const isCollapsed = terminal.isCollapsed();
const shouldShowTerminal = showTerminal || showBoltTerminal;
if (!showTerminal && !isCollapsed) {
if (!shouldShowTerminal && !isCollapsed) {
terminal.collapse();
} else if (showTerminal && isCollapsed) {
} else if (shouldShowTerminal && isCollapsed) {
terminal.resize(DEFAULT_TERMINAL_SIZE);
}
terminalToggledByShortcut.current = false;
}, [showTerminal]);
}, [showTerminal, showBoltTerminal]);
const addTerminal = () => {
if (terminalCount < MAX_TERMINALS) {
@ -124,7 +126,7 @@ export const EditorPanel = memo(
return (
<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">
<Panel defaultSize={20} minSize={10} collapsible>
<div className="flex flex-col border-r border-bolt-elements-borderColor h-full">
@ -182,25 +184,38 @@ export const EditorPanel = memo(
<PanelResizeHandle />
<Panel
ref={terminalPanelRef}
defaultSize={showTerminal ? DEFAULT_TERMINAL_SIZE : 0}
defaultSize={DEFAULT_TERMINAL_SIZE}
minSize={10}
collapsible
onExpand={() => {
if (!terminalToggledByShortcut.current) {
workbenchStore.toggleTerminal(true);
}
}}
onCollapse={() => {
if (!terminalToggledByShortcut.current) {
workbenchStore.toggleTerminal(false);
workbenchStore.toggleBoltTerminal(false);
}
}}
>
<div className="h-full">
<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">
<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) => {
const isActive = activeTerminal === index;
const isActive = !showBoltTerminal && activeTerminal === index;
return (
<button
@ -213,7 +228,11 @@ export const EditorPanel = memo(
!isActive,
},
)}
onClick={() => setActiveTerminal(index)}
onClick={() => {
setActiveTerminal(index);
workbenchStore.toggleBoltTerminal(false);
workbenchStore.toggleTerminal(true);
}}
>
<div className="i-ph:terminal-window-duotone text-lg" />
Terminal {terminalCount > 1 && index + 1}
@ -226,11 +245,23 @@ export const EditorPanel = memo(
icon="i-ph:caret-down"
title="Close"
size="md"
onClick={() => workbenchStore.toggleTerminal(false)}
onClick={() => {
workbenchStore.toggleTerminal(false);
workbenchStore.toggleBoltTerminal(false);
}}
/>
</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) => {
const isActive = activeTerminal === index;
const isActive = !showBoltTerminal && activeTerminal === index;
return (
<Terminal
@ -239,7 +270,7 @@ export const EditorPanel = memo(
hidden: !isActive,
})}
ref={(ref) => {
terminalRefs.current.push(ref);
terminalRefs.current[index] = ref;
}}
onTerminalReady={(terminal) => workbenchStore.attachTerminal(terminal)}
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 { createScopedLogger } from '~/utils/logger';
import { unreachable } from '~/utils/unreachable';
import { workbenchStore } from '~/lib/stores/workbench';
const logger = createScopedLogger('ActionRunner');
@ -128,18 +129,23 @@ export class ActionRunner {
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], {
env: { npm_config_yes: true },
});
action.abortSignal.addEventListener('abort', () => {
process.kill();
workbenchStore.writeToBoltTerminal('\x1b[1;31mCommand aborted\x1b[0m\n');
});
process.output.pipeTo(
new WritableStream({
write(data) {
console.log(data);
workbenchStore.writeToBoltTerminal(data);
},
}),
);
@ -147,6 +153,7 @@ export class ActionRunner {
const exitCode = await process.exit;
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) {
@ -161,20 +168,27 @@ export class ActionRunner {
// remove trailing slashes
folder = folder.replace(/\/+$/g, '');
// Write file creation to Bolt terminal
workbenchStore.writeToBoltTerminal(`\x1b[1;34mCreating file: ${action.filePath}\x1b[0m\n`);
if (folder !== '.') {
try {
await webcontainer.fs.mkdir(folder, { recursive: true });
logger.debug('Created folder', folder);
workbenchStore.writeToBoltTerminal(`\x1b[1;32mCreated folder: ${folder}\x1b[0m\n`);
} catch (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 {
await webcontainer.fs.writeFile(action.filePath, action.content);
logger.debug(`File written ${action.filePath}`);
workbenchStore.writeToBoltTerminal(`\x1b[1;32mFile created: ${action.filePath}\x1b[0m\n\n`);
} catch (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 {
toggleTerminal: Shortcut;
toggleBoltTerminal: Shortcut;
}
export interface Settings {
@ -25,6 +26,11 @@ export const shortcutsStore = map<Shortcuts>({
ctrlOrMetaKey: true,
action: () => workbenchStore.toggleTerminal(),
},
toggleBoltTerminal: {
key: 'k',
ctrlOrMetaKey: true,
action: () => workbenchStore.toggleBoltTerminal(),
},
});
export const settingsStore = map<Settings>({

View File

@ -3,6 +3,7 @@ import { EditorStore } from './editor';
import { FilesStore, type FileMap } from './files';
import { PreviewsStore } from './previews';
import { TerminalStore } from './terminal';
import { BoltTerminalStore } from './bolt-terminal';
import type { EditorDocument, ScrollPosition } from '~/components/editor/codemirror/CodeMirrorEditor';
import { ActionRunner } from '~/lib/runtime/action-runner';
import type { ActionCallbackData, ArtifactCallbackData } from '~/lib/runtime/message-parser';
@ -28,6 +29,7 @@ export class WorkbenchStore {
#filesStore = new FilesStore(webcontainer);
#editorStore = new EditorStore(this.#filesStore);
#terminalStore = new TerminalStore(webcontainer);
#boltTerminalStore = new BoltTerminalStore(webcontainer);
artifacts: Artifacts = import.meta.hot?.data.artifacts ?? map({});
@ -86,6 +88,26 @@ export class WorkbenchStore {
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) {
this.#editorStore.setDocuments(files);