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 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)}
|
||||||
|
|||||||
@ -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`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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 {
|
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>({
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user