Use Nut API development server instead of webcontainers (#50)

This commit is contained in:
Brian Hackett 2025-03-13 07:59:10 -07:00 committed by GitHub
parent 58f2cb0f58
commit 9e58d6bb64
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
40 changed files with 297 additions and 2356 deletions

View File

@ -1,272 +0,0 @@
import { useStore } from '@nanostores/react';
import { AnimatePresence, motion } from 'framer-motion';
import { computed } from 'nanostores';
import { memo, useEffect, useRef, useState } from 'react';
import { createHighlighter, type BundledLanguage, type BundledTheme, type HighlighterGeneric } from 'shiki';
import type { ActionState } from '~/lib/runtime/action-runner';
import { workbenchStore } from '~/lib/stores/workbench';
import { classNames } from '~/utils/classNames';
import { cubicEasingFn } from '~/utils/easings';
import { WORK_DIR } from '~/utils/constants';
import { createAsyncSuspenseValue } from '~/lib/asyncSuspenseValue';
const highlighterOptions = {
langs: ['shell'],
themes: ['light-plus', 'dark-plus'],
};
const shellHighlighter = createAsyncSuspenseValue(async () => {
const shellHighlighterPromise: Promise<HighlighterGeneric<BundledLanguage, BundledTheme>> =
import.meta.hot?.data.shellHighlighterPromise ?? createHighlighter(highlighterOptions);
if (import.meta.hot) {
import.meta.hot.data.shellHighlighterPromise = shellHighlighterPromise;
}
return shellHighlighterPromise;
});
if (typeof document !== 'undefined') {
shellHighlighter.preload();
}
interface ArtifactProps {
messageId: string;
}
export const Artifact = memo(({ messageId }: ArtifactProps) => {
const userToggledActions = useRef(false);
const [showActions, setShowActions] = useState(false);
const [allActionFinished, setAllActionFinished] = useState(false);
const artifacts = useStore(workbenchStore.artifacts);
const artifact = artifacts[messageId];
const actions = useStore(
computed(artifact.runner.actions, (actions) => {
return Object.values(actions);
}),
);
const toggleActions = () => {
userToggledActions.current = true;
setShowActions(!showActions);
};
useEffect(() => {
if (actions.length && !showActions && !userToggledActions.current) {
setShowActions(true);
}
if (actions.length !== 0 && artifact.type === 'bundled') {
const finished = !actions.find((action) => action.status !== 'complete');
if (allActionFinished !== finished) {
setAllActionFinished(finished);
}
}
}, [actions]);
return (
<div className="artifact border border-bolt-elements-borderColor flex flex-col overflow-hidden rounded-lg w-full transition-border duration-150">
<div className="flex">
<button
className="flex items-stretch bg-bolt-elements-artifacts-background hover:bg-bolt-elements-artifacts-backgroundHover w-full overflow-hidden"
onClick={() => {
const showWorkbench = workbenchStore.showWorkbench.get();
workbenchStore.showWorkbench.set(!showWorkbench);
}}
>
{artifact.type == 'bundled' && (
<>
<div className="p-4">
{allActionFinished ? (
<div className={'i-ph:files-light'} style={{ fontSize: '2rem' }}></div>
) : (
<div className={'i-svg-spinners:90-ring-with-bg'} style={{ fontSize: '2rem' }}></div>
)}
</div>
<div className="bg-bolt-elements-artifacts-borderColor w-[1px]" />
</>
)}
<div className="px-5 p-3.5 w-full text-left">
<div className="w-full text-bolt-elements-textPrimary font-medium leading-5 text-sm">{artifact?.title}</div>
<div className="w-full w-full text-bolt-elements-textSecondary text-xs mt-0.5">Click to open Workbench</div>
</div>
</button>
<div className="bg-bolt-elements-artifacts-borderColor w-[1px]" />
<AnimatePresence>
{actions.length && artifact.type !== 'bundled' && (
<motion.button
initial={{ width: 0 }}
animate={{ width: 'auto' }}
exit={{ width: 0 }}
transition={{ duration: 0.15, ease: cubicEasingFn }}
className="bg-bolt-elements-artifacts-background hover:bg-bolt-elements-artifacts-backgroundHover"
onClick={toggleActions}
>
<div className="p-4">
<div className={showActions ? 'i-ph:caret-up-bold' : 'i-ph:caret-down-bold'}></div>
</div>
</motion.button>
)}
</AnimatePresence>
</div>
<AnimatePresence>
{artifact.type !== 'bundled' && showActions && actions.length > 0 && (
<motion.div
className="actions"
initial={{ height: 0 }}
animate={{ height: 'auto' }}
exit={{ height: '0px' }}
transition={{ duration: 0.15 }}
>
<div className="bg-bolt-elements-artifacts-borderColor h-[1px]" />
<div className="p-5 text-left bg-bolt-elements-actions-background">
<ActionList actions={actions} />
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
});
interface ShellCodeBlockProps {
classsName?: string;
code: string;
}
function ShellCodeBlock({ classsName, code }: ShellCodeBlockProps) {
return (
<div
className={classNames('text-xs', classsName)}
dangerouslySetInnerHTML={{
__html: shellHighlighter.read().codeToHtml(code, {
lang: 'shell',
theme: 'dark-plus',
}),
}}
></div>
);
}
interface ActionListProps {
actions: ActionState[];
}
const actionVariants = {
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0 },
};
function openArtifactInWorkbench(filePath: any) {
if (workbenchStore.currentView.get() !== 'code') {
workbenchStore.currentView.set('code');
}
workbenchStore.setSelectedFile(`${WORK_DIR}/${filePath}`);
}
const ActionList = memo(({ actions }: ActionListProps) => {
return (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.15 }}>
<ul className="list-none space-y-2.5">
{actions.map((action, index) => {
const { status, type, content } = action;
const isLast = index === actions.length - 1;
return (
<motion.li
key={index}
variants={actionVariants}
initial="hidden"
animate="visible"
transition={{
duration: 0.2,
ease: cubicEasingFn,
}}
>
<div className="flex items-center gap-1.5 text-sm">
<div className={classNames('text-lg', getIconColor(action.status))}>
{status === 'running' ? (
<>
{type !== 'start' ? (
<div className="i-svg-spinners:90-ring-with-bg"></div>
) : (
<div className="i-ph:terminal-window-duotone"></div>
)}
</>
) : status === 'pending' ? (
<div className="i-ph:circle-duotone"></div>
) : status === 'complete' ? (
<div className="i-ph:check"></div>
) : status === 'failed' || status === 'aborted' ? (
<div className="i-ph:x"></div>
) : null}
</div>
{type === 'file' ? (
<div>
Create{' '}
<code
className="bg-bolt-elements-artifacts-inlineCode-background text-bolt-elements-artifacts-inlineCode-text px-1.5 py-1 rounded-md text-bolt-elements-item-contentAccent hover:underline cursor-pointer"
onClick={() => openArtifactInWorkbench(action.filePath)}
>
{action.filePath}
</code>
</div>
) : type === 'shell' ? (
<div className="flex items-center w-full min-h-[28px]">
<span className="flex-1">Run command</span>
</div>
) : type === 'start' ? (
<a
onClick={(e) => {
e.preventDefault();
workbenchStore.currentView.set('preview');
}}
className="flex items-center w-full min-h-[28px]"
>
<span className="flex-1">Start Application</span>
</a>
) : null}
</div>
{(type === 'shell' || type === 'start') && (
<ShellCodeBlock
classsName={classNames('mt-1', {
'mb-3.5': !isLast,
})}
code={content}
/>
)}
</motion.li>
);
})}
</ul>
</motion.div>
);
});
function getIconColor(status: ActionState['status']) {
switch (status) {
case 'pending': {
return 'text-bolt-elements-textTertiary';
}
case 'running': {
return 'text-bolt-elements-loader-progress';
}
case 'complete': {
return 'text-bolt-elements-icon-success';
}
case 'aborted': {
return 'text-bolt-elements-textSecondary';
}
case 'failed': {
return 'text-bolt-elements-icon-error';
}
default: {
return undefined;
}
}
}

View File

@ -7,7 +7,7 @@ import type { Message } from 'ai';
import { useAnimate } from 'framer-motion';
import { memo, useCallback, useEffect, useRef, useState } from 'react';
import { cssTransition, toast, ToastContainer } from 'react-toastify';
import { useMessageParser, useShortcuts, useSnapScroll } from '~/lib/hooks';
import { useMessageParser, useSnapScroll } from '~/lib/hooks';
import { description, useChatHistory } from '~/lib/persistence';
import { chatStore } from '~/lib/stores/chat';
import { workbenchStore } from '~/lib/stores/workbench';
@ -170,8 +170,7 @@ async function clearActiveChat() {
gActiveChatMessageTelemetry = undefined;
if (gUpdateSimulationAfterChatMessage) {
const { contentBase64 } = await workbenchStore.generateZipBase64();
await simulationRepositoryUpdated(contentBase64);
await simulationRepositoryUpdated();
gUpdateSimulationAfterChatMessage = false;
}
}
@ -180,8 +179,7 @@ export async function onRepositoryFileWritten() {
if (gActiveChatMessageTelemetry) {
gUpdateSimulationAfterChatMessage = true;
} else {
const { contentBase64 } = await workbenchStore.generateZipBase64();
await simulationRepositoryUpdated(contentBase64);
await simulationRepositoryUpdated();
}
}
@ -189,10 +187,14 @@ function buildMessageId(prefix: string, chatId: string) {
return `${prefix}-${chatId}`;
}
const EnhancedPromptPrefix = 'enhanced-prompt';
export function isEnhancedPromptMessage(message: Message): boolean {
return message.id.startsWith(EnhancedPromptPrefix);
}
export const ChatImpl = memo(
({ description, initialMessages, storeMessageHistory, importChat, exportChat }: ChatProps) => {
useShortcuts();
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [chatStarted, setChatStarted] = useState(initialMessages.length > 0);
const [uploadedFiles, setUploadedFiles] = useState<File[]>([]); // Move here
@ -321,7 +323,7 @@ export const ChatImpl = memo(
}
const enhancedPromptMessage: Message = {
id: buildMessageId('enhanced-prompt', chatId),
id: buildMessageId(EnhancedPromptPrefix, chatId),
role: 'assistant',
content: message,
};

View File

@ -1,7 +1,6 @@
import ignore from 'ignore';
import { useGit } from '~/lib/hooks/useGit';
import type { Message } from 'ai';
import { detectProjectCommands, createCommandsMessage } from '~/utils/projectCommands';
import { generateId } from '~/utils/fileUtils';
import { useState } from 'react';
import { toast } from 'react-toastify';
@ -59,22 +58,16 @@ export default function GitCloneButton({ importChat }: GitCloneButtonProps) {
const filePaths = Object.keys(data).filter((filePath) => !ig.ignores(filePath));
console.log(filePaths);
const textDecoder = new TextDecoder('utf-8');
const fileContents = filePaths
.map((filePath) => {
const { data: content, encoding } = data[filePath];
const file = data[filePath];
return {
path: filePath,
content:
encoding === 'utf8' ? content : content instanceof Uint8Array ? textDecoder.decode(content) : '',
content: file?.content,
};
})
.filter((f) => f.content);
const commands = await detectProjectCommands(fileContents);
const commandsMessage = createCommandsMessage(commands);
const filesMessage: Message = {
role: 'assistant',
content: `Cloning the repo ${repoUrl} into ${workdir}
@ -94,10 +87,6 @@ ${file.content}
const messages = [filesMessage];
if (commandsMessage) {
messages.push(commandsMessage);
}
await importChat(`Git Project:${repoUrl.split('/').slice(-1)[0]}`, messages);
}
} catch (error) {

View File

@ -3,7 +3,6 @@ import ReactMarkdown, { type Components } from 'react-markdown';
import type { BundledLanguage } from 'shiki';
import { createScopedLogger } from '~/utils/logger';
import { rehypePlugins, remarkPlugins, allowedHTMLElements } from '~/utils/markdown';
import { Artifact } from './Artifact';
import { CodeBlock } from './CodeBlock';
import styles from './Markdown.module.scss';
@ -22,16 +21,6 @@ export const Markdown = memo(({ children, html = false, limitedMarkdown = false
const components = useMemo(() => {
return {
div: ({ className, children, node, ...props }) => {
if (className?.includes('__boltArtifact__')) {
const messageId = node?.properties.dataMessageId as string;
if (!messageId) {
logger.error(`Invalid message id ${messageId}`);
}
return <Artifact messageId={messageId} />;
}
return (
<div className={className} {...props}>
{children}

View File

@ -7,7 +7,6 @@ import { BaseChat } from '~/components/chat/BaseChat';
import { Chat } from '~/components/chat/Chat.client';
import { useGit } from '~/lib/hooks/useGit';
import { useChatHistory } from '~/lib/persistence';
import { createCommandsMessage, detectProjectCommands } from '~/utils/projectCommands';
import { LoadingOverlay } from '~/components/ui/LoadingOverlay';
import { toast } from 'react-toastify';
@ -59,18 +58,14 @@ export function GitUrlImport() {
const fileContents = filePaths
.map((filePath) => {
const { data: content, encoding } = data[filePath];
const file = data[filePath];
return {
path: filePath,
content:
encoding === 'utf8' ? content : content instanceof Uint8Array ? textDecoder.decode(content) : '',
content: file?.content,
};
})
.filter((f) => f.content);
const commands = await detectProjectCommands(fileContents);
const commandsMessage = createCommandsMessage(commands);
const filesMessage: Message = {
role: 'assistant',
content: `Cloning the repo ${repoUrl} into ${workdir}
@ -90,10 +85,6 @@ ${file.content}
const messages = [filesMessage];
if (commandsMessage) {
messages.push(commandsMessage);
}
await importChat(`Git Project:${repoUrl.split('/').slice(-1)[0]}`, messages);
}
} catch (error) {

View File

@ -4,7 +4,7 @@ import type { ChatHistoryItem } from '~/lib/persistence';
type Bin = { category: string; items: ChatHistoryItem[] };
export function binDates(_list: ChatHistoryItem[]) {
const list = _list.toSorted((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
const list = [..._list].sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
const binLookup: Record<string, Bin> = {};
const bins: Array<Bin> = [];

View File

@ -13,13 +13,10 @@ import { PanelHeader } from '~/components/ui/PanelHeader';
import { PanelHeaderButton } from '~/components/ui/PanelHeaderButton';
import type { FileMap } from '~/lib/stores/files';
import { themeStore } from '~/lib/stores/theme';
import { WORK_DIR } from '~/utils/constants';
import { renderLogger } from '~/utils/logger';
import { isMobile } from '~/utils/mobile';
import { FileBreadcrumb } from './FileBreadcrumb';
import { FileTree } from './FileTree';
import { DEFAULT_TERMINAL_SIZE, TerminalTabs } from './terminal/TerminalTabs';
import { workbenchStore } from '~/lib/stores/workbench';
interface EditorPanelProps {
files?: FileMap;
@ -34,7 +31,7 @@ interface EditorPanelProps {
onFileReset?: () => void;
}
const DEFAULT_EDITOR_SIZE = 100 - DEFAULT_TERMINAL_SIZE;
const DEFAULT_EDITOR_SIZE = 100;
const editorSettings: EditorSettings = { tabSize: 2 };
@ -54,7 +51,6 @@ export const EditorPanel = memo(
renderLogger.trace('EditorPanel');
const theme = useStore(themeStore);
const showTerminal = useStore(workbenchStore.showTerminal);
const activeFileSegments = useMemo(() => {
if (!editorDocument) {
@ -70,7 +66,7 @@ export const EditorPanel = memo(
return (
<PanelGroup direction="vertical">
<Panel defaultSize={showTerminal ? DEFAULT_EDITOR_SIZE : 100} minSize={20}>
<Panel defaultSize={DEFAULT_EDITOR_SIZE} minSize={20}>
<PanelGroup direction="horizontal">
<Panel defaultSize={20} minSize={10} collapsible>
<div className="flex flex-col border-r border-bolt-elements-borderColor h-full">
@ -81,9 +77,7 @@ export const EditorPanel = memo(
<FileTree
className="h-full"
files={files}
hideRoot
unsavedFiles={unsavedFiles}
rootFolder={WORK_DIR}
selectedFile={selectedFile}
onFileSelect={onFileSelect}
/>
@ -126,7 +120,6 @@ export const EditorPanel = memo(
</PanelGroup>
</Panel>
<PanelResizeHandle />
<TerminalTabs />
</PanelGroup>
);
},

View File

@ -3,13 +3,10 @@ import { AnimatePresence, motion, type Variants } from 'framer-motion';
import { memo, useEffect, useRef, useState } from 'react';
import type { FileMap } from '~/lib/stores/files';
import { classNames } from '~/utils/classNames';
import { WORK_DIR } from '~/utils/constants';
import { cubicEasingFn } from '~/utils/easings';
import { renderLogger } from '~/utils/logger';
import FileTree from './FileTree';
const WORK_DIR_REGEX = new RegExp(`^${WORK_DIR.split('/').slice(0, -1).join('/').replaceAll('/', '\\/')}/`);
interface FileBreadcrumbProps {
files?: FileMap;
pathSegments?: string[];
@ -76,10 +73,6 @@ export const FileBreadcrumb = memo<FileBreadcrumbProps>(({ files, pathSegments =
const path = pathSegments.slice(0, index).join('/');
if (!WORK_DIR_REGEX.test(path)) {
return null;
}
const isActive = activeIndex === index;
return (
@ -121,8 +114,6 @@ export const FileBreadcrumb = memo<FileBreadcrumbProps>(({ files, pathSegments =
<div className="max-h-[50vh] min-w-[300px] overflow-scroll bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor shadow-sm rounded-lg">
<FileTree
files={files}
hideRoot
rootFolder={path}
collapsed
allowFolderSelection
selectedFile={`${path}/${segment}`}

View File

@ -13,8 +13,6 @@ interface Props {
files?: FileMap;
selectedFile?: string;
onFileSelect?: (filePath: string) => void;
rootFolder?: string;
hideRoot?: boolean;
collapsed?: boolean;
allowFolderSelection?: boolean;
hiddenFiles?: Array<string | RegExp>;
@ -27,8 +25,6 @@ export const FileTree = memo(
files = {},
onFileSelect,
selectedFile,
rootFolder,
hideRoot = false,
collapsed = false,
allowFolderSelection = false,
hiddenFiles,
@ -40,8 +36,8 @@ export const FileTree = memo(
const computedHiddenFiles = useMemo(() => [...DEFAULT_HIDDEN_FILES, ...(hiddenFiles ?? [])], [hiddenFiles]);
const fileList = useMemo(() => {
return buildFileList(files, rootFolder, hideRoot, computedHiddenFiles);
}, [files, rootFolder, hideRoot, computedHiddenFiles]);
return buildFileList(files, computedHiddenFiles);
}, [files, computedHiddenFiles]);
const [collapsedFolders, setCollapsedFolders] = useState(() => {
return collapsed
@ -121,7 +117,7 @@ export const FileTree = memo(
const onCopyRelativePath = (fileOrFolder: FileNode | FolderNode) => {
try {
navigator.clipboard.writeText(fileOrFolder.fullPath.substring((rootFolder || '').length));
navigator.clipboard.writeText(fileOrFolder.fullPath);
} catch (error) {
logger.error(error);
}
@ -334,21 +330,11 @@ interface FolderNode extends BaseNode {
kind: 'folder';
}
function buildFileList(
files: FileMap,
rootFolder = '/',
hideRoot: boolean,
hiddenFiles: Array<string | RegExp>,
): Node[] {
function buildFileList(files: FileMap, hiddenFiles: Array<string | RegExp>): Node[] {
const folderPaths = new Set<string>();
const fileList: Node[] = [];
let defaultDepth = 0;
if (rootFolder === '/' && !hideRoot) {
defaultDepth = 1;
fileList.push({ kind: 'folder', name: '/', depth: 0, id: 0, fullPath: '/' });
}
const defaultDepth = 0;
for (const [filePath, dirent] of Object.entries(files)) {
const segments = filePath.split('/').filter((segment) => segment);
@ -358,21 +344,16 @@ function buildFileList(
continue;
}
let currentPath = '';
let fullPath = '';
let i = 0;
let depth = 0;
while (i < segments.length) {
const name = segments[i];
const fullPath = (currentPath += `/${name}`);
fullPath += (fullPath.length ? '/' : '') + name;
if (!fullPath.startsWith(rootFolder) || (hideRoot && fullPath === rootFolder)) {
i++;
continue;
}
if (i === segments.length - 1 && dirent?.type === 'file') {
if (i === segments.length - 1) {
fileList.push({
kind: 'file',
id: fileList.length,
@ -397,7 +378,7 @@ function buildFileList(
}
}
return sortFileList(rootFolder, fileList, hideRoot);
return sortFileList(fileList);
}
function isHiddenFile(filePath: string, fileName: string, hiddenFiles: Array<string | RegExp>) {
@ -410,6 +391,16 @@ function isHiddenFile(filePath: string, fileName: string, hiddenFiles: Array<str
});
}
function getParentPath(path: string): string {
const lastSlash = path.lastIndexOf('/');
if (lastSlash === -1) {
return '';
}
return path.slice(0, lastSlash);
}
/**
* Sorts the given list of nodes into a tree structure (still a flat list).
*
@ -423,7 +414,7 @@ function isHiddenFile(filePath: string, fileName: string, hiddenFiles: Array<str
*
* @returns A new array of nodes sorted in depth-first order.
*/
function sortFileList(rootFolder: string, nodeList: Node[], hideRoot: boolean): Node[] {
function sortFileList(nodeList: Node[]): Node[] {
logger.trace('sortFileList');
const nodeMap = new Map<string, Node>();
@ -435,16 +426,14 @@ function sortFileList(rootFolder: string, nodeList: Node[], hideRoot: boolean):
for (const node of nodeList) {
nodeMap.set(node.fullPath, node);
const parentPath = node.fullPath.slice(0, node.fullPath.lastIndexOf('/'));
const parentPath = getParentPath(node.fullPath);
if (parentPath !== rootFolder.slice(0, rootFolder.lastIndexOf('/'))) {
if (!childrenMap.has(parentPath)) {
childrenMap.set(parentPath, []);
}
childrenMap.get(parentPath)?.push(node);
}
}
const sortedList: Node[] = [];
@ -468,16 +457,11 @@ function sortFileList(rootFolder: string, nodeList: Node[], hideRoot: boolean):
}
};
if (hideRoot) {
// if root is hidden, start traversal from its immediate children
const rootChildren = childrenMap.get(rootFolder) || [];
const rootChildren = childrenMap.get('') || [];
for (const child of rootChildren) {
depthFirstTraversal(child.fullPath);
}
} else {
depthFirstTraversal(rootFolder);
}
return sortedList;
}

View File

@ -1,83 +0,0 @@
import { memo, useEffect, useRef } from 'react';
import { IconButton } from '~/components/ui/IconButton';
import type { PreviewInfo } from '~/lib/stores/previews';
interface PortDropdownProps {
activePreviewIndex: number;
setActivePreviewIndex: (index: number) => void;
isDropdownOpen: boolean;
setIsDropdownOpen: (value: boolean) => void;
setHasSelectedPreview: (value: boolean) => void;
previews: PreviewInfo[];
}
export const PortDropdown = memo(
({
activePreviewIndex,
setActivePreviewIndex,
isDropdownOpen,
setIsDropdownOpen,
setHasSelectedPreview,
previews,
}: PortDropdownProps) => {
const dropdownRef = useRef<HTMLDivElement>(null);
// sort previews, preserving original index
const sortedPreviews = previews
.map((previewInfo, index) => ({ ...previewInfo, index }))
.sort((a, b) => a.port - b.port);
// close dropdown if user clicks outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsDropdownOpen(false);
}
};
if (isDropdownOpen) {
window.addEventListener('mousedown', handleClickOutside);
} else {
window.removeEventListener('mousedown', handleClickOutside);
}
return () => {
window.removeEventListener('mousedown', handleClickOutside);
};
}, [isDropdownOpen]);
return (
<div className="relative z-port-dropdown" ref={dropdownRef}>
<IconButton icon="i-ph:plug" onClick={() => setIsDropdownOpen(!isDropdownOpen)} />
{isDropdownOpen && (
<div className="absolute right-0 mt-2 bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor rounded shadow-sm min-w-[140px] dropdown-animation">
<div className="px-4 py-2 border-b border-bolt-elements-borderColor text-sm font-semibold text-bolt-elements-textPrimary">
Ports
</div>
{sortedPreviews.map((preview) => (
<div
key={preview.port}
className="flex items-center px-4 py-2 cursor-pointer hover:bg-bolt-elements-item-backgroundActive"
onClick={() => {
setActivePreviewIndex(preview.index);
setIsDropdownOpen(false);
setHasSelectedPreview(true);
}}
>
<span
className={
activePreviewIndex === preview.index
? 'text-bolt-elements-item-contentAccent'
: 'text-bolt-elements-item-contentDefault group-hover:text-bolt-elements-item-contentActive'
}
>
{preview.port}
</span>
</div>
))}
</div>
)}
</div>
);
},
);

View File

@ -3,7 +3,6 @@ import { memo, useCallback, useEffect, useRef, useState } from 'react';
import { IconButton } from '~/components/ui/IconButton';
import { workbenchStore } from '~/lib/stores/workbench';
import { simulationReloaded } from '~/lib/replay/SimulationPrompt';
import { PortDropdown } from './PortDropdown';
import { PointSelector } from './PointSelector';
type ResizeSide = 'left' | 'right' | null;
@ -19,18 +18,16 @@ export const Preview = memo(() => {
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [activePreviewIndex, setActivePreviewIndex] = useState(0);
const [isPortDropdownOpen, setIsPortDropdownOpen] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const hasSelectedPreview = useRef(false);
const previews = useStore(workbenchStore.previews);
const activePreview = previews[activePreviewIndex];
const [url, setUrl] = useState('');
const [iframeUrl, setIframeUrl] = useState<string | undefined>();
const [isSelectionMode, setIsSelectionMode] = useState(false);
const [selectionPoint, setSelectionPoint] = useState<{ x: number; y: number } | null>(null);
const previewURL = useStore(workbenchStore.previewURL);
// Toggle between responsive mode and device mode
const [isDeviceModeOn, setIsDeviceModeOn] = useState(false);
@ -51,78 +48,17 @@ export const Preview = memo(() => {
gCurrentIFrame = iframeRef.current ?? undefined;
useEffect(() => {
if (!activePreview) {
if (!previewURL) {
setUrl('');
setIframeUrl(undefined);
setSelectionPoint(null);
return;
}
const { baseUrl } = activePreview;
setUrl(baseUrl);
setIframeUrl(baseUrl);
}, [activePreview]);
// Trim any long base URL from the start of the provided URL.
const displayUrl = (url: string) => {
if (!activePreview) {
return url;
}
const { baseUrl } = activePreview;
if (url.startsWith(baseUrl)) {
const trimmedUrl = url.slice(baseUrl.length);
if (trimmedUrl.startsWith('/')) {
return trimmedUrl;
}
return '/' + trimmedUrl;
}
return url;
};
const validateUrl = useCallback(
(value: string) => {
if (!activePreview) {
return null;
}
const { baseUrl } = activePreview;
if (value === baseUrl) {
return value;
} else if (value.startsWith(baseUrl)) {
if (['/', '?', '#'].includes(value.charAt(baseUrl.length))) {
return value;
}
}
if (value.startsWith('/')) {
return baseUrl + value;
}
return null;
},
[activePreview],
);
const findMinPortIndex = useCallback(
(minIndex: number, preview: { port: number }, index: number, array: { port: number }[]) => {
return preview.port < array[minIndex].port ? index : minIndex;
},
[],
);
// When previews change, display the lowest port if user hasn't selected a preview
useEffect(() => {
if (previews.length > 1 && !hasSelectedPreview.current) {
const minPortIndex = previews.reduce(findMinPortIndex, 0);
setActivePreviewIndex(minPortIndex);
}
}, [previews, findMinPortIndex]);
setUrl(previewURL);
setIframeUrl(previewURL);
}, [previewURL]);
const reloadPreview = () => {
if (iframeRef.current) {
@ -279,14 +215,14 @@ export const Preview = memo(() => {
ref={inputRef}
className="w-full bg-transparent outline-none"
type="text"
value={displayUrl(url)}
value={url}
onChange={(event) => {
setUrl(event.target.value);
}}
onKeyDown={(event) => {
let newUrl;
if (event.key === 'Enter' && (newUrl = validateUrl(url))) {
if (event.key === 'Enter') {
setIframeUrl(newUrl);
if (inputRef.current) {
@ -297,17 +233,6 @@ export const Preview = memo(() => {
/>
</div>
{previews.length > 1 && (
<PortDropdown
activePreviewIndex={activePreviewIndex}
setActivePreviewIndex={setActivePreviewIndex}
isDropdownOpen={isPortDropdownOpen}
setHasSelectedPreview={(value) => (hasSelectedPreview.current = value)}
setIsDropdownOpen={setIsPortDropdownOpen}
previews={previews}
/>
)}
{/* Device mode toggle button */}
<IconButton
icon="i-ph:devices"
@ -334,7 +259,7 @@ export const Preview = memo(() => {
display: 'flex',
}}
>
{activePreview ? (
{previewURL ? (
<>
<iframe
ref={iframeRef}

View File

@ -59,7 +59,7 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
const [isSyncing, setIsSyncing] = useState(false);
const hasPreview = useStore(computed(workbenchStore.previews, (previews) => previews.length > 0));
const previewURL = useStore(workbenchStore.previewURL);
const showWorkbench = useStore(workbenchStore.showWorkbench);
const selectedFile = useStore(workbenchStore.selectedFile);
const currentDocument = useStore(workbenchStore.currentDocument);
@ -74,10 +74,10 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
};
useEffect(() => {
if (hasPreview) {
if (previewURL) {
setSelectedView('preview');
}
}, [hasPreview]);
}, [previewURL]);
useEffect(() => {
workbenchStore.setDocuments(files);
@ -209,15 +209,6 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
{isSyncing ? <div className="i-ph:spinner" /> : <div className="i-ph:cloud-arrow-down" />}
{isSyncing ? 'Syncing...' : 'Sync Files'}
</PanelHeaderButton>
<PanelHeaderButton
className="mr-1 text-sm"
onClick={() => {
workbenchStore.toggleTerminal(!workbenchStore.showTerminal.get());
}}
>
<div className="i-ph:terminal" />
Toggle Terminal
</PanelHeaderButton>
<PanelHeaderButton
className="mr-1 text-sm"
onClick={() => {

View File

@ -1,89 +0,0 @@
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 { createScopedLogger } from '~/utils/logger';
import { getTerminalTheme } from './theme';
const logger = createScopedLogger('Terminal');
export interface TerminalRef {
reloadStyles: () => void;
}
export interface TerminalProps {
className?: string;
theme: Theme;
readonly?: boolean;
id: string;
onTerminalReady?: (terminal: XTerm) => void;
onTerminalResize?: (cols: number, rows: number) => void;
}
export const Terminal = memo(
forwardRef<TerminalRef, TerminalProps>(
({ className, theme, readonly, id, onTerminalReady, onTerminalResize }, ref) => {
const terminalElementRef = useRef<HTMLDivElement>(null);
const terminalRef = useRef<XTerm>();
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: 12,
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);
logger.debug(`Attach [${id}]`);
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 <div className={className} ref={terminalElementRef} />;
},
),
);

View File

@ -1,186 +0,0 @@
import { useStore } from '@nanostores/react';
import React, { memo, useEffect, useRef, useState } from 'react';
import { Panel, type ImperativePanelHandle } from 'react-resizable-panels';
import { IconButton } from '~/components/ui/IconButton';
import { shortcutEventEmitter } from '~/lib/hooks';
import { themeStore } from '~/lib/stores/theme';
import { workbenchStore } from '~/lib/stores/workbench';
import { classNames } from '~/utils/classNames';
import { Terminal, type TerminalRef } from './Terminal';
import { createScopedLogger } from '~/utils/logger';
const logger = createScopedLogger('Terminal');
const MAX_TERMINALS = 3;
export const DEFAULT_TERMINAL_SIZE = 25;
export const TerminalTabs = memo(() => {
const showTerminal = useStore(workbenchStore.showTerminal);
const theme = useStore(themeStore);
const terminalRefs = useRef<Array<TerminalRef | null>>([]);
const terminalPanelRef = useRef<ImperativePanelHandle>(null);
const terminalToggledByShortcut = useRef(false);
const [activeTerminal, setActiveTerminal] = useState(0);
const [terminalCount, setTerminalCount] = useState(1);
const addTerminal = () => {
if (terminalCount < MAX_TERMINALS) {
setTerminalCount(terminalCount + 1);
setActiveTerminal(terminalCount);
}
};
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]);
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();
};
}, []);
return (
<Panel
ref={terminalPanelRef}
defaultSize={showTerminal ? DEFAULT_TERMINAL_SIZE : 0}
minSize={10}
collapsible
onExpand={() => {
if (!terminalToggledByShortcut.current) {
workbenchStore.toggleTerminal(true);
}
}}
onCollapse={() => {
if (!terminalToggledByShortcut.current) {
workbenchStore.toggleTerminal(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">
{Array.from({ length: terminalCount + 1 }, (_, index) => {
const isActive = activeTerminal === index;
return (
<React.Fragment key={index}>
{index == 0 ? (
<button
key={index}
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-textSecondary hover:text-bolt-elements-textPrimary':
isActive,
'bg-bolt-elements-background-depth-2 text-bolt-elements-textSecondary hover:bg-bolt-elements-terminals-buttonBackground':
!isActive,
},
)}
onClick={() => setActiveTerminal(index)}
>
<div className="i-ph:terminal-window-duotone text-lg" />
Bolt Terminal
</button>
) : (
<React.Fragment>
<button
key={index}
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': isActive,
'bg-bolt-elements-background-depth-2 text-bolt-elements-textSecondary hover:bg-bolt-elements-terminals-buttonBackground':
!isActive,
},
)}
onClick={() => setActiveTerminal(index)}
>
<div className="i-ph:terminal-window-duotone text-lg" />
Terminal {terminalCount > 1 && index}
</button>
</React.Fragment>
)}
</React.Fragment>
);
})}
{terminalCount < MAX_TERMINALS && <IconButton icon="i-ph:plus" size="md" onClick={addTerminal} />}
<IconButton
className="ml-auto"
icon="i-ph:caret-down"
title="Close"
size="md"
onClick={() => workbenchStore.toggleTerminal(false)}
/>
</div>
{Array.from({ length: terminalCount + 1 }, (_, index) => {
const isActive = activeTerminal === index;
logger.debug(`Starting bolt terminal [${index}]`);
if (index == 0) {
return (
<Terminal
key={index}
id={`terminal_${index}`}
className={classNames('h-full overflow-hidden', {
hidden: !isActive,
})}
ref={(ref) => {
terminalRefs.current.push(ref);
}}
onTerminalReady={(terminal) => workbenchStore.attachBoltTerminal(terminal)}
onTerminalResize={(cols, rows) => workbenchStore.onTerminalResize(cols, rows)}
theme={theme}
/>
);
} else {
return (
<Terminal
key={index}
id={`terminal_${index}`}
className={classNames('h-full overflow-hidden', {
hidden: !isActive,
})}
ref={(ref) => {
terminalRefs.current.push(ref);
}}
onTerminalReady={(terminal) => workbenchStore.attachTerminal(terminal)}
onTerminalResize={(cols, rows) => workbenchStore.onTerminalResize(cols, rows)}
theme={theme}
/>
);
}
})}
</div>
</div>
</Panel>
);
});

View File

@ -1,36 +0,0 @@
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,
};
}

View File

@ -1,6 +1,5 @@
export * from './useMessageParser';
export * from './usePromptEnhancer';
export * from './useShortcuts';
export * from './useSnapScroll';
export * from './useEditChatDescription';
export { default } from './useViewport';

View File

@ -1,10 +1,10 @@
import type { WebContainer } from '@webcontainer/api';
import { useCallback, useEffect, useRef, useState, type MutableRefObject } from 'react';
import { webcontainer as webcontainerPromise } from '~/lib/webcontainer';
import git, { type GitAuth, type PromiseFsClient } from 'isomorphic-git';
import http from 'isomorphic-git/http/web';
import Cookies from 'js-cookie';
import { toast } from 'react-toastify';
import type { ProtocolFile } from '../replay/SimulationPrompt';
import type { FileMap } from '../stores/files';
const lookupSavedPassword = (url: string) => {
const domain = url.split('/')[2];
@ -30,26 +30,19 @@ const saveGitAuth = (url: string, auth: GitAuth) => {
export function useGit() {
const [ready, setReady] = useState(false);
const [webcontainer, setWebcontainer] = useState<WebContainer>();
const [fs, setFs] = useState<PromiseFsClient>();
const fileData = useRef<Record<string, { data: any; encoding?: string }>>({});
const fileData = useRef<FileMap>({});
useEffect(() => {
webcontainerPromise.then((container) => {
fileData.current = {};
setWebcontainer(container);
setFs(getFs(container, fileData));
setFs(getFs(fileData.current));
setReady(true);
});
}, []);
const gitClone = useCallback(
async (url: string) => {
if (!webcontainer || !fs || !ready) {
throw 'Webcontainer not initialized';
if (!fs || !ready) {
throw 'Not initialized';
}
fileData.current = {};
const headers: {
[x: string]: string;
} = {
@ -66,7 +59,7 @@ export function useGit() {
await git.clone({
fs,
http,
dir: webcontainer.workdir,
dir: '/',
url,
depth: 1,
singleBranch: true,
@ -98,35 +91,35 @@ export function useGit() {
},
});
const data: Record<string, { data: any; encoding?: string }> = {};
for (const [key, value] of Object.entries(fileData.current)) {
data[key] = value;
}
return { workdir: webcontainer.workdir, data };
return { workdir: '/', data: fileData.current };
} catch (error) {
console.error('Git clone error:', error);
throw error;
}
},
[webcontainer, fs, ready],
[fs, ready],
);
return { ready, gitClone };
}
const getFs = (
webcontainer: WebContainer,
record: MutableRefObject<Record<string, { data: any; encoding?: string }>>,
) => ({
function createFileFromEncoding(path: string, data: any, encoding: string | undefined): ProtocolFile {
if (typeof data == 'string') {
return { path, content: data };
}
console.error('CreateFileFromEncodingFailed', { data, encoding });
return { path, content: 'CreateFileFromEncodingFailed' };
}
const getFs = (files: FileMap) => ({
promises: {
readFile: async (path: string, options: any) => {
const encoding = options?.encoding;
const relativePath = pathUtils.relative(webcontainer.workdir, path);
try {
const result = await webcontainer.fs.readFile(relativePath, encoding);
const result = files[path]?.content;
return result;
} catch (error) {
@ -135,191 +128,33 @@ const getFs = (
},
writeFile: async (path: string, data: any, options: any) => {
const encoding = options.encoding;
const relativePath = pathUtils.relative(webcontainer.workdir, path);
if (record.current) {
record.current[relativePath] = { data, encoding };
}
try {
const result = await webcontainer.fs.writeFile(relativePath, data, { ...options, encoding });
return result;
} catch (error) {
throw error;
}
},
mkdir: async (path: string, options: any) => {
const relativePath = pathUtils.relative(webcontainer.workdir, path);
try {
const result = await webcontainer.fs.mkdir(relativePath, { ...options, recursive: true });
return result;
} catch (error) {
throw error;
}
files[path] = createFileFromEncoding(path, data, encoding);
},
mkdir: async (path: string, options: any) => {},
readdir: async (path: string, options: any) => {
const relativePath = pathUtils.relative(webcontainer.workdir, path);
try {
const result = await webcontainer.fs.readdir(relativePath, options);
return result;
} catch (error) {
throw error;
}
throw new Error('NYI');
},
rm: async (path: string, options: any) => {
const relativePath = pathUtils.relative(webcontainer.workdir, path);
try {
const result = await webcontainer.fs.rm(relativePath, { ...(options || {}) });
return result;
} catch (error) {
throw error;
}
throw new Error('NYI');
},
rmdir: async (path: string, options: any) => {
const relativePath = pathUtils.relative(webcontainer.workdir, path);
try {
const result = await webcontainer.fs.rm(relativePath, { recursive: true, ...options });
return result;
} catch (error) {
throw error;
}
throw new Error('NYI');
},
unlink: async (path: string) => {
const relativePath = pathUtils.relative(webcontainer.workdir, path);
try {
return await webcontainer.fs.rm(relativePath, { recursive: false });
} catch (error) {
throw error;
}
throw new Error('NYI');
},
stat: async (path: string) => {
try {
const relativePath = pathUtils.relative(webcontainer.workdir, path);
const resp = await webcontainer.fs.readdir(pathUtils.dirname(relativePath), { withFileTypes: true });
const name = pathUtils.basename(relativePath);
const fileInfo = resp.find((x) => x.name == name);
if (!fileInfo) {
throw new Error(`ENOENT: no such file or directory, stat '${path}'`);
}
return {
isFile: () => fileInfo.isFile(),
isDirectory: () => fileInfo.isDirectory(),
isSymbolicLink: () => false,
size: 1,
mode: 0o666, // Default permissions
mtimeMs: Date.now(),
uid: 1000,
gid: 1000,
};
} catch (error: any) {
console.log(error?.message);
const err = new Error(`ENOENT: no such file or directory, stat '${path}'`) as NodeJS.ErrnoException;
err.code = 'ENOENT';
err.errno = -2;
err.syscall = 'stat';
err.path = path;
throw err;
}
throw new Error('NYI');
},
lstat: async (path: string) => {
return await getFs(webcontainer, record).promises.stat(path);
throw new Error('NYI');
},
readlink: async (path: string) => {
throw new Error(`EINVAL: invalid argument, readlink '${path}'`);
throw new Error('NYI');
},
symlink: async (target: string, path: string) => {
/*
* Since WebContainer doesn't support symlinks,
* we'll throw a "operation not supported" error
*/
throw new Error(`EPERM: operation not permitted, symlink '${target}' -> '${path}'`);
},
chmod: async (_path: string, _mode: number) => {
/*
* WebContainer doesn't support changing permissions,
* but we can pretend it succeeded for compatibility
*/
return await Promise.resolve();
throw new Error('NYI');
},
chmod: async (_path: string, _mode: number) => {},
},
});
const pathUtils = {
dirname: (path: string) => {
// Handle empty or just filename cases
if (!path || !path.includes('/')) {
return '.';
}
// Remove trailing slashes
path = path.replace(/\/+$/, '');
// Get directory part
return path.split('/').slice(0, -1).join('/') || '/';
},
basename: (path: string, ext?: string) => {
// Remove trailing slashes
path = path.replace(/\/+$/, '');
// Get the last part of the path
const base = path.split('/').pop() || '';
// If extension is provided, remove it from the result
if (ext && base.endsWith(ext)) {
return base.slice(0, -ext.length);
}
return base;
},
relative: (from: string, to: string): string => {
// Handle empty inputs
if (!from || !to) {
return '.';
}
// Normalize paths by removing trailing slashes and splitting
const normalizePathParts = (p: string) => p.replace(/\/+$/, '').split('/').filter(Boolean);
const fromParts = normalizePathParts(from);
const toParts = normalizePathParts(to);
// Find common parts at the start of both paths
let commonLength = 0;
const minLength = Math.min(fromParts.length, toParts.length);
for (let i = 0; i < minLength; i++) {
if (fromParts[i] !== toParts[i]) {
break;
}
commonLength++;
}
// Calculate the number of "../" needed
const upCount = fromParts.length - commonLength;
// Get the remaining path parts we need to append
const remainingPath = toParts.slice(commonLength);
// Construct the relative path
const relativeParts = [...Array(upCount).fill('..'), ...remainingPath];
// Handle empty result case
return relativeParts.length === 0 ? '.' : relativeParts.join('/');
},
};

View File

@ -21,24 +21,14 @@ const messageParser = new StreamingMessageParser({
},
onActionOpen: (data) => {
logger.trace('onActionOpen', data.action);
// we only add shell actions when when the close tag got parsed because only then we have the content
if (data.action.type === 'file') {
workbenchStore.addAction(data);
}
},
onActionClose: (data) => {
logger.trace('onActionClose', data.action);
if (data.action.type !== 'file') {
workbenchStore.addAction(data);
}
workbenchStore.runAction(data);
},
onActionStream: (data) => {
logger.trace('onActionStream', data.action);
workbenchStore.runAction(data, true);
workbenchStore.runAction(data);
},
},
});

View File

@ -1,59 +0,0 @@
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();
event.stopPropagation();
shortcut.action();
break;
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [shortcuts]);
}

View File

@ -0,0 +1,76 @@
// Support using the Nut API for the development server.
import { debounce } from '~/utils/debounce';
import { assert, ProtocolClient } from './ReplayProtocolClient';
import { workbenchStore } from '~/lib/stores/workbench';
import { toast } from 'react-toastify';
class DevelopmentServerManager {
// Empty if this chat has been destroyed.
client: ProtocolClient | undefined;
// Resolves when the chat has started.
chatIdPromise: Promise<string>;
constructor() {
this.client = new ProtocolClient();
this.chatIdPromise = (async () => {
assert(this.client, 'Chat has been destroyed');
await this.client.initialize();
const { chatId } = (await this.client.sendCommand({ method: 'Nut.startChat', params: {} })) as { chatId: string };
console.log('DevelopmentServerChat', new Date().toISOString(), chatId);
return chatId;
})();
}
destroy() {
this.client?.close();
this.client = undefined;
}
async setRepositoryContents(contents: string): Promise<string | undefined> {
assert(this.client, 'Chat has been destroyed');
try {
const chatId = await this.chatIdPromise;
const { url } = (await this.client.sendCommand({
method: 'Nut.startDevelopmentServer',
params: {
chatId,
repositoryContents: contents,
},
})) as { url: string };
return url;
} catch (e) {
console.error('DevelopmentServerError', e);
return undefined;
}
}
}
let gActiveDevelopmentServer: DevelopmentServerManager | undefined;
const debounceSetRepositoryContents = debounce(async (repositoryContents: string) => {
if (!gActiveDevelopmentServer) {
gActiveDevelopmentServer = new DevelopmentServerManager();
}
const url = await gActiveDevelopmentServer.setRepositoryContents(repositoryContents);
if (!url) {
toast.error('Failed to start development server');
}
workbenchStore.previewURL.set(url);
}, 500);
export async function updateDevelopmentServer(repositoryContents: string) {
workbenchStore.previewURL.set(undefined);
debounceSetRepositoryContents(repositoryContents);
}

View File

@ -576,9 +576,20 @@ function addRecordingMessageHandler(_messageHandlerId: string) {
};
}
export const recordingMessageHandlerScript = `
const recordingMessageHandlerScript = `
${assert}
${stringToBase64}
${uint8ArrayToBase64}
(${addRecordingMessageHandler})()
`;
export function doInjectRecordingMessageHandler(content: string) {
const headTag = content.indexOf('<head>');
assert(headTag != -1, 'No <head> tag found');
const headEnd = headTag + 6;
const scriptTag = `<script>${recordingMessageHandlerScript}</script>`;
return content.slice(0, headEnd) + scriptTag + content.slice(headEnd);
}

View File

@ -89,7 +89,7 @@ export class ProtocolClient {
openDeferred = createDeferred<void>();
eventListeners = new Map<string, Set<EventListener>>();
nextMessageId = 1;
pendingCommands = new Map<number, Deferred<any>>();
pendingCommands = new Map<number, { method: string; deferred: Deferred<any> }>();
socket: WebSocket;
constructor() {
@ -147,7 +147,7 @@ export class ProtocolClient {
this.socket.send(JSON.stringify(command));
const deferred = createDeferred();
this.pendingCommands.set(id, deferred);
this.pendingCommands.set(id, { method, deferred });
return deferred.promise;
}
@ -164,18 +164,18 @@ export class ProtocolClient {
const { error, id, method, params, result } = JSON.parse(String(event.data));
if (id) {
const deferred = this.pendingCommands.get(id);
assert(deferred, `Received message with unknown id: ${id}`);
const info = this.pendingCommands.get(id);
assert(info, `Received message with unknown id: ${id}`);
this.pendingCommands.delete(id);
if (result) {
deferred.resolve(result);
info.deferred.resolve(result);
} else if (error) {
console.error('ProtocolError', error);
deferred.reject(new ProtocolError(error));
console.error('ProtocolError', info.method, id, error);
info.deferred.reject(new ProtocolError(error));
} else {
deferred.reject(new Error('Channel error'));
info.deferred.reject(new Error('Channel error'));
}
} else if (this.eventListeners.has(method)) {
const callbacks = this.eventListeners.get(method);

View File

@ -10,8 +10,10 @@ import { assert, generateRandomId, ProtocolClient } from './ReplayProtocolClient
import type { MouseData } from './Recording';
import type { FileMap } from '~/lib/stores/files';
import { shouldIncludeFile } from '~/utils/fileUtils';
import { developerSystemPrompt } from '~/lib/common/prompts/prompts';
import { detectProjectCommands } from '~/utils/projectCommands';
import { developerSystemPrompt } from '../common/prompts/prompts';
import { updateDevelopmentServer } from './DevelopmentServer';
import { workbenchStore } from '../stores/workbench';
import { isEnhancedPromptMessage } from '~/components/chat/Chat.client';
function createRepositoryContentsPacket(contents: string): SimulationPacket {
return {
@ -184,15 +186,6 @@ class ChatManager {
if (responseId == eventResponseId) {
console.log('ChatModifiedFile', file);
modifiedFiles.push(file);
const content = `
<boltArtifact id="modified-file-${generateRandomId()}" title="File Changes">
<boltAction type="file" filePath="${file.path}">${file.content}</boltAction>
</boltArtifact>
`;
response += content;
options?.onResponsePart?.(content);
}
},
);
@ -211,15 +204,14 @@ class ChatManager {
params: { chatId, responseId, messages, chatOnly: options?.chatOnly, developerFiles: options?.developerFiles },
});
removeResponseListener();
removeFileListener();
if (modifiedFiles.length) {
const commands = await detectProjectCommands(modifiedFiles);
/*
* The modified files are added at the end as inserting them in the middle of the
* response can cause weird rendering behavior.
*/
for (const file of modifiedFiles) {
const content = `
<boltArtifact id="project-setup" title="Project Setup">
<boltAction type="shell">${commands.setupCommand}</boltAction>
<boltArtifact id="modified-file-${generateRandomId()}" title="File Changes">
<boltAction type="file" filePath="${file.path}">${file.content}</boltAction>
</boltArtifact>
`;
@ -227,6 +219,11 @@ class ChatManager {
options?.onResponsePart?.(content);
}
console.log('ChatResponse', chatId, response);
removeResponseListener();
removeFileListener();
return response;
}
}
@ -250,10 +247,17 @@ function startChat(repositoryContents: string, pageData: SimulationData) {
/*
* Called when the repository contents have changed. We'll start a new chat
* with the same interaction data as any existing chat.
* with the same interaction data as any existing chat. The remote development
* server will also be updated.
*/
export async function simulationRepositoryUpdated(repositoryContents: string) {
export async function simulationRepositoryUpdated() {
const { contentBase64: repositoryContents } = await workbenchStore.generateZipBase64();
startChat(repositoryContents, gChatManager?.pageData ?? []);
const { contentBase64: injectedContents } = await workbenchStore.generateZipBase64(
/* injectRecordingMessageHandler */ true,
);
updateDevelopmentServer(injectedContents);
}
/*
@ -400,7 +404,7 @@ Here is the user message you need to evaluate: <user_message>${messageInput}</us
return false;
}
function getProtocolRule(message: Message): 'user' | 'assistant' | 'system' {
function getProtocolRole(message: Message): 'user' | 'assistant' | 'system' {
switch (message.role) {
case 'user':
return 'user';
@ -443,7 +447,7 @@ function buildProtocolMessages(messages: Message[]): ProtocolMessage[] {
const rv: ProtocolMessage[] = [];
for (const msg of messages) {
const role = getProtocolRule(msg);
const role = getProtocolRole(msg);
if (Array.isArray(msg.content)) {
for (const content of msg.content) {
@ -478,6 +482,22 @@ function buildProtocolMessages(messages: Message[]): ProtocolMessage[] {
return rv;
}
function messagesHaveEnhancedPrompt(messages: Message[]): boolean {
const lastEnhancedPromptMessage = messages.findLastIndex((msg) => isEnhancedPromptMessage(msg));
if (lastEnhancedPromptMessage == -1) {
return false;
}
const lastUserMessage = messages.findLastIndex((msg) => msg.role == 'user');
if (lastUserMessage == -1) {
return false;
}
return lastUserMessage < lastEnhancedPromptMessage;
}
export async function sendDeveloperChatMessage(
messages: Message[],
files: FileMap,
@ -490,7 +510,7 @@ export async function sendDeveloperChatMessage(
const developerFiles: ProtocolFile[] = [];
for (const [path, file] of Object.entries(files)) {
if (file?.type == 'file' && shouldIncludeFile(path)) {
if (file && shouldIncludeFile(path)) {
developerFiles.push({
path,
content: file.content,
@ -498,11 +518,22 @@ export async function sendDeveloperChatMessage(
}
}
let systemPrompt = developerSystemPrompt;
if (messagesHaveEnhancedPrompt(messages)) {
// Add directions to the LLM when we have an enhanced prompt describing the bug to fix.
const systemEnhancedPrompt = `
ULTRA IMPORTANT: You have been given a detailed description of a bug you need to fix.
Focus specifically on fixing this bug. Do not guess about other problems.
`;
systemPrompt += systemEnhancedPrompt;
}
const protocolMessages = buildProtocolMessages(messages);
protocolMessages.unshift({
role: 'system',
type: 'text',
content: developerSystemPrompt,
content: systemPrompt,
});
return gChatManager.sendChatMessage(protocolMessages, { chatOnly: true, developerFiles, onResponsePart });

View File

@ -1,309 +0,0 @@
import { WebContainer } from '@webcontainer/api';
import { atom, map, type MapStore } from 'nanostores';
import * as nodePath from 'node:path';
import type { ActionAlert, BoltAction } from '~/types/actions';
import { createScopedLogger } from '~/utils/logger';
import { unreachable } from '~/utils/unreachable';
import type { ActionCallbackData } from './message-parser';
import type { BoltShell } from '~/utils/shell';
import { onRepositoryFileWritten } from '~/components/chat/Chat.client';
const logger = createScopedLogger('ActionRunner');
export type ActionStatus = 'pending' | 'running' | 'complete' | 'aborted' | 'failed';
export type BaseActionState = BoltAction & {
status: Exclude<ActionStatus, 'failed'>;
abort: () => void;
executed: boolean;
abortSignal: AbortSignal;
};
export type FailedActionState = BoltAction &
Omit<BaseActionState, 'status'> & {
status: Extract<ActionStatus, 'failed'>;
error: string;
};
export type ActionState = BaseActionState | FailedActionState;
type BaseActionUpdate = Partial<Pick<BaseActionState, 'status' | 'abort' | 'executed'>>;
export type ActionStateUpdate =
| BaseActionUpdate
| (Omit<BaseActionUpdate, 'status'> & { status: 'failed'; error: string });
type ActionsMap = MapStore<Record<string, ActionState>>;
class ActionCommandError extends Error {
readonly _output: string;
readonly _header: string;
constructor(message: string, output: string) {
// Create a formatted message that includes both the error message and output
const formattedMessage = `Failed To Execute Shell Command: ${message}\n\nOutput:\n${output}`;
super(formattedMessage);
// Set the output separately so it can be accessed programmatically
this._header = message;
this._output = output;
// Maintain proper prototype chain
Object.setPrototypeOf(this, ActionCommandError.prototype);
// Set the name of the error for better debugging
this.name = 'ActionCommandError';
}
// Optional: Add a method to get just the terminal output
get output() {
return this._output;
}
get header() {
return this._header;
}
}
export class ActionRunner {
#webcontainer: Promise<WebContainer>;
#currentExecutionPromise: Promise<void> = Promise.resolve();
#shellTerminal: () => BoltShell;
runnerId = atom<string>(`${Date.now()}`);
actions: ActionsMap = map({});
onAlert?: (alert: ActionAlert) => void;
constructor(
webcontainerPromise: Promise<WebContainer>,
getShellTerminal: () => BoltShell,
onAlert?: (alert: ActionAlert) => void,
) {
this.#webcontainer = webcontainerPromise;
this.#shellTerminal = getShellTerminal;
this.onAlert = onAlert;
}
addAction(data: ActionCallbackData) {
const { actionId } = data;
const actions = this.actions.get();
const action = actions[actionId];
if (action) {
// action already added
return;
}
const abortController = new AbortController();
this.actions.setKey(actionId, {
...data.action,
status: 'pending',
executed: false,
abort: () => {
abortController.abort();
this.#updateAction(actionId, { status: 'aborted' });
},
abortSignal: abortController.signal,
});
this.#currentExecutionPromise.then(() => {
this.#updateAction(actionId, { status: 'running' });
});
}
async runAction(data: ActionCallbackData, isStreaming: boolean = false) {
const { actionId } = data;
const action = this.actions.get()[actionId];
if (!action) {
unreachable(`Action ${actionId} not found`);
}
if (action.executed) {
return; // No return value here
}
if (isStreaming && action.type !== 'file') {
return; // No return value here
}
this.#updateAction(actionId, { ...action, ...data.action, executed: !isStreaming });
this.#currentExecutionPromise = this.#currentExecutionPromise
.then(() => {
return this.#executeAction(actionId, isStreaming);
})
.catch((error) => {
console.error('Action failed:', error);
});
await this.#currentExecutionPromise;
return;
}
async #executeAction(actionId: string, isStreaming: boolean = false) {
const action = this.actions.get()[actionId];
this.#updateAction(actionId, { status: 'running' });
try {
switch (action.type) {
case 'shell': {
await this.#runShellAction(action);
break;
}
case 'file': {
await this.#runFileAction(action);
break;
}
case 'start': {
// making the start app non blocking
this.#runStartAction(action)
.then(() => this.#updateAction(actionId, { status: 'complete' }))
.catch((err: Error) => {
if (action.abortSignal.aborted) {
return;
}
this.#updateAction(actionId, { status: 'failed', error: 'Action failed' });
logger.error(`[${action.type}]:Action failed\n\n`, err);
if (!(err instanceof ActionCommandError)) {
return;
}
this.onAlert?.({
type: 'error',
title: 'Dev Server Failed',
description: err.header,
content: err.output,
});
});
/*
* adding a delay to avoid any race condition between 2 start actions
* i am up for a better approach
*/
await new Promise((resolve) => setTimeout(resolve, 2000));
return;
}
}
this.#updateAction(actionId, {
status: isStreaming ? 'running' : action.abortSignal.aborted ? 'aborted' : 'complete',
});
} catch (error) {
if (action.abortSignal.aborted) {
return;
}
this.#updateAction(actionId, { status: 'failed', error: 'Action failed' });
logger.error(`[${action.type}]:Action failed\n\n`, error);
if (!(error instanceof ActionCommandError)) {
return;
}
this.onAlert?.({
type: 'error',
title: 'Dev Server Failed',
description: error.header,
content: error.output,
});
// re-throw the error to be caught in the promise chain
throw error;
}
}
async #runShellAction(action: ActionState) {
if (action.type !== 'shell') {
unreachable('Expected shell action');
}
const shell = this.#shellTerminal();
await shell.ready();
if (!shell || !shell.terminal || !shell.process) {
unreachable('Shell terminal not found');
}
const resp = await shell.executeCommand(this.runnerId.get(), action.content, () => {
logger.debug(`[${action.type}]:Aborting Action\n\n`, action);
action.abort();
});
logger.debug(`${action.type} Shell Response: [exit code:${resp?.exitCode}]`);
if (resp?.exitCode != 0) {
throw new ActionCommandError(`Failed To Execute Shell Command`, resp?.output || 'No Output Available');
}
}
async #runStartAction(action: ActionState) {
if (action.type !== 'start') {
unreachable('Expected shell action');
}
if (!this.#shellTerminal) {
unreachable('Shell terminal not found');
}
const shell = this.#shellTerminal();
await shell.ready();
if (!shell || !shell.terminal || !shell.process) {
unreachable('Shell terminal not found');
}
const resp = await shell.executeCommand(this.runnerId.get(), action.content, () => {
logger.debug(`[${action.type}]:Aborting Action\n\n`, action);
action.abort();
});
logger.debug(`${action.type} Shell Response: [exit code:${resp?.exitCode}]`);
if (resp?.exitCode != 0) {
throw new ActionCommandError('Failed To Start Application', resp?.output || 'No Output Available');
}
return resp;
}
async #runFileAction(action: ActionState) {
if (action.type !== 'file') {
unreachable('Expected file action');
}
const webcontainer = await this.#webcontainer;
const relativePath = nodePath.relative(webcontainer.workdir, action.filePath);
let folder = nodePath.dirname(relativePath);
// remove trailing slashes
folder = folder.replace(/\/+$/g, '');
if (folder !== '.') {
try {
await webcontainer.fs.mkdir(folder, { recursive: true });
logger.debug('Created folder', folder);
} catch (error) {
logger.error('Failed to create folder\n\n', error);
}
}
try {
await webcontainer.fs.writeFile(relativePath, action.content);
onRepositoryFileWritten();
logger.debug(`File written ${relativePath}`);
} catch (error) {
logger.error('Failed to write file\n\n', error);
}
}
#updateAction(id: string, newState: ActionStateUpdate) {
const actions = this.actions.get();
this.actions.setKey(id, { ...actions[id], ...newState });
}
}

View File

@ -36,7 +36,7 @@ export class EditorStore {
Object.fromEntries<EditorDocument>(
Object.entries(files)
.map(([filePath, dirent]) => {
if (dirent === undefined || dirent.type === 'folder') {
if (dirent === undefined) {
return undefined;
}

View File

@ -1,35 +1,14 @@
import type { PathWatcherEvent, WebContainer } from '@webcontainer/api';
import { getEncoding } from 'istextorbinary';
import { map, type MapStore } from 'nanostores';
import { Buffer } from 'node:buffer';
import * as nodePath from 'node:path';
import { bufferWatchEvents } from '~/utils/buffer';
import { WORK_DIR } from '~/utils/constants';
import { computeFileModifications } from '~/utils/diff';
import { createScopedLogger } from '~/utils/logger';
import { unreachable } from '~/utils/unreachable';
import type { ProtocolFile } from '../replay/SimulationPrompt';
const logger = createScopedLogger('FilesStore');
const utf8TextDecoder = new TextDecoder('utf8', { fatal: true });
export interface File {
type: 'file';
content: string;
isBinary: boolean;
}
export interface Folder {
type: 'folder';
}
type Dirent = File | Folder;
export type FileMap = Record<string, Dirent | undefined>;
export type FileMap = Record<string, ProtocolFile | undefined>;
export class FilesStore {
#webcontainer: Promise<WebContainer>;
/**
* Tracks the number of files without folders.
*/
@ -51,24 +30,15 @@ export class FilesStore {
return this.#size;
}
constructor(webcontainerPromise: Promise<WebContainer>) {
this.#webcontainer = webcontainerPromise;
constructor() {
if (import.meta.hot) {
import.meta.hot.data.files = this.files;
import.meta.hot.data.modifiedFiles = this.#modifiedFiles;
}
this.#init();
}
getFile(filePath: string) {
const dirent = this.files.get()[filePath];
if (dirent?.type !== 'file') {
return undefined;
}
return dirent;
}
@ -81,15 +51,7 @@ export class FilesStore {
}
async saveFile(filePath: string, content: string) {
const webcontainer = await this.#webcontainer;
try {
const relativePath = nodePath.relative(webcontainer.workdir, filePath);
if (!relativePath) {
throw new Error(`EINVAL: invalid file path, write '${relativePath}'`);
}
const oldContent = this.getFile(filePath)?.content;
if (!oldContent) {
@ -97,14 +59,12 @@ export class FilesStore {
unreachable(`Cannot save unknown file ${filePath}`);
}
await webcontainer.fs.writeFile(relativePath, content);
if (!this.#modifiedFiles.has(filePath)) {
this.#modifiedFiles.set(filePath, oldContent);
}
// we immediately update the file and don't rely on the `change` event coming from the watcher
this.files.setKey(filePath, { type: 'file', content, isBinary: false });
this.files.setKey(filePath, { path: filePath, content });
logger.info('File updated');
} catch (error) {
@ -113,105 +73,4 @@ export class FilesStore {
throw error;
}
}
async #init() {
const webcontainer = await this.#webcontainer;
webcontainer.internal.watchPaths(
{ include: [`${WORK_DIR}/**`], exclude: ['**/node_modules', '.git'], includeContent: true },
bufferWatchEvents(100, this.#processEventBuffer.bind(this)),
);
}
#processEventBuffer(events: Array<[events: PathWatcherEvent[]]>) {
const watchEvents = events.flat(2);
for (const { type, path, buffer } of watchEvents) {
// remove any trailing slashes
const sanitizedPath = path.replace(/\/+$/g, '');
switch (type) {
case 'add_dir': {
// we intentionally add a trailing slash so we can distinguish files from folders in the file tree
this.files.setKey(sanitizedPath, { type: 'folder' });
break;
}
case 'remove_dir': {
this.files.setKey(sanitizedPath, undefined);
for (const [direntPath] of Object.entries(this.files)) {
if (direntPath.startsWith(sanitizedPath)) {
this.files.setKey(direntPath, undefined);
}
}
break;
}
case 'add_file':
case 'change': {
if (type === 'add_file') {
this.#size++;
}
let content = '';
/**
* @note This check is purely for the editor. The way we detect this is not
* bullet-proof and it's a best guess so there might be false-positives.
* The reason we do this is because we don't want to display binary files
* in the editor nor allow to edit them.
*/
const isBinary = isBinaryFile(buffer);
if (!isBinary) {
content = this.#decodeFileContent(buffer);
}
this.files.setKey(sanitizedPath, { type: 'file', content, isBinary });
break;
}
case 'remove_file': {
this.#size--;
this.files.setKey(sanitizedPath, undefined);
break;
}
case 'update_directory': {
// we don't care about these events
break;
}
}
}
}
#decodeFileContent(buffer?: Uint8Array) {
if (!buffer || buffer.byteLength === 0) {
return '';
}
try {
return utf8TextDecoder.decode(buffer);
} catch (error) {
console.log(error);
return '';
}
}
}
function isBinaryFile(buffer: Uint8Array | undefined) {
if (buffer === undefined) {
return false;
}
return getEncoding(convertToBuffer(buffer), { chunkLength: 100 }) === 'binary';
}
/**
* Converts a `Uint8Array` into a Node.js `Buffer` by copying the prototype.
* The goal is to avoid expensive copies. It does create a new typed array
* but that's generally cheap as long as it uses the same underlying
* array buffer.
*/
function convertToBuffer(view: Uint8Array): Buffer {
return Buffer.from(view.buffer, view.byteOffset, view.byteLength);
}

View File

@ -1,49 +0,0 @@
import type { WebContainer } from '@webcontainer/api';
import { atom } from 'nanostores';
export interface PreviewInfo {
port: number;
ready: boolean;
baseUrl: string;
}
export class PreviewsStore {
#availablePreviews = new Map<number, PreviewInfo>();
#webcontainer: Promise<WebContainer>;
previews = atom<PreviewInfo[]>([]);
constructor(webcontainerPromise: Promise<WebContainer>) {
this.#webcontainer = webcontainerPromise;
this.#init();
}
async #init() {
const webcontainer = await this.#webcontainer;
webcontainer.on('port', (port, type, url) => {
let previewInfo = this.#availablePreviews.get(port);
if (type === 'close' && previewInfo) {
this.#availablePreviews.delete(port);
this.previews.set(this.previews.get().filter((preview) => preview.port !== port));
return;
}
const previews = this.previews.get();
if (!previewInfo) {
previewInfo = { port, ready: type === 'open', baseUrl: url };
this.#availablePreviews.set(port, previewInfo);
previews.push(previewInfo);
}
previewInfo.ready = type === 'open';
previewInfo.baseUrl = url;
this.previews.set([...previews]);
});
}
}

View File

@ -22,14 +22,6 @@ export const LOCAL_PROVIDERS = ['OpenAILike', 'LMStudio', 'Ollama'];
export type ProviderSetting = Record<string, IProviderConfig>;
export const shortcutsStore = map<Shortcuts>({
toggleTerminal: {
key: 'j',
ctrlOrMetaKey: true,
action: () => workbenchStore.toggleTerminal(),
},
});
const initialProviderSettings: ProviderSetting = {};
PROVIDER_LIST.forEach((provider) => {
initialProviderSettings[provider.name] = {

View File

@ -1,53 +0,0 @@
import type { WebContainer, WebContainerProcess } from '@webcontainer/api';
import { atom, type WritableAtom } from 'nanostores';
import type { ITerminal } from '~/types/terminal';
import { newBoltShellProcess, newShellProcess } from '~/utils/shell';
import { coloredText } from '~/utils/terminal';
export class TerminalStore {
#webcontainer: Promise<WebContainer>;
#terminals: Array<{ terminal: ITerminal; process: WebContainerProcess }> = [];
#boltTerminal = newBoltShellProcess();
showTerminal: WritableAtom<boolean> = import.meta.hot?.data.showTerminal ?? atom(true);
constructor(webcontainerPromise: Promise<WebContainer>) {
this.#webcontainer = webcontainerPromise;
if (import.meta.hot) {
import.meta.hot.data.showTerminal = this.showTerminal;
}
}
get boltTerminal() {
return this.#boltTerminal;
}
toggleTerminal(value?: boolean) {
this.showTerminal.set(value !== undefined ? value : !this.showTerminal.get());
}
async attachBoltTerminal(terminal: ITerminal) {
try {
const wc = await this.#webcontainer;
await this.#boltTerminal.init(wc, terminal);
} catch (error: any) {
terminal.write(coloredText.red('Failed to spawn bolt shell\n\n') + error.message);
return;
}
}
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 });
}
}
}

View File

@ -1,32 +1,25 @@
import { atom, map, type MapStore, type ReadableAtom, type WritableAtom } from 'nanostores';
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';
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';
import JSZip from 'jszip';
import { saveAs } from 'file-saver';
import { Octokit, type RestEndpointMethodTypes } from '@octokit/rest';
import * as nodePath from 'node:path';
import { extractRelativePath } from '~/utils/diff';
import { description } from '~/lib/persistence';
import Cookies from 'js-cookie';
import { createSampler } from '~/utils/sampler';
import { uint8ArrayToBase64 } from '~/lib/replay/ReplayProtocolClient';
import { uint8ArrayToBase64 } from '../replay/ReplayProtocolClient';
import type { ActionAlert } from '~/types/actions';
import { extractFileArtifactsFromRepositoryContents } from '~/lib/replay/Problems';
import { extractFileArtifactsFromRepositoryContents } from '../replay/Problems';
import { onRepositoryFileWritten } from '~/components/chat/Chat.client';
import { doInjectRecordingMessageHandler } from '../replay/Recording';
export interface ArtifactState {
id: string;
title: string;
type?: string;
closed: boolean;
runner: ActionRunner;
}
export type ArtifactUpdateState = Pick<ArtifactState, 'title' | 'closed'>;
@ -36,12 +29,10 @@ type Artifacts = MapStore<Record<string, ArtifactState>>;
export type WorkbenchViewType = 'code' | 'preview';
export class WorkbenchStore {
#previewsStore = new PreviewsStore(webcontainer);
#filesStore = new FilesStore(webcontainer);
#filesStore = new FilesStore();
#editorStore = new EditorStore(this.#filesStore);
#terminalStore = new TerminalStore(webcontainer);
#reloadedMessages = new Set<string>();
previewURL = atom<string | undefined>(undefined);
artifacts: Artifacts = import.meta.hot?.data.artifacts ?? map({});
@ -54,8 +45,6 @@ export class WorkbenchStore {
artifactIdList: string[] = [];
#globalExecutionQueue = Promise.resolve();
private _fileMap: FileMap = {};
constructor() {
if (import.meta.hot) {
import.meta.hot.data.artifacts = this.artifacts;
@ -70,10 +59,6 @@ export class WorkbenchStore {
this.#globalExecutionQueue = this.#globalExecutionQueue.then(() => callback());
}
get previews() {
return this.#previewsStore.previews;
}
get files() {
return this.#filesStore.files;
}
@ -94,12 +79,6 @@ export class WorkbenchStore {
return this.#filesStore.filesCount;
}
get showTerminal() {
return this.#terminalStore.showTerminal;
}
get boltTerminal() {
return this.#terminalStore.boltTerminal;
}
get alert() {
return this.actionAlert;
}
@ -107,28 +86,13 @@ export class WorkbenchStore {
this.actionAlert.set(undefined);
}
toggleTerminal(value?: boolean) {
this.#terminalStore.toggleTerminal(value);
}
attachTerminal(terminal: ITerminal) {
this.#terminalStore.attachTerminal(terminal);
}
attachBoltTerminal(terminal: ITerminal) {
this.#terminalStore.attachBoltTerminal(terminal);
}
onTerminalResize(cols: number, rows: number) {
this.#terminalStore.onTerminalResize(cols, rows);
}
setDocuments(files: FileMap) {
this.#editorStore.setDocuments(files);
if (this.#filesStore.filesCount > 0 && this.currentDocument.get() === undefined) {
// we find the first file and select it
for (const [filePath, dirent] of Object.entries(files)) {
if (dirent?.type === 'file') {
if (dirent) {
this.setSelectedFile(filePath);
break;
}
@ -254,10 +218,6 @@ export class WorkbenchStore {
// TODO: what do we wanna do and how do we wanna recover from this?
}
setReloadedMessages(messages: string[]) {
this.#reloadedMessages = new Set(messages);
}
addArtifact({ messageId, title, id, type }: ArtifactCallbackData) {
const artifact = this.#getArtifact(messageId);
@ -274,17 +234,6 @@ export class WorkbenchStore {
title,
closed: false,
type,
runner: new ActionRunner(
webcontainer,
() => this.boltTerminal,
(alert) => {
if (this.#reloadedMessages.has(messageId)) {
return;
}
this.actionAlert.set(alert);
},
),
});
}
@ -297,12 +246,12 @@ export class WorkbenchStore {
this.artifacts.setKey(messageId, { ...artifact, ...state });
}
addAction(data: ActionCallbackData) {
// this._addAction(data);
this.addToExecutionQueue(() => this._addAction(data));
runAction(data: ActionCallbackData) {
this.addToExecutionQueue(() => this._runAction(data));
}
async _addAction(data: ActionCallbackData) {
async _runAction(data: ActionCallbackData) {
const { messageId } = data;
const artifact = this.#getArtifact(messageId);
@ -311,80 +260,40 @@ export class WorkbenchStore {
unreachable('Artifact not found');
}
return artifact.runner.addAction(data);
}
runAction(data: ActionCallbackData, isStreaming: boolean = false) {
if (isStreaming) {
this.actionStreamSampler(data, isStreaming);
} else {
this.addToExecutionQueue(() => this._runAction(data, isStreaming));
}
}
async _runAction(data: ActionCallbackData, isStreaming: boolean = false) {
const { messageId } = data;
const artifact = this.#getArtifact(messageId);
if (!artifact) {
unreachable('Artifact not found');
}
if (data.actionId != 'restore-contents-action-id') {
const action = artifact.runner.actions.get()[data.actionId];
if (!action || action.executed) {
return;
}
}
if (data.action.type === 'file') {
const wc = await webcontainer;
const fullPath = nodePath.join(wc.workdir, data.action.filePath);
const { filePath, content } = data.action;
this._fileMap[fullPath] = {
type: 'file',
content: data.action.content,
isBinary: false,
};
const existingFiles = this.files.get();
this.files.set({
...existingFiles,
[filePath]: {
path: filePath,
content,
},
});
if (this.selectedFile.value !== fullPath) {
this.setSelectedFile(fullPath);
onRepositoryFileWritten();
if (this.selectedFile.value !== filePath) {
this.setSelectedFile(filePath);
}
if (this.currentView.value !== 'code') {
this.currentView.set('code');
}
const doc = this.#editorStore.documents.get()[fullPath];
if (!doc) {
await artifact.runner.runAction(data, isStreaming);
}
this.#editorStore.updateFile(fullPath, data.action.content);
if (!isStreaming) {
await artifact.runner.runAction(data);
this.resetAllFileModifications();
}
} else {
await artifact.runner.runAction(data);
this.#editorStore.updateFile(filePath, content);
}
}
actionStreamSampler = createSampler(async (data: ActionCallbackData, isStreaming: boolean = false) => {
return await this._runAction(data, isStreaming);
}, 100); // TODO: remove this magic number to have it configurable
#getArtifact(id: string) {
const artifacts = this.artifacts.get();
return artifacts[id];
}
private async _generateZip() {
private async _generateZip(injectRecordingMessageHandler = false) {
const zip = new JSZip();
const files = this._fileMap;
const files = this.files.get();
// Get the project name from the description input, or use a default name
const projectName = (description.value ?? 'project').toLocaleLowerCase().split(' ').join('_');
@ -394,12 +303,15 @@ export class WorkbenchStore {
const uniqueProjectName = `${projectName}_${timestampHash}`;
for (const [filePath, dirent] of Object.entries(files)) {
if (dirent?.type === 'file' && !dirent.isBinary) {
const relativePath = extractRelativePath(filePath);
const content = dirent.content;
if (dirent) {
let content = dirent.content;
if (injectRecordingMessageHandler && filePath == 'index.html') {
content = doInjectRecordingMessageHandler(content);
}
// split the path into segments
const pathSegments = relativePath.split('/');
const pathSegments = filePath.split('/');
// if there's more than one segment, we need to create folders
if (pathSegments.length > 1) {
@ -411,7 +323,7 @@ export class WorkbenchStore {
currentFolder.file(pathSegments[pathSegments.length - 1], content);
} else {
// if there's only one segment, it's a file in the root
zip.file(relativePath, content);
zip.file(filePath, content);
}
}
}
@ -427,8 +339,8 @@ export class WorkbenchStore {
saveAs(content, `${uniqueProjectName}.zip`);
}
async generateZipBase64() {
const { content, uniqueProjectName } = await this._generateZip();
async generateZipBase64(injectRecordingMessageHandler = false) {
const { content, uniqueProjectName } = await this._generateZip(injectRecordingMessageHandler);
const buf = await content.arrayBuffer();
const contentBase64 = uint8ArrayToBase64(new Uint8Array(buf));
@ -445,17 +357,16 @@ export class WorkbenchStore {
const fileRelativePaths = new Set<string>();
for (const [filePath, dirent] of Object.entries(files)) {
if (dirent?.type === 'file' && !dirent.isBinary) {
const relativePath = extractRelativePath(filePath);
fileRelativePaths.add(relativePath);
if (dirent) {
fileRelativePaths.add(filePath);
const content = dirent.content;
const artifact = fileArtifacts.find((artifact) => artifact.path === relativePath);
const artifact = fileArtifacts.find((artifact) => artifact.path === filePath);
const artifactContent = artifact?.content ?? '';
if (content != artifactContent) {
modifiedFilePaths.add(relativePath);
modifiedFilePaths.add(filePath);
}
}
}
@ -487,7 +398,6 @@ export class WorkbenchStore {
},
};
this.addAction(data);
this.runAction(data);
}
}
@ -497,9 +407,8 @@ export class WorkbenchStore {
const syncedFiles = [];
for (const [filePath, dirent] of Object.entries(files)) {
if (dirent?.type === 'file' && !dirent.isBinary) {
const relativePath = extractRelativePath(filePath);
const pathSegments = relativePath.split('/');
if (dirent) {
const pathSegments = filePath.split('/');
let currentHandle = targetHandle;
for (let i = 0; i < pathSegments.length - 1; i++) {
@ -516,7 +425,7 @@ export class WorkbenchStore {
await writable.write(dirent.content);
await writable.close();
syncedFiles.push(relativePath);
syncedFiles.push(filePath);
}
}
@ -567,14 +476,14 @@ export class WorkbenchStore {
// Create blobs for each file
const blobs = await Promise.all(
Object.entries(files).map(async ([filePath, dirent]) => {
if (dirent?.type === 'file' && dirent.content) {
if (dirent) {
const { data: blob } = await octokit.git.createBlob({
owner: repo.owner.login,
repo: repo.name,
content: Buffer.from(dirent.content).toString('base64'),
encoding: 'base64',
});
return { path: extractRelativePath(filePath), sha: blob.sha };
return { path: filePath, sha: blob.sha };
}
return null;

View File

@ -1,6 +0,0 @@
/**
* This client-only module that contains everything related to auth and is used
* to avoid importing `@webcontainer/api` in the server bundle.
*/
export { auth, type AuthAPI } from '@webcontainer/api';

View File

@ -1,62 +0,0 @@
import { WebContainer } from '@webcontainer/api';
import { WORK_DIR_NAME } from '~/utils/constants';
import { cleanStackTrace } from '~/utils/stacktrace';
import { recordingMessageHandlerScript } from '~/lib/replay/Recording';
interface WebContainerContext {
loaded: boolean;
}
export const webcontainerContext: WebContainerContext = import.meta.hot?.data.webcontainerContext ?? {
loaded: false,
};
if (import.meta.hot) {
import.meta.hot.data.webcontainerContext = webcontainerContext;
}
export let webcontainer: Promise<WebContainer> = new Promise(() => {
// noop for ssr
});
if (!import.meta.env.SSR) {
webcontainer =
import.meta.hot?.data.webcontainer ??
Promise.resolve()
.then(() => {
return WebContainer.boot({
coep: 'credentialless',
workdirName: WORK_DIR_NAME,
forwardPreviewErrors: true, // Enable error forwarding from iframes
});
})
.then(async (webcontainer) => {
await webcontainer.setPreviewScript(recordingMessageHandlerScript);
webcontainerContext.loaded = true;
const { workbenchStore } = await import('~/lib/stores/workbench');
// Listen for preview errors
webcontainer.on('preview-message', (message) => {
console.log('WebContainer preview message:', message);
// Handle both uncaught exceptions and unhandled promise rejections
if (message.type === 'PREVIEW_UNCAUGHT_EXCEPTION' || message.type === 'PREVIEW_UNHANDLED_REJECTION') {
const isPromise = message.type === 'PREVIEW_UNHANDLED_REJECTION';
workbenchStore.actionAlert.set({
type: 'preview',
title: isPromise ? 'Unhandled Promise Rejection' : 'Uncaught Exception',
description: message.message,
content: `Error occurred at ${message.pathname}${message.search}${message.hash}\nPort: ${message.port}\n\nStack trace:\n${cleanStackTrace(message.stack || '')}`,
source: 'preview',
});
}
});
return webcontainer;
});
if (import.meta.hot) {
import.meta.hot.data.webcontainer = webcontainer;
}
}

View File

@ -5,7 +5,6 @@ import type { ModelInfo } from '~/lib/modules/llm/types';
import type { Template } from '~/types/template';
export const WORK_DIR_NAME = 'project';
export const WORK_DIR = `/home/${WORK_DIR_NAME}`;
export const MODIFICATIONS_TAG_NAME = 'bolt_file_modifications';
export const MODEL_REGEX = /^\[Model: (.*?)\]\n\n/;
export const PROVIDER_REGEX = /\[Provider: (.*?)\]\n\n/;

View File

@ -1,11 +0,0 @@
import { describe, expect, it } from 'vitest';
import { extractRelativePath } from './diff';
import { WORK_DIR } from './constants';
describe('Diff', () => {
it('should strip out Work_dir', () => {
const filePath = `${WORK_DIR}/index.js`;
const result = extractRelativePath(filePath);
expect(result).toBe('index.js');
});
});

View File

@ -1,6 +1,6 @@
import { createTwoFilesPatch } from 'diff';
import type { FileMap } from '~/lib/stores/files';
import { MODIFICATIONS_TAG_NAME, WORK_DIR } from './constants';
import { MODIFICATIONS_TAG_NAME } from './constants';
export const modificationsRegex = new RegExp(
`^<${MODIFICATIONS_TAG_NAME}>[\\s\\S]*?<\\/${MODIFICATIONS_TAG_NAME}>\\s+`,
@ -22,7 +22,7 @@ export function computeFileModifications(files: FileMap, modifiedFiles: Map<stri
for (const [filePath, originalContent] of modifiedFiles) {
const file = files[filePath];
if (file?.type !== 'file') {
if (!file) {
continue;
}
@ -75,15 +75,6 @@ export function diffFiles(fileName: string, oldFileContent: string, newFileConte
return unifiedDiff;
}
const regex = new RegExp(`^${WORK_DIR}\/`);
/**
* Strips out the work directory from the file path.
*/
export function extractRelativePath(filePath: string) {
return filePath.replace(regex, '');
}
/**
* Converts the unified diff to HTML.
*

View File

@ -1,6 +1,5 @@
import type { Message } from 'ai';
import { generateId, shouldIncludeFile } from './fileUtils';
import { detectProjectCommands, createCommandsMessage } from './projectCommands';
export interface FileArtifact {
content: string;
@ -33,9 +32,6 @@ export const createChatFromFolder = async (
binaryFiles: string[],
folderName: string,
): Promise<Message[]> => {
const commands = await detectProjectCommands(fileArtifacts);
const commandsMessage = createCommandsMessage(commands);
const binaryFilesMessage =
binaryFiles.length > 0
? `\n\nSkipped ${binaryFiles.length} binary files:\n${binaryFiles.map((f) => `- ${f}`).join('\n')}`
@ -67,9 +63,5 @@ export const createChatFromFolder = async (
const messages = [userMessage, filesMessage];
if (commandsMessage) {
messages.push(commandsMessage);
}
return messages;
};

View File

@ -1,80 +0,0 @@
import type { Message } from 'ai';
import { generateId } from './fileUtils';
export interface ProjectCommands {
type: string;
setupCommand: string;
followupMessage: string;
}
interface FileContent {
content: string;
path: string;
}
export async function detectProjectCommands(files: FileContent[]): Promise<ProjectCommands> {
const hasFile = (name: string) => files.some((f) => f.path.endsWith(name));
if (hasFile('package.json')) {
const packageJsonFile = files.find((f) => f.path.endsWith('package.json'));
if (!packageJsonFile) {
return { type: '', setupCommand: '', followupMessage: '' };
}
try {
const packageJson = JSON.parse(packageJsonFile.content);
const scripts = packageJson?.scripts || {};
// Check for preferred commands in priority order
const preferredCommands = ['dev', 'start', 'preview'];
const availableCommand = preferredCommands.find((cmd) => scripts[cmd]);
if (availableCommand) {
return {
type: 'Node.js',
setupCommand: `npm install && npm run ${availableCommand}`,
followupMessage: `Found "${availableCommand}" script in package.json. Running "npm run ${availableCommand}" after installation.`,
};
}
return {
type: 'Node.js',
setupCommand: 'npm install',
followupMessage:
'Would you like me to inspect package.json to determine the available scripts for running this project?',
};
} catch (error) {
console.error('Error parsing package.json:', error);
return { type: '', setupCommand: '', followupMessage: '' };
}
}
if (hasFile('index.html')) {
return {
type: 'Static',
setupCommand: 'npx --yes serve',
followupMessage: '',
};
}
return { type: '', setupCommand: '', followupMessage: '' };
}
export function createCommandsMessage(commands: ProjectCommands): Message | null {
if (!commands.setupCommand) {
return null;
}
return {
role: 'assistant',
content: `
<boltArtifact id="project-setup" title="Project Setup">
<boltAction type="shell">
${commands.setupCommand}
</boltAction>
</boltArtifact>${commands.followupMessage ? `\n\n${commands.followupMessage}` : ''}`,
id: generateId(),
createdAt: new Date(),
};
}

View File

@ -1,294 +0,0 @@
import type { WebContainer, WebContainerProcess } from '@webcontainer/api';
import type { ITerminal } from '~/types/terminal';
import { withResolvers } from './promises';
import { atom } from 'nanostores';
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<void>();
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) => {
// console.log('terminal onData', { data, isInteractive });
if (isInteractive) {
input.write(data);
}
});
await jshReady.promise;
return process;
}
export type ExecutionResult = { output: string; exitCode: number } | undefined;
export class BoltShell {
#initialized: (() => void) | undefined;
#readyPromise: Promise<void>;
#webcontainer: WebContainer | undefined;
#terminal: ITerminal | undefined;
#process: WebContainerProcess | undefined;
executionState = atom<
{ sessionId: string; active: boolean; executionPrms?: Promise<any>; abort?: () => void } | undefined
>();
#outputStream: ReadableStreamDefaultReader<string> | undefined;
#shellInputStream: WritableStreamDefaultWriter<string> | undefined;
constructor() {
this.#readyPromise = new Promise((resolve) => {
this.#initialized = resolve;
});
}
ready() {
return this.#readyPromise;
}
async init(webcontainer: WebContainer, terminal: ITerminal) {
this.#webcontainer = webcontainer;
this.#terminal = terminal;
const { process, output } = await this.newBoltShellProcess(webcontainer, terminal);
this.#process = process;
this.#outputStream = output.getReader();
await this.waitTillOscCode('interactive');
this.#initialized?.();
}
get terminal() {
return this.#terminal;
}
get process() {
return this.#process;
}
async executeCommand(sessionId: string, command: string, abort?: () => void): Promise<ExecutionResult> {
if (!this.process || !this.terminal) {
return undefined;
}
const state = this.executionState.get();
if (state?.active && state.abort) {
state.abort();
}
/*
* interrupt the current execution
* this.#shellInputStream?.write('\x03');
*/
this.terminal.input('\x03');
await this.waitTillOscCode('prompt');
if (state && state.executionPrms) {
await state.executionPrms;
}
//start a new execution
this.terminal.input(command.trim() + '\n');
//wait for the execution to finish
const executionPromise = this.getCurrentExecutionResult();
this.executionState.set({ sessionId, active: true, executionPrms: executionPromise, abort });
const resp = await executionPromise;
this.executionState.set({ sessionId, active: false });
if (resp) {
try {
resp.output = cleanTerminalOutput(resp.output);
} catch (error) {
console.log('failed to format terminal output', error);
}
}
return resp;
}
async newBoltShellProcess(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();
this.#shellInputStream = input;
const [internalOutput, terminalOutput] = process.output.tee();
const jshReady = withResolvers<void>();
let isInteractive = false;
terminalOutput.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) => {
// console.log('terminal onData', { data, isInteractive });
if (isInteractive) {
input.write(data);
}
});
await jshReady.promise;
return { process, output: internalOutput };
}
async getCurrentExecutionResult(): Promise<ExecutionResult> {
const { output, exitCode } = await this.waitTillOscCode('exit');
return { output, exitCode };
}
async waitTillOscCode(waitCode: string) {
let fullOutput = '';
let exitCode: number = 0;
if (!this.#outputStream) {
return { output: fullOutput, exitCode };
}
const tappedStream = this.#outputStream;
while (true) {
const { value, done } = await tappedStream.read();
if (done) {
break;
}
const text = value || '';
fullOutput += text;
// Check if command completion signal with exit code
const [, osc, , , code] = text.match(/\x1b\]654;([^\x07=]+)=?((-?\d+):(\d+))?\x07/) || [];
if (osc === 'exit') {
exitCode = parseInt(code, 10);
}
if (osc === waitCode) {
break;
}
}
return { output: fullOutput, exitCode };
}
}
/**
* Cleans and formats terminal output while preserving structure and paths
* Handles ANSI, OSC, and various terminal control sequences
*/
export function cleanTerminalOutput(input: string): string {
// Step 1: Remove OSC sequences (including those with parameters)
const removeOsc = input
.replace(/\x1b\](\d+;[^\x07\x1b]*|\d+[^\x07\x1b]*)\x07/g, '')
.replace(/\](\d+;[^\n]*|\d+[^\n]*)/g, '');
// Step 2: Remove ANSI escape sequences and color codes more thoroughly
const removeAnsi = removeOsc
// Remove all escape sequences with parameters
.replace(/\u001b\[[\?]?[0-9;]*[a-zA-Z]/g, '')
.replace(/\x1b\[[\?]?[0-9;]*[a-zA-Z]/g, '')
// Remove color codes
.replace(/\u001b\[[0-9;]*m/g, '')
.replace(/\x1b\[[0-9;]*m/g, '')
// Clean up any remaining escape characters
.replace(/\u001b/g, '')
.replace(/\x1b/g, '');
// Step 3: Clean up carriage returns and newlines
const cleanNewlines = removeAnsi
.replace(/\r\n/g, '\n')
.replace(/\r/g, '\n')
.replace(/\n{3,}/g, '\n\n');
// Step 4: Add newlines at key breakpoints while preserving paths
const formatOutput = cleanNewlines
// Preserve prompt line
.replace(/^([~\/][^\n]+)/m, '$1\n')
// Add newline before command output indicators
.replace(/(?<!^|\n)>/g, '\n>')
// Add newline before error keywords without breaking paths
.replace(/(?<!^|\n|\w)(error|failed|warning|Error|Failed|Warning):/g, '\n$1:')
// Add newline before 'at' in stack traces without breaking paths
.replace(/(?<!^|\n|\/)(at\s+(?!async|sync))/g, '\nat ')
// Ensure 'at async' stays on same line
.replace(/\bat\s+async/g, 'at async')
// Add newline before npm error indicators
.replace(/(?<!^|\n)(npm ERR!)/g, '\n$1');
// Step 5: Clean up whitespace while preserving intentional spacing
const cleanSpaces = formatOutput
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0)
.join('\n');
// Step 6: Final cleanup
return cleanSpaces
.replace(/\n{3,}/g, '\n\n') // Replace multiple newlines with double newlines
.replace(/:\s+/g, ': ') // Normalize spacing after colons
.replace(/\s{2,}/g, ' ') // Remove multiple spaces
.replace(/^\s+|\s+$/g, '') // Trim start and end
.replace(/\u0000/g, ''); // Remove null characters
}
export function newBoltShellProcess() {
return new BoltShell();
}

View File

@ -86,7 +86,6 @@
"@supabase/supabase-js": "^2.49.1",
"@uiw/codemirror-theme-vscode": "^4.23.6",
"@unocss/reset": "^0.61.9",
"@webcontainer/api": "1.5.1-internal.8",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/xterm": "^5.5.0",

View File

@ -178,9 +178,6 @@ importers:
'@unocss/reset':
specifier: ^0.61.9
version: 0.61.9
'@webcontainer/api':
specifier: 1.5.1-internal.8
version: 1.5.1-internal.8
'@xterm/addon-fit':
specifier: ^0.10.0
version: 0.10.0(@xterm/xterm@5.5.0)
@ -3459,9 +3456,6 @@ packages:
'@web3-storage/multipart-parser@1.0.0':
resolution: {integrity: sha512-BEO6al7BYqcnfX15W2cnGR+Q566ACXAT9UQykORCWW80lmkpWsnEob6zJS1ZVBKsSJC8+7vJkHwlp+lXG1UCdw==}
'@webcontainer/api@1.5.1-internal.8':
resolution: {integrity: sha512-EgtnRLOIFhBiTS/1nQmg3A6RtQXvp+kDK08cbEZZpnXGyaNd1y9dSCJUEn3OUCuHSEZr3LeqBtwR5mYOxFeiUQ==}
'@xterm/addon-fit@0.10.0':
resolution: {integrity: sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==}
peerDependencies:
@ -10828,8 +10822,6 @@ snapshots:
'@web3-storage/multipart-parser@1.0.0': {}
'@webcontainer/api@1.5.1-internal.8': {}
'@xterm/addon-fit@0.10.0(@xterm/xterm@5.5.0)':
dependencies:
'@xterm/xterm': 5.5.0