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 { useAnimate } from 'framer-motion';
|
||||||
import { memo, useCallback, useEffect, useRef, useState } from 'react';
|
import { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { cssTransition, toast, ToastContainer } from 'react-toastify';
|
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 { description, useChatHistory } from '~/lib/persistence';
|
||||||
import { chatStore } from '~/lib/stores/chat';
|
import { chatStore } from '~/lib/stores/chat';
|
||||||
import { workbenchStore } from '~/lib/stores/workbench';
|
import { workbenchStore } from '~/lib/stores/workbench';
|
||||||
@ -170,8 +170,7 @@ async function clearActiveChat() {
|
|||||||
gActiveChatMessageTelemetry = undefined;
|
gActiveChatMessageTelemetry = undefined;
|
||||||
|
|
||||||
if (gUpdateSimulationAfterChatMessage) {
|
if (gUpdateSimulationAfterChatMessage) {
|
||||||
const { contentBase64 } = await workbenchStore.generateZipBase64();
|
await simulationRepositoryUpdated();
|
||||||
await simulationRepositoryUpdated(contentBase64);
|
|
||||||
gUpdateSimulationAfterChatMessage = false;
|
gUpdateSimulationAfterChatMessage = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -180,8 +179,7 @@ export async function onRepositoryFileWritten() {
|
|||||||
if (gActiveChatMessageTelemetry) {
|
if (gActiveChatMessageTelemetry) {
|
||||||
gUpdateSimulationAfterChatMessage = true;
|
gUpdateSimulationAfterChatMessage = true;
|
||||||
} else {
|
} else {
|
||||||
const { contentBase64 } = await workbenchStore.generateZipBase64();
|
await simulationRepositoryUpdated();
|
||||||
await simulationRepositoryUpdated(contentBase64);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -189,10 +187,14 @@ function buildMessageId(prefix: string, chatId: string) {
|
|||||||
return `${prefix}-${chatId}`;
|
return `${prefix}-${chatId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const EnhancedPromptPrefix = 'enhanced-prompt';
|
||||||
|
|
||||||
|
export function isEnhancedPromptMessage(message: Message): boolean {
|
||||||
|
return message.id.startsWith(EnhancedPromptPrefix);
|
||||||
|
}
|
||||||
|
|
||||||
export const ChatImpl = memo(
|
export const ChatImpl = memo(
|
||||||
({ description, initialMessages, storeMessageHistory, importChat, exportChat }: ChatProps) => {
|
({ description, initialMessages, storeMessageHistory, importChat, exportChat }: ChatProps) => {
|
||||||
useShortcuts();
|
|
||||||
|
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const [chatStarted, setChatStarted] = useState(initialMessages.length > 0);
|
const [chatStarted, setChatStarted] = useState(initialMessages.length > 0);
|
||||||
const [uploadedFiles, setUploadedFiles] = useState<File[]>([]); // Move here
|
const [uploadedFiles, setUploadedFiles] = useState<File[]>([]); // Move here
|
||||||
@ -321,7 +323,7 @@ export const ChatImpl = memo(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const enhancedPromptMessage: Message = {
|
const enhancedPromptMessage: Message = {
|
||||||
id: buildMessageId('enhanced-prompt', chatId),
|
id: buildMessageId(EnhancedPromptPrefix, chatId),
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: message,
|
content: message,
|
||||||
};
|
};
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import ignore from 'ignore';
|
import ignore from 'ignore';
|
||||||
import { useGit } from '~/lib/hooks/useGit';
|
import { useGit } from '~/lib/hooks/useGit';
|
||||||
import type { Message } from 'ai';
|
import type { Message } from 'ai';
|
||||||
import { detectProjectCommands, createCommandsMessage } from '~/utils/projectCommands';
|
|
||||||
import { generateId } from '~/utils/fileUtils';
|
import { generateId } from '~/utils/fileUtils';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { toast } from 'react-toastify';
|
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));
|
const filePaths = Object.keys(data).filter((filePath) => !ig.ignores(filePath));
|
||||||
console.log(filePaths);
|
console.log(filePaths);
|
||||||
|
|
||||||
const textDecoder = new TextDecoder('utf-8');
|
|
||||||
|
|
||||||
const fileContents = filePaths
|
const fileContents = filePaths
|
||||||
.map((filePath) => {
|
.map((filePath) => {
|
||||||
const { data: content, encoding } = data[filePath];
|
const file = data[filePath];
|
||||||
return {
|
return {
|
||||||
path: filePath,
|
path: filePath,
|
||||||
content:
|
content: file?.content,
|
||||||
encoding === 'utf8' ? content : content instanceof Uint8Array ? textDecoder.decode(content) : '',
|
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((f) => f.content);
|
.filter((f) => f.content);
|
||||||
|
|
||||||
const commands = await detectProjectCommands(fileContents);
|
|
||||||
const commandsMessage = createCommandsMessage(commands);
|
|
||||||
|
|
||||||
const filesMessage: Message = {
|
const filesMessage: Message = {
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: `Cloning the repo ${repoUrl} into ${workdir}
|
content: `Cloning the repo ${repoUrl} into ${workdir}
|
||||||
@ -94,10 +87,6 @@ ${file.content}
|
|||||||
|
|
||||||
const messages = [filesMessage];
|
const messages = [filesMessage];
|
||||||
|
|
||||||
if (commandsMessage) {
|
|
||||||
messages.push(commandsMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
await importChat(`Git Project:${repoUrl.split('/').slice(-1)[0]}`, messages);
|
await importChat(`Git Project:${repoUrl.split('/').slice(-1)[0]}`, messages);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
@ -3,7 +3,6 @@ import ReactMarkdown, { type Components } from 'react-markdown';
|
|||||||
import type { BundledLanguage } from 'shiki';
|
import type { BundledLanguage } from 'shiki';
|
||||||
import { createScopedLogger } from '~/utils/logger';
|
import { createScopedLogger } from '~/utils/logger';
|
||||||
import { rehypePlugins, remarkPlugins, allowedHTMLElements } from '~/utils/markdown';
|
import { rehypePlugins, remarkPlugins, allowedHTMLElements } from '~/utils/markdown';
|
||||||
import { Artifact } from './Artifact';
|
|
||||||
import { CodeBlock } from './CodeBlock';
|
import { CodeBlock } from './CodeBlock';
|
||||||
|
|
||||||
import styles from './Markdown.module.scss';
|
import styles from './Markdown.module.scss';
|
||||||
@ -22,16 +21,6 @@ export const Markdown = memo(({ children, html = false, limitedMarkdown = false
|
|||||||
const components = useMemo(() => {
|
const components = useMemo(() => {
|
||||||
return {
|
return {
|
||||||
div: ({ className, children, node, ...props }) => {
|
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 (
|
return (
|
||||||
<div className={className} {...props}>
|
<div className={className} {...props}>
|
||||||
{children}
|
{children}
|
||||||
|
@ -7,7 +7,6 @@ import { BaseChat } from '~/components/chat/BaseChat';
|
|||||||
import { Chat } from '~/components/chat/Chat.client';
|
import { Chat } from '~/components/chat/Chat.client';
|
||||||
import { useGit } from '~/lib/hooks/useGit';
|
import { useGit } from '~/lib/hooks/useGit';
|
||||||
import { useChatHistory } from '~/lib/persistence';
|
import { useChatHistory } from '~/lib/persistence';
|
||||||
import { createCommandsMessage, detectProjectCommands } from '~/utils/projectCommands';
|
|
||||||
import { LoadingOverlay } from '~/components/ui/LoadingOverlay';
|
import { LoadingOverlay } from '~/components/ui/LoadingOverlay';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
@ -59,18 +58,14 @@ export function GitUrlImport() {
|
|||||||
|
|
||||||
const fileContents = filePaths
|
const fileContents = filePaths
|
||||||
.map((filePath) => {
|
.map((filePath) => {
|
||||||
const { data: content, encoding } = data[filePath];
|
const file = data[filePath];
|
||||||
return {
|
return {
|
||||||
path: filePath,
|
path: filePath,
|
||||||
content:
|
content: file?.content,
|
||||||
encoding === 'utf8' ? content : content instanceof Uint8Array ? textDecoder.decode(content) : '',
|
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((f) => f.content);
|
.filter((f) => f.content);
|
||||||
|
|
||||||
const commands = await detectProjectCommands(fileContents);
|
|
||||||
const commandsMessage = createCommandsMessage(commands);
|
|
||||||
|
|
||||||
const filesMessage: Message = {
|
const filesMessage: Message = {
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: `Cloning the repo ${repoUrl} into ${workdir}
|
content: `Cloning the repo ${repoUrl} into ${workdir}
|
||||||
@ -90,10 +85,6 @@ ${file.content}
|
|||||||
|
|
||||||
const messages = [filesMessage];
|
const messages = [filesMessage];
|
||||||
|
|
||||||
if (commandsMessage) {
|
|
||||||
messages.push(commandsMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
await importChat(`Git Project:${repoUrl.split('/').slice(-1)[0]}`, messages);
|
await importChat(`Git Project:${repoUrl.split('/').slice(-1)[0]}`, messages);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
@ -4,7 +4,7 @@ import type { ChatHistoryItem } from '~/lib/persistence';
|
|||||||
type Bin = { category: string; items: ChatHistoryItem[] };
|
type Bin = { category: string; items: ChatHistoryItem[] };
|
||||||
|
|
||||||
export function binDates(_list: 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 binLookup: Record<string, Bin> = {};
|
||||||
const bins: Array<Bin> = [];
|
const bins: Array<Bin> = [];
|
||||||
|
@ -13,13 +13,10 @@ import { PanelHeader } from '~/components/ui/PanelHeader';
|
|||||||
import { PanelHeaderButton } from '~/components/ui/PanelHeaderButton';
|
import { PanelHeaderButton } from '~/components/ui/PanelHeaderButton';
|
||||||
import type { FileMap } from '~/lib/stores/files';
|
import type { FileMap } from '~/lib/stores/files';
|
||||||
import { themeStore } from '~/lib/stores/theme';
|
import { themeStore } from '~/lib/stores/theme';
|
||||||
import { WORK_DIR } from '~/utils/constants';
|
|
||||||
import { renderLogger } from '~/utils/logger';
|
import { renderLogger } from '~/utils/logger';
|
||||||
import { isMobile } from '~/utils/mobile';
|
import { isMobile } from '~/utils/mobile';
|
||||||
import { FileBreadcrumb } from './FileBreadcrumb';
|
import { FileBreadcrumb } from './FileBreadcrumb';
|
||||||
import { FileTree } from './FileTree';
|
import { FileTree } from './FileTree';
|
||||||
import { DEFAULT_TERMINAL_SIZE, TerminalTabs } from './terminal/TerminalTabs';
|
|
||||||
import { workbenchStore } from '~/lib/stores/workbench';
|
|
||||||
|
|
||||||
interface EditorPanelProps {
|
interface EditorPanelProps {
|
||||||
files?: FileMap;
|
files?: FileMap;
|
||||||
@ -34,7 +31,7 @@ interface EditorPanelProps {
|
|||||||
onFileReset?: () => void;
|
onFileReset?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_EDITOR_SIZE = 100 - DEFAULT_TERMINAL_SIZE;
|
const DEFAULT_EDITOR_SIZE = 100;
|
||||||
|
|
||||||
const editorSettings: EditorSettings = { tabSize: 2 };
|
const editorSettings: EditorSettings = { tabSize: 2 };
|
||||||
|
|
||||||
@ -54,7 +51,6 @@ export const EditorPanel = memo(
|
|||||||
renderLogger.trace('EditorPanel');
|
renderLogger.trace('EditorPanel');
|
||||||
|
|
||||||
const theme = useStore(themeStore);
|
const theme = useStore(themeStore);
|
||||||
const showTerminal = useStore(workbenchStore.showTerminal);
|
|
||||||
|
|
||||||
const activeFileSegments = useMemo(() => {
|
const activeFileSegments = useMemo(() => {
|
||||||
if (!editorDocument) {
|
if (!editorDocument) {
|
||||||
@ -70,7 +66,7 @@ export const EditorPanel = memo(
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PanelGroup direction="vertical">
|
<PanelGroup direction="vertical">
|
||||||
<Panel defaultSize={showTerminal ? DEFAULT_EDITOR_SIZE : 100} minSize={20}>
|
<Panel defaultSize={DEFAULT_EDITOR_SIZE} minSize={20}>
|
||||||
<PanelGroup direction="horizontal">
|
<PanelGroup direction="horizontal">
|
||||||
<Panel defaultSize={20} minSize={10} collapsible>
|
<Panel defaultSize={20} minSize={10} collapsible>
|
||||||
<div className="flex flex-col border-r border-bolt-elements-borderColor h-full">
|
<div className="flex flex-col border-r border-bolt-elements-borderColor h-full">
|
||||||
@ -81,9 +77,7 @@ export const EditorPanel = memo(
|
|||||||
<FileTree
|
<FileTree
|
||||||
className="h-full"
|
className="h-full"
|
||||||
files={files}
|
files={files}
|
||||||
hideRoot
|
|
||||||
unsavedFiles={unsavedFiles}
|
unsavedFiles={unsavedFiles}
|
||||||
rootFolder={WORK_DIR}
|
|
||||||
selectedFile={selectedFile}
|
selectedFile={selectedFile}
|
||||||
onFileSelect={onFileSelect}
|
onFileSelect={onFileSelect}
|
||||||
/>
|
/>
|
||||||
@ -126,7 +120,6 @@ export const EditorPanel = memo(
|
|||||||
</PanelGroup>
|
</PanelGroup>
|
||||||
</Panel>
|
</Panel>
|
||||||
<PanelResizeHandle />
|
<PanelResizeHandle />
|
||||||
<TerminalTabs />
|
|
||||||
</PanelGroup>
|
</PanelGroup>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
@ -3,13 +3,10 @@ import { AnimatePresence, motion, type Variants } from 'framer-motion';
|
|||||||
import { memo, useEffect, useRef, useState } from 'react';
|
import { memo, useEffect, useRef, useState } from 'react';
|
||||||
import type { FileMap } from '~/lib/stores/files';
|
import type { FileMap } from '~/lib/stores/files';
|
||||||
import { classNames } from '~/utils/classNames';
|
import { classNames } from '~/utils/classNames';
|
||||||
import { WORK_DIR } from '~/utils/constants';
|
|
||||||
import { cubicEasingFn } from '~/utils/easings';
|
import { cubicEasingFn } from '~/utils/easings';
|
||||||
import { renderLogger } from '~/utils/logger';
|
import { renderLogger } from '~/utils/logger';
|
||||||
import FileTree from './FileTree';
|
import FileTree from './FileTree';
|
||||||
|
|
||||||
const WORK_DIR_REGEX = new RegExp(`^${WORK_DIR.split('/').slice(0, -1).join('/').replaceAll('/', '\\/')}/`);
|
|
||||||
|
|
||||||
interface FileBreadcrumbProps {
|
interface FileBreadcrumbProps {
|
||||||
files?: FileMap;
|
files?: FileMap;
|
||||||
pathSegments?: string[];
|
pathSegments?: string[];
|
||||||
@ -76,10 +73,6 @@ export const FileBreadcrumb = memo<FileBreadcrumbProps>(({ files, pathSegments =
|
|||||||
|
|
||||||
const path = pathSegments.slice(0, index).join('/');
|
const path = pathSegments.slice(0, index).join('/');
|
||||||
|
|
||||||
if (!WORK_DIR_REGEX.test(path)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isActive = activeIndex === index;
|
const isActive = activeIndex === index;
|
||||||
|
|
||||||
return (
|
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">
|
<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
|
<FileTree
|
||||||
files={files}
|
files={files}
|
||||||
hideRoot
|
|
||||||
rootFolder={path}
|
|
||||||
collapsed
|
collapsed
|
||||||
allowFolderSelection
|
allowFolderSelection
|
||||||
selectedFile={`${path}/${segment}`}
|
selectedFile={`${path}/${segment}`}
|
||||||
|
@ -13,8 +13,6 @@ interface Props {
|
|||||||
files?: FileMap;
|
files?: FileMap;
|
||||||
selectedFile?: string;
|
selectedFile?: string;
|
||||||
onFileSelect?: (filePath: string) => void;
|
onFileSelect?: (filePath: string) => void;
|
||||||
rootFolder?: string;
|
|
||||||
hideRoot?: boolean;
|
|
||||||
collapsed?: boolean;
|
collapsed?: boolean;
|
||||||
allowFolderSelection?: boolean;
|
allowFolderSelection?: boolean;
|
||||||
hiddenFiles?: Array<string | RegExp>;
|
hiddenFiles?: Array<string | RegExp>;
|
||||||
@ -27,8 +25,6 @@ export const FileTree = memo(
|
|||||||
files = {},
|
files = {},
|
||||||
onFileSelect,
|
onFileSelect,
|
||||||
selectedFile,
|
selectedFile,
|
||||||
rootFolder,
|
|
||||||
hideRoot = false,
|
|
||||||
collapsed = false,
|
collapsed = false,
|
||||||
allowFolderSelection = false,
|
allowFolderSelection = false,
|
||||||
hiddenFiles,
|
hiddenFiles,
|
||||||
@ -40,8 +36,8 @@ export const FileTree = memo(
|
|||||||
const computedHiddenFiles = useMemo(() => [...DEFAULT_HIDDEN_FILES, ...(hiddenFiles ?? [])], [hiddenFiles]);
|
const computedHiddenFiles = useMemo(() => [...DEFAULT_HIDDEN_FILES, ...(hiddenFiles ?? [])], [hiddenFiles]);
|
||||||
|
|
||||||
const fileList = useMemo(() => {
|
const fileList = useMemo(() => {
|
||||||
return buildFileList(files, rootFolder, hideRoot, computedHiddenFiles);
|
return buildFileList(files, computedHiddenFiles);
|
||||||
}, [files, rootFolder, hideRoot, computedHiddenFiles]);
|
}, [files, computedHiddenFiles]);
|
||||||
|
|
||||||
const [collapsedFolders, setCollapsedFolders] = useState(() => {
|
const [collapsedFolders, setCollapsedFolders] = useState(() => {
|
||||||
return collapsed
|
return collapsed
|
||||||
@ -121,7 +117,7 @@ export const FileTree = memo(
|
|||||||
|
|
||||||
const onCopyRelativePath = (fileOrFolder: FileNode | FolderNode) => {
|
const onCopyRelativePath = (fileOrFolder: FileNode | FolderNode) => {
|
||||||
try {
|
try {
|
||||||
navigator.clipboard.writeText(fileOrFolder.fullPath.substring((rootFolder || '').length));
|
navigator.clipboard.writeText(fileOrFolder.fullPath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(error);
|
logger.error(error);
|
||||||
}
|
}
|
||||||
@ -334,21 +330,11 @@ interface FolderNode extends BaseNode {
|
|||||||
kind: 'folder';
|
kind: 'folder';
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildFileList(
|
function buildFileList(files: FileMap, hiddenFiles: Array<string | RegExp>): Node[] {
|
||||||
files: FileMap,
|
|
||||||
rootFolder = '/',
|
|
||||||
hideRoot: boolean,
|
|
||||||
hiddenFiles: Array<string | RegExp>,
|
|
||||||
): Node[] {
|
|
||||||
const folderPaths = new Set<string>();
|
const folderPaths = new Set<string>();
|
||||||
const fileList: Node[] = [];
|
const fileList: Node[] = [];
|
||||||
|
|
||||||
let defaultDepth = 0;
|
const defaultDepth = 0;
|
||||||
|
|
||||||
if (rootFolder === '/' && !hideRoot) {
|
|
||||||
defaultDepth = 1;
|
|
||||||
fileList.push({ kind: 'folder', name: '/', depth: 0, id: 0, fullPath: '/' });
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [filePath, dirent] of Object.entries(files)) {
|
for (const [filePath, dirent] of Object.entries(files)) {
|
||||||
const segments = filePath.split('/').filter((segment) => segment);
|
const segments = filePath.split('/').filter((segment) => segment);
|
||||||
@ -358,21 +344,16 @@ function buildFileList(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let currentPath = '';
|
let fullPath = '';
|
||||||
|
|
||||||
let i = 0;
|
let i = 0;
|
||||||
let depth = 0;
|
let depth = 0;
|
||||||
|
|
||||||
while (i < segments.length) {
|
while (i < segments.length) {
|
||||||
const name = segments[i];
|
const name = segments[i];
|
||||||
const fullPath = (currentPath += `/${name}`);
|
fullPath += (fullPath.length ? '/' : '') + name;
|
||||||
|
|
||||||
if (!fullPath.startsWith(rootFolder) || (hideRoot && fullPath === rootFolder)) {
|
if (i === segments.length - 1) {
|
||||||
i++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (i === segments.length - 1 && dirent?.type === 'file') {
|
|
||||||
fileList.push({
|
fileList.push({
|
||||||
kind: 'file',
|
kind: 'file',
|
||||||
id: fileList.length,
|
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>) {
|
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).
|
* 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.
|
* @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');
|
logger.trace('sortFileList');
|
||||||
|
|
||||||
const nodeMap = new Map<string, Node>();
|
const nodeMap = new Map<string, Node>();
|
||||||
@ -435,16 +426,14 @@ function sortFileList(rootFolder: string, nodeList: Node[], hideRoot: boolean):
|
|||||||
for (const node of nodeList) {
|
for (const node of nodeList) {
|
||||||
nodeMap.set(node.fullPath, node);
|
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)) {
|
if (!childrenMap.has(parentPath)) {
|
||||||
childrenMap.set(parentPath, []);
|
childrenMap.set(parentPath, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
childrenMap.get(parentPath)?.push(node);
|
childrenMap.get(parentPath)?.push(node);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const sortedList: Node[] = [];
|
const sortedList: Node[] = [];
|
||||||
|
|
||||||
@ -468,16 +457,11 @@ function sortFileList(rootFolder: string, nodeList: Node[], hideRoot: boolean):
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (hideRoot) {
|
const rootChildren = childrenMap.get('') || [];
|
||||||
// if root is hidden, start traversal from its immediate children
|
|
||||||
const rootChildren = childrenMap.get(rootFolder) || [];
|
|
||||||
|
|
||||||
for (const child of rootChildren) {
|
for (const child of rootChildren) {
|
||||||
depthFirstTraversal(child.fullPath);
|
depthFirstTraversal(child.fullPath);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
depthFirstTraversal(rootFolder);
|
|
||||||
}
|
|
||||||
|
|
||||||
return sortedList;
|
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 { IconButton } from '~/components/ui/IconButton';
|
||||||
import { workbenchStore } from '~/lib/stores/workbench';
|
import { workbenchStore } from '~/lib/stores/workbench';
|
||||||
import { simulationReloaded } from '~/lib/replay/SimulationPrompt';
|
import { simulationReloaded } from '~/lib/replay/SimulationPrompt';
|
||||||
import { PortDropdown } from './PortDropdown';
|
|
||||||
import { PointSelector } from './PointSelector';
|
import { PointSelector } from './PointSelector';
|
||||||
|
|
||||||
type ResizeSide = 'left' | 'right' | null;
|
type ResizeSide = 'left' | 'right' | null;
|
||||||
@ -19,18 +18,16 @@ export const Preview = memo(() => {
|
|||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const [activePreviewIndex, setActivePreviewIndex] = useState(0);
|
|
||||||
const [isPortDropdownOpen, setIsPortDropdownOpen] = useState(false);
|
const [isPortDropdownOpen, setIsPortDropdownOpen] = useState(false);
|
||||||
const [isFullscreen, setIsFullscreen] = 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 [url, setUrl] = useState('');
|
||||||
const [iframeUrl, setIframeUrl] = useState<string | undefined>();
|
const [iframeUrl, setIframeUrl] = useState<string | undefined>();
|
||||||
const [isSelectionMode, setIsSelectionMode] = useState(false);
|
const [isSelectionMode, setIsSelectionMode] = useState(false);
|
||||||
const [selectionPoint, setSelectionPoint] = useState<{ x: number; y: number } | null>(null);
|
const [selectionPoint, setSelectionPoint] = useState<{ x: number; y: number } | null>(null);
|
||||||
|
|
||||||
|
const previewURL = useStore(workbenchStore.previewURL);
|
||||||
|
|
||||||
// Toggle between responsive mode and device mode
|
// Toggle between responsive mode and device mode
|
||||||
const [isDeviceModeOn, setIsDeviceModeOn] = useState(false);
|
const [isDeviceModeOn, setIsDeviceModeOn] = useState(false);
|
||||||
|
|
||||||
@ -51,78 +48,17 @@ export const Preview = memo(() => {
|
|||||||
gCurrentIFrame = iframeRef.current ?? undefined;
|
gCurrentIFrame = iframeRef.current ?? undefined;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activePreview) {
|
if (!previewURL) {
|
||||||
setUrl('');
|
setUrl('');
|
||||||
setIframeUrl(undefined);
|
setIframeUrl(undefined);
|
||||||
|
setSelectionPoint(null);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { baseUrl } = activePreview;
|
setUrl(previewURL);
|
||||||
setUrl(baseUrl);
|
setIframeUrl(previewURL);
|
||||||
setIframeUrl(baseUrl);
|
}, [previewURL]);
|
||||||
}, [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]);
|
|
||||||
|
|
||||||
const reloadPreview = () => {
|
const reloadPreview = () => {
|
||||||
if (iframeRef.current) {
|
if (iframeRef.current) {
|
||||||
@ -279,14 +215,14 @@ export const Preview = memo(() => {
|
|||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
className="w-full bg-transparent outline-none"
|
className="w-full bg-transparent outline-none"
|
||||||
type="text"
|
type="text"
|
||||||
value={displayUrl(url)}
|
value={url}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
setUrl(event.target.value);
|
setUrl(event.target.value);
|
||||||
}}
|
}}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
let newUrl;
|
let newUrl;
|
||||||
|
|
||||||
if (event.key === 'Enter' && (newUrl = validateUrl(url))) {
|
if (event.key === 'Enter') {
|
||||||
setIframeUrl(newUrl);
|
setIframeUrl(newUrl);
|
||||||
|
|
||||||
if (inputRef.current) {
|
if (inputRef.current) {
|
||||||
@ -297,17 +233,6 @@ export const Preview = memo(() => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{previews.length > 1 && (
|
|
||||||
<PortDropdown
|
|
||||||
activePreviewIndex={activePreviewIndex}
|
|
||||||
setActivePreviewIndex={setActivePreviewIndex}
|
|
||||||
isDropdownOpen={isPortDropdownOpen}
|
|
||||||
setHasSelectedPreview={(value) => (hasSelectedPreview.current = value)}
|
|
||||||
setIsDropdownOpen={setIsPortDropdownOpen}
|
|
||||||
previews={previews}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Device mode toggle button */}
|
{/* Device mode toggle button */}
|
||||||
<IconButton
|
<IconButton
|
||||||
icon="i-ph:devices"
|
icon="i-ph:devices"
|
||||||
@ -334,7 +259,7 @@ export const Preview = memo(() => {
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{activePreview ? (
|
{previewURL ? (
|
||||||
<>
|
<>
|
||||||
<iframe
|
<iframe
|
||||||
ref={iframeRef}
|
ref={iframeRef}
|
||||||
|
@ -59,7 +59,7 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
|
|||||||
|
|
||||||
const [isSyncing, setIsSyncing] = useState(false);
|
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 showWorkbench = useStore(workbenchStore.showWorkbench);
|
||||||
const selectedFile = useStore(workbenchStore.selectedFile);
|
const selectedFile = useStore(workbenchStore.selectedFile);
|
||||||
const currentDocument = useStore(workbenchStore.currentDocument);
|
const currentDocument = useStore(workbenchStore.currentDocument);
|
||||||
@ -74,10 +74,10 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasPreview) {
|
if (previewURL) {
|
||||||
setSelectedView('preview');
|
setSelectedView('preview');
|
||||||
}
|
}
|
||||||
}, [hasPreview]);
|
}, [previewURL]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
workbenchStore.setDocuments(files);
|
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 ? <div className="i-ph:spinner" /> : <div className="i-ph:cloud-arrow-down" />}
|
||||||
{isSyncing ? 'Syncing...' : 'Sync Files'}
|
{isSyncing ? 'Syncing...' : 'Sync Files'}
|
||||||
</PanelHeaderButton>
|
</PanelHeaderButton>
|
||||||
<PanelHeaderButton
|
|
||||||
className="mr-1 text-sm"
|
|
||||||
onClick={() => {
|
|
||||||
workbenchStore.toggleTerminal(!workbenchStore.showTerminal.get());
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="i-ph:terminal" />
|
|
||||||
Toggle Terminal
|
|
||||||
</PanelHeaderButton>
|
|
||||||
<PanelHeaderButton
|
<PanelHeaderButton
|
||||||
className="mr-1 text-sm"
|
className="mr-1 text-sm"
|
||||||
onClick={() => {
|
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 './useMessageParser';
|
||||||
export * from './usePromptEnhancer';
|
export * from './usePromptEnhancer';
|
||||||
export * from './useShortcuts';
|
|
||||||
export * from './useSnapScroll';
|
export * from './useSnapScroll';
|
||||||
export * from './useEditChatDescription';
|
export * from './useEditChatDescription';
|
||||||
export { default } from './useViewport';
|
export { default } from './useViewport';
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
import type { WebContainer } from '@webcontainer/api';
|
|
||||||
import { useCallback, useEffect, useRef, useState, type MutableRefObject } from 'react';
|
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 git, { type GitAuth, type PromiseFsClient } from 'isomorphic-git';
|
||||||
import http from 'isomorphic-git/http/web';
|
import http from 'isomorphic-git/http/web';
|
||||||
import Cookies from 'js-cookie';
|
import Cookies from 'js-cookie';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
|
import type { ProtocolFile } from '../replay/SimulationPrompt';
|
||||||
|
import type { FileMap } from '../stores/files';
|
||||||
|
|
||||||
const lookupSavedPassword = (url: string) => {
|
const lookupSavedPassword = (url: string) => {
|
||||||
const domain = url.split('/')[2];
|
const domain = url.split('/')[2];
|
||||||
@ -30,26 +30,19 @@ const saveGitAuth = (url: string, auth: GitAuth) => {
|
|||||||
|
|
||||||
export function useGit() {
|
export function useGit() {
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
const [webcontainer, setWebcontainer] = useState<WebContainer>();
|
|
||||||
const [fs, setFs] = useState<PromiseFsClient>();
|
const [fs, setFs] = useState<PromiseFsClient>();
|
||||||
const fileData = useRef<Record<string, { data: any; encoding?: string }>>({});
|
const fileData = useRef<FileMap>({});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
webcontainerPromise.then((container) => {
|
setFs(getFs(fileData.current));
|
||||||
fileData.current = {};
|
|
||||||
setWebcontainer(container);
|
|
||||||
setFs(getFs(container, fileData));
|
|
||||||
setReady(true);
|
setReady(true);
|
||||||
});
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const gitClone = useCallback(
|
const gitClone = useCallback(
|
||||||
async (url: string) => {
|
async (url: string) => {
|
||||||
if (!webcontainer || !fs || !ready) {
|
if (!fs || !ready) {
|
||||||
throw 'Webcontainer not initialized';
|
throw 'Not initialized';
|
||||||
}
|
}
|
||||||
|
|
||||||
fileData.current = {};
|
|
||||||
|
|
||||||
const headers: {
|
const headers: {
|
||||||
[x: string]: string;
|
[x: string]: string;
|
||||||
} = {
|
} = {
|
||||||
@ -66,7 +59,7 @@ export function useGit() {
|
|||||||
await git.clone({
|
await git.clone({
|
||||||
fs,
|
fs,
|
||||||
http,
|
http,
|
||||||
dir: webcontainer.workdir,
|
dir: '/',
|
||||||
url,
|
url,
|
||||||
depth: 1,
|
depth: 1,
|
||||||
singleBranch: true,
|
singleBranch: true,
|
||||||
@ -98,35 +91,35 @@ export function useGit() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const data: Record<string, { data: any; encoding?: string }> = {};
|
return { workdir: '/', data: fileData.current };
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(fileData.current)) {
|
|
||||||
data[key] = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { workdir: webcontainer.workdir, data };
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Git clone error:', error);
|
console.error('Git clone error:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[webcontainer, fs, ready],
|
[fs, ready],
|
||||||
);
|
);
|
||||||
|
|
||||||
return { ready, gitClone };
|
return { ready, gitClone };
|
||||||
}
|
}
|
||||||
|
|
||||||
const getFs = (
|
function createFileFromEncoding(path: string, data: any, encoding: string | undefined): ProtocolFile {
|
||||||
webcontainer: WebContainer,
|
if (typeof data == 'string') {
|
||||||
record: MutableRefObject<Record<string, { data: any; encoding?: string }>>,
|
return { path, content: data };
|
||||||
) => ({
|
}
|
||||||
|
|
||||||
|
console.error('CreateFileFromEncodingFailed', { data, encoding });
|
||||||
|
|
||||||
|
return { path, content: 'CreateFileFromEncodingFailed' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const getFs = (files: FileMap) => ({
|
||||||
promises: {
|
promises: {
|
||||||
readFile: async (path: string, options: any) => {
|
readFile: async (path: string, options: any) => {
|
||||||
const encoding = options?.encoding;
|
const encoding = options?.encoding;
|
||||||
const relativePath = pathUtils.relative(webcontainer.workdir, path);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await webcontainer.fs.readFile(relativePath, encoding);
|
const result = files[path]?.content;
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -135,191 +128,33 @@ const getFs = (
|
|||||||
},
|
},
|
||||||
writeFile: async (path: string, data: any, options: any) => {
|
writeFile: async (path: string, data: any, options: any) => {
|
||||||
const encoding = options.encoding;
|
const encoding = options.encoding;
|
||||||
const relativePath = pathUtils.relative(webcontainer.workdir, path);
|
files[path] = createFileFromEncoding(path, data, encoding);
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
mkdir: async (path: string, options: any) => {},
|
||||||
readdir: async (path: string, options: any) => {
|
readdir: async (path: string, options: any) => {
|
||||||
const relativePath = pathUtils.relative(webcontainer.workdir, path);
|
throw new Error('NYI');
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await webcontainer.fs.readdir(relativePath, options);
|
|
||||||
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
rm: async (path: string, options: any) => {
|
rm: async (path: string, options: any) => {
|
||||||
const relativePath = pathUtils.relative(webcontainer.workdir, path);
|
throw new Error('NYI');
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await webcontainer.fs.rm(relativePath, { ...(options || {}) });
|
|
||||||
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
rmdir: async (path: string, options: any) => {
|
rmdir: async (path: string, options: any) => {
|
||||||
const relativePath = pathUtils.relative(webcontainer.workdir, path);
|
throw new Error('NYI');
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await webcontainer.fs.rm(relativePath, { recursive: true, ...options });
|
|
||||||
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
unlink: async (path: string) => {
|
unlink: async (path: string) => {
|
||||||
const relativePath = pathUtils.relative(webcontainer.workdir, path);
|
throw new Error('NYI');
|
||||||
|
|
||||||
try {
|
|
||||||
return await webcontainer.fs.rm(relativePath, { recursive: false });
|
|
||||||
} catch (error) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
stat: async (path: string) => {
|
stat: async (path: string) => {
|
||||||
try {
|
throw new Error('NYI');
|
||||||
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;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
lstat: async (path: string) => {
|
lstat: async (path: string) => {
|
||||||
return await getFs(webcontainer, record).promises.stat(path);
|
throw new Error('NYI');
|
||||||
},
|
},
|
||||||
readlink: async (path: string) => {
|
readlink: async (path: string) => {
|
||||||
throw new Error(`EINVAL: invalid argument, readlink '${path}'`);
|
throw new Error('NYI');
|
||||||
},
|
},
|
||||||
symlink: async (target: string, path: string) => {
|
symlink: async (target: string, path: string) => {
|
||||||
/*
|
throw new Error('NYI');
|
||||||
* 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();
|
|
||||||
},
|
},
|
||||||
|
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) => {
|
onActionOpen: (data) => {
|
||||||
logger.trace('onActionOpen', data.action);
|
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) => {
|
onActionClose: (data) => {
|
||||||
logger.trace('onActionClose', data.action);
|
logger.trace('onActionClose', data.action);
|
||||||
|
|
||||||
if (data.action.type !== 'file') {
|
|
||||||
workbenchStore.addAction(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
workbenchStore.runAction(data);
|
workbenchStore.runAction(data);
|
||||||
},
|
},
|
||||||
onActionStream: (data) => {
|
onActionStream: (data) => {
|
||||||
logger.trace('onActionStream', data.action);
|
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}
|
${assert}
|
||||||
${stringToBase64}
|
${stringToBase64}
|
||||||
${uint8ArrayToBase64}
|
${uint8ArrayToBase64}
|
||||||
(${addRecordingMessageHandler})()
|
(${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>();
|
openDeferred = createDeferred<void>();
|
||||||
eventListeners = new Map<string, Set<EventListener>>();
|
eventListeners = new Map<string, Set<EventListener>>();
|
||||||
nextMessageId = 1;
|
nextMessageId = 1;
|
||||||
pendingCommands = new Map<number, Deferred<any>>();
|
pendingCommands = new Map<number, { method: string; deferred: Deferred<any> }>();
|
||||||
socket: WebSocket;
|
socket: WebSocket;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@ -147,7 +147,7 @@ export class ProtocolClient {
|
|||||||
this.socket.send(JSON.stringify(command));
|
this.socket.send(JSON.stringify(command));
|
||||||
|
|
||||||
const deferred = createDeferred();
|
const deferred = createDeferred();
|
||||||
this.pendingCommands.set(id, deferred);
|
this.pendingCommands.set(id, { method, deferred });
|
||||||
|
|
||||||
return deferred.promise;
|
return deferred.promise;
|
||||||
}
|
}
|
||||||
@ -164,18 +164,18 @@ export class ProtocolClient {
|
|||||||
const { error, id, method, params, result } = JSON.parse(String(event.data));
|
const { error, id, method, params, result } = JSON.parse(String(event.data));
|
||||||
|
|
||||||
if (id) {
|
if (id) {
|
||||||
const deferred = this.pendingCommands.get(id);
|
const info = this.pendingCommands.get(id);
|
||||||
assert(deferred, `Received message with unknown id: ${id}`);
|
assert(info, `Received message with unknown id: ${id}`);
|
||||||
|
|
||||||
this.pendingCommands.delete(id);
|
this.pendingCommands.delete(id);
|
||||||
|
|
||||||
if (result) {
|
if (result) {
|
||||||
deferred.resolve(result);
|
info.deferred.resolve(result);
|
||||||
} else if (error) {
|
} else if (error) {
|
||||||
console.error('ProtocolError', error);
|
console.error('ProtocolError', info.method, id, error);
|
||||||
deferred.reject(new ProtocolError(error));
|
info.deferred.reject(new ProtocolError(error));
|
||||||
} else {
|
} else {
|
||||||
deferred.reject(new Error('Channel error'));
|
info.deferred.reject(new Error('Channel error'));
|
||||||
}
|
}
|
||||||
} else if (this.eventListeners.has(method)) {
|
} else if (this.eventListeners.has(method)) {
|
||||||
const callbacks = this.eventListeners.get(method);
|
const callbacks = this.eventListeners.get(method);
|
||||||
|
@ -10,8 +10,10 @@ import { assert, generateRandomId, ProtocolClient } from './ReplayProtocolClient
|
|||||||
import type { MouseData } from './Recording';
|
import type { MouseData } from './Recording';
|
||||||
import type { FileMap } from '~/lib/stores/files';
|
import type { FileMap } from '~/lib/stores/files';
|
||||||
import { shouldIncludeFile } from '~/utils/fileUtils';
|
import { shouldIncludeFile } from '~/utils/fileUtils';
|
||||||
import { developerSystemPrompt } from '~/lib/common/prompts/prompts';
|
import { developerSystemPrompt } from '../common/prompts/prompts';
|
||||||
import { detectProjectCommands } from '~/utils/projectCommands';
|
import { updateDevelopmentServer } from './DevelopmentServer';
|
||||||
|
import { workbenchStore } from '../stores/workbench';
|
||||||
|
import { isEnhancedPromptMessage } from '~/components/chat/Chat.client';
|
||||||
|
|
||||||
function createRepositoryContentsPacket(contents: string): SimulationPacket {
|
function createRepositoryContentsPacket(contents: string): SimulationPacket {
|
||||||
return {
|
return {
|
||||||
@ -184,15 +186,6 @@ class ChatManager {
|
|||||||
if (responseId == eventResponseId) {
|
if (responseId == eventResponseId) {
|
||||||
console.log('ChatModifiedFile', file);
|
console.log('ChatModifiedFile', file);
|
||||||
modifiedFiles.push(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 },
|
params: { chatId, responseId, messages, chatOnly: options?.chatOnly, developerFiles: options?.developerFiles },
|
||||||
});
|
});
|
||||||
|
|
||||||
removeResponseListener();
|
/*
|
||||||
removeFileListener();
|
* The modified files are added at the end as inserting them in the middle of the
|
||||||
|
* response can cause weird rendering behavior.
|
||||||
if (modifiedFiles.length) {
|
*/
|
||||||
const commands = await detectProjectCommands(modifiedFiles);
|
for (const file of modifiedFiles) {
|
||||||
|
|
||||||
const content = `
|
const content = `
|
||||||
<boltArtifact id="project-setup" title="Project Setup">
|
<boltArtifact id="modified-file-${generateRandomId()}" title="File Changes">
|
||||||
<boltAction type="shell">${commands.setupCommand}</boltAction>
|
<boltAction type="file" filePath="${file.path}">${file.content}</boltAction>
|
||||||
</boltArtifact>
|
</boltArtifact>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@ -227,6 +219,11 @@ class ChatManager {
|
|||||||
options?.onResponsePart?.(content);
|
options?.onResponsePart?.(content);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('ChatResponse', chatId, response);
|
||||||
|
|
||||||
|
removeResponseListener();
|
||||||
|
removeFileListener();
|
||||||
|
|
||||||
return response;
|
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
|
* 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 ?? []);
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getProtocolRule(message: Message): 'user' | 'assistant' | 'system' {
|
function getProtocolRole(message: Message): 'user' | 'assistant' | 'system' {
|
||||||
switch (message.role) {
|
switch (message.role) {
|
||||||
case 'user':
|
case 'user':
|
||||||
return 'user';
|
return 'user';
|
||||||
@ -443,7 +447,7 @@ function buildProtocolMessages(messages: Message[]): ProtocolMessage[] {
|
|||||||
const rv: ProtocolMessage[] = [];
|
const rv: ProtocolMessage[] = [];
|
||||||
|
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
const role = getProtocolRule(msg);
|
const role = getProtocolRole(msg);
|
||||||
|
|
||||||
if (Array.isArray(msg.content)) {
|
if (Array.isArray(msg.content)) {
|
||||||
for (const content of msg.content) {
|
for (const content of msg.content) {
|
||||||
@ -478,6 +482,22 @@ function buildProtocolMessages(messages: Message[]): ProtocolMessage[] {
|
|||||||
return rv;
|
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(
|
export async function sendDeveloperChatMessage(
|
||||||
messages: Message[],
|
messages: Message[],
|
||||||
files: FileMap,
|
files: FileMap,
|
||||||
@ -490,7 +510,7 @@ export async function sendDeveloperChatMessage(
|
|||||||
const developerFiles: ProtocolFile[] = [];
|
const developerFiles: ProtocolFile[] = [];
|
||||||
|
|
||||||
for (const [path, file] of Object.entries(files)) {
|
for (const [path, file] of Object.entries(files)) {
|
||||||
if (file?.type == 'file' && shouldIncludeFile(path)) {
|
if (file && shouldIncludeFile(path)) {
|
||||||
developerFiles.push({
|
developerFiles.push({
|
||||||
path,
|
path,
|
||||||
content: file.content,
|
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);
|
const protocolMessages = buildProtocolMessages(messages);
|
||||||
protocolMessages.unshift({
|
protocolMessages.unshift({
|
||||||
role: 'system',
|
role: 'system',
|
||||||
type: 'text',
|
type: 'text',
|
||||||
content: developerSystemPrompt,
|
content: systemPrompt,
|
||||||
});
|
});
|
||||||
|
|
||||||
return gChatManager.sendChatMessage(protocolMessages, { chatOnly: true, developerFiles, onResponsePart });
|
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.fromEntries<EditorDocument>(
|
||||||
Object.entries(files)
|
Object.entries(files)
|
||||||
.map(([filePath, dirent]) => {
|
.map(([filePath, dirent]) => {
|
||||||
if (dirent === undefined || dirent.type === 'folder') {
|
if (dirent === undefined) {
|
||||||
return 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 { 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 { computeFileModifications } from '~/utils/diff';
|
||||||
import { createScopedLogger } from '~/utils/logger';
|
import { createScopedLogger } from '~/utils/logger';
|
||||||
import { unreachable } from '~/utils/unreachable';
|
import { unreachable } from '~/utils/unreachable';
|
||||||
|
import type { ProtocolFile } from '../replay/SimulationPrompt';
|
||||||
|
|
||||||
const logger = createScopedLogger('FilesStore');
|
const logger = createScopedLogger('FilesStore');
|
||||||
|
|
||||||
const utf8TextDecoder = new TextDecoder('utf8', { fatal: true });
|
export type FileMap = Record<string, ProtocolFile | undefined>;
|
||||||
|
|
||||||
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 class FilesStore {
|
export class FilesStore {
|
||||||
#webcontainer: Promise<WebContainer>;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tracks the number of files without folders.
|
* Tracks the number of files without folders.
|
||||||
*/
|
*/
|
||||||
@ -51,24 +30,15 @@ export class FilesStore {
|
|||||||
return this.#size;
|
return this.#size;
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(webcontainerPromise: Promise<WebContainer>) {
|
constructor() {
|
||||||
this.#webcontainer = webcontainerPromise;
|
|
||||||
|
|
||||||
if (import.meta.hot) {
|
if (import.meta.hot) {
|
||||||
import.meta.hot.data.files = this.files;
|
import.meta.hot.data.files = this.files;
|
||||||
import.meta.hot.data.modifiedFiles = this.#modifiedFiles;
|
import.meta.hot.data.modifiedFiles = this.#modifiedFiles;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.#init();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getFile(filePath: string) {
|
getFile(filePath: string) {
|
||||||
const dirent = this.files.get()[filePath];
|
const dirent = this.files.get()[filePath];
|
||||||
|
|
||||||
if (dirent?.type !== 'file') {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return dirent;
|
return dirent;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -81,15 +51,7 @@ export class FilesStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async saveFile(filePath: string, content: string) {
|
async saveFile(filePath: string, content: string) {
|
||||||
const webcontainer = await this.#webcontainer;
|
|
||||||
|
|
||||||
try {
|
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;
|
const oldContent = this.getFile(filePath)?.content;
|
||||||
|
|
||||||
if (!oldContent) {
|
if (!oldContent) {
|
||||||
@ -97,14 +59,12 @@ export class FilesStore {
|
|||||||
unreachable(`Cannot save unknown file ${filePath}`);
|
unreachable(`Cannot save unknown file ${filePath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await webcontainer.fs.writeFile(relativePath, content);
|
|
||||||
|
|
||||||
if (!this.#modifiedFiles.has(filePath)) {
|
if (!this.#modifiedFiles.has(filePath)) {
|
||||||
this.#modifiedFiles.set(filePath, oldContent);
|
this.#modifiedFiles.set(filePath, oldContent);
|
||||||
}
|
}
|
||||||
|
|
||||||
// we immediately update the file and don't rely on the `change` event coming from the watcher
|
// 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');
|
logger.info('File updated');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -113,105 +73,4 @@ export class FilesStore {
|
|||||||
throw error;
|
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 type ProviderSetting = Record<string, IProviderConfig>;
|
||||||
|
|
||||||
export const shortcutsStore = map<Shortcuts>({
|
|
||||||
toggleTerminal: {
|
|
||||||
key: 'j',
|
|
||||||
ctrlOrMetaKey: true,
|
|
||||||
action: () => workbenchStore.toggleTerminal(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const initialProviderSettings: ProviderSetting = {};
|
const initialProviderSettings: ProviderSetting = {};
|
||||||
PROVIDER_LIST.forEach((provider) => {
|
PROVIDER_LIST.forEach((provider) => {
|
||||||
initialProviderSettings[provider.name] = {
|
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 { atom, map, type MapStore, type ReadableAtom, type WritableAtom } from 'nanostores';
|
||||||
import type { EditorDocument, ScrollPosition } from '~/components/editor/codemirror/CodeMirrorEditor';
|
import type { EditorDocument, ScrollPosition } from '~/components/editor/codemirror/CodeMirrorEditor';
|
||||||
import { ActionRunner } from '~/lib/runtime/action-runner';
|
|
||||||
import type { ActionCallbackData, ArtifactCallbackData } from '~/lib/runtime/message-parser';
|
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 { unreachable } from '~/utils/unreachable';
|
||||||
import { EditorStore } from './editor';
|
import { EditorStore } from './editor';
|
||||||
import { FilesStore, type FileMap } from './files';
|
import { FilesStore, type FileMap } from './files';
|
||||||
import { PreviewsStore } from './previews';
|
|
||||||
import { TerminalStore } from './terminal';
|
|
||||||
import JSZip from 'jszip';
|
import JSZip from 'jszip';
|
||||||
import { saveAs } from 'file-saver';
|
import { saveAs } from 'file-saver';
|
||||||
import { Octokit, type RestEndpointMethodTypes } from '@octokit/rest';
|
import { Octokit, type RestEndpointMethodTypes } from '@octokit/rest';
|
||||||
import * as nodePath from 'node:path';
|
|
||||||
import { extractRelativePath } from '~/utils/diff';
|
|
||||||
import { description } from '~/lib/persistence';
|
import { description } from '~/lib/persistence';
|
||||||
import Cookies from 'js-cookie';
|
import Cookies from 'js-cookie';
|
||||||
import { createSampler } from '~/utils/sampler';
|
import { uint8ArrayToBase64 } from '../replay/ReplayProtocolClient';
|
||||||
import { uint8ArrayToBase64 } from '~/lib/replay/ReplayProtocolClient';
|
|
||||||
import type { ActionAlert } from '~/types/actions';
|
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 {
|
export interface ArtifactState {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
closed: boolean;
|
closed: boolean;
|
||||||
runner: ActionRunner;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ArtifactUpdateState = Pick<ArtifactState, 'title' | 'closed'>;
|
export type ArtifactUpdateState = Pick<ArtifactState, 'title' | 'closed'>;
|
||||||
@ -36,12 +29,10 @@ type Artifacts = MapStore<Record<string, ArtifactState>>;
|
|||||||
export type WorkbenchViewType = 'code' | 'preview';
|
export type WorkbenchViewType = 'code' | 'preview';
|
||||||
|
|
||||||
export class WorkbenchStore {
|
export class WorkbenchStore {
|
||||||
#previewsStore = new PreviewsStore(webcontainer);
|
#filesStore = new FilesStore();
|
||||||
#filesStore = new FilesStore(webcontainer);
|
|
||||||
#editorStore = new EditorStore(this.#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({});
|
artifacts: Artifacts = import.meta.hot?.data.artifacts ?? map({});
|
||||||
|
|
||||||
@ -54,8 +45,6 @@ export class WorkbenchStore {
|
|||||||
artifactIdList: string[] = [];
|
artifactIdList: string[] = [];
|
||||||
#globalExecutionQueue = Promise.resolve();
|
#globalExecutionQueue = Promise.resolve();
|
||||||
|
|
||||||
private _fileMap: FileMap = {};
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
if (import.meta.hot) {
|
if (import.meta.hot) {
|
||||||
import.meta.hot.data.artifacts = this.artifacts;
|
import.meta.hot.data.artifacts = this.artifacts;
|
||||||
@ -70,10 +59,6 @@ export class WorkbenchStore {
|
|||||||
this.#globalExecutionQueue = this.#globalExecutionQueue.then(() => callback());
|
this.#globalExecutionQueue = this.#globalExecutionQueue.then(() => callback());
|
||||||
}
|
}
|
||||||
|
|
||||||
get previews() {
|
|
||||||
return this.#previewsStore.previews;
|
|
||||||
}
|
|
||||||
|
|
||||||
get files() {
|
get files() {
|
||||||
return this.#filesStore.files;
|
return this.#filesStore.files;
|
||||||
}
|
}
|
||||||
@ -94,12 +79,6 @@ export class WorkbenchStore {
|
|||||||
return this.#filesStore.filesCount;
|
return this.#filesStore.filesCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
get showTerminal() {
|
|
||||||
return this.#terminalStore.showTerminal;
|
|
||||||
}
|
|
||||||
get boltTerminal() {
|
|
||||||
return this.#terminalStore.boltTerminal;
|
|
||||||
}
|
|
||||||
get alert() {
|
get alert() {
|
||||||
return this.actionAlert;
|
return this.actionAlert;
|
||||||
}
|
}
|
||||||
@ -107,28 +86,13 @@ export class WorkbenchStore {
|
|||||||
this.actionAlert.set(undefined);
|
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) {
|
setDocuments(files: FileMap) {
|
||||||
this.#editorStore.setDocuments(files);
|
this.#editorStore.setDocuments(files);
|
||||||
|
|
||||||
if (this.#filesStore.filesCount > 0 && this.currentDocument.get() === undefined) {
|
if (this.#filesStore.filesCount > 0 && this.currentDocument.get() === undefined) {
|
||||||
// we find the first file and select it
|
// we find the first file and select it
|
||||||
for (const [filePath, dirent] of Object.entries(files)) {
|
for (const [filePath, dirent] of Object.entries(files)) {
|
||||||
if (dirent?.type === 'file') {
|
if (dirent) {
|
||||||
this.setSelectedFile(filePath);
|
this.setSelectedFile(filePath);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -254,10 +218,6 @@ export class WorkbenchStore {
|
|||||||
// TODO: what do we wanna do and how do we wanna recover from this?
|
// 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) {
|
addArtifact({ messageId, title, id, type }: ArtifactCallbackData) {
|
||||||
const artifact = this.#getArtifact(messageId);
|
const artifact = this.#getArtifact(messageId);
|
||||||
|
|
||||||
@ -274,17 +234,6 @@ export class WorkbenchStore {
|
|||||||
title,
|
title,
|
||||||
closed: false,
|
closed: false,
|
||||||
type,
|
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 });
|
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 { messageId } = data;
|
||||||
|
|
||||||
const artifact = this.#getArtifact(messageId);
|
const artifact = this.#getArtifact(messageId);
|
||||||
@ -311,80 +260,40 @@ export class WorkbenchStore {
|
|||||||
unreachable('Artifact not found');
|
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') {
|
if (data.action.type === 'file') {
|
||||||
const wc = await webcontainer;
|
const { filePath, content } = data.action;
|
||||||
const fullPath = nodePath.join(wc.workdir, data.action.filePath);
|
|
||||||
|
|
||||||
this._fileMap[fullPath] = {
|
const existingFiles = this.files.get();
|
||||||
type: 'file',
|
this.files.set({
|
||||||
content: data.action.content,
|
...existingFiles,
|
||||||
isBinary: false,
|
[filePath]: {
|
||||||
};
|
path: filePath,
|
||||||
|
content,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (this.selectedFile.value !== fullPath) {
|
onRepositoryFileWritten();
|
||||||
this.setSelectedFile(fullPath);
|
|
||||||
|
if (this.selectedFile.value !== filePath) {
|
||||||
|
this.setSelectedFile(filePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.currentView.value !== 'code') {
|
if (this.currentView.value !== 'code') {
|
||||||
this.currentView.set('code');
|
this.currentView.set('code');
|
||||||
}
|
}
|
||||||
|
|
||||||
const doc = this.#editorStore.documents.get()[fullPath];
|
this.#editorStore.updateFile(filePath, content);
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
#getArtifact(id: string) {
|
||||||
const artifacts = this.artifacts.get();
|
const artifacts = this.artifacts.get();
|
||||||
return artifacts[id];
|
return artifacts[id];
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _generateZip() {
|
private async _generateZip(injectRecordingMessageHandler = false) {
|
||||||
const zip = new JSZip();
|
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
|
// Get the project name from the description input, or use a default name
|
||||||
const projectName = (description.value ?? 'project').toLocaleLowerCase().split(' ').join('_');
|
const projectName = (description.value ?? 'project').toLocaleLowerCase().split(' ').join('_');
|
||||||
@ -394,12 +303,15 @@ export class WorkbenchStore {
|
|||||||
const uniqueProjectName = `${projectName}_${timestampHash}`;
|
const uniqueProjectName = `${projectName}_${timestampHash}`;
|
||||||
|
|
||||||
for (const [filePath, dirent] of Object.entries(files)) {
|
for (const [filePath, dirent] of Object.entries(files)) {
|
||||||
if (dirent?.type === 'file' && !dirent.isBinary) {
|
if (dirent) {
|
||||||
const relativePath = extractRelativePath(filePath);
|
let content = dirent.content;
|
||||||
const content = dirent.content;
|
|
||||||
|
if (injectRecordingMessageHandler && filePath == 'index.html') {
|
||||||
|
content = doInjectRecordingMessageHandler(content);
|
||||||
|
}
|
||||||
|
|
||||||
// split the path into segments
|
// 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 there's more than one segment, we need to create folders
|
||||||
if (pathSegments.length > 1) {
|
if (pathSegments.length > 1) {
|
||||||
@ -411,7 +323,7 @@ export class WorkbenchStore {
|
|||||||
currentFolder.file(pathSegments[pathSegments.length - 1], content);
|
currentFolder.file(pathSegments[pathSegments.length - 1], content);
|
||||||
} else {
|
} else {
|
||||||
// if there's only one segment, it's a file in the root
|
// 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`);
|
saveAs(content, `${uniqueProjectName}.zip`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async generateZipBase64() {
|
async generateZipBase64(injectRecordingMessageHandler = false) {
|
||||||
const { content, uniqueProjectName } = await this._generateZip();
|
const { content, uniqueProjectName } = await this._generateZip(injectRecordingMessageHandler);
|
||||||
const buf = await content.arrayBuffer();
|
const buf = await content.arrayBuffer();
|
||||||
const contentBase64 = uint8ArrayToBase64(new Uint8Array(buf));
|
const contentBase64 = uint8ArrayToBase64(new Uint8Array(buf));
|
||||||
|
|
||||||
@ -445,17 +357,16 @@ export class WorkbenchStore {
|
|||||||
const fileRelativePaths = new Set<string>();
|
const fileRelativePaths = new Set<string>();
|
||||||
|
|
||||||
for (const [filePath, dirent] of Object.entries(files)) {
|
for (const [filePath, dirent] of Object.entries(files)) {
|
||||||
if (dirent?.type === 'file' && !dirent.isBinary) {
|
if (dirent) {
|
||||||
const relativePath = extractRelativePath(filePath);
|
fileRelativePaths.add(filePath);
|
||||||
fileRelativePaths.add(relativePath);
|
|
||||||
|
|
||||||
const content = dirent.content;
|
const content = dirent.content;
|
||||||
|
|
||||||
const artifact = fileArtifacts.find((artifact) => artifact.path === relativePath);
|
const artifact = fileArtifacts.find((artifact) => artifact.path === filePath);
|
||||||
const artifactContent = artifact?.content ?? '';
|
const artifactContent = artifact?.content ?? '';
|
||||||
|
|
||||||
if (content != artifactContent) {
|
if (content != artifactContent) {
|
||||||
modifiedFilePaths.add(relativePath);
|
modifiedFilePaths.add(filePath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -487,7 +398,6 @@ export class WorkbenchStore {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
this.addAction(data);
|
|
||||||
this.runAction(data);
|
this.runAction(data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -497,9 +407,8 @@ export class WorkbenchStore {
|
|||||||
const syncedFiles = [];
|
const syncedFiles = [];
|
||||||
|
|
||||||
for (const [filePath, dirent] of Object.entries(files)) {
|
for (const [filePath, dirent] of Object.entries(files)) {
|
||||||
if (dirent?.type === 'file' && !dirent.isBinary) {
|
if (dirent) {
|
||||||
const relativePath = extractRelativePath(filePath);
|
const pathSegments = filePath.split('/');
|
||||||
const pathSegments = relativePath.split('/');
|
|
||||||
let currentHandle = targetHandle;
|
let currentHandle = targetHandle;
|
||||||
|
|
||||||
for (let i = 0; i < pathSegments.length - 1; i++) {
|
for (let i = 0; i < pathSegments.length - 1; i++) {
|
||||||
@ -516,7 +425,7 @@ export class WorkbenchStore {
|
|||||||
await writable.write(dirent.content);
|
await writable.write(dirent.content);
|
||||||
await writable.close();
|
await writable.close();
|
||||||
|
|
||||||
syncedFiles.push(relativePath);
|
syncedFiles.push(filePath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -567,14 +476,14 @@ export class WorkbenchStore {
|
|||||||
// Create blobs for each file
|
// Create blobs for each file
|
||||||
const blobs = await Promise.all(
|
const blobs = await Promise.all(
|
||||||
Object.entries(files).map(async ([filePath, dirent]) => {
|
Object.entries(files).map(async ([filePath, dirent]) => {
|
||||||
if (dirent?.type === 'file' && dirent.content) {
|
if (dirent) {
|
||||||
const { data: blob } = await octokit.git.createBlob({
|
const { data: blob } = await octokit.git.createBlob({
|
||||||
owner: repo.owner.login,
|
owner: repo.owner.login,
|
||||||
repo: repo.name,
|
repo: repo.name,
|
||||||
content: Buffer.from(dirent.content).toString('base64'),
|
content: Buffer.from(dirent.content).toString('base64'),
|
||||||
encoding: 'base64',
|
encoding: 'base64',
|
||||||
});
|
});
|
||||||
return { path: extractRelativePath(filePath), sha: blob.sha };
|
return { path: filePath, sha: blob.sha };
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
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';
|
import type { Template } from '~/types/template';
|
||||||
|
|
||||||
export const WORK_DIR_NAME = 'project';
|
export const WORK_DIR_NAME = 'project';
|
||||||
export const WORK_DIR = `/home/${WORK_DIR_NAME}`;
|
|
||||||
export const MODIFICATIONS_TAG_NAME = 'bolt_file_modifications';
|
export const MODIFICATIONS_TAG_NAME = 'bolt_file_modifications';
|
||||||
export const MODEL_REGEX = /^\[Model: (.*?)\]\n\n/;
|
export const MODEL_REGEX = /^\[Model: (.*?)\]\n\n/;
|
||||||
export const PROVIDER_REGEX = /\[Provider: (.*?)\]\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 { createTwoFilesPatch } from 'diff';
|
||||||
import type { FileMap } from '~/lib/stores/files';
|
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(
|
export const modificationsRegex = new RegExp(
|
||||||
`^<${MODIFICATIONS_TAG_NAME}>[\\s\\S]*?<\\/${MODIFICATIONS_TAG_NAME}>\\s+`,
|
`^<${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) {
|
for (const [filePath, originalContent] of modifiedFiles) {
|
||||||
const file = files[filePath];
|
const file = files[filePath];
|
||||||
|
|
||||||
if (file?.type !== 'file') {
|
if (!file) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -75,15 +75,6 @@ export function diffFiles(fileName: string, oldFileContent: string, newFileConte
|
|||||||
return unifiedDiff;
|
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.
|
* Converts the unified diff to HTML.
|
||||||
*
|
*
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import type { Message } from 'ai';
|
import type { Message } from 'ai';
|
||||||
import { generateId, shouldIncludeFile } from './fileUtils';
|
import { generateId, shouldIncludeFile } from './fileUtils';
|
||||||
import { detectProjectCommands, createCommandsMessage } from './projectCommands';
|
|
||||||
|
|
||||||
export interface FileArtifact {
|
export interface FileArtifact {
|
||||||
content: string;
|
content: string;
|
||||||
@ -33,9 +32,6 @@ export const createChatFromFolder = async (
|
|||||||
binaryFiles: string[],
|
binaryFiles: string[],
|
||||||
folderName: string,
|
folderName: string,
|
||||||
): Promise<Message[]> => {
|
): Promise<Message[]> => {
|
||||||
const commands = await detectProjectCommands(fileArtifacts);
|
|
||||||
const commandsMessage = createCommandsMessage(commands);
|
|
||||||
|
|
||||||
const binaryFilesMessage =
|
const binaryFilesMessage =
|
||||||
binaryFiles.length > 0
|
binaryFiles.length > 0
|
||||||
? `\n\nSkipped ${binaryFiles.length} binary files:\n${binaryFiles.map((f) => `- ${f}`).join('\n')}`
|
? `\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];
|
const messages = [userMessage, filesMessage];
|
||||||
|
|
||||||
if (commandsMessage) {
|
|
||||||
messages.push(commandsMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
return messages;
|
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",
|
"@supabase/supabase-js": "^2.49.1",
|
||||||
"@uiw/codemirror-theme-vscode": "^4.23.6",
|
"@uiw/codemirror-theme-vscode": "^4.23.6",
|
||||||
"@unocss/reset": "^0.61.9",
|
"@unocss/reset": "^0.61.9",
|
||||||
"@webcontainer/api": "1.5.1-internal.8",
|
|
||||||
"@xterm/addon-fit": "^0.10.0",
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
"@xterm/addon-web-links": "^0.11.0",
|
"@xterm/addon-web-links": "^0.11.0",
|
||||||
"@xterm/xterm": "^5.5.0",
|
"@xterm/xterm": "^5.5.0",
|
||||||
|
@ -178,9 +178,6 @@ importers:
|
|||||||
'@unocss/reset':
|
'@unocss/reset':
|
||||||
specifier: ^0.61.9
|
specifier: ^0.61.9
|
||||||
version: 0.61.9
|
version: 0.61.9
|
||||||
'@webcontainer/api':
|
|
||||||
specifier: 1.5.1-internal.8
|
|
||||||
version: 1.5.1-internal.8
|
|
||||||
'@xterm/addon-fit':
|
'@xterm/addon-fit':
|
||||||
specifier: ^0.10.0
|
specifier: ^0.10.0
|
||||||
version: 0.10.0(@xterm/xterm@5.5.0)
|
version: 0.10.0(@xterm/xterm@5.5.0)
|
||||||
@ -3459,9 +3456,6 @@ packages:
|
|||||||
'@web3-storage/multipart-parser@1.0.0':
|
'@web3-storage/multipart-parser@1.0.0':
|
||||||
resolution: {integrity: sha512-BEO6al7BYqcnfX15W2cnGR+Q566ACXAT9UQykORCWW80lmkpWsnEob6zJS1ZVBKsSJC8+7vJkHwlp+lXG1UCdw==}
|
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':
|
'@xterm/addon-fit@0.10.0':
|
||||||
resolution: {integrity: sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==}
|
resolution: {integrity: sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -10828,8 +10822,6 @@ snapshots:
|
|||||||
|
|
||||||
'@web3-storage/multipart-parser@1.0.0': {}
|
'@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)':
|
'@xterm/addon-fit@0.10.0(@xterm/xterm@5.5.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@xterm/xterm': 5.5.0
|
'@xterm/xterm': 5.5.0
|
||||||
|
Loading…
Reference in New Issue
Block a user