mirror of
https://github.com/stackblitz/bolt.new
synced 2025-06-26 18:17:50 +00:00
feat: bolt terminal
added dedicated bolt terminal in workbench
This commit is contained in:
parent
3c15db95f7
commit
99666d7ab4
@ -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)}
|
||||
|
||||
@ -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`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
45
app/lib/stores/bolt-terminal.ts
Normal file
45
app/lib/stores/bolt-terminal.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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>({
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user