From 8486d85f64b91fc55b365efcf53dca8f77f3ad3d Mon Sep 17 00:00:00 2001 From: Dominic Elm Date: Mon, 29 Jul 2024 14:37:23 +0200 Subject: [PATCH] feat: add terminal and simple shortcut system (#16) --- .../bolt/app/components/chat/Chat.client.tsx | 6 +- .../bolt/app/components/ui/PanelHeader.tsx | 15 ++ .../app/components/workbench/EditorPanel.tsx | 185 +++++++++++++----- .../components/workbench/Workbench.client.tsx | 88 ++++----- .../workbench/terminal/Terminal.tsx | 83 ++++++++ .../components/workbench/terminal/theme.ts | 36 ++++ packages/bolt/app/entry.client.tsx | 9 +- packages/bolt/app/lib/.server/llm/prompts.ts | 6 +- packages/bolt/app/lib/hooks/index.ts | 1 + packages/bolt/app/lib/hooks/useShortcuts.ts | 56 ++++++ packages/bolt/app/lib/stores/settings.ts | 39 ++++ packages/bolt/app/lib/stores/terminal.ts | 40 ++++ packages/bolt/app/lib/stores/theme.ts | 6 +- packages/bolt/app/lib/stores/workbench.ts | 19 ++ .../app/styles/components/resize-handle.scss | 28 +++ .../bolt/app/styles/components/terminal.scss | 3 + packages/bolt/app/styles/index.scss | 3 + packages/bolt/app/styles/variables.scss | 86 +++++++- packages/bolt/app/styles/z-index.scss | 1 + packages/bolt/app/types/terminal.ts | 8 + packages/bolt/app/utils/shell.ts | 51 +++++ packages/bolt/app/utils/terminal.ts | 11 ++ packages/bolt/package.json | 3 +- pnpm-lock.yaml | 24 ++- 24 files changed, 696 insertions(+), 111 deletions(-) create mode 100644 packages/bolt/app/components/ui/PanelHeader.tsx create mode 100644 packages/bolt/app/components/workbench/terminal/Terminal.tsx create mode 100644 packages/bolt/app/components/workbench/terminal/theme.ts create mode 100644 packages/bolt/app/lib/hooks/useShortcuts.ts create mode 100644 packages/bolt/app/lib/stores/settings.ts create mode 100644 packages/bolt/app/lib/stores/terminal.ts create mode 100644 packages/bolt/app/styles/components/resize-handle.scss create mode 100644 packages/bolt/app/styles/components/terminal.scss create mode 100644 packages/bolt/app/styles/z-index.scss create mode 100644 packages/bolt/app/types/terminal.ts create mode 100644 packages/bolt/app/utils/shell.ts create mode 100644 packages/bolt/app/utils/terminal.ts diff --git a/packages/bolt/app/components/chat/Chat.client.tsx b/packages/bolt/app/components/chat/Chat.client.tsx index a8fa27c..42ff709 100644 --- a/packages/bolt/app/components/chat/Chat.client.tsx +++ b/packages/bolt/app/components/chat/Chat.client.tsx @@ -3,7 +3,7 @@ import { useChat } from 'ai/react'; import { useAnimate } from 'framer-motion'; import { useEffect, useRef, useState } from 'react'; import { cssTransition, toast, ToastContainer } from 'react-toastify'; -import { useMessageParser, usePromptEnhancer, useSnapScroll } from '~/lib/hooks'; +import { useMessageParser, usePromptEnhancer, useShortcuts, useSnapScroll } from '~/lib/hooks'; import { useChatHistory } from '~/lib/persistence'; import { chatStore } from '~/lib/stores/chat'; import { workbenchStore } from '~/lib/stores/workbench'; @@ -25,7 +25,7 @@ export function Chat() { return ( <> {ready && } - ; + ); } @@ -36,6 +36,8 @@ interface ChatProps { } export function ChatImpl({ initialMessages, storeMessageHistory }: ChatProps) { + useShortcuts(); + const textareaRef = useRef(null); const [chatStarted, setChatStarted] = useState(initialMessages.length > 0); diff --git a/packages/bolt/app/components/ui/PanelHeader.tsx b/packages/bolt/app/components/ui/PanelHeader.tsx new file mode 100644 index 0000000..e684ae1 --- /dev/null +++ b/packages/bolt/app/components/ui/PanelHeader.tsx @@ -0,0 +1,15 @@ +import { memo } from 'react'; +import { classNames } from '~/utils/classNames'; + +interface PanelHeaderProps { + className?: string; + children: React.ReactNode; +} + +export const PanelHeader = memo(({ className, children }: PanelHeaderProps) => { + return ( +
+ {children} +
+ ); +}); diff --git a/packages/bolt/app/components/workbench/EditorPanel.tsx b/packages/bolt/app/components/workbench/EditorPanel.tsx index c684e86..4851c03 100644 --- a/packages/bolt/app/components/workbench/EditorPanel.tsx +++ b/packages/bolt/app/components/workbench/EditorPanel.tsx @@ -1,6 +1,6 @@ import { useStore } from '@nanostores/react'; -import { memo, useMemo } from 'react'; -import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; +import { memo, useEffect, useMemo, useRef, useState } from 'react'; +import { Panel, PanelGroup, PanelResizeHandle, type ImperativePanelHandle } from 'react-resizable-panels'; import { CodeMirrorEditor, type EditorDocument, @@ -9,12 +9,16 @@ import { type OnSaveCallback as OnEditorSave, type OnScrollCallback as OnEditorScroll, } from '~/components/editor/codemirror/CodeMirrorEditor'; +import { PanelHeader } from '~/components/ui/PanelHeader'; import { PanelHeaderButton } from '~/components/ui/PanelHeaderButton'; +import { shortcutEventEmitter } from '~/lib/hooks'; import type { FileMap } from '~/lib/stores/files'; import { themeStore } from '~/lib/stores/theme'; +import { workbenchStore } from '~/lib/stores/workbench'; import { renderLogger } from '~/utils/logger'; import { isMobile } from '~/utils/mobile'; import { FileTreePanel } from './FileTreePanel'; +import { Terminal, type TerminalRef } from './terminal/Terminal'; interface EditorPanelProps { files?: FileMap; @@ -29,6 +33,9 @@ interface EditorPanelProps { onFileReset?: () => void; } +const DEFAULT_TERMINAL_SIZE = 25; +const DEFAULT_EDITOR_SIZE = 100 - DEFAULT_TERMINAL_SIZE; + const editorSettings: EditorSettings = { tabSize: 2 }; export const EditorPanel = memo( @@ -47,6 +54,13 @@ export const EditorPanel = memo( renderLogger.trace('EditorPanel'); const theme = useStore(themeStore); + const showTerminal = useStore(workbenchStore.showTerminal); + + const terminalRefs = useRef>([]); + const terminalPanelRef = useRef(null); + const terminalToggledByShortcut = useRef(false); + + const [terminalCount] = useState(1); const activeFile = useMemo(() => { if (!editorDocument) { @@ -60,54 +74,133 @@ export const EditorPanel = memo( return editorDocument !== undefined && unsavedFiles?.has(editorDocument.filePath); }, [editorDocument, unsavedFiles]); + useEffect(() => { + const unsubscribeFromEventEmitter = shortcutEventEmitter.on('toggleTerminal', () => { + terminalToggledByShortcut.current = true; + }); + + const unsubscribeFromThemeStore = themeStore.subscribe(() => { + for (const ref of Object.values(terminalRefs.current)) { + ref?.reloadStyles(); + } + }); + + return () => { + unsubscribeFromEventEmitter(); + unsubscribeFromThemeStore(); + }; + }, []); + + useEffect(() => { + const { current: terminal } = terminalPanelRef; + + if (!terminal) { + return; + } + + const isCollapsed = terminal.isCollapsed(); + + if (!showTerminal && !isCollapsed) { + terminal.collapse(); + } else if (showTerminal && isCollapsed) { + terminal.resize(DEFAULT_TERMINAL_SIZE); + } + + terminalToggledByShortcut.current = false; + }, [showTerminal]); + return ( - - -
-
-
- Files -
- -
- - - -
- {activeFile && ( -
- {activeFile} {isStreaming && (read-only)} - {activeFileUnsaved && ( -
- -
- Save - - -
- Reset - + + + + +
+ +
+ Files + + +
+ + + + + {activeFile && ( +
+ {activeFile} {isStreaming && (read-only)} + {activeFileUnsaved && ( +
+ +
+ Save + + +
+ Reset + +
+ )}
)} + +
+
- )} -
-
- + + + + + { + if (!terminalToggledByShortcut.current) { + workbenchStore.toggleTerminal(true); + } + }} + onCollapse={() => { + if (!terminalToggledByShortcut.current) { + workbenchStore.toggleTerminal(false); + } + }} + > +
+ + Terminal + +
+ {Array.from({ length: terminalCount }, (_, index) => { + return ( +
+ { + terminalRefs.current.push(ref); + }} + onTerminalReady={(terminal) => workbenchStore.attachTerminal(terminal)} + onTerminalResize={(cols, rows) => workbenchStore.onTerminalResize(cols, rows)} + className="h-full" + theme={theme} + /> +
+ ); + })} +
diff --git a/packages/bolt/app/components/workbench/Workbench.client.tsx b/packages/bolt/app/components/workbench/Workbench.client.tsx index e3bd7e6..987ef79 100644 --- a/packages/bolt/app/components/workbench/Workbench.client.tsx +++ b/packages/bolt/app/components/workbench/Workbench.client.tsx @@ -1,5 +1,5 @@ import { useStore } from '@nanostores/react'; -import { AnimatePresence, motion, type HTMLMotionProps, type Variants } from 'framer-motion'; +import { motion, type HTMLMotionProps, type Variants } from 'framer-motion'; import { computed } from 'nanostores'; import { memo, useCallback, useEffect, useState } from 'react'; import { toast } from 'react-toastify'; @@ -98,52 +98,48 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) => return ( chatStarted && ( - - {showWorkbench && ( - -
-
-
- - { - workbenchStore.showWorkbench.set(false); - }} - /> -
-
- - - - - - -
-
+ +
+
+
+ + { + workbenchStore.showWorkbench.set(false); + }} + />
- - )} - +
+ + + + + + +
+
+
+
) ); }); diff --git a/packages/bolt/app/components/workbench/terminal/Terminal.tsx b/packages/bolt/app/components/workbench/terminal/Terminal.tsx new file mode 100644 index 0000000..2e386c4 --- /dev/null +++ b/packages/bolt/app/components/workbench/terminal/Terminal.tsx @@ -0,0 +1,83 @@ +import { FitAddon } from '@xterm/addon-fit'; +import { WebLinksAddon } from '@xterm/addon-web-links'; +import { Terminal as XTerm } from '@xterm/xterm'; +import { forwardRef, memo, useEffect, useImperativeHandle, useRef } from 'react'; +import type { Theme } from '~/lib/stores/theme'; +import { getTerminalTheme } from './theme'; + +import '@xterm/xterm/css/xterm.css'; + +export interface TerminalRef { + reloadStyles: () => void; +} + +export interface TerminalProps { + className?: string; + theme: Theme; + readonly?: boolean; + onTerminalReady?: (terminal: XTerm) => void; + onTerminalResize?: (cols: number, rows: number) => void; +} + +export const Terminal = memo( + forwardRef(({ className, theme, readonly, onTerminalReady, onTerminalResize }, ref) => { + const terminalElementRef = useRef(null); + const terminalRef = useRef(); + + useEffect(() => { + const element = terminalElementRef.current!; + + const fitAddon = new FitAddon(); + const webLinksAddon = new WebLinksAddon(); + + const terminal = new XTerm({ + cursorBlink: true, + convertEol: true, + disableStdin: readonly, + theme: getTerminalTheme(readonly ? { cursor: '#00000000' } : {}), + fontSize: 13, + fontFamily: 'Menlo, courier-new, courier, monospace', + }); + + terminalRef.current = terminal; + + terminal.loadAddon(fitAddon); + terminal.loadAddon(webLinksAddon); + terminal.open(element); + + const resizeObserver = new ResizeObserver(() => { + fitAddon.fit(); + onTerminalResize?.(terminal.cols, terminal.rows); + }); + + resizeObserver.observe(element); + + onTerminalReady?.(terminal); + + return () => { + resizeObserver.disconnect(); + terminal.dispose(); + }; + }, []); + + useEffect(() => { + const terminal = terminalRef.current!; + + // we render a transparent cursor in case the terminal is readonly + terminal.options.theme = getTerminalTheme(readonly ? { cursor: '#00000000' } : {}); + + terminal.options.disableStdin = readonly; + }, [theme, readonly]); + + useImperativeHandle(ref, () => { + return { + reloadStyles: () => { + const terminal = terminalRef.current!; + terminal.options.theme = getTerminalTheme(readonly ? { cursor: '#00000000' } : {}); + }, + }; + }, []); + + return
; + }), +); diff --git a/packages/bolt/app/components/workbench/terminal/theme.ts b/packages/bolt/app/components/workbench/terminal/theme.ts new file mode 100644 index 0000000..a9ef07d --- /dev/null +++ b/packages/bolt/app/components/workbench/terminal/theme.ts @@ -0,0 +1,36 @@ +import type { ITheme } from '@xterm/xterm'; + +const style = getComputedStyle(document.documentElement); +const cssVar = (token: string) => style.getPropertyValue(token) || undefined; + +export function getTerminalTheme(overrides?: ITheme): ITheme { + return { + cursor: cssVar('--bolt-elements-terminal-cursorColor'), + cursorAccent: cssVar('--bolt-elements-terminal-cursorColorAccent'), + foreground: cssVar('--bolt-elements-terminal-textColor'), + background: cssVar('--bolt-elements-terminal-backgroundColor'), + selectionBackground: cssVar('--bolt-elements-terminal-selection-backgroundColor'), + selectionForeground: cssVar('--bolt-elements-terminal-selection-textColor'), + selectionInactiveBackground: cssVar('--bolt-elements-terminal-selection-backgroundColorInactive'), + + // ansi escape code colors + black: cssVar('--bolt-elements-terminal-color-black'), + red: cssVar('--bolt-elements-terminal-color-red'), + green: cssVar('--bolt-elements-terminal-color-green'), + yellow: cssVar('--bolt-elements-terminal-color-yellow'), + blue: cssVar('--bolt-elements-terminal-color-blue'), + magenta: cssVar('--bolt-elements-terminal-color-magenta'), + cyan: cssVar('--bolt-elements-terminal-color-cyan'), + white: cssVar('--bolt-elements-terminal-color-white'), + brightBlack: cssVar('--bolt-elements-terminal-color-brightBlack'), + brightRed: cssVar('--bolt-elements-terminal-color-brightRed'), + brightGreen: cssVar('--bolt-elements-terminal-color-brightGreen'), + brightYellow: cssVar('--bolt-elements-terminal-color-brightYellow'), + brightBlue: cssVar('--bolt-elements-terminal-color-brightBlue'), + brightMagenta: cssVar('--bolt-elements-terminal-color-brightMagenta'), + brightCyan: cssVar('--bolt-elements-terminal-color-brightCyan'), + brightWhite: cssVar('--bolt-elements-terminal-color-brightWhite'), + + ...overrides, + }; +} diff --git a/packages/bolt/app/entry.client.tsx b/packages/bolt/app/entry.client.tsx index 11ffec2..f95b51d 100644 --- a/packages/bolt/app/entry.client.tsx +++ b/packages/bolt/app/entry.client.tsx @@ -1,12 +1,7 @@ import { RemixBrowser } from '@remix-run/react'; -import { startTransition, StrictMode } from 'react'; +import { startTransition } from 'react'; import { hydrateRoot } from 'react-dom/client'; startTransition(() => { - hydrateRoot( - document, - - - , - ); + hydrateRoot(document, ); }); diff --git a/packages/bolt/app/lib/.server/llm/prompts.ts b/packages/bolt/app/lib/.server/llm/prompts.ts index 1346295..96eb621 100644 --- a/packages/bolt/app/lib/.server/llm/prompts.ts +++ b/packages/bolt/app/lib/.server/llm/prompts.ts @@ -13,6 +13,10 @@ You are Bolt, an expert AI assistant and exceptional senior software developer w IMPORTANT: Git is NOT available. + IMPORTANT: Prefer writing Node.js scripts instead of shell scripts. The environment doesn't fully support shell scripts, so use Node.js for scripting tasks whenever possible! + + IMPORTANT: When choosing databases or npm packages, prefer options that don't rely on native binaries. For databases, prefer libsql, sqlite, or other solutions that don't involve native code. WebContainer CANNOT execute arbitrary native binaries. + Available shell commands: cat, chmod, cp, echo, hostname, kill, ln, ls, mkdir, mv, ps, pwd, rm, rmdir, xxd, alias, cd, clear, curl, env, false, getconf, head, sort, tail, touch, true, uptime, which, code, jq, loadenv, node, python3, wasm, xdg-open, command, exit, export, source @@ -92,7 +96,7 @@ You are Bolt, an expert AI assistant and exceptional senior software developer w - When Using \`npx\`, ALWAYS provide the \`--yes\` flag. - When running multiple shell commands, use \`&&\` to run them sequentially. - - IMPORTANT: Do NOT re-run a dev command if there is one that starts a dev server and new dependencies were installed or files updated! If a dev server has started already, assume that installing dependencies will be executed in a different process and will be picked up by the dev server. + - ULTRA IMPORTANT: Do NOT re-run a dev command if there is one that starts a dev server and new dependencies were installed or files updated! If a dev server has started already, assume that installing dependencies will be executed in a different process and will be picked up by the dev server. - file: For writing new files or updating existing files. For each file add a \`filePath\` attribute to the opening \`\` tag to specify the file path. The content of the file artifact is the file contents. All file paths MUST BE relative to the current working directory. diff --git a/packages/bolt/app/lib/hooks/index.ts b/packages/bolt/app/lib/hooks/index.ts index 9837e12..fb60321 100644 --- a/packages/bolt/app/lib/hooks/index.ts +++ b/packages/bolt/app/lib/hooks/index.ts @@ -1,3 +1,4 @@ export * from './useMessageParser'; export * from './usePromptEnhancer'; +export * from './useShortcuts'; export * from './useSnapScroll'; diff --git a/packages/bolt/app/lib/hooks/useShortcuts.ts b/packages/bolt/app/lib/hooks/useShortcuts.ts new file mode 100644 index 0000000..4f982ad --- /dev/null +++ b/packages/bolt/app/lib/hooks/useShortcuts.ts @@ -0,0 +1,56 @@ +import { useStore } from '@nanostores/react'; +import { useEffect } from 'react'; +import { shortcutsStore, type Shortcuts } from '~/lib/stores/settings'; + +class ShortcutEventEmitter { + #emitter = new EventTarget(); + + dispatch(type: keyof Shortcuts) { + this.#emitter.dispatchEvent(new Event(type)); + } + + on(type: keyof Shortcuts, cb: VoidFunction) { + this.#emitter.addEventListener(type, cb); + + return () => { + this.#emitter.removeEventListener(type, cb); + }; + } +} + +export const shortcutEventEmitter = new ShortcutEventEmitter(); + +export function useShortcuts(): void { + const shortcuts = useStore(shortcutsStore); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent): void => { + const { key, ctrlKey, shiftKey, altKey, metaKey } = event; + + for (const name in shortcuts) { + const shortcut = shortcuts[name as keyof Shortcuts]; + + if ( + shortcut.key.toLowerCase() === key.toLowerCase() && + (shortcut.ctrlOrMetaKey + ? ctrlKey || metaKey + : (shortcut.ctrlKey === undefined || shortcut.ctrlKey === ctrlKey) && + (shortcut.metaKey === undefined || shortcut.metaKey === metaKey)) && + (shortcut.shiftKey === undefined || shortcut.shiftKey === shiftKey) && + (shortcut.altKey === undefined || shortcut.altKey === altKey) + ) { + shortcutEventEmitter.dispatch(name as keyof Shortcuts); + event.preventDefault(); + shortcut.action(); + break; + } + } + }; + + window.addEventListener('keydown', handleKeyDown); + + return () => { + window.removeEventListener('keydown', handleKeyDown); + }; + }, [shortcuts]); +} diff --git a/packages/bolt/app/lib/stores/settings.ts b/packages/bolt/app/lib/stores/settings.ts new file mode 100644 index 0000000..5e48bfe --- /dev/null +++ b/packages/bolt/app/lib/stores/settings.ts @@ -0,0 +1,39 @@ +import { map } from 'nanostores'; +import { workbenchStore } from './workbench'; + +export interface Shortcut { + key: string; + ctrlKey?: boolean; + shiftKey?: boolean; + altKey?: boolean; + metaKey?: boolean; + ctrlOrMetaKey?: boolean; + action: () => void; +} + +export interface Shortcuts { + toggleTerminal: Shortcut; +} + +export interface Settings { + shortcuts: Shortcuts; +} + +export const shortcutsStore = map({ + toggleTerminal: { + key: 'j', + ctrlOrMetaKey: true, + action: () => workbenchStore.toggleTerminal(), + }, +}); + +export const settingsStore = map({ + shortcuts: shortcutsStore.get(), +}); + +shortcutsStore.subscribe((shortcuts) => { + settingsStore.set({ + ...settingsStore.get(), + shortcuts, + }); +}); diff --git a/packages/bolt/app/lib/stores/terminal.ts b/packages/bolt/app/lib/stores/terminal.ts new file mode 100644 index 0000000..419320e --- /dev/null +++ b/packages/bolt/app/lib/stores/terminal.ts @@ -0,0 +1,40 @@ +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 TerminalStore { + #webcontainer: Promise; + #terminals: Array<{ terminal: ITerminal; process: WebContainerProcess }> = []; + + showTerminal: WritableAtom = import.meta.hot?.data.showTerminal ?? atom(false); + + constructor(webcontainerPromise: Promise) { + this.#webcontainer = webcontainerPromise; + + if (import.meta.hot) { + import.meta.hot.data.showTerminal = this.showTerminal; + } + } + + toggleTerminal(value?: boolean) { + this.showTerminal.set(value !== undefined ? value : !this.showTerminal.get()); + } + + async attachTerminal(terminal: ITerminal) { + try { + const shellProcess = await newShellProcess(await this.#webcontainer, terminal); + this.#terminals.push({ terminal, process: shellProcess }); + } catch (error: any) { + terminal.write(coloredText.red('Failed to spawn shell\n\n') + error.message); + return; + } + } + + onTerminalResize(cols: number, rows: number) { + for (const { process } of this.#terminals) { + process.resize({ cols, rows }); + } + } +} diff --git a/packages/bolt/app/lib/stores/theme.ts b/packages/bolt/app/lib/stores/theme.ts index f9d5fed..4f3e47b 100644 --- a/packages/bolt/app/lib/stores/theme.ts +++ b/packages/bolt/app/lib/stores/theme.ts @@ -8,6 +8,8 @@ export function themeIsDark() { return themeStore.get() === 'dark'; } +export const DEFAULT_THEME = 'light'; + export const themeStore = atom(initStore()); function initStore() { @@ -15,10 +17,10 @@ function initStore() { const persistedTheme = localStorage.getItem(kTheme) as Theme | undefined; const themeAttribute = document.querySelector('html')?.getAttribute('data-theme'); - return persistedTheme ?? (themeAttribute as Theme) ?? 'light'; + return persistedTheme ?? (themeAttribute as Theme) ?? DEFAULT_THEME; } - return 'light'; + return DEFAULT_THEME; } export function toggleTheme() { diff --git a/packages/bolt/app/lib/stores/workbench.ts b/packages/bolt/app/lib/stores/workbench.ts index 8603fe9..41cd86a 100644 --- a/packages/bolt/app/lib/stores/workbench.ts +++ b/packages/bolt/app/lib/stores/workbench.ts @@ -3,10 +3,12 @@ import type { EditorDocument, ScrollPosition } from '~/components/editor/codemir import { ActionRunner } from '~/lib/runtime/action-runner'; import type { ActionCallbackData, ArtifactCallbackData } from '~/lib/runtime/message-parser'; import { webcontainer } from '~/lib/webcontainer'; +import type { ITerminal } from '~/types/terminal'; import { unreachable } from '~/utils/unreachable'; import { EditorStore } from './editor'; import { FilesStore, type FileMap } from './files'; import { PreviewsStore } from './previews'; +import { TerminalStore } from './terminal'; export interface ArtifactState { title: string; @@ -22,6 +24,7 @@ export class WorkbenchStore { #previewsStore = new PreviewsStore(webcontainer); #filesStore = new FilesStore(webcontainer); #editorStore = new EditorStore(this.#filesStore); + #terminalStore = new TerminalStore(webcontainer); artifacts: Artifacts = import.meta.hot?.data.artifacts ?? map({}); showWorkbench: WritableAtom = import.meta.hot?.data.showWorkbench ?? atom(false); @@ -53,6 +56,22 @@ export class WorkbenchStore { return this.#editorStore.selectedFile; } + get showTerminal() { + return this.#terminalStore.showTerminal; + } + + toggleTerminal(value?: boolean) { + this.#terminalStore.toggleTerminal(value); + } + + attachTerminal(terminal: ITerminal) { + this.#terminalStore.attachTerminal(terminal); + } + + onTerminalResize(cols: number, rows: number) { + this.#terminalStore.onTerminalResize(cols, rows); + } + setDocuments(files: FileMap) { this.#editorStore.setDocuments(files); diff --git a/packages/bolt/app/styles/components/resize-handle.scss b/packages/bolt/app/styles/components/resize-handle.scss new file mode 100644 index 0000000..0b75009 --- /dev/null +++ b/packages/bolt/app/styles/components/resize-handle.scss @@ -0,0 +1,28 @@ +[data-resize-handle] { + position: relative; + + &[data-panel-group-direction='horizontal']:after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: -6px; + right: -5px; + z-index: $zIndexMax; + } + + &[data-panel-group-direction='vertical']:after { + content: ''; + position: absolute; + left: 0; + right: 0; + top: -5px; + bottom: -6px; + z-index: $zIndexMax; + } + + &[data-resize-handle-state='hover']:after, + &[data-resize-handle-state='drag']:after { + background-color: #8882; + } +} diff --git a/packages/bolt/app/styles/components/terminal.scss b/packages/bolt/app/styles/components/terminal.scss new file mode 100644 index 0000000..dc1d224 --- /dev/null +++ b/packages/bolt/app/styles/components/terminal.scss @@ -0,0 +1,3 @@ +.xterm { + height: 100%; +} diff --git a/packages/bolt/app/styles/index.scss b/packages/bolt/app/styles/index.scss index d3e5016..3901a96 100644 --- a/packages/bolt/app/styles/index.scss +++ b/packages/bolt/app/styles/index.scss @@ -1,5 +1,8 @@ @import './variables.scss'; +@import './z-index.scss'; @import './animations.scss'; +@import './components/terminal.scss'; +@import './components/resize-handle.scss'; body { --at-apply: bg-bolt-elements-app-backgroundColor; diff --git a/packages/bolt/app/styles/variables.scss b/packages/bolt/app/styles/variables.scss index 3daf8f8..8e275d5 100644 --- a/packages/bolt/app/styles/variables.scss +++ b/packages/bolt/app/styles/variables.scss @@ -20,12 +20,72 @@ --bolt-border-primary: theme('colors.gray.200'); --bolt-border-accent: theme('colors.accent.600'); + + /* Terminal Colors */ + --bolt-terminal-background: var(--bolt-background-primary); + --bolt-terminal-foreground: #333333; + --bolt-terminal-selection-background: #00000040; + --bolt-terminal-black: #000000; + --bolt-terminal-red: #cd3131; + --bolt-terminal-green: #00bc00; + --bolt-terminal-yellow: #949800; + --bolt-terminal-blue: #0451a5; + --bolt-terminal-magenta: #bc05bc; + --bolt-terminal-cyan: #0598bc; + --bolt-terminal-white: #555555; + --bolt-terminal-brightBlack: #686868; + --bolt-terminal-brightRed: #cd3131; + --bolt-terminal-brightGreen: #00bc00; + --bolt-terminal-brightYellow: #949800; + --bolt-terminal-brightBlue: #0451a5; + --bolt-terminal-brightMagenta: #bc05bc; + --bolt-terminal-brightCyan: #0598bc; + --bolt-terminal-brightWhite: #a5a5a5; } /* Color Tokens Dark Theme */ :root, :root[data-theme='dark'] { - --bolt-background-primary: theme('colors.gray.50'); + --bolt-background-primary: theme('colors.gray.0'); + --bolt-background-secondary: theme('colors.gray.50'); + --bolt-background-active: theme('colors.gray.200'); + --bolt-background-accent: theme('colors.accent.600'); + --bolt-background-accent-secondary: theme('colors.accent.600'); + --bolt-background-accent-active: theme('colors.accent.500'); + + --bolt-text-primary: theme('colors.gray.800'); + --bolt-text-primary-inverted: theme('colors.gray.0'); + --bolt-text-secondary: theme('colors.gray.600'); + --bolt-text-secondary-inverted: theme('colors.gray.200'); + --bolt-text-disabled: theme('colors.gray.400'); + --bolt-text-accent: theme('colors.accent.600'); + --bolt-text-positive: theme('colors.positive.700'); + --bolt-text-warning: theme('colors.warning.600'); + --bolt-text-negative: theme('colors.negative.600'); + + --bolt-border-primary: theme('colors.gray.200'); + --bolt-border-accent: theme('colors.accent.600'); + + /* Terminal Colors */ + --bolt-terminal-background: #16181d; + --bolt-terminal-foreground: #eff0eb; + --bolt-terminal-selection-background: #97979b33; + --bolt-terminal-black: #000000; + --bolt-terminal-red: #ff5c57; + --bolt-terminal-green: #5af78e; + --bolt-terminal-yellow: #f3f99d; + --bolt-terminal-blue: #57c7ff; + --bolt-terminal-magenta: #ff6ac1; + --bolt-terminal-cyan: #9aedfe; + --bolt-terminal-white: #f1f1f0; + --bolt-terminal-brightBlack: #686868; + --bolt-terminal-brightRed: #ff5c57; + --bolt-terminal-brightGreen: #5af78e; + --bolt-terminal-brightYellow: #f3f99d; + --bolt-terminal-brightBlue: #57c7ff; + --bolt-terminal-brightMagenta: #ff6ac1; + --bolt-terminal-brightCyan: #9aedfe; + --bolt-terminal-brightWhite: #f1f1f0; } /* @@ -36,9 +96,33 @@ :root { --header-height: 65px; + --z-index-max: 999; + /* App */ --bolt-elements-app-backgroundColor: var(--bolt-background-primary); --bolt-elements-app-borderColor: var(--bolt-border-primary); --bolt-elements-app-textColor: var(--bolt-text-primary); --bolt-elements-app-linkColor: var(--bolt-text-accent); + + /* Terminal */ + --bolt-elements-terminal-backgroundColor: var(--bolt-terminal-background); + --bolt-elements-terminal-textColor: var(--bolt-terminal-foreground); + --bolt-elements-terminal-cursorColor: var(--bolt-terminal-foreground); + --bolt-elements-terminal-selection-backgroundColor: var(--bolt-terminal-selection-background); + --bolt-elements-terminal-color-black: var(--bolt-terminal-black); + --bolt-elements-terminal-color-red: var(--bolt-terminal-red); + --bolt-elements-terminal-color-green: var(--bolt-terminal-green); + --bolt-elements-terminal-color-yellow: var(--bolt-terminal-yellow); + --bolt-elements-terminal-color-blue: var(--bolt-terminal-blue); + --bolt-elements-terminal-color-magenta: var(--bolt-terminal-magenta); + --bolt-elements-terminal-color-cyan: var(--bolt-terminal-cyan); + --bolt-elements-terminal-color-white: var(--bolt-terminal-white); + --bolt-elements-terminal-color-brightBlack: var(--bolt-terminal-brightBlack); + --bolt-elements-terminal-color-brightRed: var(--bolt-terminal-brightRed); + --bolt-elements-terminal-color-brightGreen: var(--bolt-terminal-brightGreen); + --bolt-elements-terminal-color-brightYellow: var(--bolt-terminal-brightYellow); + --bolt-elements-terminal-color-brightBlue: var(--bolt-terminal-brightBlue); + --bolt-elements-terminal-color-brightMagenta: var(--bolt-terminal-brightMagenta); + --bolt-elements-terminal-color-brightCyan: var(--bolt-terminal-brightCyan); + --bolt-elements-terminal-color-brightWhite: var(--bolt-terminal-brightWhite); } diff --git a/packages/bolt/app/styles/z-index.scss b/packages/bolt/app/styles/z-index.scss new file mode 100644 index 0000000..8f5dc59 --- /dev/null +++ b/packages/bolt/app/styles/z-index.scss @@ -0,0 +1 @@ +$zIndexMax: 999; diff --git a/packages/bolt/app/types/terminal.ts b/packages/bolt/app/types/terminal.ts new file mode 100644 index 0000000..75ae3a3 --- /dev/null +++ b/packages/bolt/app/types/terminal.ts @@ -0,0 +1,8 @@ +export interface ITerminal { + readonly cols?: number; + readonly rows?: number; + + reset: () => void; + write: (data: string) => void; + onData: (cb: (data: string) => void) => void; +} diff --git a/packages/bolt/app/utils/shell.ts b/packages/bolt/app/utils/shell.ts new file mode 100644 index 0000000..1c5c834 --- /dev/null +++ b/packages/bolt/app/utils/shell.ts @@ -0,0 +1,51 @@ +import type { WebContainer } from '@webcontainer/api'; +import type { ITerminal } from '~/types/terminal'; +import { withResolvers } from './promises'; + +export async function newShellProcess(webcontainer: WebContainer, terminal: ITerminal) { + const args: string[] = []; + + // we spawn a JSH process with a fallback cols and rows in case the process is not attached yet to a visible terminal + const process = await webcontainer.spawn('/bin/jsh', ['--osc', ...args], { + terminal: { + cols: terminal.cols ?? 80, + rows: terminal.rows ?? 15, + }, + }); + + const input = process.input.getWriter(); + const output = process.output; + + const jshReady = withResolvers(); + + let isInteractive = false; + + output.pipeTo( + new WritableStream({ + write(data) { + if (!isInteractive) { + const [, osc] = data.match(/\x1b\]654;([^\x07]+)\x07/) || []; + + if (osc === 'interactive') { + // wait until we see the interactive OSC + isInteractive = true; + + jshReady.resolve(); + } + } + + terminal.write(data); + }, + }), + ); + + terminal.onData((data) => { + if (isInteractive) { + input.write(data); + } + }); + + await jshReady.promise; + + return process; +} diff --git a/packages/bolt/app/utils/terminal.ts b/packages/bolt/app/utils/terminal.ts new file mode 100644 index 0000000..324816a --- /dev/null +++ b/packages/bolt/app/utils/terminal.ts @@ -0,0 +1,11 @@ +const reset = '\x1b[0m'; + +export const escapeCodes = { + reset, + clear: '\x1b[g', + red: '\x1b[1;31m', +}; + +export const coloredText = { + red: (text: string) => `${escapeCodes.red}${text}${reset}`, +}; diff --git a/packages/bolt/package.json b/packages/bolt/package.json index 1511b89..7a48c52 100644 --- a/packages/bolt/package.json +++ b/packages/bolt/package.json @@ -39,7 +39,7 @@ "@remix-run/react": "^2.10.2", "@stackblitz/sdk": "^1.11.0", "@unocss/reset": "^0.61.0", - "@webcontainer/api": "^1.3.0-internal.1", + "@webcontainer/api": "^1.3.0-internal.2", "@xterm/addon-fit": "^0.10.0", "@xterm/addon-web-links": "^0.11.0", "@xterm/xterm": "^5.5.0", @@ -51,6 +51,7 @@ "nanostores": "^0.10.3", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-hotkeys-hook": "^4.5.0", "react-markdown": "^9.0.1", "react-resizable-panels": "^2.0.20", "react-toastify": "^10.0.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ce43c08..cc2e950 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -105,8 +105,8 @@ importers: specifier: ^0.61.0 version: 0.61.0 '@webcontainer/api': - specifier: ^1.3.0-internal.1 - version: 1.3.0-internal.1 + specifier: ^1.3.0-internal.2 + version: 1.3.0-internal.2 '@xterm/addon-fit': specifier: ^0.10.0 version: 0.10.0(@xterm/xterm@5.5.0) @@ -140,6 +140,9 @@ importers: react-dom: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) + react-hotkeys-hook: + specifier: ^4.5.0 + version: 4.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-markdown: specifier: ^9.0.1 version: 9.0.1(@types/react@18.3.3)(react@18.3.1) @@ -1722,8 +1725,8 @@ packages: '@web3-storage/multipart-parser@1.0.0': resolution: {integrity: sha512-BEO6al7BYqcnfX15W2cnGR+Q566ACXAT9UQykORCWW80lmkpWsnEob6zJS1ZVBKsSJC8+7vJkHwlp+lXG1UCdw==} - '@webcontainer/api@1.3.0-internal.1': - resolution: {integrity: sha512-XHveAaZgZItLWieict8xTteBbPLeAwCJLuc80Zq6Mmk0LEWTw8yYZep0dTKbet6bd9MPTQ1+vjPAsEtD0H1fOA==} + '@webcontainer/api@1.3.0-internal.2': + resolution: {integrity: sha512-lLSlSehbuYc9E7ecK+tMRX4BbWETNX1OgRlS+NerQh3X3sHNbxLD86eScEMAiA5VBnUeSnLtLe7eC/ftM8fR3Q==} '@xterm/addon-fit@0.10.0': resolution: {integrity: sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==} @@ -4090,6 +4093,12 @@ packages: peerDependencies: react: ^18.3.1 + react-hotkeys-hook@4.5.0: + resolution: {integrity: sha512-Samb85GSgAWFQNvVt3PS90LPPGSf9mkH/r4au81ZP1yOIFayLC3QAvqTgGtJ8YEDMXtPmaVBs6NgipHO6h4Mug==} + peerDependencies: + react: '>=16.8.1' + react-dom: '>=16.8.1' + react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} @@ -6814,7 +6823,7 @@ snapshots: '@web3-storage/multipart-parser@1.0.0': {} - '@webcontainer/api@1.3.0-internal.1': {} + '@webcontainer/api@1.3.0-internal.2': {} '@xterm/addon-fit@0.10.0(@xterm/xterm@5.5.0)': dependencies: @@ -9748,6 +9757,11 @@ snapshots: react: 18.3.1 scheduler: 0.23.2 + react-hotkeys-hook@4.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-is@18.3.1: {} react-markdown@9.0.1(@types/react@18.3.3)(react@18.3.1):