mirror of
https://github.com/stackblitz-labs/bolt.diy
synced 2025-06-26 18:26:38 +00:00
Use Nut API development server instead of webcontainers (#50)
This commit is contained in:
parent
58f2cb0f58
commit
9e58d6bb64
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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,
|
||||
};
|
||||
|
@ -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) {
|
||||
|
@ -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}
|
||||
|
@ -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) {
|
||||
|
@ -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> = [];
|
||||
|
@ -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>
|
||||
);
|
||||
},
|
||||
|
@ -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}`}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
},
|
||||
);
|
@ -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}
|
||||
|
@ -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={() => {
|
||||
|
@ -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} />;
|
||||
},
|
||||
),
|
||||
);
|
@ -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>
|
||||
);
|
||||
});
|
@ -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,
|
||||
};
|
||||
}
|
@ -1,6 +1,5 @@
|
||||
export * from './useMessageParser';
|
||||
export * from './usePromptEnhancer';
|
||||
export * from './useShortcuts';
|
||||
export * from './useSnapScroll';
|
||||
export * from './useEditChatDescription';
|
||||
export { default } from './useViewport';
|
||||
|
@ -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('/');
|
||||
},
|
||||
};
|
||||
|
@ -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);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
@ -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]);
|
||||
}
|
76
app/lib/replay/DevelopmentServer.ts
Normal file
76
app/lib/replay/DevelopmentServer.ts
Normal 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);
|
||||
}
|
@ -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);
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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 });
|
||||
|
@ -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 });
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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]);
|
||||
});
|
||||
}
|
||||
}
|
@ -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] = {
|
||||
|
@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
@ -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';
|
@ -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;
|
||||
}
|
||||
}
|
@ -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/;
|
||||
|
@ -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');
|
||||
});
|
||||
});
|
@ -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.
|
||||
*
|
||||
|
@ -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;
|
||||
};
|
||||
|
@ -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(),
|
||||
};
|
||||
}
|
@ -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();
|
||||
}
|
@ -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",
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user