mirror of
https://github.com/stackblitz-labs/bolt.diy
synced 2025-06-26 18:26:38 +00:00
Remove file handling and operate on repository IDs (#64)
This commit is contained in:
@@ -1,25 +1,25 @@
|
||||
#!/bin/sh
|
||||
|
||||
echo "🔍 Running pre-commit hook to check the code looks good... 🔍"
|
||||
#echo "🔍 Running pre-commit hook to check the code looks good... 🔍"
|
||||
|
||||
# Load NVM if available (useful for managing Node.js versions)
|
||||
export NVM_DIR="$HOME/.nvm"
|
||||
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
|
||||
#export NVM_DIR="$HOME/.nvm"
|
||||
#[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
|
||||
|
||||
# Ensure `pnpm` is available
|
||||
echo "Checking if pnpm is available..."
|
||||
if ! command -v pnpm >/dev/null 2>&1; then
|
||||
echo "❌ pnpm not found! Please ensure pnpm is installed and available in PATH."
|
||||
exit 1
|
||||
fi
|
||||
#echo "Checking if pnpm is available..."
|
||||
#if ! command -v pnpm >/dev/null 2>&1; then
|
||||
# echo "❌ pnpm not found! Please ensure pnpm is installed and available in PATH."
|
||||
# exit 1
|
||||
#fi
|
||||
|
||||
# Run typecheck
|
||||
echo "Running typecheck..."
|
||||
if ! pnpm typecheck; then
|
||||
echo "❌ Type checking failed! Please review TypeScript types."
|
||||
echo "Once you're done, don't forget to add your changes to the commit! 🚀"
|
||||
exit 1
|
||||
fi
|
||||
#echo "Running typecheck..."
|
||||
#if ! pnpm typecheck; then
|
||||
# echo "❌ Type checking failed! Please review TypeScript types."
|
||||
# echo "Once you're done, don't forget to add your changes to the commit! 🚀"
|
||||
# exit 1
|
||||
#fi
|
||||
|
||||
# Run lint
|
||||
#echo "Running lint..."
|
||||
@@ -29,4 +29,4 @@ fi
|
||||
# exit 1
|
||||
#fi
|
||||
|
||||
echo "👍 All checks passed! Committing changes..."
|
||||
#echo "👍 All checks passed! Committing changes..."
|
||||
|
||||
@@ -5,7 +5,6 @@ import { TEXTAREA_MIN_HEIGHT } from './BaseChat';
|
||||
export interface RejectChangeData {
|
||||
explanation: string;
|
||||
shareProject: boolean;
|
||||
retry: boolean;
|
||||
}
|
||||
|
||||
interface ApproveChangeProps {
|
||||
@@ -20,14 +19,13 @@ const ApproveChange: React.FC<ApproveChangeProps> = ({ rejectFormOpen, setReject
|
||||
const [shareProject, setShareProject] = useState(false);
|
||||
|
||||
if (rejectFormOpen) {
|
||||
const performReject = (retry: boolean) => {
|
||||
const performReject = () => {
|
||||
setRejectFormOpen(false);
|
||||
|
||||
const explanation = textareaRef.current?.value ?? '';
|
||||
onReject({
|
||||
explanation,
|
||||
shareProject,
|
||||
retry,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -52,7 +50,7 @@ const ApproveChange: React.FC<ApproveChangeProps> = ({ rejectFormOpen, setReject
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
performReject(true);
|
||||
performReject();
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
@@ -79,21 +77,13 @@ const ApproveChange: React.FC<ApproveChangeProps> = ({ rejectFormOpen, setReject
|
||||
|
||||
<div className="flex items-center gap-1 w-full h-[30px] pt-2">
|
||||
<button
|
||||
onClick={() => performReject(false)}
|
||||
onClick={() => performReject()}
|
||||
className="flex-1 h-[30px] flex justify-center items-center bg-red-100 border border-red-500 text-red-500 hover:bg-red-200 hover:text-red-600 transition-colors rounded"
|
||||
aria-label="Revert changes"
|
||||
title="Revert changes"
|
||||
>
|
||||
<div className="i-ph:arrow-arc-left-bold"></div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => performReject(true)}
|
||||
className="flex-1 h-[30px] flex justify-center items-center bg-green-100 border border-green-500 text-green-500 hover:bg-green-200 hover:text-green-600 transition-colors rounded"
|
||||
aria-label="Retry changes"
|
||||
title="Retry changes"
|
||||
>
|
||||
<div className="i-ph:repeat-bold"></div>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
* @ts-nocheck
|
||||
* Preventing TS checks with files presented in the video for a better presentation.
|
||||
*/
|
||||
import type { Message } from 'ai';
|
||||
import React, { type RefCallback, useEffect, useState } from 'react';
|
||||
import { ClientOnly } from 'remix-utils/client-only';
|
||||
import { Menu } from '~/components/sidebar/Menu.client';
|
||||
import { IconButton } from '~/components/ui/IconButton';
|
||||
import { Workbench } from '~/components/workbench/Workbench.client';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { getLastMessageProjectContents, hasFileModifications, Messages } from './Messages.client';
|
||||
import { Messages } from './Messages.client';
|
||||
import { getPreviousRepositoryId, type Message } from '~/lib/persistence/useChatHistory';
|
||||
import { SendButton } from './SendButton.client';
|
||||
import * as Tooltip from '@radix-ui/react-tooltip';
|
||||
|
||||
@@ -17,7 +17,6 @@ import styles from './BaseChat.module.scss';
|
||||
import { ExportChatButton } from '~/components/chat/chatExportAndImport/ExportChatButton';
|
||||
import { ImportButtons } from '~/components/chat/chatExportAndImport/ImportButtons';
|
||||
import { ExamplePrompts } from '~/components/chat/ExamplePrompts';
|
||||
import GitCloneButton from './GitCloneButton';
|
||||
|
||||
import FilePreview from './FilePreview';
|
||||
import { SpeechRecognitionButton } from '~/components/chat/SpeechRecognition';
|
||||
@@ -51,10 +50,10 @@ interface BaseChatProps {
|
||||
setUploadedFiles?: (files: File[]) => void;
|
||||
imageDataList?: string[];
|
||||
setImageDataList?: (dataList: string[]) => void;
|
||||
onRewind?: (messageId: string, contents: string) => void;
|
||||
onRewind?: (messageId: string) => void;
|
||||
approveChangesMessageId?: string;
|
||||
onApproveChange?: (messageId: string) => void;
|
||||
onRejectChange?: (lastMessageId: string, rewindMessageId: string, contents: string, data: RejectChangeData) => void;
|
||||
onRejectChange?: (lastMessageId: string, data: RejectChangeData) => void;
|
||||
}
|
||||
|
||||
export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
@@ -226,19 +225,17 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
return false;
|
||||
}
|
||||
|
||||
const lastMessageProjectContents = getLastMessageProjectContents(messages, messages.length - 1);
|
||||
|
||||
if (!lastMessageProjectContents) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (lastMessageProjectContents.contentsMessageId != approveChangesMessageId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
|
||||
if (!hasFileModifications(lastMessage.content)) {
|
||||
if (!lastMessage.repositoryId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!getPreviousRepositoryId(messages, messages.length - 1)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (lastMessage.id != approveChangesMessageId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -469,11 +466,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
if (onRejectChange && messages) {
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
assert(lastMessage);
|
||||
|
||||
const info = getLastMessageProjectContents(messages, messages.length - 1);
|
||||
assert(info);
|
||||
|
||||
onRejectChange(lastMessage.id, info.rewindMessageId, info.contents.content, data);
|
||||
onRejectChange(lastMessage.id, data);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -484,7 +477,6 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
{!chatStarted && (
|
||||
<div className="flex justify-center gap-2">
|
||||
{ImportButtons(importChat)}
|
||||
<GitCloneButton importChat={importChat} />
|
||||
</div>
|
||||
)}
|
||||
{!chatStarted &&
|
||||
@@ -497,7 +489,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
handleSendMessage?.(event, messageInput);
|
||||
})}
|
||||
</div>
|
||||
<ClientOnly>{() => <Workbench chatStarted={chatStarted} isStreaming={isStreaming} />}</ClientOnly>
|
||||
<ClientOnly>{() => <Workbench chatStarted={chatStarted} />}</ClientOnly>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,14 +3,12 @@
|
||||
* Preventing TS checks with files presented in the video for a better presentation.
|
||||
*/
|
||||
import { useStore } from '@nanostores/react';
|
||||
import type { Message } from 'ai';
|
||||
import { useAnimate } from 'framer-motion';
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { cssTransition, toast, ToastContainer } from 'react-toastify';
|
||||
import { useMessageParser, useSnapScroll } from '~/lib/hooks';
|
||||
import { useSnapScroll } from '~/lib/hooks';
|
||||
import { description, useChatHistory } from '~/lib/persistence';
|
||||
import { chatStore } from '~/lib/stores/chat';
|
||||
import { workbenchStore } from '~/lib/stores/workbench';
|
||||
import { PROMPT_COOKIE_KEY } from '~/utils/constants';
|
||||
import { cubicEasingFn } from '~/utils/easings';
|
||||
import { renderLogger } from '~/utils/logger';
|
||||
@@ -19,7 +17,6 @@ import Cookies from 'js-cookie';
|
||||
import { debounce } from '~/utils/debounce';
|
||||
import { useSearchParams } from '@remix-run/react';
|
||||
import { createSampler } from '~/utils/sampler';
|
||||
import { saveProjectContents } from './Messages.client';
|
||||
import {
|
||||
getSimulationRecording,
|
||||
getSimulationEnhancedPrompt,
|
||||
@@ -36,16 +33,12 @@ import { getNutLoginKey, submitFeedback } from '~/lib/replay/Problems';
|
||||
import { ChatMessageTelemetry, pingTelemetry } from '~/lib/hooks/pingTelemetry';
|
||||
import type { RejectChangeData } from './ApproveChange';
|
||||
import { generateRandomId } from '~/lib/replay/ReplayProtocolClient';
|
||||
import { getMessagesRepositoryId, getPreviousRepositoryId, type Message } from '~/lib/persistence/useChatHistory';
|
||||
|
||||
const toastAnimation = cssTransition({
|
||||
enter: 'animated fadeInRight',
|
||||
exit: 'animated fadeOutRight',
|
||||
});
|
||||
let gLastProjectContents: string | undefined;
|
||||
|
||||
export function getLastProjectContents() {
|
||||
return gLastProjectContents;
|
||||
}
|
||||
|
||||
let gLastChatMessages: Message[] | undefined;
|
||||
|
||||
@@ -134,12 +127,9 @@ const processSampledMessages = createSampler(
|
||||
(options: {
|
||||
messages: Message[];
|
||||
initialMessages: Message[];
|
||||
isLoading: boolean;
|
||||
parseMessages: (messages: Message[], isLoading: boolean) => void;
|
||||
storeMessageHistory: (messages: Message[]) => Promise<void>;
|
||||
}) => {
|
||||
const { messages, initialMessages, isLoading, parseMessages, storeMessageHistory } = options;
|
||||
parseMessages(messages, isLoading);
|
||||
const { messages, initialMessages, storeMessageHistory } = options;
|
||||
|
||||
if (messages.length > initialMessages.length) {
|
||||
storeMessageHistory(messages).catch((error) => toast.error(error.message));
|
||||
@@ -160,27 +150,8 @@ let gNumAborts = 0;
|
||||
|
||||
let gActiveChatMessageTelemetry: ChatMessageTelemetry | undefined;
|
||||
|
||||
/*
|
||||
* When files are modified during a chat message we wait until the message finishes
|
||||
* before updating the simulation.
|
||||
*/
|
||||
let gUpdateSimulationAfterChatMessage = false;
|
||||
|
||||
async function clearActiveChat() {
|
||||
gActiveChatMessageTelemetry = undefined;
|
||||
|
||||
if (gUpdateSimulationAfterChatMessage) {
|
||||
await simulationRepositoryUpdated();
|
||||
gUpdateSimulationAfterChatMessage = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function onRepositoryFileWritten() {
|
||||
if (gActiveChatMessageTelemetry) {
|
||||
gUpdateSimulationAfterChatMessage = true;
|
||||
} else {
|
||||
await simulationRepositoryUpdated();
|
||||
}
|
||||
}
|
||||
|
||||
function buildMessageId(prefix: string, chatId: string) {
|
||||
@@ -200,7 +171,6 @@ export const ChatImpl = memo(
|
||||
const [uploadedFiles, setUploadedFiles] = useState<File[]>([]); // Move here
|
||||
const [imageDataList, setImageDataList] = useState<string[]>([]); // Move here
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const files = useStore(workbenchStore.files);
|
||||
const [approveChangesMessageId, setApproveChangesMessageId] = useState<string | undefined>(undefined);
|
||||
|
||||
// Input currently in the textarea.
|
||||
@@ -228,7 +198,13 @@ export const ChatImpl = memo(
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
const { parsedMessages, setParsedMessages, parseMessages } = useMessageParser();
|
||||
// Load any repository in the initial messages.
|
||||
useEffect(() => {
|
||||
const repositoryId = getMessagesRepositoryId(initialMessages);
|
||||
if (repositoryId) {
|
||||
simulationRepositoryUpdated(repositoryId);
|
||||
}
|
||||
}, [initialMessages]);
|
||||
|
||||
const TEXTAREA_MAX_HEIGHT = chatStarted ? 400 : 200;
|
||||
|
||||
@@ -240,17 +216,14 @@ export const ChatImpl = memo(
|
||||
processSampledMessages({
|
||||
messages,
|
||||
initialMessages,
|
||||
isLoading,
|
||||
parseMessages,
|
||||
storeMessageHistory,
|
||||
});
|
||||
}, [messages, isLoading, parseMessages]);
|
||||
}, [messages, isLoading]);
|
||||
|
||||
const abort = () => {
|
||||
stop();
|
||||
gNumAborts++;
|
||||
chatStore.setKey('aborted', true);
|
||||
workbenchStore.abortAllActions();
|
||||
setActiveChatId(undefined);
|
||||
|
||||
if (gActiveChatMessageTelemetry) {
|
||||
@@ -387,15 +360,6 @@ export const ChatImpl = memo(
|
||||
setUploadedFiles([]);
|
||||
setImageDataList([]);
|
||||
|
||||
/**
|
||||
* @note (delm) Usually saving files shouldn't take long but it may take longer if there
|
||||
* many unsaved files. In that case we need to block user input and show an indicator
|
||||
* of some kind so the user is aware that something is happening. But I consider the
|
||||
* happy case to be no unsaved files and I would expect users to save their changes
|
||||
* before they send another message.
|
||||
*/
|
||||
await workbenchStore.saveAllFiles();
|
||||
|
||||
let simulation = false;
|
||||
|
||||
try {
|
||||
@@ -455,8 +419,6 @@ export const ChatImpl = memo(
|
||||
}
|
||||
}
|
||||
|
||||
const fileModifications = workbenchStore.getFileModifcations();
|
||||
|
||||
chatStore.setKey('aborted', false);
|
||||
|
||||
runAnimation();
|
||||
@@ -465,11 +427,10 @@ export const ChatImpl = memo(
|
||||
|
||||
const responseMessageId = buildMessageId('response', chatId);
|
||||
let responseMessageContent = '';
|
||||
let responseRepositoryId: string | undefined;
|
||||
let hasResponseMessage = false;
|
||||
|
||||
const addResponseContent = (content: string) => {
|
||||
responseMessageContent += content;
|
||||
|
||||
const updateResponseMessage = () => {
|
||||
if (gNumAborts != numAbortsAtStart) {
|
||||
return;
|
||||
}
|
||||
@@ -484,13 +445,21 @@ export const ChatImpl = memo(
|
||||
id: responseMessageId,
|
||||
role: 'assistant',
|
||||
content: responseMessageContent,
|
||||
repositoryId: responseRepositoryId,
|
||||
});
|
||||
setMessages(newMessages);
|
||||
hasResponseMessage = true;
|
||||
};
|
||||
|
||||
const addResponseContent = (content: string) => {
|
||||
responseMessageContent += content;
|
||||
updateResponseMessage();
|
||||
};
|
||||
|
||||
try {
|
||||
await sendDeveloperChatMessage(newMessages, files, addResponseContent);
|
||||
const repositoryId = getMessagesRepositoryId(newMessages);
|
||||
responseRepositoryId = await sendDeveloperChatMessage(newMessages, repositoryId, addResponseContent);
|
||||
updateResponseMessage();
|
||||
} catch (e) {
|
||||
console.error('Error sending message', e);
|
||||
addResponseContent('Error sending message.');
|
||||
@@ -505,44 +474,38 @@ export const ChatImpl = memo(
|
||||
|
||||
setActiveChatId(undefined);
|
||||
|
||||
if (fileModifications !== undefined) {
|
||||
/**
|
||||
* After sending a new message we reset all modifications since the model
|
||||
* should now be aware of all the changes.
|
||||
*/
|
||||
workbenchStore.resetAllFileModifications();
|
||||
}
|
||||
|
||||
setInput('');
|
||||
Cookies.remove(PROMPT_COOKIE_KEY);
|
||||
|
||||
textareaRef.current?.blur();
|
||||
|
||||
// The project contents are associated with the response message.
|
||||
const { contentBase64 } = await workbenchStore.generateZipBase64();
|
||||
saveProjectContents(responseMessageId, { content: contentBase64 });
|
||||
gLastProjectContents = contentBase64;
|
||||
setApproveChangesMessageId(responseMessageId);
|
||||
if (responseRepositoryId) {
|
||||
simulationRepositoryUpdated(responseRepositoryId);
|
||||
setApproveChangesMessageId(responseMessageId);
|
||||
}
|
||||
};
|
||||
|
||||
const onRewind = async (messageId: string, contents: string) => {
|
||||
console.log('Rewinding', messageId, contents);
|
||||
|
||||
await workbenchStore.restoreProjectContentsBase64(messageId, contents);
|
||||
// Rewind far enough to erase the specified message.
|
||||
const onRewind = async (messageId: string) => {
|
||||
console.log('Rewinding', messageId);
|
||||
|
||||
const messageIndex = messages.findIndex((message) => message.id === messageId);
|
||||
|
||||
if (messageIndex >= 0) {
|
||||
const newParsedMessages = { ...parsedMessages };
|
||||
|
||||
for (let i = messageIndex + 1; i < messages.length; i++) {
|
||||
delete newParsedMessages[i];
|
||||
}
|
||||
setParsedMessages(newParsedMessages);
|
||||
setMessages(messages.slice(0, messageIndex + 1));
|
||||
if (messageIndex < 0) {
|
||||
toast.error('Rewind message not found');
|
||||
return;
|
||||
}
|
||||
|
||||
await pingTelemetry('RewindChat', {
|
||||
const previousRepositoryId = getPreviousRepositoryId(messages, messageIndex);
|
||||
if (!previousRepositoryId) {
|
||||
toast.error('No repository ID found for rewind');
|
||||
return;
|
||||
}
|
||||
|
||||
setMessages(messages.slice(0, messageIndex));
|
||||
simulationRepositoryUpdated(previousRepositoryId);
|
||||
|
||||
pingTelemetry('RewindChat', {
|
||||
numMessages: messages.length,
|
||||
rewindIndex: messageIndex,
|
||||
loginKey: getNutLoginKey(),
|
||||
@@ -578,7 +541,7 @@ export const ChatImpl = memo(
|
||||
|
||||
await flashScreen();
|
||||
|
||||
await pingTelemetry('ApproveChange', {
|
||||
pingTelemetry('ApproveChange', {
|
||||
numMessages: messages.length,
|
||||
loginKey: getNutLoginKey(),
|
||||
});
|
||||
@@ -586,8 +549,6 @@ export const ChatImpl = memo(
|
||||
|
||||
const onRejectChange = async (
|
||||
messageId: string,
|
||||
rewindMessageId: string,
|
||||
projectContents: string,
|
||||
data: RejectChangeData,
|
||||
) => {
|
||||
console.log('RejectChange', messageId, data);
|
||||
@@ -595,30 +556,23 @@ export const ChatImpl = memo(
|
||||
setApproveChangesMessageId(undefined);
|
||||
|
||||
const message = messages.find((message) => message.id === messageId);
|
||||
const messageContents = message?.content ?? '';
|
||||
|
||||
await onRewind(rewindMessageId, projectContents);
|
||||
await onRewind(messageId);
|
||||
|
||||
let shareProjectSuccess = false;
|
||||
|
||||
if (data.shareProject) {
|
||||
const feedbackData: any = {
|
||||
explanation: data.explanation,
|
||||
retry: data.retry,
|
||||
chatMessages: messages,
|
||||
repositoryContents: getLastProjectContents(),
|
||||
repositoryId: message?.repositoryId,
|
||||
loginKey: getNutLoginKey(),
|
||||
};
|
||||
|
||||
shareProjectSuccess = await submitFeedback(feedbackData);
|
||||
}
|
||||
|
||||
if (data.retry) {
|
||||
sendMessage(messageContents);
|
||||
}
|
||||
|
||||
await pingTelemetry('RejectChange', {
|
||||
retry: data.retry,
|
||||
pingTelemetry('RejectChange', {
|
||||
shareProject: data.shareProject,
|
||||
shareProjectSuccess,
|
||||
numMessages: messages.length,
|
||||
@@ -648,18 +602,7 @@ export const ChatImpl = memo(
|
||||
|
||||
const [messageRef, scrollRef] = useSnapScroll();
|
||||
|
||||
const chatMessages = messages.map((message, i) => {
|
||||
if (message.role === 'user') {
|
||||
return message;
|
||||
}
|
||||
|
||||
return {
|
||||
...message,
|
||||
content: parsedMessages[i] || '',
|
||||
};
|
||||
});
|
||||
|
||||
gLastChatMessages = chatMessages;
|
||||
gLastChatMessages = messages;
|
||||
|
||||
return (
|
||||
<BaseChat
|
||||
@@ -680,7 +623,7 @@ export const ChatImpl = memo(
|
||||
description={description}
|
||||
importChat={importChat}
|
||||
exportChat={exportChat}
|
||||
messages={chatMessages}
|
||||
messages={messages}
|
||||
uploadedFiles={uploadedFiles}
|
||||
setUploadedFiles={setUploadedFiles}
|
||||
imageDataList={imageDataList}
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
import ignore from 'ignore';
|
||||
import { useGit } from '~/lib/hooks/useGit';
|
||||
import type { Message } from 'ai';
|
||||
import { generateId } from '~/utils/fileUtils';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { LoadingOverlay } from '~/components/ui/LoadingOverlay';
|
||||
|
||||
const IGNORE_PATTERNS = [
|
||||
'node_modules/**',
|
||||
'.git/**',
|
||||
'.github/**',
|
||||
'.vscode/**',
|
||||
'**/*.jpg',
|
||||
'**/*.jpeg',
|
||||
'**/*.png',
|
||||
'dist/**',
|
||||
'build/**',
|
||||
'.next/**',
|
||||
'coverage/**',
|
||||
'.cache/**',
|
||||
'.vscode/**',
|
||||
'.idea/**',
|
||||
'**/*.log',
|
||||
'**/.DS_Store',
|
||||
'**/npm-debug.log*',
|
||||
'**/yarn-debug.log*',
|
||||
'**/yarn-error.log*',
|
||||
'**/*lock.json',
|
||||
'**/*lock.yaml',
|
||||
];
|
||||
|
||||
const ig = ignore().add(IGNORE_PATTERNS);
|
||||
|
||||
interface GitCloneButtonProps {
|
||||
className?: string;
|
||||
importChat?: (description: string, messages: Message[]) => Promise<void>;
|
||||
}
|
||||
|
||||
export default function GitCloneButton({ importChat }: GitCloneButtonProps) {
|
||||
const { ready, gitClone } = useGit();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const onClick = async (_e: any) => {
|
||||
if (!ready) {
|
||||
return;
|
||||
}
|
||||
|
||||
const repoUrl = prompt('Enter the Git url');
|
||||
|
||||
if (repoUrl) {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const { workdir, data } = await gitClone(repoUrl);
|
||||
|
||||
if (importChat) {
|
||||
const filePaths = Object.keys(data).filter((filePath) => !ig.ignores(filePath));
|
||||
console.log(filePaths);
|
||||
|
||||
const fileContents = filePaths
|
||||
.map((filePath) => {
|
||||
const file = data[filePath];
|
||||
return {
|
||||
path: filePath,
|
||||
content: file?.content,
|
||||
};
|
||||
})
|
||||
.filter((f) => f.content);
|
||||
|
||||
const filesMessage: Message = {
|
||||
role: 'assistant',
|
||||
content: `Cloning the repo ${repoUrl} into ${workdir}
|
||||
<boltArtifact id="imported-files" title="Git Cloned Files" type="bundled">
|
||||
${fileContents
|
||||
.map(
|
||||
(file) =>
|
||||
`<boltAction type="file" filePath="${file.path}">
|
||||
${file.content}
|
||||
</boltAction>`,
|
||||
)
|
||||
.join('\n')}
|
||||
</boltArtifact>`,
|
||||
id: generateId(),
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
const messages = [filesMessage];
|
||||
|
||||
await importChat(`Git Project:${repoUrl.split('/').slice(-1)[0]}`, messages);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error during import:', error);
|
||||
toast.error('Failed to import repository');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={onClick}
|
||||
title="Clone a Git Repo"
|
||||
className="px-4 py-2 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background text-bolt-elements-textPrimary hover:bg-bolt-elements-background-depth-3 transition-all flex items-center gap-2"
|
||||
>
|
||||
<span className="i-ph:git-branch" />
|
||||
Clone a Git Repo
|
||||
</button>
|
||||
{loading && <LoadingOverlay message="Please wait while we clone the repository..." />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,8 +2,9 @@ import React, { useState } from 'react';
|
||||
import type { Message } from 'ai';
|
||||
import { toast } from 'react-toastify';
|
||||
import { MAX_FILES, isBinaryFile, shouldIncludeFile } from '~/utils/fileUtils';
|
||||
import { createChatFromFolder, getFileArtifacts } from '~/utils/folderImport';
|
||||
import { createChatFromFolder, getFileRepositoryContents } from '~/utils/folderImport';
|
||||
import { logStore } from '~/lib/stores/logs'; // Assuming logStore is imported from this location
|
||||
import { createRepositoryImported } from '~/lib/replay/Repository';
|
||||
|
||||
interface ImportFolderButtonProps {
|
||||
className?: string;
|
||||
@@ -78,8 +79,10 @@ export const ImportFolderButton: React.FC<ImportFolderButtonProps> = ({ classNam
|
||||
toast.info(`Skipping ${binaryFilePaths.length} binary files`);
|
||||
}
|
||||
|
||||
const textFileArtifacts = await getFileArtifacts(textFiles);
|
||||
const messages = await createChatFromFolder(textFileArtifacts, binaryFilePaths, folderName);
|
||||
const repositoryContents = await getFileRepositoryContents(textFiles);
|
||||
const repositoryId = await createRepositoryImported("ImportFolder", repositoryContents);
|
||||
|
||||
const messages = createChatFromFolder(folderName, repositoryId);
|
||||
|
||||
if (importChat) {
|
||||
await importChat(folderName, [...messages]);
|
||||
|
||||
@@ -2,10 +2,11 @@ import React, { useState } from 'react';
|
||||
import type { Message } from 'ai';
|
||||
import { toast } from 'react-toastify';
|
||||
import { createChatFromFolder } from '~/utils/folderImport';
|
||||
import { logStore } from '~/lib/stores/logs'; // Assuming logStore is imported from this location
|
||||
import { logStore } from '~/lib/stores/logs';
|
||||
import { assert } from '~/lib/replay/ReplayProtocolClient';
|
||||
import type { BoltProblem } from '~/lib/replay/Problems';
|
||||
import { getProblem, extractFileArtifactsFromRepositoryContents } from '~/lib/replay/Problems';
|
||||
import { getProblem } from '~/lib/replay/Problems';
|
||||
import { createRepositoryImported } from '~/lib/replay/Repository';
|
||||
|
||||
interface LoadProblemButtonProps {
|
||||
className?: string;
|
||||
@@ -71,15 +72,13 @@ export async function loadProblem(
|
||||
|
||||
const { repositoryContents, title: problemTitle } = problem;
|
||||
|
||||
const fileArtifacts = await extractFileArtifactsFromRepositoryContents(repositoryContents);
|
||||
|
||||
try {
|
||||
const messages = await createChatFromFolder(fileArtifacts, [], 'problem');
|
||||
const repositoryId = await createRepositoryImported(`ImportProblem:${problemId}`, repositoryContents);
|
||||
const messages = createChatFromFolder('problem', repositoryId);
|
||||
await importChat(`Problem: ${problemTitle}`, [...messages]);
|
||||
|
||||
logStore.logSystem('Problem loaded successfully', {
|
||||
problemId,
|
||||
textFileCount: fileArtifacts.length,
|
||||
});
|
||||
toast.success('Problem loaded successfully');
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,59 +1,16 @@
|
||||
import type { Message } from 'ai';
|
||||
import React, { Suspense } from 'react';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { AssistantMessage } from './AssistantMessage';
|
||||
import { UserMessage } from './UserMessage';
|
||||
import WithTooltip from '~/components/ui/Tooltip';
|
||||
import { assert } from '~/lib/replay/ReplayProtocolClient';
|
||||
import { getPreviousRepositoryId, type Message } from '~/lib/persistence/useChatHistory';
|
||||
|
||||
interface MessagesProps {
|
||||
id?: string;
|
||||
className?: string;
|
||||
isStreaming?: boolean;
|
||||
messages?: Message[];
|
||||
onRewind?: (messageId: string, contents: string) => void;
|
||||
}
|
||||
|
||||
interface ProjectContents {
|
||||
content: string; // base64 encoded
|
||||
}
|
||||
|
||||
const gProjectContentsByMessageId = new Map<string, ProjectContents>();
|
||||
|
||||
export function saveProjectContents(messageId: string, contents: ProjectContents) {
|
||||
gProjectContentsByMessageId.set(messageId, contents);
|
||||
}
|
||||
|
||||
export function getLastMessageProjectContents(messages: Message[], index: number) {
|
||||
/*
|
||||
* The message index is for the model response, and the project
|
||||
* contents will be associated with the last message present when
|
||||
* the user prompt was sent to the model. This could be either two
|
||||
* or three messages back, depending on whether a bug explanation was added.
|
||||
*/
|
||||
const beforeUserMessage = messages[index - 2];
|
||||
const contents = gProjectContentsByMessageId.get(beforeUserMessage?.id);
|
||||
|
||||
if (!contents) {
|
||||
const priorMessage = messages[index - 3];
|
||||
const priorContents = gProjectContentsByMessageId.get(priorMessage?.id);
|
||||
|
||||
if (!priorContents) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/*
|
||||
* We still rewind to just before the user message to retain any
|
||||
* explanation from the Nut API.
|
||||
*/
|
||||
return { rewindMessageId: beforeUserMessage.id, contentsMessageId: priorMessage.id, contents: priorContents };
|
||||
}
|
||||
|
||||
return { rewindMessageId: beforeUserMessage.id, contentsMessageId: beforeUserMessage.id, contents };
|
||||
}
|
||||
|
||||
export function hasFileModifications(content: string) {
|
||||
return content.includes('__boltArtifact__');
|
||||
onRewind?: (messageId: string) => void;
|
||||
}
|
||||
|
||||
export const Messages = React.forwardRef<HTMLDivElement, MessagesProps>((props: MessagesProps, ref) => {
|
||||
@@ -63,7 +20,8 @@ export const Messages = React.forwardRef<HTMLDivElement, MessagesProps>((props:
|
||||
<div id={id} ref={ref} className={props.className}>
|
||||
{messages.length > 0
|
||||
? messages.map((message, index) => {
|
||||
const { role, content, id: messageId } = message;
|
||||
const { role, content, id: messageId, repositoryId } = message;
|
||||
const previousRepositoryId = getPreviousRepositoryId(messages, index);
|
||||
const isUserMessage = role === 'user';
|
||||
const isFirst = index === 0;
|
||||
const isLast = index === messages.length - 1;
|
||||
@@ -95,18 +53,12 @@ export const Messages = React.forwardRef<HTMLDivElement, MessagesProps>((props:
|
||||
<AssistantMessage content={content} annotations={message.annotations} />
|
||||
)}
|
||||
</div>
|
||||
{!isUserMessage &&
|
||||
messageId &&
|
||||
onRewind &&
|
||||
getLastMessageProjectContents(messages, index) &&
|
||||
hasFileModifications(content) && (
|
||||
{previousRepositoryId && repositoryId && onRewind && (
|
||||
<div className="flex gap-2 flex-col lg:flex-row">
|
||||
<WithTooltip tooltip="Undo changes in this message">
|
||||
<button
|
||||
onClick={() => {
|
||||
const info = getLastMessageProjectContents(messages, index);
|
||||
assert(info);
|
||||
onRewind(info.rewindMessageId, info.contents.content);
|
||||
onRewind(messageId);
|
||||
}}
|
||||
key="i-ph:arrow-u-up-left"
|
||||
className={classNames(
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
export function BinaryContent() {
|
||||
return (
|
||||
<div className="flex items-center justify-center absolute inset-0 z-10 text-sm bg-tk-elements-app-backgroundColor text-tk-elements-app-textColor">
|
||||
File format cannot be displayed.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,463 +0,0 @@
|
||||
import { acceptCompletion, autocompletion, closeBrackets } from '@codemirror/autocomplete';
|
||||
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
|
||||
import { bracketMatching, foldGutter, indentOnInput, indentUnit } from '@codemirror/language';
|
||||
import { searchKeymap } from '@codemirror/search';
|
||||
import { Compartment, EditorSelection, EditorState, StateEffect, StateField, type Extension } from '@codemirror/state';
|
||||
import {
|
||||
drawSelection,
|
||||
dropCursor,
|
||||
EditorView,
|
||||
highlightActiveLine,
|
||||
highlightActiveLineGutter,
|
||||
keymap,
|
||||
lineNumbers,
|
||||
scrollPastEnd,
|
||||
showTooltip,
|
||||
tooltips,
|
||||
type Tooltip,
|
||||
} from '@codemirror/view';
|
||||
import { memo, useEffect, useRef, useState, type MutableRefObject } from 'react';
|
||||
import type { Theme } from '~/types/theme';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { debounce } from '~/utils/debounce';
|
||||
import { createScopedLogger, renderLogger } from '~/utils/logger';
|
||||
import { BinaryContent } from './BinaryContent';
|
||||
import { getTheme, reconfigureTheme } from './cm-theme';
|
||||
import { indentKeyBinding } from './indent';
|
||||
import { getLanguage } from './languages';
|
||||
|
||||
const logger = createScopedLogger('CodeMirrorEditor');
|
||||
|
||||
export interface EditorDocument {
|
||||
value: string;
|
||||
isBinary: boolean;
|
||||
filePath: string;
|
||||
scroll?: ScrollPosition;
|
||||
}
|
||||
|
||||
export interface EditorSettings {
|
||||
fontSize?: string;
|
||||
gutterFontSize?: string;
|
||||
tabSize?: number;
|
||||
}
|
||||
|
||||
type TextEditorDocument = EditorDocument & {
|
||||
value: string;
|
||||
};
|
||||
|
||||
export interface ScrollPosition {
|
||||
top: number;
|
||||
left: number;
|
||||
}
|
||||
|
||||
export interface EditorUpdate {
|
||||
selection: EditorSelection;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export type OnChangeCallback = (update: EditorUpdate) => void;
|
||||
export type OnScrollCallback = (position: ScrollPosition) => void;
|
||||
export type OnSaveCallback = () => void;
|
||||
|
||||
interface Props {
|
||||
theme: Theme;
|
||||
id?: unknown;
|
||||
doc?: EditorDocument;
|
||||
editable?: boolean;
|
||||
debounceChange?: number;
|
||||
debounceScroll?: number;
|
||||
autoFocusOnDocumentChange?: boolean;
|
||||
onChange?: OnChangeCallback;
|
||||
onScroll?: OnScrollCallback;
|
||||
onSave?: OnSaveCallback;
|
||||
className?: string;
|
||||
settings?: EditorSettings;
|
||||
}
|
||||
|
||||
type EditorStates = Map<string, EditorState>;
|
||||
|
||||
const readOnlyTooltipStateEffect = StateEffect.define<boolean>();
|
||||
|
||||
const editableTooltipField = StateField.define<readonly Tooltip[]>({
|
||||
create: () => [],
|
||||
update(_tooltips, transaction) {
|
||||
if (!transaction.state.readOnly) {
|
||||
return [];
|
||||
}
|
||||
|
||||
for (const effect of transaction.effects) {
|
||||
if (effect.is(readOnlyTooltipStateEffect) && effect.value) {
|
||||
return getReadOnlyTooltip(transaction.state);
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
},
|
||||
provide: (field) => {
|
||||
return showTooltip.computeN([field], (state) => state.field(field));
|
||||
},
|
||||
});
|
||||
|
||||
const editableStateEffect = StateEffect.define<boolean>();
|
||||
|
||||
const editableStateField = StateField.define<boolean>({
|
||||
create() {
|
||||
return true;
|
||||
},
|
||||
update(value, transaction) {
|
||||
for (const effect of transaction.effects) {
|
||||
if (effect.is(editableStateEffect)) {
|
||||
return effect.value;
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
},
|
||||
});
|
||||
|
||||
export const CodeMirrorEditor = memo(
|
||||
({
|
||||
id,
|
||||
doc,
|
||||
debounceScroll = 100,
|
||||
debounceChange = 150,
|
||||
autoFocusOnDocumentChange = false,
|
||||
editable = true,
|
||||
onScroll,
|
||||
onChange,
|
||||
onSave,
|
||||
theme,
|
||||
settings,
|
||||
className = '',
|
||||
}: Props) => {
|
||||
renderLogger.trace('CodeMirrorEditor');
|
||||
|
||||
const [languageCompartment] = useState(new Compartment());
|
||||
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const viewRef = useRef<EditorView>();
|
||||
const themeRef = useRef<Theme>();
|
||||
const docRef = useRef<EditorDocument>();
|
||||
const editorStatesRef = useRef<EditorStates>();
|
||||
const onScrollRef = useRef(onScroll);
|
||||
const onChangeRef = useRef(onChange);
|
||||
const onSaveRef = useRef(onSave);
|
||||
|
||||
/**
|
||||
* This effect is used to avoid side effects directly in the render function
|
||||
* and instead the refs are updated after each render.
|
||||
*/
|
||||
useEffect(() => {
|
||||
onScrollRef.current = onScroll;
|
||||
onChangeRef.current = onChange;
|
||||
onSaveRef.current = onSave;
|
||||
docRef.current = doc;
|
||||
themeRef.current = theme;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const onUpdate = debounce((update: EditorUpdate) => {
|
||||
onChangeRef.current?.(update);
|
||||
}, debounceChange);
|
||||
|
||||
const view = new EditorView({
|
||||
parent: containerRef.current!,
|
||||
dispatchTransactions(transactions) {
|
||||
const previousSelection = view.state.selection;
|
||||
|
||||
view.update(transactions);
|
||||
|
||||
const newSelection = view.state.selection;
|
||||
|
||||
const selectionChanged =
|
||||
newSelection !== previousSelection &&
|
||||
(newSelection === undefined || previousSelection === undefined || !newSelection.eq(previousSelection));
|
||||
|
||||
if (docRef.current && (transactions.some((transaction) => transaction.docChanged) || selectionChanged)) {
|
||||
onUpdate({
|
||||
selection: view.state.selection,
|
||||
content: view.state.doc.toString(),
|
||||
});
|
||||
|
||||
editorStatesRef.current!.set(docRef.current.filePath, view.state);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
viewRef.current = view;
|
||||
|
||||
return () => {
|
||||
viewRef.current?.destroy();
|
||||
viewRef.current = undefined;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!viewRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
viewRef.current.dispatch({
|
||||
effects: [reconfigureTheme(theme)],
|
||||
});
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
editorStatesRef.current = new Map<string, EditorState>();
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
const editorStates = editorStatesRef.current!;
|
||||
const view = viewRef.current!;
|
||||
const theme = themeRef.current!;
|
||||
|
||||
if (!doc) {
|
||||
const state = newEditorState('', theme, settings, onScrollRef, debounceScroll, onSaveRef, [
|
||||
languageCompartment.of([]),
|
||||
]);
|
||||
|
||||
view.setState(state);
|
||||
|
||||
setNoDocument(view);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (doc.isBinary) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (doc.filePath === '') {
|
||||
logger.warn('File path should not be empty');
|
||||
}
|
||||
|
||||
let state = editorStates.get(doc.filePath);
|
||||
|
||||
if (!state) {
|
||||
state = newEditorState(doc.value, theme, settings, onScrollRef, debounceScroll, onSaveRef, [
|
||||
languageCompartment.of([]),
|
||||
]);
|
||||
|
||||
editorStates.set(doc.filePath, state);
|
||||
}
|
||||
|
||||
view.setState(state);
|
||||
|
||||
setEditorDocument(
|
||||
view,
|
||||
theme,
|
||||
editable,
|
||||
languageCompartment,
|
||||
autoFocusOnDocumentChange,
|
||||
doc as TextEditorDocument,
|
||||
);
|
||||
}, [doc?.value, editable, doc?.filePath, autoFocusOnDocumentChange]);
|
||||
|
||||
return (
|
||||
<div className={classNames('relative h-full', className)}>
|
||||
{doc?.isBinary && <BinaryContent />}
|
||||
<div className="h-full overflow-hidden" ref={containerRef} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default CodeMirrorEditor;
|
||||
|
||||
CodeMirrorEditor.displayName = 'CodeMirrorEditor';
|
||||
|
||||
function newEditorState(
|
||||
content: string,
|
||||
theme: Theme,
|
||||
settings: EditorSettings | undefined,
|
||||
onScrollRef: MutableRefObject<OnScrollCallback | undefined>,
|
||||
debounceScroll: number,
|
||||
onFileSaveRef: MutableRefObject<OnSaveCallback | undefined>,
|
||||
extensions: Extension[],
|
||||
) {
|
||||
return EditorState.create({
|
||||
doc: content,
|
||||
extensions: [
|
||||
EditorView.domEventHandlers({
|
||||
scroll: debounce((event, view) => {
|
||||
if (event.target !== view.scrollDOM) {
|
||||
return;
|
||||
}
|
||||
|
||||
onScrollRef.current?.({ left: view.scrollDOM.scrollLeft, top: view.scrollDOM.scrollTop });
|
||||
}, debounceScroll),
|
||||
keydown: (event, view) => {
|
||||
if (view.state.readOnly) {
|
||||
view.dispatch({
|
||||
effects: [readOnlyTooltipStateEffect.of(event.key !== 'Escape')],
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
getTheme(theme, settings),
|
||||
history(),
|
||||
keymap.of([
|
||||
...defaultKeymap,
|
||||
...historyKeymap,
|
||||
...searchKeymap,
|
||||
{ key: 'Tab', run: acceptCompletion },
|
||||
{
|
||||
key: 'Mod-s',
|
||||
preventDefault: true,
|
||||
run: () => {
|
||||
onFileSaveRef.current?.();
|
||||
return true;
|
||||
},
|
||||
},
|
||||
indentKeyBinding,
|
||||
]),
|
||||
indentUnit.of('\t'),
|
||||
autocompletion({
|
||||
closeOnBlur: false,
|
||||
}),
|
||||
tooltips({
|
||||
position: 'absolute',
|
||||
parent: document.body,
|
||||
tooltipSpace: (view) => {
|
||||
const rect = view.dom.getBoundingClientRect();
|
||||
|
||||
return {
|
||||
top: rect.top - 50,
|
||||
left: rect.left,
|
||||
bottom: rect.bottom,
|
||||
right: rect.right + 10,
|
||||
};
|
||||
},
|
||||
}),
|
||||
closeBrackets(),
|
||||
lineNumbers(),
|
||||
scrollPastEnd(),
|
||||
dropCursor(),
|
||||
drawSelection(),
|
||||
bracketMatching(),
|
||||
EditorState.tabSize.of(settings?.tabSize ?? 2),
|
||||
indentOnInput(),
|
||||
editableTooltipField,
|
||||
editableStateField,
|
||||
EditorState.readOnly.from(editableStateField, (editable) => !editable),
|
||||
highlightActiveLineGutter(),
|
||||
highlightActiveLine(),
|
||||
foldGutter({
|
||||
markerDOM: (open) => {
|
||||
const icon = document.createElement('div');
|
||||
|
||||
icon.className = `fold-icon ${open ? 'i-ph-caret-down-bold' : 'i-ph-caret-right-bold'}`;
|
||||
|
||||
return icon;
|
||||
},
|
||||
}),
|
||||
...extensions,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function setNoDocument(view: EditorView) {
|
||||
view.dispatch({
|
||||
selection: { anchor: 0 },
|
||||
changes: {
|
||||
from: 0,
|
||||
to: view.state.doc.length,
|
||||
insert: '',
|
||||
},
|
||||
});
|
||||
|
||||
view.scrollDOM.scrollTo(0, 0);
|
||||
}
|
||||
|
||||
function setEditorDocument(
|
||||
view: EditorView,
|
||||
theme: Theme,
|
||||
editable: boolean,
|
||||
languageCompartment: Compartment,
|
||||
autoFocus: boolean,
|
||||
doc: TextEditorDocument,
|
||||
) {
|
||||
const content = doc.value;
|
||||
|
||||
if (content !== view.state.doc.toString()) {
|
||||
view.dispatch({
|
||||
selection: { anchor: 0 },
|
||||
changes: {
|
||||
from: 0,
|
||||
to: view.state.doc.length,
|
||||
insert: content,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
view.dispatch({
|
||||
effects: [editableStateEffect.of(editable && !doc.isBinary)],
|
||||
});
|
||||
|
||||
getLanguage(doc.filePath).then((languageSupport) => {
|
||||
if (!languageSupport) {
|
||||
return;
|
||||
}
|
||||
|
||||
view.dispatch({
|
||||
effects: [languageCompartment.reconfigure([languageSupport]), reconfigureTheme(theme)],
|
||||
});
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const currentLeft = view.scrollDOM.scrollLeft;
|
||||
const currentTop = view.scrollDOM.scrollTop;
|
||||
const newLeft = doc.scroll?.left ?? 0;
|
||||
const newTop = doc.scroll?.top ?? 0;
|
||||
|
||||
const needsScrolling = currentLeft !== newLeft || currentTop !== newTop;
|
||||
|
||||
if (autoFocus && editable) {
|
||||
if (needsScrolling) {
|
||||
// we have to wait until the scroll position was changed before we can set the focus
|
||||
view.scrollDOM.addEventListener(
|
||||
'scroll',
|
||||
() => {
|
||||
view.focus();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
} else {
|
||||
// if the scroll position is still the same we can focus immediately
|
||||
view.focus();
|
||||
}
|
||||
}
|
||||
|
||||
view.scrollDOM.scrollTo(newLeft, newTop);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getReadOnlyTooltip(state: EditorState) {
|
||||
if (!state.readOnly) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return state.selection.ranges
|
||||
.filter((range) => {
|
||||
return range.empty;
|
||||
})
|
||||
.map((range) => {
|
||||
return {
|
||||
pos: range.head,
|
||||
above: true,
|
||||
strictSide: true,
|
||||
arrow: true,
|
||||
create: () => {
|
||||
const divElement = document.createElement('div');
|
||||
divElement.className = 'cm-readonly-tooltip';
|
||||
divElement.textContent = 'Cannot edit file while AI response is being generated';
|
||||
|
||||
return { dom: divElement };
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
import { Compartment, type Extension } from '@codemirror/state';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import { vscodeDark, vscodeLight } from '@uiw/codemirror-theme-vscode';
|
||||
import type { Theme } from '~/types/theme.js';
|
||||
import type { EditorSettings } from './CodeMirrorEditor.js';
|
||||
|
||||
export const darkTheme = EditorView.theme({}, { dark: true });
|
||||
export const themeSelection = new Compartment();
|
||||
|
||||
export function getTheme(theme: Theme, settings: EditorSettings = {}): Extension {
|
||||
return [
|
||||
getEditorTheme(settings),
|
||||
theme === 'dark' ? themeSelection.of([getDarkTheme()]) : themeSelection.of([getLightTheme()]),
|
||||
];
|
||||
}
|
||||
|
||||
export function reconfigureTheme(theme: Theme) {
|
||||
return themeSelection.reconfigure(theme === 'dark' ? getDarkTheme() : getLightTheme());
|
||||
}
|
||||
|
||||
function getEditorTheme(settings: EditorSettings) {
|
||||
return EditorView.theme({
|
||||
'&': {
|
||||
fontSize: settings.fontSize ?? '12px',
|
||||
},
|
||||
'&.cm-editor': {
|
||||
height: '100%',
|
||||
background: 'var(--cm-backgroundColor)',
|
||||
color: 'var(--cm-textColor)',
|
||||
},
|
||||
'.cm-cursor': {
|
||||
borderLeft: 'var(--cm-cursor-width) solid var(--cm-cursor-backgroundColor)',
|
||||
},
|
||||
'.cm-scroller': {
|
||||
lineHeight: '1.5',
|
||||
'&:focus-visible': {
|
||||
outline: 'none',
|
||||
},
|
||||
},
|
||||
'.cm-line': {
|
||||
padding: '0 0 0 4px',
|
||||
},
|
||||
'&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground': {
|
||||
backgroundColor: 'var(--cm-selection-backgroundColorFocused) !important',
|
||||
opacity: 'var(--cm-selection-backgroundOpacityFocused, 0.3)',
|
||||
},
|
||||
'&:not(.cm-focused) > .cm-scroller > .cm-selectionLayer .cm-selectionBackground': {
|
||||
backgroundColor: 'var(--cm-selection-backgroundColorBlured)',
|
||||
opacity: 'var(--cm-selection-backgroundOpacityBlured, 0.3)',
|
||||
},
|
||||
'&.cm-focused > .cm-scroller .cm-matchingBracket': {
|
||||
backgroundColor: 'var(--cm-matching-bracket)',
|
||||
},
|
||||
'.cm-activeLine': {
|
||||
background: 'var(--cm-activeLineBackgroundColor)',
|
||||
},
|
||||
'.cm-gutters': {
|
||||
background: 'var(--cm-gutter-backgroundColor)',
|
||||
borderRight: 0,
|
||||
color: 'var(--cm-gutter-textColor)',
|
||||
},
|
||||
'.cm-gutter': {
|
||||
'&.cm-lineNumbers': {
|
||||
fontFamily: 'Roboto Mono, monospace',
|
||||
fontSize: settings.gutterFontSize ?? settings.fontSize ?? '12px',
|
||||
minWidth: '40px',
|
||||
},
|
||||
'& .cm-activeLineGutter': {
|
||||
background: 'transparent',
|
||||
color: 'var(--cm-gutter-activeLineTextColor)',
|
||||
},
|
||||
'&.cm-foldGutter .cm-gutterElement > .fold-icon': {
|
||||
cursor: 'pointer',
|
||||
color: 'var(--cm-foldGutter-textColor)',
|
||||
transform: 'translateY(2px)',
|
||||
'&:hover': {
|
||||
color: 'var(--cm-foldGutter-textColorHover)',
|
||||
},
|
||||
},
|
||||
},
|
||||
'.cm-foldGutter .cm-gutterElement': {
|
||||
padding: '0 4px',
|
||||
},
|
||||
'.cm-tooltip-autocomplete > ul > li': {
|
||||
minHeight: '18px',
|
||||
},
|
||||
'.cm-panel.cm-search label': {
|
||||
marginLeft: '2px',
|
||||
fontSize: '12px',
|
||||
},
|
||||
'.cm-panel.cm-search .cm-button': {
|
||||
fontSize: '12px',
|
||||
},
|
||||
'.cm-panel.cm-search .cm-textfield': {
|
||||
fontSize: '12px',
|
||||
},
|
||||
'.cm-panel.cm-search input[type=checkbox]': {
|
||||
position: 'relative',
|
||||
transform: 'translateY(2px)',
|
||||
marginRight: '4px',
|
||||
},
|
||||
'.cm-panels': {
|
||||
borderColor: 'var(--cm-panels-borderColor)',
|
||||
},
|
||||
'.cm-panels-bottom': {
|
||||
borderTop: '1px solid var(--cm-panels-borderColor)',
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
'.cm-panel.cm-search': {
|
||||
background: 'var(--cm-search-backgroundColor)',
|
||||
color: 'var(--cm-search-textColor)',
|
||||
padding: '8px',
|
||||
},
|
||||
'.cm-search .cm-button': {
|
||||
background: 'var(--cm-search-button-backgroundColor)',
|
||||
borderColor: 'var(--cm-search-button-borderColor)',
|
||||
color: 'var(--cm-search-button-textColor)',
|
||||
borderRadius: '4px',
|
||||
'&:hover': {
|
||||
color: 'var(--cm-search-button-textColorHover)',
|
||||
},
|
||||
'&:focus-visible': {
|
||||
outline: 'none',
|
||||
borderColor: 'var(--cm-search-button-borderColorFocused)',
|
||||
},
|
||||
'&:hover:not(:focus-visible)': {
|
||||
background: 'var(--cm-search-button-backgroundColorHover)',
|
||||
borderColor: 'var(--cm-search-button-borderColorHover)',
|
||||
},
|
||||
'&:hover:focus-visible': {
|
||||
background: 'var(--cm-search-button-backgroundColorHover)',
|
||||
borderColor: 'var(--cm-search-button-borderColorFocused)',
|
||||
},
|
||||
},
|
||||
'.cm-panel.cm-search [name=close]': {
|
||||
top: '6px',
|
||||
right: '6px',
|
||||
padding: '0 6px',
|
||||
fontSize: '1rem',
|
||||
backgroundColor: 'var(--cm-search-closeButton-backgroundColor)',
|
||||
color: 'var(--cm-search-closeButton-textColor)',
|
||||
'&:hover': {
|
||||
'border-radius': '6px',
|
||||
color: 'var(--cm-search-closeButton-textColorHover)',
|
||||
backgroundColor: 'var(--cm-search-closeButton-backgroundColorHover)',
|
||||
},
|
||||
},
|
||||
'.cm-search input': {
|
||||
background: 'var(--cm-search-input-backgroundColor)',
|
||||
borderColor: 'var(--cm-search-input-borderColor)',
|
||||
color: 'var(--cm-search-input-textColor)',
|
||||
outline: 'none',
|
||||
borderRadius: '4px',
|
||||
'&:focus-visible': {
|
||||
borderColor: 'var(--cm-search-input-borderColorFocused)',
|
||||
},
|
||||
},
|
||||
'.cm-tooltip': {
|
||||
background: 'var(--cm-tooltip-backgroundColor)',
|
||||
border: '1px solid transparent',
|
||||
borderColor: 'var(--cm-tooltip-borderColor)',
|
||||
color: 'var(--cm-tooltip-textColor)',
|
||||
},
|
||||
'.cm-tooltip.cm-tooltip-autocomplete ul li[aria-selected]': {
|
||||
background: 'var(--cm-tooltip-backgroundColorSelected)',
|
||||
color: 'var(--cm-tooltip-textColorSelected)',
|
||||
},
|
||||
'.cm-searchMatch': {
|
||||
backgroundColor: 'var(--cm-searchMatch-backgroundColor)',
|
||||
},
|
||||
'.cm-tooltip.cm-readonly-tooltip': {
|
||||
padding: '4px',
|
||||
whiteSpace: 'nowrap',
|
||||
backgroundColor: 'var(--bolt-elements-bg-depth-2)',
|
||||
borderColor: 'var(--bolt-elements-borderColorActive)',
|
||||
'& .cm-tooltip-arrow:before': {
|
||||
borderTopColor: 'var(--bolt-elements-borderColorActive)',
|
||||
},
|
||||
'& .cm-tooltip-arrow:after': {
|
||||
borderTopColor: 'transparent',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function getLightTheme() {
|
||||
return vscodeLight;
|
||||
}
|
||||
|
||||
function getDarkTheme() {
|
||||
return vscodeDark;
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import { indentLess } from '@codemirror/commands';
|
||||
import { indentUnit } from '@codemirror/language';
|
||||
import { EditorSelection, EditorState, Line, type ChangeSpec } from '@codemirror/state';
|
||||
import { EditorView, type KeyBinding } from '@codemirror/view';
|
||||
|
||||
export const indentKeyBinding: KeyBinding = {
|
||||
key: 'Tab',
|
||||
run: indentMore,
|
||||
shift: indentLess,
|
||||
};
|
||||
|
||||
function indentMore({ state, dispatch }: EditorView) {
|
||||
if (state.readOnly) {
|
||||
return false;
|
||||
}
|
||||
|
||||
dispatch(
|
||||
state.update(
|
||||
changeBySelectedLine(state, (from, to, changes) => {
|
||||
changes.push({ from, to, insert: state.facet(indentUnit) });
|
||||
}),
|
||||
{ userEvent: 'input.indent' },
|
||||
),
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function changeBySelectedLine(
|
||||
state: EditorState,
|
||||
cb: (from: number, to: number | undefined, changes: ChangeSpec[], line: Line) => void,
|
||||
) {
|
||||
return state.changeByRange((range) => {
|
||||
const changes: ChangeSpec[] = [];
|
||||
|
||||
const line = state.doc.lineAt(range.from);
|
||||
|
||||
// just insert single indent unit at the current cursor position
|
||||
if (range.from === range.to) {
|
||||
cb(range.from, undefined, changes, line);
|
||||
}
|
||||
// handle the case when multiple characters are selected in a single line
|
||||
else if (range.from < range.to && range.to <= line.to) {
|
||||
cb(range.from, range.to, changes, line);
|
||||
} else {
|
||||
let atLine = -1;
|
||||
|
||||
// handle the case when selection spans multiple lines
|
||||
for (let pos = range.from; pos <= range.to; ) {
|
||||
const line = state.doc.lineAt(pos);
|
||||
|
||||
if (line.number > atLine && (range.empty || range.to > line.from)) {
|
||||
cb(line.from, undefined, changes, line);
|
||||
atLine = line.number;
|
||||
}
|
||||
|
||||
pos = line.to + 1;
|
||||
}
|
||||
}
|
||||
|
||||
const changeSet = state.changes(changes);
|
||||
|
||||
return {
|
||||
changes,
|
||||
range: EditorSelection.range(changeSet.mapPos(range.anchor, 1), changeSet.mapPos(range.head, 1)),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
import { LanguageDescription } from '@codemirror/language';
|
||||
|
||||
export const supportedLanguages = [
|
||||
LanguageDescription.of({
|
||||
name: 'VUE',
|
||||
extensions: ['vue'],
|
||||
async load() {
|
||||
return import('@codemirror/lang-vue').then((module) => module.vue());
|
||||
},
|
||||
}),
|
||||
LanguageDescription.of({
|
||||
name: 'TS',
|
||||
extensions: ['ts'],
|
||||
async load() {
|
||||
return import('@codemirror/lang-javascript').then((module) => module.javascript({ typescript: true }));
|
||||
},
|
||||
}),
|
||||
LanguageDescription.of({
|
||||
name: 'JS',
|
||||
extensions: ['js', 'mjs', 'cjs'],
|
||||
async load() {
|
||||
return import('@codemirror/lang-javascript').then((module) => module.javascript());
|
||||
},
|
||||
}),
|
||||
LanguageDescription.of({
|
||||
name: 'TSX',
|
||||
extensions: ['tsx'],
|
||||
async load() {
|
||||
return import('@codemirror/lang-javascript').then((module) => module.javascript({ jsx: true, typescript: true }));
|
||||
},
|
||||
}),
|
||||
LanguageDescription.of({
|
||||
name: 'JSX',
|
||||
extensions: ['jsx'],
|
||||
async load() {
|
||||
return import('@codemirror/lang-javascript').then((module) => module.javascript({ jsx: true }));
|
||||
},
|
||||
}),
|
||||
LanguageDescription.of({
|
||||
name: 'HTML',
|
||||
extensions: ['html'],
|
||||
async load() {
|
||||
return import('@codemirror/lang-html').then((module) => module.html());
|
||||
},
|
||||
}),
|
||||
LanguageDescription.of({
|
||||
name: 'CSS',
|
||||
extensions: ['css'],
|
||||
async load() {
|
||||
return import('@codemirror/lang-css').then((module) => module.css());
|
||||
},
|
||||
}),
|
||||
LanguageDescription.of({
|
||||
name: 'SASS',
|
||||
extensions: ['sass'],
|
||||
async load() {
|
||||
return import('@codemirror/lang-sass').then((module) => module.sass({ indented: true }));
|
||||
},
|
||||
}),
|
||||
LanguageDescription.of({
|
||||
name: 'SCSS',
|
||||
extensions: ['scss'],
|
||||
async load() {
|
||||
return import('@codemirror/lang-sass').then((module) => module.sass({ indented: false }));
|
||||
},
|
||||
}),
|
||||
LanguageDescription.of({
|
||||
name: 'JSON',
|
||||
extensions: ['json'],
|
||||
async load() {
|
||||
return import('@codemirror/lang-json').then((module) => module.json());
|
||||
},
|
||||
}),
|
||||
LanguageDescription.of({
|
||||
name: 'Markdown',
|
||||
extensions: ['md'],
|
||||
async load() {
|
||||
return import('@codemirror/lang-markdown').then((module) => module.markdown());
|
||||
},
|
||||
}),
|
||||
LanguageDescription.of({
|
||||
name: 'Wasm',
|
||||
extensions: ['wat'],
|
||||
async load() {
|
||||
return import('@codemirror/lang-wast').then((module) => module.wast());
|
||||
},
|
||||
}),
|
||||
LanguageDescription.of({
|
||||
name: 'Python',
|
||||
extensions: ['py'],
|
||||
async load() {
|
||||
return import('@codemirror/lang-python').then((module) => module.python());
|
||||
},
|
||||
}),
|
||||
LanguageDescription.of({
|
||||
name: 'C++',
|
||||
extensions: ['cpp'],
|
||||
async load() {
|
||||
return import('@codemirror/lang-cpp').then((module) => module.cpp());
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
export async function getLanguage(fileName: string) {
|
||||
const languageDescription = LanguageDescription.matchFilename(supportedLanguages, fileName);
|
||||
|
||||
if (languageDescription) {
|
||||
return await languageDescription.load();
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
import { useSearchParams } from '@remix-run/react';
|
||||
import { generateId, type Message } from 'ai';
|
||||
import ignore from 'ignore';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ClientOnly } from 'remix-utils/client-only';
|
||||
import { BaseChat } from '~/components/chat/BaseChat';
|
||||
import { Chat } from '~/components/chat/Chat.client';
|
||||
import { useGit } from '~/lib/hooks/useGit';
|
||||
import { useChatHistory } from '~/lib/persistence';
|
||||
import { LoadingOverlay } from '~/components/ui/LoadingOverlay';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
const IGNORE_PATTERNS = [
|
||||
'node_modules/**',
|
||||
'.git/**',
|
||||
'.github/**',
|
||||
'.vscode/**',
|
||||
'**/*.jpg',
|
||||
'**/*.jpeg',
|
||||
'**/*.png',
|
||||
'dist/**',
|
||||
'build/**',
|
||||
'.next/**',
|
||||
'coverage/**',
|
||||
'.cache/**',
|
||||
'.vscode/**',
|
||||
'.idea/**',
|
||||
'**/*.log',
|
||||
'**/.DS_Store',
|
||||
'**/npm-debug.log*',
|
||||
'**/yarn-debug.log*',
|
||||
'**/yarn-error.log*',
|
||||
'**/*lock.json',
|
||||
'**/*lock.yaml',
|
||||
];
|
||||
|
||||
export function GitUrlImport() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const { ready: historyReady, importChat } = useChatHistory();
|
||||
const { ready: gitReady, gitClone } = useGit();
|
||||
const [imported, setImported] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const importRepo = async (repoUrl?: string) => {
|
||||
if (!gitReady && !historyReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (repoUrl) {
|
||||
const ig = ignore().add(IGNORE_PATTERNS);
|
||||
|
||||
try {
|
||||
const { workdir, data } = await gitClone(repoUrl);
|
||||
|
||||
if (importChat) {
|
||||
const filePaths = Object.keys(data).filter((filePath) => !ig.ignores(filePath));
|
||||
|
||||
const fileContents = filePaths
|
||||
.map((filePath) => {
|
||||
const file = data[filePath];
|
||||
return {
|
||||
path: filePath,
|
||||
content: file?.content,
|
||||
};
|
||||
})
|
||||
.filter((f) => f.content);
|
||||
|
||||
const filesMessage: Message = {
|
||||
role: 'assistant',
|
||||
content: `Cloning the repo ${repoUrl} into ${workdir}
|
||||
<boltArtifact id="imported-files" title="Git Cloned Files" type="bundled">
|
||||
${fileContents
|
||||
.map(
|
||||
(file) =>
|
||||
`<boltAction type="file" filePath="${file.path}">
|
||||
${file.content}
|
||||
</boltAction>`,
|
||||
)
|
||||
.join('\n')}
|
||||
</boltArtifact>`,
|
||||
id: generateId(),
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
const messages = [filesMessage];
|
||||
|
||||
await importChat(`Git Project:${repoUrl.split('/').slice(-1)[0]}`, messages);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error during import:', error);
|
||||
toast.error('Failed to import repository');
|
||||
setLoading(false);
|
||||
window.location.href = '/';
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!historyReady || !gitReady || imported) {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = searchParams.get('url');
|
||||
|
||||
if (!url) {
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
|
||||
importRepo(url).catch((error) => {
|
||||
console.error('Error importing repo:', error);
|
||||
toast.error('Failed to import repository');
|
||||
setLoading(false);
|
||||
window.location.href = '/';
|
||||
});
|
||||
setImported(true);
|
||||
}, [searchParams, historyReady, gitReady, imported]);
|
||||
|
||||
return (
|
||||
<ClientOnly fallback={<BaseChat />}>
|
||||
{() => (
|
||||
<>
|
||||
<Chat />
|
||||
{loading && <LoadingOverlay message="Please wait while we clone the repository..." />}
|
||||
</>
|
||||
)}
|
||||
</ClientOnly>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { toast } from 'react-toastify';
|
||||
import ReactModal from 'react-modal';
|
||||
import { useState } from 'react';
|
||||
import { submitFeedback } from '~/lib/replay/Problems';
|
||||
import { getLastProjectContents, getLastChatMessages } from '~/components/chat/Chat.client';
|
||||
import { getLastChatMessages } from '~/components/chat/Chat.client';
|
||||
import { shouldUseSupabase } from '~/lib/supabase/client';
|
||||
|
||||
ReactModal.setAppElement('#root');
|
||||
@@ -56,7 +56,6 @@ export function Feedback() {
|
||||
};
|
||||
|
||||
if (feedbackData.share) {
|
||||
feedbackData.repositoryContents = getLastProjectContents();
|
||||
feedbackData.chatMessages = getLastChatMessages();
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from 'react';
|
||||
import { workbenchStore } from '~/lib/stores/workbench';
|
||||
import { getProblemsUsername, submitProblem } from '~/lib/replay/Problems';
|
||||
import type { BoltProblemInput } from '~/lib/replay/Problems';
|
||||
import { getRepositoryContents } from '~/lib/replay/Repository';
|
||||
|
||||
ReactModal.setAppElement('#root');
|
||||
|
||||
@@ -54,16 +55,21 @@ export function SaveProblem() {
|
||||
|
||||
console.log('SubmitProblem', formData);
|
||||
|
||||
await workbenchStore.saveAllFiles();
|
||||
const repositoryId = workbenchStore.repositoryId.get();
|
||||
|
||||
const { contentBase64 } = await workbenchStore.generateZipBase64();
|
||||
if (!repositoryId) {
|
||||
toast.error('No repository ID found');
|
||||
return;
|
||||
}
|
||||
|
||||
const repositoryContents = await getRepositoryContents(repositoryId);
|
||||
|
||||
const problem: BoltProblemInput = {
|
||||
version: 2,
|
||||
title: formData.title,
|
||||
description: formData.description,
|
||||
username,
|
||||
repositoryContents: contentBase64,
|
||||
repositoryContents,
|
||||
};
|
||||
|
||||
const problemId = await submitProblem(problem);
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import { memo } from 'react';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { cubicEasingFn } from '~/utils/easings';
|
||||
import { genericMemo } from '~/utils/react';
|
||||
|
||||
interface SliderOption<T> {
|
||||
value: T;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface SliderOptions<T> {
|
||||
left: SliderOption<T>;
|
||||
right: SliderOption<T>;
|
||||
}
|
||||
|
||||
interface SliderProps<T> {
|
||||
selected: T;
|
||||
options: SliderOptions<T>;
|
||||
setSelected?: (selected: T) => void;
|
||||
}
|
||||
|
||||
export const Slider = genericMemo(<T,>({ selected, options, setSelected }: SliderProps<T>) => {
|
||||
const isLeftSelected = selected === options.left.value;
|
||||
|
||||
return (
|
||||
<div className="flex items-center flex-wrap shrink-0 gap-1 bg-bolt-elements-background-depth-1 overflow-hidden rounded-full p-1">
|
||||
<SliderButton selected={isLeftSelected} setSelected={() => setSelected?.(options.left.value)}>
|
||||
{options.left.text}
|
||||
</SliderButton>
|
||||
<SliderButton selected={!isLeftSelected} setSelected={() => setSelected?.(options.right.value)}>
|
||||
{options.right.text}
|
||||
</SliderButton>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
interface SliderButtonProps {
|
||||
selected: boolean;
|
||||
children: string | JSX.Element | Array<JSX.Element | string>;
|
||||
setSelected: () => void;
|
||||
}
|
||||
|
||||
const SliderButton = memo(({ selected, children, setSelected }: SliderButtonProps) => {
|
||||
return (
|
||||
<button
|
||||
onClick={setSelected}
|
||||
className={classNames(
|
||||
'bg-transparent text-sm px-2.5 py-0.5 rounded-full relative',
|
||||
selected
|
||||
? 'text-bolt-elements-item-contentAccent'
|
||||
: 'text-bolt-elements-item-contentDefault hover:text-bolt-elements-item-contentActive',
|
||||
)}
|
||||
>
|
||||
<span className="relative z-10">{children}</span>
|
||||
{selected && (
|
||||
<motion.span
|
||||
layoutId="pill-tab"
|
||||
transition={{ duration: 0.2, ease: cubicEasingFn }}
|
||||
className="absolute inset-0 z-0 bg-bolt-elements-item-backgroundAccent rounded-full"
|
||||
></motion.span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
@@ -1,126 +0,0 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
|
||||
import {
|
||||
CodeMirrorEditor,
|
||||
type EditorDocument,
|
||||
type EditorSettings,
|
||||
type OnChangeCallback as OnEditorChange,
|
||||
type OnSaveCallback as OnEditorSave,
|
||||
type OnScrollCallback as OnEditorScroll,
|
||||
} from '~/components/editor/codemirror/CodeMirrorEditor';
|
||||
import { PanelHeader } from '~/components/ui/PanelHeader';
|
||||
import { PanelHeaderButton } from '~/components/ui/PanelHeaderButton';
|
||||
import type { FileMap } from '~/lib/stores/files';
|
||||
import { themeStore } from '~/lib/stores/theme';
|
||||
import { renderLogger } from '~/utils/logger';
|
||||
import { isMobile } from '~/utils/mobile';
|
||||
import { FileBreadcrumb } from './FileBreadcrumb';
|
||||
import { FileTree } from './FileTree';
|
||||
|
||||
interface EditorPanelProps {
|
||||
files?: FileMap;
|
||||
unsavedFiles?: Set<string>;
|
||||
editorDocument?: EditorDocument;
|
||||
selectedFile?: string | undefined;
|
||||
isStreaming?: boolean;
|
||||
onEditorChange?: OnEditorChange;
|
||||
onEditorScroll?: OnEditorScroll;
|
||||
onFileSelect?: (value?: string) => void;
|
||||
onFileSave?: OnEditorSave;
|
||||
onFileReset?: () => void;
|
||||
}
|
||||
|
||||
const DEFAULT_EDITOR_SIZE = 100;
|
||||
|
||||
const editorSettings: EditorSettings = { tabSize: 2 };
|
||||
|
||||
export const EditorPanel = memo(
|
||||
({
|
||||
files,
|
||||
unsavedFiles,
|
||||
editorDocument,
|
||||
selectedFile,
|
||||
isStreaming,
|
||||
onFileSelect,
|
||||
onEditorChange,
|
||||
onEditorScroll,
|
||||
onFileSave,
|
||||
onFileReset,
|
||||
}: EditorPanelProps) => {
|
||||
renderLogger.trace('EditorPanel');
|
||||
|
||||
const theme = useStore(themeStore);
|
||||
|
||||
const activeFileSegments = useMemo(() => {
|
||||
if (!editorDocument) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return editorDocument.filePath.split('/');
|
||||
}, [editorDocument]);
|
||||
|
||||
const activeFileUnsaved = useMemo(() => {
|
||||
return editorDocument !== undefined && unsavedFiles?.has(editorDocument.filePath);
|
||||
}, [editorDocument, unsavedFiles]);
|
||||
|
||||
return (
|
||||
<PanelGroup direction="vertical">
|
||||
<Panel defaultSize={DEFAULT_EDITOR_SIZE} minSize={20}>
|
||||
<PanelGroup direction="horizontal">
|
||||
<Panel defaultSize={20} minSize={10} collapsible>
|
||||
<div className="flex flex-col border-r border-bolt-elements-borderColor h-full">
|
||||
<PanelHeader>
|
||||
<div className="i-ph:tree-structure-duotone shrink-0" />
|
||||
Files
|
||||
</PanelHeader>
|
||||
<FileTree
|
||||
className="h-full"
|
||||
files={files}
|
||||
unsavedFiles={unsavedFiles}
|
||||
selectedFile={selectedFile}
|
||||
onFileSelect={onFileSelect}
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
<PanelResizeHandle />
|
||||
<Panel className="flex flex-col" defaultSize={80} minSize={20}>
|
||||
<PanelHeader className="overflow-x-auto">
|
||||
{activeFileSegments?.length && (
|
||||
<div className="flex items-center flex-1 text-sm">
|
||||
<FileBreadcrumb pathSegments={activeFileSegments} files={files} onFileSelect={onFileSelect} />
|
||||
{activeFileUnsaved && (
|
||||
<div className="flex gap-1 ml-auto -mr-1.5">
|
||||
<PanelHeaderButton onClick={onFileSave}>
|
||||
<div className="i-ph:floppy-disk-duotone" />
|
||||
Save
|
||||
</PanelHeaderButton>
|
||||
<PanelHeaderButton onClick={onFileReset}>
|
||||
<div className="i-ph:clock-counter-clockwise-duotone" />
|
||||
Reset
|
||||
</PanelHeaderButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</PanelHeader>
|
||||
<div className="h-full flex-1 overflow-hidden">
|
||||
<CodeMirrorEditor
|
||||
theme={theme}
|
||||
editable={!isStreaming && editorDocument !== undefined}
|
||||
settings={editorSettings}
|
||||
doc={editorDocument}
|
||||
autoFocusOnDocumentChange={!isMobile()}
|
||||
onScroll={onEditorScroll}
|
||||
onChange={onEditorChange}
|
||||
onSave={onFileSave}
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
</PanelGroup>
|
||||
</Panel>
|
||||
<PanelResizeHandle />
|
||||
</PanelGroup>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -1,142 +0,0 @@
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
||||
import { AnimatePresence, motion, type Variants } from 'framer-motion';
|
||||
import { memo, useEffect, useRef, useState } from 'react';
|
||||
import type { FileMap } from '~/lib/stores/files';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { cubicEasingFn } from '~/utils/easings';
|
||||
import { renderLogger } from '~/utils/logger';
|
||||
import FileTree from './FileTree';
|
||||
|
||||
interface FileBreadcrumbProps {
|
||||
files?: FileMap;
|
||||
pathSegments?: string[];
|
||||
onFileSelect?: (filePath: string) => void;
|
||||
}
|
||||
|
||||
const contextMenuVariants = {
|
||||
open: {
|
||||
y: 0,
|
||||
opacity: 1,
|
||||
transition: {
|
||||
duration: 0.15,
|
||||
ease: cubicEasingFn,
|
||||
},
|
||||
},
|
||||
close: {
|
||||
y: 6,
|
||||
opacity: 0,
|
||||
transition: {
|
||||
duration: 0.15,
|
||||
ease: cubicEasingFn,
|
||||
},
|
||||
},
|
||||
} satisfies Variants;
|
||||
|
||||
export const FileBreadcrumb = memo<FileBreadcrumbProps>(({ files, pathSegments = [], onFileSelect }) => {
|
||||
renderLogger.trace('FileBreadcrumb');
|
||||
|
||||
const [activeIndex, setActiveIndex] = useState<number | null>(null);
|
||||
|
||||
const contextMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const segmentRefs = useRef<(HTMLSpanElement | null)[]>([]);
|
||||
|
||||
const handleSegmentClick = (index: number) => {
|
||||
setActiveIndex((prevIndex) => (prevIndex === index ? null : index));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleOutsideClick = (event: MouseEvent) => {
|
||||
if (
|
||||
activeIndex !== null &&
|
||||
!contextMenuRef.current?.contains(event.target as Node) &&
|
||||
!segmentRefs.current.some((ref) => ref?.contains(event.target as Node))
|
||||
) {
|
||||
setActiveIndex(null);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleOutsideClick);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleOutsideClick);
|
||||
};
|
||||
}, [activeIndex]);
|
||||
|
||||
if (files === undefined || pathSegments.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex">
|
||||
{pathSegments.map((segment, index) => {
|
||||
const isLast = index === pathSegments.length - 1;
|
||||
|
||||
const path = pathSegments.slice(0, index).join('/');
|
||||
|
||||
const isActive = activeIndex === index;
|
||||
|
||||
return (
|
||||
<div key={index} className="relative flex items-center">
|
||||
<DropdownMenu.Root open={isActive} modal={false}>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<span
|
||||
ref={(ref) => {
|
||||
segmentRefs.current[index] = ref;
|
||||
return undefined;
|
||||
}}
|
||||
className={classNames('flex items-center gap-1.5 cursor-pointer shrink-0', {
|
||||
'text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary': !isActive,
|
||||
'text-bolt-elements-textPrimary underline': isActive,
|
||||
'pr-4': isLast,
|
||||
})}
|
||||
onClick={() => handleSegmentClick(index)}
|
||||
>
|
||||
{isLast && <div className="i-ph:file-duotone" />}
|
||||
{segment}
|
||||
</span>
|
||||
</DropdownMenu.Trigger>
|
||||
{index > 0 && !isLast && <span className="i-ph:caret-right inline-block mx-1" />}
|
||||
<AnimatePresence>
|
||||
{isActive && (
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
className="z-file-tree-breadcrumb"
|
||||
asChild
|
||||
align="start"
|
||||
side="bottom"
|
||||
avoidCollisions={false}
|
||||
>
|
||||
<motion.div
|
||||
ref={contextMenuRef}
|
||||
initial="close"
|
||||
animate="open"
|
||||
exit="close"
|
||||
variants={contextMenuVariants}
|
||||
>
|
||||
<div className="rounded-lg overflow-hidden">
|
||||
<div className="max-h-[50vh] min-w-[300px] overflow-scroll bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor shadow-sm rounded-lg">
|
||||
<FileTree
|
||||
files={files}
|
||||
collapsed
|
||||
allowFolderSelection
|
||||
selectedFile={`${path}/${segment}`}
|
||||
onFileSelect={(filePath) => {
|
||||
setActiveIndex(null);
|
||||
onFileSelect?.(filePath);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenu.Arrow className="fill-bolt-elements-borderColor" />
|
||||
</motion.div>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</DropdownMenu.Root>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,475 +0,0 @@
|
||||
import { memo, useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import type { FileMap } from '~/lib/stores/files';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { createScopedLogger, renderLogger } from '~/utils/logger';
|
||||
import * as ContextMenu from '@radix-ui/react-context-menu';
|
||||
|
||||
const logger = createScopedLogger('FileTree');
|
||||
|
||||
const NODE_PADDING_LEFT = 8;
|
||||
const DEFAULT_HIDDEN_FILES = [/\/node_modules\//, /\/\.next/, /\/\.astro/];
|
||||
|
||||
interface Props {
|
||||
files?: FileMap;
|
||||
selectedFile?: string;
|
||||
onFileSelect?: (filePath: string) => void;
|
||||
collapsed?: boolean;
|
||||
allowFolderSelection?: boolean;
|
||||
hiddenFiles?: Array<string | RegExp>;
|
||||
unsavedFiles?: Set<string>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const FileTree = memo(
|
||||
({
|
||||
files = {},
|
||||
onFileSelect,
|
||||
selectedFile,
|
||||
collapsed = false,
|
||||
allowFolderSelection = false,
|
||||
hiddenFiles,
|
||||
className,
|
||||
unsavedFiles,
|
||||
}: Props) => {
|
||||
renderLogger.trace('FileTree');
|
||||
|
||||
const computedHiddenFiles = useMemo(() => [...DEFAULT_HIDDEN_FILES, ...(hiddenFiles ?? [])], [hiddenFiles]);
|
||||
|
||||
const fileList = useMemo(() => {
|
||||
return buildFileList(files, computedHiddenFiles);
|
||||
}, [files, computedHiddenFiles]);
|
||||
|
||||
const [collapsedFolders, setCollapsedFolders] = useState(() => {
|
||||
return collapsed
|
||||
? new Set(fileList.filter((item) => item.kind === 'folder').map((item) => item.fullPath))
|
||||
: new Set<string>();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (collapsed) {
|
||||
setCollapsedFolders(new Set(fileList.filter((item) => item.kind === 'folder').map((item) => item.fullPath)));
|
||||
return;
|
||||
}
|
||||
|
||||
setCollapsedFolders((prevCollapsed) => {
|
||||
const newCollapsed = new Set<string>();
|
||||
|
||||
for (const folder of fileList) {
|
||||
if (folder.kind === 'folder' && prevCollapsed.has(folder.fullPath)) {
|
||||
newCollapsed.add(folder.fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
return newCollapsed;
|
||||
});
|
||||
}, [fileList, collapsed]);
|
||||
|
||||
const filteredFileList = useMemo(() => {
|
||||
const list = [];
|
||||
|
||||
let lastDepth = Number.MAX_SAFE_INTEGER;
|
||||
|
||||
for (const fileOrFolder of fileList) {
|
||||
const depth = fileOrFolder.depth;
|
||||
|
||||
// if the depth is equal we reached the end of the collaped group
|
||||
if (lastDepth === depth) {
|
||||
lastDepth = Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
|
||||
// ignore collapsed folders
|
||||
if (collapsedFolders.has(fileOrFolder.fullPath)) {
|
||||
lastDepth = Math.min(lastDepth, depth);
|
||||
}
|
||||
|
||||
// ignore files and folders below the last collapsed folder
|
||||
if (lastDepth < depth) {
|
||||
continue;
|
||||
}
|
||||
|
||||
list.push(fileOrFolder);
|
||||
}
|
||||
|
||||
return list;
|
||||
}, [fileList, collapsedFolders]);
|
||||
|
||||
const toggleCollapseState = (fullPath: string) => {
|
||||
setCollapsedFolders((prevSet) => {
|
||||
const newSet = new Set(prevSet);
|
||||
|
||||
if (newSet.has(fullPath)) {
|
||||
newSet.delete(fullPath);
|
||||
} else {
|
||||
newSet.add(fullPath);
|
||||
}
|
||||
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const onCopyPath = (fileOrFolder: FileNode | FolderNode) => {
|
||||
try {
|
||||
navigator.clipboard.writeText(fileOrFolder.fullPath);
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const onCopyRelativePath = (fileOrFolder: FileNode | FolderNode) => {
|
||||
try {
|
||||
navigator.clipboard.writeText(fileOrFolder.fullPath);
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classNames('text-sm', className, 'overflow-y-auto')}>
|
||||
{filteredFileList.map((fileOrFolder) => {
|
||||
switch (fileOrFolder.kind) {
|
||||
case 'file': {
|
||||
return (
|
||||
<File
|
||||
key={fileOrFolder.id}
|
||||
selected={selectedFile === fileOrFolder.fullPath}
|
||||
file={fileOrFolder}
|
||||
unsavedChanges={unsavedFiles?.has(fileOrFolder.fullPath)}
|
||||
onCopyPath={() => {
|
||||
onCopyPath(fileOrFolder);
|
||||
}}
|
||||
onCopyRelativePath={() => {
|
||||
onCopyRelativePath(fileOrFolder);
|
||||
}}
|
||||
onClick={() => {
|
||||
onFileSelect?.(fileOrFolder.fullPath);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case 'folder': {
|
||||
return (
|
||||
<Folder
|
||||
key={fileOrFolder.id}
|
||||
folder={fileOrFolder}
|
||||
selected={allowFolderSelection && selectedFile === fileOrFolder.fullPath}
|
||||
collapsed={collapsedFolders.has(fileOrFolder.fullPath)}
|
||||
onCopyPath={() => {
|
||||
onCopyPath(fileOrFolder);
|
||||
}}
|
||||
onCopyRelativePath={() => {
|
||||
onCopyRelativePath(fileOrFolder);
|
||||
}}
|
||||
onClick={() => {
|
||||
toggleCollapseState(fileOrFolder.fullPath);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
default: {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default FileTree;
|
||||
|
||||
interface FolderProps {
|
||||
folder: FolderNode;
|
||||
collapsed: boolean;
|
||||
selected?: boolean;
|
||||
onCopyPath: () => void;
|
||||
onCopyRelativePath: () => void;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
interface FolderContextMenuProps {
|
||||
onCopyPath?: () => void;
|
||||
onCopyRelativePath?: () => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function ContextMenuItem({ onSelect, children }: { onSelect?: () => void; children: ReactNode }) {
|
||||
return (
|
||||
<ContextMenu.Item
|
||||
onSelect={onSelect}
|
||||
className="flex items-center gap-2 px-2 py-1.5 outline-0 text-sm text-bolt-elements-textPrimary cursor-pointer ws-nowrap text-bolt-elements-item-contentDefault hover:text-bolt-elements-item-contentActive hover:bg-bolt-elements-item-backgroundActive rounded-md"
|
||||
>
|
||||
<span className="size-4 shrink-0"></span>
|
||||
<span>{children}</span>
|
||||
</ContextMenu.Item>
|
||||
);
|
||||
}
|
||||
|
||||
function FileContextMenu({ onCopyPath, onCopyRelativePath, children }: FolderContextMenuProps) {
|
||||
return (
|
||||
<ContextMenu.Root>
|
||||
<ContextMenu.Trigger>{children}</ContextMenu.Trigger>
|
||||
<ContextMenu.Portal>
|
||||
<ContextMenu.Content
|
||||
style={{ zIndex: 998 }}
|
||||
className="border border-bolt-elements-borderColor rounded-md z-context-menu bg-bolt-elements-background-depth-1 dark:bg-bolt-elements-background-depth-2 data-[state=open]:animate-in animate-duration-100 data-[state=open]:fade-in-0 data-[state=open]:zoom-in-98 w-56"
|
||||
>
|
||||
<ContextMenu.Group className="p-1 border-b-px border-solid border-bolt-elements-borderColor">
|
||||
<ContextMenuItem onSelect={onCopyPath}>Copy path</ContextMenuItem>
|
||||
<ContextMenuItem onSelect={onCopyRelativePath}>Copy relative path</ContextMenuItem>
|
||||
</ContextMenu.Group>
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Portal>
|
||||
</ContextMenu.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function Folder({ folder, collapsed, selected = false, onCopyPath, onCopyRelativePath, onClick }: FolderProps) {
|
||||
return (
|
||||
<FileContextMenu onCopyPath={onCopyPath} onCopyRelativePath={onCopyRelativePath}>
|
||||
<NodeButton
|
||||
className={classNames('group', {
|
||||
'bg-transparent text-bolt-elements-item-contentDefault hover:text-bolt-elements-item-contentActive hover:bg-bolt-elements-item-backgroundActive':
|
||||
!selected,
|
||||
'bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent': selected,
|
||||
})}
|
||||
depth={folder.depth}
|
||||
iconClasses={classNames({
|
||||
'i-ph:caret-right scale-98': collapsed,
|
||||
'i-ph:caret-down scale-98': !collapsed,
|
||||
})}
|
||||
onClick={onClick}
|
||||
>
|
||||
{folder.name}
|
||||
</NodeButton>
|
||||
</FileContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
interface FileProps {
|
||||
file: FileNode;
|
||||
selected: boolean;
|
||||
unsavedChanges?: boolean;
|
||||
onCopyPath: () => void;
|
||||
onCopyRelativePath: () => void;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function File({
|
||||
file: { depth, name },
|
||||
onClick,
|
||||
onCopyPath,
|
||||
onCopyRelativePath,
|
||||
selected,
|
||||
unsavedChanges = false,
|
||||
}: FileProps) {
|
||||
return (
|
||||
<FileContextMenu onCopyPath={onCopyPath} onCopyRelativePath={onCopyRelativePath}>
|
||||
<NodeButton
|
||||
className={classNames('group', {
|
||||
'bg-transparent hover:bg-bolt-elements-item-backgroundActive text-bolt-elements-item-contentDefault':
|
||||
!selected,
|
||||
'bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent': selected,
|
||||
})}
|
||||
depth={depth}
|
||||
iconClasses={classNames('i-ph:file-duotone scale-98', {
|
||||
'group-hover:text-bolt-elements-item-contentActive': !selected,
|
||||
})}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div
|
||||
className={classNames('flex items-center', {
|
||||
'group-hover:text-bolt-elements-item-contentActive': !selected,
|
||||
})}
|
||||
>
|
||||
<div className="flex-1 truncate pr-2">{name}</div>
|
||||
{unsavedChanges && <span className="i-ph:circle-fill scale-68 shrink-0 text-orange-500" />}
|
||||
</div>
|
||||
</NodeButton>
|
||||
</FileContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
interface ButtonProps {
|
||||
depth: number;
|
||||
iconClasses: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
function NodeButton({ depth, iconClasses, onClick, className, children }: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className={classNames(
|
||||
'flex items-center gap-1.5 w-full pr-2 border-2 border-transparent text-faded py-0.5',
|
||||
className,
|
||||
)}
|
||||
style={{ paddingLeft: `${6 + depth * NODE_PADDING_LEFT}px` }}
|
||||
onClick={() => onClick?.()}
|
||||
>
|
||||
<div className={classNames('scale-120 shrink-0', iconClasses)}></div>
|
||||
<div className="truncate w-full text-left">{children}</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
type Node = FileNode | FolderNode;
|
||||
|
||||
interface BaseNode {
|
||||
id: number;
|
||||
depth: number;
|
||||
name: string;
|
||||
fullPath: string;
|
||||
}
|
||||
|
||||
interface FileNode extends BaseNode {
|
||||
kind: 'file';
|
||||
}
|
||||
|
||||
interface FolderNode extends BaseNode {
|
||||
kind: 'folder';
|
||||
}
|
||||
|
||||
function buildFileList(files: FileMap, hiddenFiles: Array<string | RegExp>): Node[] {
|
||||
const folderPaths = new Set<string>();
|
||||
const fileList: Node[] = [];
|
||||
|
||||
const defaultDepth = 0;
|
||||
|
||||
for (const filePath of Object.keys(files)) {
|
||||
const segments = filePath.split('/').filter((segment) => segment);
|
||||
const fileName = segments.at(-1);
|
||||
|
||||
if (!fileName || isHiddenFile(filePath, fileName, hiddenFiles)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let fullPath = '';
|
||||
|
||||
let i = 0;
|
||||
let depth = 0;
|
||||
|
||||
while (i < segments.length) {
|
||||
const name = segments[i];
|
||||
fullPath += (fullPath.length ? '/' : '') + name;
|
||||
|
||||
if (i === segments.length - 1) {
|
||||
fileList.push({
|
||||
kind: 'file',
|
||||
id: fileList.length,
|
||||
name,
|
||||
fullPath,
|
||||
depth: depth + defaultDepth,
|
||||
});
|
||||
} else if (!folderPaths.has(fullPath)) {
|
||||
folderPaths.add(fullPath);
|
||||
|
||||
fileList.push({
|
||||
kind: 'folder',
|
||||
id: fileList.length,
|
||||
name,
|
||||
fullPath,
|
||||
depth: depth + defaultDepth,
|
||||
});
|
||||
}
|
||||
|
||||
i++;
|
||||
depth++;
|
||||
}
|
||||
}
|
||||
|
||||
return sortFileList(fileList);
|
||||
}
|
||||
|
||||
function isHiddenFile(filePath: string, fileName: string, hiddenFiles: Array<string | RegExp>) {
|
||||
return hiddenFiles.some((pathOrRegex) => {
|
||||
if (typeof pathOrRegex === 'string') {
|
||||
return fileName === pathOrRegex;
|
||||
}
|
||||
|
||||
return pathOrRegex.test(filePath);
|
||||
});
|
||||
}
|
||||
|
||||
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).
|
||||
*
|
||||
* This function organizes the nodes into a hierarchical structure based on their paths,
|
||||
* with folders appearing before files and all items sorted alphabetically within their level.
|
||||
*
|
||||
* @note This function mutates the given `nodeList` array for performance reasons.
|
||||
*
|
||||
* @param rootFolder - The path of the root folder to start the sorting from.
|
||||
* @param nodeList - The list of nodes to be sorted.
|
||||
*
|
||||
* @returns A new array of nodes sorted in depth-first order.
|
||||
*/
|
||||
function sortFileList(nodeList: Node[]): Node[] {
|
||||
logger.trace('sortFileList');
|
||||
|
||||
const nodeMap = new Map<string, Node>();
|
||||
const childrenMap = new Map<string, Node[]>();
|
||||
|
||||
// pre-sort nodes by name and type
|
||||
nodeList.sort((a, b) => compareNodes(a, b));
|
||||
|
||||
for (const node of nodeList) {
|
||||
nodeMap.set(node.fullPath, node);
|
||||
|
||||
const parentPath = getParentPath(node.fullPath);
|
||||
|
||||
if (!childrenMap.has(parentPath)) {
|
||||
childrenMap.set(parentPath, []);
|
||||
}
|
||||
|
||||
childrenMap.get(parentPath)?.push(node);
|
||||
}
|
||||
|
||||
const sortedList: Node[] = [];
|
||||
|
||||
const depthFirstTraversal = (path: string): void => {
|
||||
const node = nodeMap.get(path);
|
||||
|
||||
if (node) {
|
||||
sortedList.push(node);
|
||||
}
|
||||
|
||||
const children = childrenMap.get(path);
|
||||
|
||||
if (children) {
|
||||
for (const child of children) {
|
||||
if (child.kind === 'folder') {
|
||||
depthFirstTraversal(child.fullPath);
|
||||
} else {
|
||||
sortedList.push(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const rootChildren = childrenMap.get('') || [];
|
||||
|
||||
for (const child of rootChildren) {
|
||||
depthFirstTraversal(child.fullPath);
|
||||
}
|
||||
|
||||
return sortedList;
|
||||
}
|
||||
|
||||
function compareNodes(a: Node, b: Node): number {
|
||||
if (a.kind !== b.kind) {
|
||||
return a.kind === 'folder' ? -1 : 1;
|
||||
}
|
||||
|
||||
return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' });
|
||||
}
|
||||
@@ -27,6 +27,7 @@ export const Preview = memo(() => {
|
||||
const [selectionPoint, setSelectionPoint] = useState<{ x: number; y: number } | null>(null);
|
||||
|
||||
const previewURL = useStore(workbenchStore.previewURL);
|
||||
const previewError = useStore(workbenchStore.previewError);
|
||||
|
||||
// Toggle between responsive mode and device mode
|
||||
const [isDeviceModeOn, setIsDeviceModeOn] = useState(false);
|
||||
@@ -277,7 +278,9 @@ export const Preview = memo(() => {
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex w-full h-full justify-center items-center bg-white">No preview available</div>
|
||||
<div className="flex w-full h-full justify-center items-center bg-white">
|
||||
{previewError ? 'Failed to load preview' : 'Preview loading...'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isDeviceModeOn && (
|
||||
|
||||
@@ -1,41 +1,18 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { motion, type HTMLMotionProps, type Variants } from 'framer-motion';
|
||||
import { memo, useCallback, useEffect, useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import {
|
||||
type OnChangeCallback as OnEditorChange,
|
||||
type OnScrollCallback as OnEditorScroll,
|
||||
} from '~/components/editor/codemirror/CodeMirrorEditor';
|
||||
import { motion, type Variants } from 'framer-motion';
|
||||
import { memo } from 'react';
|
||||
import { IconButton } from '~/components/ui/IconButton';
|
||||
import { PanelHeaderButton } from '~/components/ui/PanelHeaderButton';
|
||||
import { Slider, type SliderOptions } from '~/components/ui/Slider';
|
||||
import { workbenchStore, type WorkbenchViewType } from '~/lib/stores/workbench';
|
||||
import { workbenchStore } from '~/lib/stores/workbench';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { cubicEasingFn } from '~/utils/easings';
|
||||
import { renderLogger } from '~/utils/logger';
|
||||
import { EditorPanel } from './EditorPanel';
|
||||
import { Preview } from './Preview';
|
||||
import useViewport from '~/lib/hooks';
|
||||
import Cookies from 'js-cookie';
|
||||
|
||||
interface WorkspaceProps {
|
||||
chatStarted?: boolean;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
const viewTransition = { ease: cubicEasingFn };
|
||||
|
||||
const sliderOptions: SliderOptions<WorkbenchViewType> = {
|
||||
left: {
|
||||
value: 'code',
|
||||
text: 'Code',
|
||||
},
|
||||
right: {
|
||||
value: 'preview',
|
||||
text: 'Preview',
|
||||
},
|
||||
};
|
||||
|
||||
const workbenchVariants = {
|
||||
closed: {
|
||||
width: 0,
|
||||
@@ -53,118 +30,13 @@ const workbenchVariants = {
|
||||
},
|
||||
} satisfies Variants;
|
||||
|
||||
export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) => {
|
||||
export const Workbench = memo(({ chatStarted }: WorkspaceProps) => {
|
||||
renderLogger.trace('Workbench');
|
||||
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
|
||||
const previewURL = useStore(workbenchStore.previewURL);
|
||||
const showWorkbench = useStore(workbenchStore.showWorkbench);
|
||||
const selectedFile = useStore(workbenchStore.selectedFile);
|
||||
const currentDocument = useStore(workbenchStore.currentDocument);
|
||||
const unsavedFiles = useStore(workbenchStore.unsavedFiles);
|
||||
const files = useStore(workbenchStore.files);
|
||||
const selectedView = useStore(workbenchStore.currentView);
|
||||
|
||||
const isSmallViewport = useViewport(1024);
|
||||
|
||||
const setSelectedView = (view: WorkbenchViewType) => {
|
||||
workbenchStore.currentView.set(view);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (previewURL) {
|
||||
setSelectedView('preview');
|
||||
}
|
||||
}, [previewURL]);
|
||||
|
||||
useEffect(() => {
|
||||
workbenchStore.setDocuments(files);
|
||||
}, [files]);
|
||||
|
||||
const onEditorChange = useCallback<OnEditorChange>((update) => {
|
||||
workbenchStore.setCurrentDocumentContent(update.content);
|
||||
}, []);
|
||||
|
||||
const onEditorScroll = useCallback<OnEditorScroll>((position) => {
|
||||
workbenchStore.setCurrentDocumentScrollPosition(position);
|
||||
}, []);
|
||||
|
||||
const onFileSelect = useCallback((filePath: string | undefined) => {
|
||||
workbenchStore.setSelectedFile(filePath);
|
||||
}, []);
|
||||
|
||||
const onFileSave = useCallback(() => {
|
||||
workbenchStore.saveCurrentDocument().catch(() => {
|
||||
toast.error('Failed to update file content');
|
||||
});
|
||||
}, []);
|
||||
|
||||
const onFileReset = useCallback(() => {
|
||||
workbenchStore.resetCurrentDocument();
|
||||
}, []);
|
||||
|
||||
const handleSyncFiles = useCallback(async () => {
|
||||
setIsSyncing(true);
|
||||
|
||||
try {
|
||||
const directoryHandle = await window.showDirectoryPicker();
|
||||
await workbenchStore.syncFiles(directoryHandle);
|
||||
toast.success('Files synced successfully');
|
||||
} catch (error) {
|
||||
console.error('Error syncing files:', error);
|
||||
toast.error('Failed to sync files');
|
||||
} finally {
|
||||
setIsSyncing(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleApplyChanges = useCallback(async () => {
|
||||
try {
|
||||
// Open file picker and get file handle
|
||||
const [fileHandle] = await (window as any).showOpenFilePicker({
|
||||
types: [
|
||||
{
|
||||
description: 'Text Files',
|
||||
accept: {
|
||||
'text/*': ['.txt', '.js', '.ts', '.jsx', '.tsx', '.json', '.html', '.css'],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const changesFile = await fileHandle.getFile();
|
||||
const changesContent = await changesFile.text();
|
||||
|
||||
let path = '';
|
||||
let contents = '';
|
||||
|
||||
async function saveCurrentFile() {
|
||||
if (path) {
|
||||
await workbenchStore.saveFileContents('/home/project/src/' + path, contents);
|
||||
}
|
||||
}
|
||||
|
||||
for (const line of changesContent.split('\n')) {
|
||||
const match = /^FILE (.*)/.exec(line);
|
||||
|
||||
if (match) {
|
||||
await saveCurrentFile();
|
||||
path = match[1];
|
||||
contents = '';
|
||||
continue;
|
||||
}
|
||||
|
||||
contents += line + '\n';
|
||||
}
|
||||
|
||||
await saveCurrentFile();
|
||||
} catch (error) {
|
||||
console.error('Error applying changes:', error);
|
||||
toast.error('Failed to apply changes');
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
chatStarted && (
|
||||
<motion.div
|
||||
@@ -187,63 +59,7 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
|
||||
<div className="absolute inset-0 px-2 lg:px-6">
|
||||
<div className="h-full flex flex-col bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor shadow-sm rounded-lg overflow-hidden">
|
||||
<div className="flex items-center px-3 py-2 border-b border-bolt-elements-borderColor">
|
||||
<Slider selected={selectedView} options={sliderOptions} setSelected={setSelectedView} />
|
||||
<div className="ml-auto" />
|
||||
{selectedView === 'code' && (
|
||||
<div className="flex overflow-y-auto">
|
||||
<PanelHeaderButton className="mr-1 text-sm" onClick={handleApplyChanges}>
|
||||
<div className="i-ph:code" />
|
||||
Apply Changes
|
||||
</PanelHeaderButton>
|
||||
<PanelHeaderButton
|
||||
className="mr-1 text-sm"
|
||||
onClick={() => {
|
||||
workbenchStore.downloadZip();
|
||||
}}
|
||||
>
|
||||
<div className="i-ph:code" />
|
||||
Download Code
|
||||
</PanelHeaderButton>
|
||||
<PanelHeaderButton className="mr-1 text-sm" onClick={handleSyncFiles} disabled={isSyncing}>
|
||||
{isSyncing ? <div className="i-ph:spinner" /> : <div className="i-ph:cloud-arrow-down" />}
|
||||
{isSyncing ? 'Syncing...' : 'Sync Files'}
|
||||
</PanelHeaderButton>
|
||||
<PanelHeaderButton
|
||||
className="mr-1 text-sm"
|
||||
onClick={() => {
|
||||
const repoName = prompt(
|
||||
'Please enter a name for your new GitHub repository:',
|
||||
'bolt-generated-project',
|
||||
);
|
||||
|
||||
if (!repoName) {
|
||||
alert('Repository name is required. Push to GitHub cancelled.');
|
||||
return;
|
||||
}
|
||||
|
||||
const githubUsername = Cookies.get('githubUsername');
|
||||
const githubToken = Cookies.get('githubToken');
|
||||
|
||||
if (!githubUsername || !githubToken) {
|
||||
const usernameInput = prompt('Please enter your GitHub username:');
|
||||
const tokenInput = prompt('Please enter your GitHub personal access token:');
|
||||
|
||||
if (!usernameInput || !tokenInput) {
|
||||
alert('GitHub username and token are required. Push to GitHub cancelled.');
|
||||
return;
|
||||
}
|
||||
|
||||
workbenchStore.pushToGitHub(repoName, usernameInput, tokenInput);
|
||||
} else {
|
||||
workbenchStore.pushToGitHub(repoName, githubUsername, githubToken);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="i-ph:github-logo" />
|
||||
Push to GitHub
|
||||
</PanelHeaderButton>
|
||||
</div>
|
||||
)}
|
||||
<IconButton
|
||||
icon="i-ph:x-circle"
|
||||
className="-mr-1"
|
||||
@@ -254,29 +70,7 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
|
||||
/>
|
||||
</div>
|
||||
<div className="relative flex-1 overflow-hidden">
|
||||
<View
|
||||
initial={{ x: selectedView === 'code' ? 0 : '-100%' }}
|
||||
animate={{ x: selectedView === 'code' ? 0 : '-100%' }}
|
||||
>
|
||||
<EditorPanel
|
||||
editorDocument={currentDocument}
|
||||
isStreaming={isStreaming}
|
||||
selectedFile={selectedFile}
|
||||
files={files}
|
||||
unsavedFiles={unsavedFiles}
|
||||
onFileSelect={onFileSelect}
|
||||
onEditorScroll={onEditorScroll}
|
||||
onEditorChange={onEditorChange}
|
||||
onFileSave={onFileSave}
|
||||
onFileReset={onFileReset}
|
||||
/>
|
||||
</View>
|
||||
<View
|
||||
initial={{ x: selectedView === 'preview' ? 0 : '100%' }}
|
||||
animate={{ x: selectedView === 'preview' ? 0 : '100%' }}
|
||||
>
|
||||
<Preview />
|
||||
</View>
|
||||
<Preview />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -285,14 +79,3 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
|
||||
)
|
||||
);
|
||||
});
|
||||
interface ViewProps extends HTMLMotionProps<'div'> {
|
||||
children: JSX.Element;
|
||||
}
|
||||
|
||||
const View = memo(({ children, ...props }: ViewProps) => {
|
||||
return (
|
||||
<motion.div className="absolute inset-0" transition={viewTransition} {...props}>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export * from './useMessageParser';
|
||||
export * from './usePromptEnhancer';
|
||||
export * from './useSnapScroll';
|
||||
export * from './useEditChatDescription';
|
||||
|
||||
@@ -9,7 +9,7 @@ export async function pingTelemetry(event: string, data: any) {
|
||||
data,
|
||||
};
|
||||
|
||||
await fetch('/api/ping-telemetry', {
|
||||
fetch('/api/ping-telemetry', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import git, { type GitAuth, type PromiseFsClient } from 'isomorphic-git';
|
||||
import http from 'isomorphic-git/http/web';
|
||||
import Cookies from 'js-cookie';
|
||||
import { toast } from 'react-toastify';
|
||||
import type { ProtocolFile } from '~/lib/replay/SimulationPrompt';
|
||||
import type { FileMap } from '~/lib/stores/files';
|
||||
|
||||
const lookupSavedPassword = (url: string) => {
|
||||
const domain = url.split('/')[2];
|
||||
const gitCreds = Cookies.get(`git:${domain}`);
|
||||
|
||||
if (!gitCreds) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const { username, password } = JSON.parse(gitCreds || '{}');
|
||||
return { username, password };
|
||||
} catch (error) {
|
||||
console.log(`Failed to parse Git Cookie ${error}`);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const saveGitAuth = (url: string, auth: GitAuth) => {
|
||||
const domain = url.split('/')[2];
|
||||
Cookies.set(`git:${domain}`, JSON.stringify(auth));
|
||||
};
|
||||
|
||||
export function useGit() {
|
||||
const [ready, setReady] = useState(false);
|
||||
const [fs, setFs] = useState<PromiseFsClient>();
|
||||
const fileData = useRef<FileMap>({});
|
||||
useEffect(() => {
|
||||
setFs(getFs(fileData.current));
|
||||
setReady(true);
|
||||
}, []);
|
||||
|
||||
const gitClone = useCallback(
|
||||
async (url: string) => {
|
||||
if (!fs || !ready) {
|
||||
throw 'Not initialized';
|
||||
}
|
||||
|
||||
const headers: {
|
||||
[x: string]: string;
|
||||
} = {
|
||||
'User-Agent': 'bolt.diy',
|
||||
};
|
||||
|
||||
const auth = lookupSavedPassword(url);
|
||||
|
||||
if (auth) {
|
||||
headers.Authorization = `Basic ${Buffer.from(`${auth.username}:${auth.password}`).toString('base64')}`;
|
||||
}
|
||||
|
||||
try {
|
||||
await git.clone({
|
||||
fs,
|
||||
http,
|
||||
dir: '/',
|
||||
url,
|
||||
depth: 1,
|
||||
singleBranch: true,
|
||||
corsProxy: '/api/git-proxy',
|
||||
headers,
|
||||
|
||||
onAuth: (url) => {
|
||||
let auth = lookupSavedPassword(url);
|
||||
|
||||
if (auth) {
|
||||
return auth;
|
||||
}
|
||||
|
||||
if (confirm('This repo is password protected. Ready to enter a username & password?')) {
|
||||
auth = {
|
||||
username: prompt('Enter username'),
|
||||
password: prompt('Enter password'),
|
||||
};
|
||||
return auth;
|
||||
} else {
|
||||
return { cancel: true };
|
||||
}
|
||||
},
|
||||
onAuthFailure: (url, _auth) => {
|
||||
toast.error(`Error Authenticating with ${url.split('/')[2]}`);
|
||||
},
|
||||
onAuthSuccess: (url, auth) => {
|
||||
saveGitAuth(url, auth);
|
||||
},
|
||||
});
|
||||
|
||||
return { workdir: '/', data: fileData.current };
|
||||
} catch (error) {
|
||||
console.error('Git clone error:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[fs, ready],
|
||||
);
|
||||
|
||||
return { ready, gitClone };
|
||||
}
|
||||
|
||||
function createFileFromEncoding(path: string, data: any, encoding: string | undefined): ProtocolFile {
|
||||
if (typeof data == 'string') {
|
||||
return { path, content: data };
|
||||
}
|
||||
|
||||
console.error('CreateFileFromEncodingFailed', { data, encoding });
|
||||
|
||||
return { path, content: 'CreateFileFromEncodingFailed' };
|
||||
}
|
||||
|
||||
const getFs = (files: FileMap) => ({
|
||||
promises: {
|
||||
readFile: async (path: string, _options: any) => {
|
||||
try {
|
||||
const result = files[path]?.content;
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
writeFile: async (path: string, data: any, options: any) => {
|
||||
const encoding = options.encoding;
|
||||
files[path] = createFileFromEncoding(path, data, encoding);
|
||||
},
|
||||
mkdir: (_path: string, _options: any) => {},
|
||||
readdir: async (_path: string, _options: any) => {
|
||||
throw new Error('NYI');
|
||||
},
|
||||
rm: async (_path: string, _options: any) => {
|
||||
throw new Error('NYI');
|
||||
},
|
||||
rmdir: async (_path: string, _options: any) => {
|
||||
throw new Error('NYI');
|
||||
},
|
||||
unlink: async (_path: string) => {
|
||||
throw new Error('NYI');
|
||||
},
|
||||
stat: async (_path: string) => {
|
||||
throw new Error('NYI');
|
||||
},
|
||||
lstat: async (_path: string) => {
|
||||
throw new Error('NYI');
|
||||
},
|
||||
readlink: async (_path: string) => {
|
||||
throw new Error('NYI');
|
||||
},
|
||||
symlink: async (_target: string, _path: string) => {
|
||||
throw new Error('NYI');
|
||||
},
|
||||
chmod: (_path: string, _mode: number) => {},
|
||||
},
|
||||
});
|
||||
@@ -1,60 +0,0 @@
|
||||
import type { Message } from 'ai';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { StreamingMessageParser } from '~/lib/runtime/message-parser';
|
||||
import { workbenchStore } from '~/lib/stores/workbench';
|
||||
import { createScopedLogger } from '~/utils/logger';
|
||||
|
||||
const logger = createScopedLogger('useMessageParser');
|
||||
|
||||
const messageParser = new StreamingMessageParser({
|
||||
callbacks: {
|
||||
onArtifactOpen: (data) => {
|
||||
logger.trace('onArtifactOpen', data);
|
||||
|
||||
workbenchStore.showWorkbench.set(true);
|
||||
workbenchStore.addArtifact(data);
|
||||
},
|
||||
onArtifactClose: (data) => {
|
||||
logger.trace('onArtifactClose');
|
||||
|
||||
workbenchStore.updateArtifact(data, { closed: true });
|
||||
},
|
||||
onActionOpen: (data) => {
|
||||
logger.trace('onActionOpen', data.action);
|
||||
},
|
||||
onActionClose: (data) => {
|
||||
logger.trace('onActionClose', data.action);
|
||||
workbenchStore.runAction(data);
|
||||
},
|
||||
onActionStream: (data) => {
|
||||
logger.trace('onActionStream', data.action);
|
||||
workbenchStore.runAction(data);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export function useMessageParser() {
|
||||
const [parsedMessages, setParsedMessages] = useState<{ [key: number]: string }>({});
|
||||
|
||||
const parseMessages = useCallback((messages: Message[], isLoading: boolean) => {
|
||||
let reset = false;
|
||||
|
||||
if (import.meta.env.DEV && !isLoading) {
|
||||
reset = true;
|
||||
messageParser.reset();
|
||||
}
|
||||
|
||||
for (const [index, message] of messages.entries()) {
|
||||
if (message.role === 'assistant') {
|
||||
const newParsedContent = messageParser.parse(message.id, message.content);
|
||||
|
||||
setParsedMessages((prevParsed) => ({
|
||||
...prevParsed,
|
||||
[index]: !reset ? (prevParsed[index] || '') + newParsedContent : newParsedContent,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { parsedMessages, setParsedMessages, parseMessages };
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useLoaderData, useNavigate, useSearchParams } from '@remix-run/react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { atom } from 'nanostores';
|
||||
import type { Message } from 'ai';
|
||||
import type { Message as BaseMessage } from 'ai';
|
||||
import { toast } from 'react-toastify';
|
||||
import { workbenchStore } from '~/lib/stores/workbench';
|
||||
import { logStore } from '~/lib/stores/logs'; // Import logStore
|
||||
@@ -17,6 +17,18 @@ import {
|
||||
import { loadProblem } from '~/components/chat/LoadProblemButton';
|
||||
import { createAsyncSuspenseValue } from '~/lib/asyncSuspenseValue';
|
||||
|
||||
// Messages in a chat's history. The repository may update in response to changes in the messages.
|
||||
// Each message which changes the repository state must have a repositoryId.
|
||||
export interface Message extends BaseMessage {
|
||||
// Describes the state of the project after changes in this message were applied.
|
||||
repositoryId?: string;
|
||||
}
|
||||
|
||||
export interface ChatState {
|
||||
description: string;
|
||||
messages: Message[];
|
||||
}
|
||||
|
||||
export interface ChatHistoryItem {
|
||||
id: string;
|
||||
urlId?: string;
|
||||
@@ -113,19 +125,6 @@ export function useChatHistory() {
|
||||
return;
|
||||
}
|
||||
|
||||
const { firstArtifact } = workbenchStore;
|
||||
|
||||
if (!urlId && firstArtifact?.id) {
|
||||
const urlId = await getUrlId(db, firstArtifact.id);
|
||||
|
||||
navigateChat(urlId);
|
||||
setUrlId(urlId);
|
||||
}
|
||||
|
||||
if (!description.get() && firstArtifact?.title) {
|
||||
description.set(firstArtifact?.title);
|
||||
}
|
||||
|
||||
if (initialMessages.length === 0 && !chatId.get()) {
|
||||
const nextId = await getNextId(db);
|
||||
|
||||
@@ -189,3 +188,19 @@ function navigateChat(nextId: string) {
|
||||
|
||||
window.history.replaceState({}, '', url);
|
||||
}
|
||||
|
||||
// Get the repositoryId before any changes in the message at the given index.
|
||||
export function getPreviousRepositoryId(messages: Message[], index: number): string | undefined {
|
||||
for (let i = index - 1; i >= 0; i--) {
|
||||
const message = messages[i];
|
||||
if (message.repositoryId) {
|
||||
return message.repositoryId;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Get the repositoryId after applying some messages.
|
||||
export function getMessagesRepositoryId(messages: Message[]): string | undefined {
|
||||
return getPreviousRepositoryId(messages, messages.length);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
// 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';
|
||||
import { recordingMessageHandlerScript } from './Recording';
|
||||
|
||||
class DevelopmentServerManager {
|
||||
// Empty if this chat has been destroyed.
|
||||
@@ -33,7 +32,7 @@ class DevelopmentServerManager {
|
||||
this.client = undefined;
|
||||
}
|
||||
|
||||
async setRepositoryContents(contents: string): Promise<string | undefined> {
|
||||
async setRepositoryContents(repositoryId: string): Promise<string | undefined> {
|
||||
assert(this.client, 'Chat has been destroyed');
|
||||
|
||||
try {
|
||||
@@ -42,7 +41,8 @@ class DevelopmentServerManager {
|
||||
method: 'Nut.startDevelopmentServer',
|
||||
params: {
|
||||
chatId,
|
||||
repositoryContents: contents,
|
||||
repositoryId,
|
||||
injectedScript: recordingMessageHandlerScript,
|
||||
},
|
||||
})) as { url: string };
|
||||
|
||||
@@ -56,21 +56,21 @@ class DevelopmentServerManager {
|
||||
|
||||
let gActiveDevelopmentServer: DevelopmentServerManager | undefined;
|
||||
|
||||
const debounceSetRepositoryContents = debounce(async (repositoryContents: string) => {
|
||||
export async function updateDevelopmentServer(repositoryId: string) {
|
||||
workbenchStore.showWorkbench.set(true);
|
||||
workbenchStore.repositoryId.set(repositoryId);
|
||||
workbenchStore.previewURL.set(undefined);
|
||||
workbenchStore.previewError.set(false);
|
||||
|
||||
if (!gActiveDevelopmentServer) {
|
||||
gActiveDevelopmentServer = new DevelopmentServerManager();
|
||||
}
|
||||
|
||||
const url = await gActiveDevelopmentServer.setRepositoryContents(repositoryContents);
|
||||
const url = await gActiveDevelopmentServer.setRepositoryContents(repositoryId);
|
||||
|
||||
if (!url) {
|
||||
toast.error('Failed to start development server');
|
||||
if (url) {
|
||||
workbenchStore.previewURL.set(url);
|
||||
} else {
|
||||
workbenchStore.previewError.set(true);
|
||||
}
|
||||
|
||||
workbenchStore.previewURL.set(url);
|
||||
}, 500);
|
||||
|
||||
export async function updateDevelopmentServer(repositoryContents: string) {
|
||||
workbenchStore.previewURL.set(undefined);
|
||||
debounceSetRepositoryContents(repositoryContents);
|
||||
}
|
||||
|
||||
@@ -4,8 +4,6 @@ import { toast } from 'react-toastify';
|
||||
import { sendCommandDedicatedClient } from './ReplayProtocolClient';
|
||||
import type { ProtocolMessage } from './SimulationPrompt';
|
||||
import Cookies from 'js-cookie';
|
||||
import JSZip from 'jszip';
|
||||
import type { FileArtifact } from '~/utils/folderImport';
|
||||
import { shouldUseSupabase } from '~/lib/supabase/client';
|
||||
import {
|
||||
supabaseListAllProblems,
|
||||
@@ -241,26 +239,6 @@ export function saveProblemsUsername(username: string) {
|
||||
Cookies.set(nutProblemsUsernameCookieName, username);
|
||||
}
|
||||
|
||||
export async function extractFileArtifactsFromRepositoryContents(repositoryContents: string): Promise<FileArtifact[]> {
|
||||
const zip = new JSZip();
|
||||
await zip.loadAsync(repositoryContents, { base64: true });
|
||||
|
||||
const fileArtifacts: FileArtifact[] = [];
|
||||
|
||||
for (const [key, object] of Object.entries(zip.files)) {
|
||||
if (object.dir) {
|
||||
continue;
|
||||
}
|
||||
|
||||
fileArtifacts.push({
|
||||
content: await object.async('text'),
|
||||
path: key,
|
||||
});
|
||||
}
|
||||
|
||||
return fileArtifacts;
|
||||
}
|
||||
|
||||
export async function submitFeedback(feedback: any): Promise<boolean> {
|
||||
if (shouldUseSupabase()) {
|
||||
return supabaseSubmitFeedback(feedback);
|
||||
|
||||
@@ -576,20 +576,9 @@ function addRecordingMessageHandler(_messageHandlerId: string) {
|
||||
};
|
||||
}
|
||||
|
||||
const recordingMessageHandlerScript = `
|
||||
export const recordingMessageHandlerScript = `
|
||||
${assert}
|
||||
${stringToBase64}
|
||||
${uint8ArrayToBase64}
|
||||
(${addRecordingMessageHandler})()
|
||||
`;
|
||||
|
||||
export function doInjectRecordingMessageHandler(content: string) {
|
||||
const headTag = content.indexOf('<head>');
|
||||
assert(headTag != -1, 'No <head> tag found');
|
||||
|
||||
const headEnd = headTag + 6;
|
||||
|
||||
const scriptTag = `<script>${recordingMessageHandlerScript}</script>`;
|
||||
|
||||
return content.slice(0, headEnd) + scriptTag + content.slice(headEnd);
|
||||
}
|
||||
|
||||
25
app/lib/replay/Repository.ts
Normal file
25
app/lib/replay/Repository.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { sendCommandDedicatedClient } from "./ReplayProtocolClient";
|
||||
|
||||
// Get the contents of a repository as a base64 string of the zip file.
|
||||
export async function getRepositoryContents(repositoryId: string): Promise<string> {
|
||||
const rv = await sendCommandDedicatedClient({
|
||||
method: 'Nut.getRepository',
|
||||
params: { repositoryId },
|
||||
}) as { repositoryContents: string };
|
||||
return rv.repositoryContents;
|
||||
}
|
||||
|
||||
// Remotely create an imported repository from the given contents.
|
||||
export async function createRepositoryImported(reason: string, repositoryContents: string): Promise<string> {
|
||||
const rv = await sendCommandDedicatedClient({
|
||||
method: 'Nut.createRepository',
|
||||
params: {
|
||||
repositoryContents,
|
||||
origin: {
|
||||
kind: 'imported',
|
||||
reason,
|
||||
},
|
||||
},
|
||||
}) as { repositoryId: string };
|
||||
return rv.repositoryId;
|
||||
}
|
||||
@@ -8,13 +8,11 @@ interface SimulationPacketServerURL {
|
||||
url: string;
|
||||
}
|
||||
|
||||
/*
|
||||
* Simulation data specifying the contents of the repository to set up a dev server
|
||||
* for static resources.
|
||||
*/
|
||||
interface SimulationPacketRepositoryContents {
|
||||
kind: 'repositoryContents';
|
||||
contents: string; // base64 encoded zip of the repository.
|
||||
// Simulation data specifying a repository ID to set up a dev server
|
||||
// for static resources and any initial database contents.
|
||||
interface SimulationPacketRepositoryId {
|
||||
kind: "repositoryId";
|
||||
repositoryId: string;
|
||||
}
|
||||
|
||||
// Simulation data specifying the viewport size
|
||||
@@ -178,7 +176,7 @@ interface SimulationPacketLocalStorage {
|
||||
|
||||
type SimulationPacketBase =
|
||||
| SimulationPacketServerURL
|
||||
| SimulationPacketRepositoryContents
|
||||
| SimulationPacketRepositoryId
|
||||
| SimulationPacketViewport
|
||||
| SimulationPacketLocationHref
|
||||
| SimulationPacketDocumentURL
|
||||
|
||||
@@ -8,17 +8,14 @@ import type { SimulationData, SimulationPacket } from './SimulationData';
|
||||
import { simulationDataVersion } from './SimulationData';
|
||||
import { assert, generateRandomId, ProtocolClient } from './ReplayProtocolClient';
|
||||
import type { MouseData } from './Recording';
|
||||
import type { FileMap } from '~/lib/stores/files';
|
||||
import { shouldIncludeFile } from '~/utils/fileUtils';
|
||||
import { developerSystemPrompt } from '~/lib/common/prompts/prompts';
|
||||
import { updateDevelopmentServer } from './DevelopmentServer';
|
||||
import { workbenchStore } from '~/lib/stores/workbench';
|
||||
import { isEnhancedPromptMessage } from '~/components/chat/Chat.client';
|
||||
|
||||
function createRepositoryContentsPacket(contents: string): SimulationPacket {
|
||||
function createRepositoryIdPacket(repositoryId: string): SimulationPacket {
|
||||
return {
|
||||
kind: 'repositoryContents',
|
||||
contents,
|
||||
kind: 'repositoryId',
|
||||
repositoryId,
|
||||
time: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -39,17 +36,12 @@ type ProtocolMessageImage = {
|
||||
|
||||
export type ProtocolMessage = ProtocolMessageText | ProtocolMessageImage;
|
||||
|
||||
export type ProtocolFile = {
|
||||
path: string;
|
||||
content: string;
|
||||
base64?: boolean;
|
||||
};
|
||||
|
||||
type ChatResponsePartCallback = (response: string) => void;
|
||||
|
||||
type ChatMessageMode = 'recording' | 'static' | 'developer';
|
||||
|
||||
interface ChatMessageOptions {
|
||||
chatOnly?: boolean;
|
||||
developerFiles?: ProtocolFile[];
|
||||
baseRepositoryId?: string;
|
||||
onResponsePart?: ChatResponsePartCallback;
|
||||
}
|
||||
|
||||
@@ -66,8 +58,8 @@ class ChatManager {
|
||||
// Whether all simulation data has been sent.
|
||||
simulationFinished?: boolean;
|
||||
|
||||
// Any repository contents we sent up for this chat.
|
||||
repositoryContents?: string;
|
||||
// Any repository ID we specified for this chat.
|
||||
repositoryId?: string;
|
||||
|
||||
// Simulation data for the page itself and any user interactions.
|
||||
pageData: SimulationData = [];
|
||||
@@ -96,11 +88,11 @@ class ChatManager {
|
||||
this.client = undefined;
|
||||
}
|
||||
|
||||
async setRepositoryContents(contents: string) {
|
||||
async setRepositoryId(repositoryId: string) {
|
||||
assert(this.client, 'Chat has been destroyed');
|
||||
this.repositoryContents = contents;
|
||||
this.repositoryId = repositoryId;
|
||||
|
||||
const packet = createRepositoryContentsPacket(contents);
|
||||
const packet = createRepositoryIdPacket(repositoryId);
|
||||
|
||||
const chatId = await this.chatIdPromise;
|
||||
await this.client.sendCommand({
|
||||
@@ -117,7 +109,7 @@ class ChatManager {
|
||||
|
||||
async addPageData(data: SimulationData) {
|
||||
assert(this.client, 'Chat has been destroyed');
|
||||
assert(this.repositoryContents, 'Expected repository contents');
|
||||
assert(this.repositoryId, 'Expected repository ID');
|
||||
|
||||
this.pageData.push(...data);
|
||||
|
||||
@@ -139,7 +131,7 @@ class ChatManager {
|
||||
finishSimulationData(): SimulationData {
|
||||
assert(this.client, 'Chat has been destroyed');
|
||||
assert(!this.simulationFinished, 'Simulation has been finished');
|
||||
assert(this.repositoryContents, 'Expected repository contents');
|
||||
assert(this.repositoryId, 'Expected repository ID');
|
||||
|
||||
this.recordingIdPromise = (async () => {
|
||||
assert(this.client, 'Chat has been destroyed');
|
||||
@@ -155,13 +147,13 @@ class ChatManager {
|
||||
return recordingId;
|
||||
})();
|
||||
|
||||
const allData = [createRepositoryContentsPacket(this.repositoryContents), ...this.pageData];
|
||||
const allData = [createRepositoryIdPacket(this.repositoryId), ...this.pageData];
|
||||
this.simulationFinished = true;
|
||||
|
||||
return allData;
|
||||
}
|
||||
|
||||
async sendChatMessage(messages: ProtocolMessage[], options?: ChatMessageOptions) {
|
||||
async sendChatMessage(mode: ChatMessageMode, messages: ProtocolMessage[], options?: ChatMessageOptions) {
|
||||
assert(this.client, 'Chat has been destroyed');
|
||||
|
||||
const responseId = `response-${generateRandomId()}`;
|
||||
@@ -179,66 +171,39 @@ class ChatManager {
|
||||
},
|
||||
);
|
||||
|
||||
const modifiedFiles: ProtocolFile[] = [];
|
||||
const removeFileListener = this.client.listenForMessage(
|
||||
'Nut.chatModifiedFile',
|
||||
({ responseId: eventResponseId, file }: { responseId: string; file: ProtocolFile }) => {
|
||||
if (responseId == eventResponseId) {
|
||||
console.log('ChatModifiedFile', file);
|
||||
modifiedFiles.push(file);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const chatId = await this.chatIdPromise;
|
||||
|
||||
console.log(
|
||||
'ChatSendMessage',
|
||||
new Date().toISOString(),
|
||||
chatId,
|
||||
JSON.stringify({ messages, developerFiles: options?.developerFiles }),
|
||||
JSON.stringify({ mode, messages, baseRepositoryId: options?.baseRepositoryId }),
|
||||
);
|
||||
|
||||
await this.client.sendCommand({
|
||||
const { repositoryId } = (await this.client.sendCommand({
|
||||
method: 'Nut.sendChatMessage',
|
||||
params: { chatId, responseId, messages, chatOnly: options?.chatOnly, developerFiles: options?.developerFiles },
|
||||
});
|
||||
params: { chatId, responseId, mode, messages, baseRepositoryId: options?.baseRepositoryId },
|
||||
})) as { repositoryId?: string };
|
||||
|
||||
/*
|
||||
* The modified files are added at the end as inserting them in the middle of the
|
||||
* response can cause weird rendering behavior.
|
||||
*/
|
||||
for (const file of modifiedFiles) {
|
||||
const content = `
|
||||
<boltArtifact id="modified-file-${generateRandomId()}" title="File Changes">
|
||||
<boltAction type="file" filePath="${file.path}">${file.content}</boltAction>
|
||||
</boltArtifact>
|
||||
`;
|
||||
|
||||
response += content;
|
||||
options?.onResponsePart?.(content);
|
||||
}
|
||||
|
||||
console.log('ChatResponse', chatId, response);
|
||||
console.log('ChatResponse', chatId, repositoryId, response);
|
||||
|
||||
removeResponseListener();
|
||||
removeFileListener();
|
||||
|
||||
return response;
|
||||
return { response, repositoryId };
|
||||
}
|
||||
}
|
||||
|
||||
// There is only one chat active at a time.
|
||||
let gChatManager: ChatManager | undefined;
|
||||
|
||||
function startChat(repositoryContents: string, pageData: SimulationData) {
|
||||
function startChat(repositoryId: string, pageData: SimulationData) {
|
||||
if (gChatManager) {
|
||||
gChatManager.destroy();
|
||||
}
|
||||
|
||||
gChatManager = new ChatManager();
|
||||
|
||||
gChatManager.setRepositoryContents(repositoryContents);
|
||||
gChatManager.setRepositoryId(repositoryId);
|
||||
|
||||
if (pageData.length) {
|
||||
gChatManager.addPageData(pageData);
|
||||
@@ -246,18 +211,12 @@ function startChat(repositoryContents: string, pageData: SimulationData) {
|
||||
}
|
||||
|
||||
/*
|
||||
* Called when the repository contents have changed. We'll start a new chat
|
||||
* with the same interaction data as any existing chat. The remote development
|
||||
* server will also be updated.
|
||||
* Called when the repository has changed. We'll start a new chat
|
||||
* and update the remote development server.
|
||||
*/
|
||||
export async function simulationRepositoryUpdated() {
|
||||
const { contentBase64: repositoryContents } = await workbenchStore.generateZipBase64();
|
||||
startChat(repositoryContents, gChatManager?.pageData ?? []);
|
||||
|
||||
const { contentBase64: injectedContents } = await workbenchStore.generateZipBase64(
|
||||
/* injectRecordingMessageHandler */ true,
|
||||
);
|
||||
updateDevelopmentServer(injectedContents);
|
||||
export function simulationRepositoryUpdated(repositoryId: string) {
|
||||
startChat(repositoryId, []);
|
||||
updateDevelopmentServer(repositoryId);
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -267,10 +226,10 @@ export async function simulationRepositoryUpdated() {
|
||||
export async function simulationReloaded() {
|
||||
assert(gChatManager, 'Expected to have an active chat');
|
||||
|
||||
const repositoryContents = gChatManager.repositoryContents;
|
||||
assert(repositoryContents, 'Expected active chat to have repository contents');
|
||||
const repositoryId = gChatManager.repositoryId;
|
||||
assert(repositoryId, 'Expected active chat to have repository ID');
|
||||
|
||||
startChat(repositoryContents, []);
|
||||
startChat(repositoryId, []);
|
||||
}
|
||||
|
||||
export async function simulationAddData(data: SimulationData) {
|
||||
@@ -293,7 +252,7 @@ export async function getSimulationRecording(): Promise<string> {
|
||||
* The repository contents are part of the problem and excluded from the simulation data
|
||||
* reported for solutions.
|
||||
*/
|
||||
gLastUserSimulationData = simulationData.filter((packet) => packet.kind != 'repositoryContents');
|
||||
gLastUserSimulationData = simulationData.filter((packet) => packet.kind != 'repositoryId');
|
||||
|
||||
console.log('SimulationData', new Date().toISOString(), JSON.stringify(simulationData));
|
||||
|
||||
@@ -354,7 +313,9 @@ export async function getSimulationEnhancedPrompt(
|
||||
|
||||
gLastSimulationChatMessages = messages;
|
||||
|
||||
return await gChatManager.sendChatMessage(messages);
|
||||
const { response } = await gChatManager.sendChatMessage('recording', messages);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function shouldUseSimulation(messageInput: string) {
|
||||
@@ -391,7 +352,7 @@ Here is the user message you need to evaluate: <user_message>${messageInput}</us
|
||||
},
|
||||
];
|
||||
|
||||
const response = await gChatManager.sendChatMessage(messages, { chatOnly: true });
|
||||
const { response } = await gChatManager.sendChatMessage('static', messages);
|
||||
|
||||
console.log('UseSimulationResponse', response);
|
||||
|
||||
@@ -500,24 +461,13 @@ function messagesHaveEnhancedPrompt(messages: Message[]): boolean {
|
||||
|
||||
export async function sendDeveloperChatMessage(
|
||||
messages: Message[],
|
||||
files: FileMap,
|
||||
baseRepositoryId: string | undefined,
|
||||
onResponsePart: ChatResponsePartCallback,
|
||||
) {
|
||||
if (!gChatManager) {
|
||||
gChatManager = new ChatManager();
|
||||
}
|
||||
|
||||
const developerFiles: ProtocolFile[] = [];
|
||||
|
||||
for (const [path, file] of Object.entries(files)) {
|
||||
if (file && shouldIncludeFile(path)) {
|
||||
developerFiles.push({
|
||||
path,
|
||||
content: file.content,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let systemPrompt = developerSystemPrompt;
|
||||
|
||||
if (messagesHaveEnhancedPrompt(messages)) {
|
||||
@@ -536,5 +486,6 @@ Focus specifically on fixing this bug. Do not guess about other problems.
|
||||
content: systemPrompt,
|
||||
});
|
||||
|
||||
return gChatManager.sendChatMessage(protocolMessages, { chatOnly: true, developerFiles, onResponsePart });
|
||||
const { repositoryId } = await gChatManager.sendChatMessage('developer', protocolMessages, { baseRepositoryId, onResponsePart });
|
||||
return repositoryId;
|
||||
}
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import { atom, computed, map, type MapStore, type WritableAtom } from 'nanostores';
|
||||
import type { EditorDocument, ScrollPosition } from '~/components/editor/codemirror/CodeMirrorEditor';
|
||||
import type { FileMap, FilesStore } from './files';
|
||||
|
||||
export type EditorDocuments = Record<string, EditorDocument>;
|
||||
|
||||
type SelectedFile = WritableAtom<string | undefined>;
|
||||
|
||||
export class EditorStore {
|
||||
#filesStore: FilesStore;
|
||||
|
||||
selectedFile: SelectedFile = import.meta.hot?.data.selectedFile ?? atom<string | undefined>();
|
||||
documents: MapStore<EditorDocuments> = import.meta.hot?.data.documents ?? map({});
|
||||
|
||||
currentDocument = computed([this.documents, this.selectedFile], (documents, selectedFile) => {
|
||||
if (!selectedFile) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return documents[selectedFile];
|
||||
});
|
||||
|
||||
constructor(filesStore: FilesStore) {
|
||||
this.#filesStore = filesStore;
|
||||
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.data.documents = this.documents;
|
||||
import.meta.hot.data.selectedFile = this.selectedFile;
|
||||
}
|
||||
}
|
||||
|
||||
setDocuments(files: FileMap) {
|
||||
const previousDocuments = this.documents.value;
|
||||
|
||||
this.documents.set(
|
||||
Object.fromEntries<EditorDocument>(
|
||||
Object.entries(files)
|
||||
.map(([filePath, dirent]) => {
|
||||
if (dirent === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const previousDocument = previousDocuments?.[filePath];
|
||||
|
||||
return [
|
||||
filePath,
|
||||
{
|
||||
value: dirent.content,
|
||||
filePath,
|
||||
scroll: previousDocument?.scroll,
|
||||
},
|
||||
] as [string, EditorDocument];
|
||||
})
|
||||
.filter(Boolean) as Array<[string, EditorDocument]>,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
setSelectedFile(filePath: string | undefined) {
|
||||
this.selectedFile.set(filePath);
|
||||
}
|
||||
|
||||
updateScrollPosition(filePath: string, position: ScrollPosition) {
|
||||
const documents = this.documents.get();
|
||||
const documentState = documents[filePath];
|
||||
|
||||
if (!documentState) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.documents.setKey(filePath, {
|
||||
...documentState,
|
||||
scroll: position,
|
||||
});
|
||||
}
|
||||
|
||||
updateFile(filePath: string, newContent: string) {
|
||||
const documents = this.documents.get();
|
||||
const documentState = documents[filePath];
|
||||
|
||||
if (!documentState) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentContent = documentState.value;
|
||||
const contentChanged = currentContent !== newContent;
|
||||
|
||||
if (contentChanged) {
|
||||
this.documents.setKey(filePath, {
|
||||
...documentState,
|
||||
value: newContent,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import { map, type MapStore } from 'nanostores';
|
||||
import { computeFileModifications } from '~/utils/diff';
|
||||
import { createScopedLogger } from '~/utils/logger';
|
||||
import { unreachable } from '~/utils/unreachable';
|
||||
import type { ProtocolFile } from '~/lib/replay/SimulationPrompt';
|
||||
|
||||
const logger = createScopedLogger('FilesStore');
|
||||
|
||||
export type FileMap = Record<string, ProtocolFile | undefined>;
|
||||
|
||||
export class FilesStore {
|
||||
/**
|
||||
* Tracks the number of files without folders.
|
||||
*/
|
||||
#size = 0;
|
||||
|
||||
/**
|
||||
* @note Keeps track all modified files with their original content since the last user message.
|
||||
* Needs to be reset when the user sends another message and all changes have to be submitted
|
||||
* for the model to be aware of the changes.
|
||||
*/
|
||||
#modifiedFiles: Map<string, string> = import.meta.hot?.data.modifiedFiles ?? new Map();
|
||||
|
||||
/**
|
||||
* Map of files that matches the state of WebContainer.
|
||||
*/
|
||||
files: MapStore<FileMap> = import.meta.hot?.data.files ?? map({});
|
||||
|
||||
get filesCount() {
|
||||
return this.#size;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.data.files = this.files;
|
||||
import.meta.hot.data.modifiedFiles = this.#modifiedFiles;
|
||||
}
|
||||
}
|
||||
|
||||
getFile(filePath: string) {
|
||||
const dirent = this.files.get()[filePath];
|
||||
return dirent;
|
||||
}
|
||||
|
||||
getFileModifications() {
|
||||
return computeFileModifications(this.files.get(), this.#modifiedFiles);
|
||||
}
|
||||
|
||||
resetFileModifications() {
|
||||
this.#modifiedFiles.clear();
|
||||
}
|
||||
|
||||
async saveFile(filePath: string, content: string) {
|
||||
try {
|
||||
const oldContent = this.getFile(filePath)?.content;
|
||||
|
||||
if (!oldContent) {
|
||||
console.log('CurrentFiles', JSON.stringify(Object.keys(this.files.get())));
|
||||
unreachable(`Cannot save unknown file ${filePath}`);
|
||||
}
|
||||
|
||||
if (!this.#modifiedFiles.has(filePath)) {
|
||||
this.#modifiedFiles.set(filePath, oldContent);
|
||||
}
|
||||
|
||||
// we immediately update the file and don't rely on the `change` event coming from the watcher
|
||||
this.files.setKey(filePath, { path: filePath, content });
|
||||
|
||||
logger.info('File updated');
|
||||
} catch (error) {
|
||||
logger.error('Failed to update file content\n\n', error);
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,545 +1,26 @@
|
||||
import { atom, map, type MapStore, type ReadableAtom, type WritableAtom } from 'nanostores';
|
||||
import type { EditorDocument, ScrollPosition } from '~/components/editor/codemirror/CodeMirrorEditor';
|
||||
import type { ActionCallbackData, ArtifactCallbackData } from '~/lib/runtime/message-parser';
|
||||
import { unreachable } from '~/utils/unreachable';
|
||||
import { EditorStore } from './editor';
|
||||
import { FilesStore, type FileMap } from './files';
|
||||
import JSZip from 'jszip';
|
||||
import { saveAs } from 'file-saver';
|
||||
import { Octokit, type RestEndpointMethodTypes } from '@octokit/rest';
|
||||
import { description } from '~/lib/persistence';
|
||||
import Cookies from 'js-cookie';
|
||||
import { uint8ArrayToBase64 } from '~/lib/replay/ReplayProtocolClient';
|
||||
import type { ActionAlert } from '~/types/actions';
|
||||
import { extractFileArtifactsFromRepositoryContents } from '~/lib/replay/Problems';
|
||||
import { onRepositoryFileWritten } from '~/components/chat/Chat.client';
|
||||
import { doInjectRecordingMessageHandler } from '~/lib/replay/Recording';
|
||||
|
||||
export interface ArtifactState {
|
||||
id: string;
|
||||
title: string;
|
||||
type?: string;
|
||||
closed: boolean;
|
||||
}
|
||||
|
||||
export type ArtifactUpdateState = Pick<ArtifactState, 'title' | 'closed'>;
|
||||
|
||||
type Artifacts = MapStore<Record<string, ArtifactState>>;
|
||||
|
||||
export type WorkbenchViewType = 'code' | 'preview';
|
||||
import { atom, type WritableAtom } from 'nanostores';
|
||||
|
||||
export class WorkbenchStore {
|
||||
#filesStore = new FilesStore();
|
||||
#editorStore = new EditorStore(this.#filesStore);
|
||||
// The current repository.
|
||||
repositoryId = atom<string | undefined>(undefined);
|
||||
|
||||
// Any available preview URL for the current repository.
|
||||
previewURL = atom<string | undefined>(undefined);
|
||||
|
||||
artifacts: Artifacts = import.meta.hot?.data.artifacts ?? map({});
|
||||
// Whether there was an error loading the preview.
|
||||
previewError = atom<boolean>(false);
|
||||
|
||||
showWorkbench: WritableAtom<boolean> = import.meta.hot?.data.showWorkbench ?? atom(false);
|
||||
currentView: WritableAtom<WorkbenchViewType> = import.meta.hot?.data.currentView ?? atom('code');
|
||||
unsavedFiles: WritableAtom<Set<string>> = import.meta.hot?.data.unsavedFiles ?? atom(new Set<string>());
|
||||
actionAlert: WritableAtom<ActionAlert | undefined> =
|
||||
import.meta.hot?.data.unsavedFiles ?? atom<ActionAlert | undefined>(undefined);
|
||||
modifiedFiles = new Set<string>();
|
||||
artifactIdList: string[] = [];
|
||||
#globalExecutionQueue = Promise.resolve();
|
||||
|
||||
constructor() {
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.data.artifacts = this.artifacts;
|
||||
import.meta.hot.data.unsavedFiles = this.unsavedFiles;
|
||||
import.meta.hot.data.showWorkbench = this.showWorkbench;
|
||||
import.meta.hot.data.currentView = this.currentView;
|
||||
import.meta.hot.data.actionAlert = this.actionAlert;
|
||||
}
|
||||
}
|
||||
|
||||
addToExecutionQueue(callback: () => Promise<void>) {
|
||||
this.#globalExecutionQueue = this.#globalExecutionQueue.then(() => callback());
|
||||
}
|
||||
|
||||
get files() {
|
||||
return this.#filesStore.files;
|
||||
}
|
||||
|
||||
get currentDocument(): ReadableAtom<EditorDocument | undefined> {
|
||||
return this.#editorStore.currentDocument;
|
||||
}
|
||||
|
||||
get selectedFile(): ReadableAtom<string | undefined> {
|
||||
return this.#editorStore.selectedFile;
|
||||
}
|
||||
|
||||
get firstArtifact(): ArtifactState | undefined {
|
||||
return this.#getArtifact(this.artifactIdList[0]);
|
||||
}
|
||||
|
||||
get filesCount(): number {
|
||||
return this.#filesStore.filesCount;
|
||||
}
|
||||
|
||||
get alert() {
|
||||
return this.actionAlert;
|
||||
}
|
||||
clearAlert() {
|
||||
this.actionAlert.set(undefined);
|
||||
}
|
||||
|
||||
setDocuments(files: FileMap) {
|
||||
this.#editorStore.setDocuments(files);
|
||||
|
||||
if (this.#filesStore.filesCount > 0 && this.currentDocument.get() === undefined) {
|
||||
// we find the first file and select it
|
||||
for (const [filePath, dirent] of Object.entries(files)) {
|
||||
if (dirent) {
|
||||
this.setSelectedFile(filePath);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setShowWorkbench(show: boolean) {
|
||||
this.showWorkbench.set(show);
|
||||
}
|
||||
|
||||
setCurrentDocumentContent(newContent: string) {
|
||||
const filePath = this.currentDocument.get()?.filePath;
|
||||
|
||||
if (!filePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
const originalContent = this.#filesStore.getFile(filePath)?.content;
|
||||
const unsavedChanges = originalContent !== undefined && originalContent !== newContent;
|
||||
|
||||
this.#editorStore.updateFile(filePath, newContent);
|
||||
|
||||
const currentDocument = this.currentDocument.get();
|
||||
|
||||
if (currentDocument) {
|
||||
const previousUnsavedFiles = this.unsavedFiles.get();
|
||||
|
||||
if (unsavedChanges && previousUnsavedFiles.has(currentDocument.filePath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newUnsavedFiles = new Set(previousUnsavedFiles);
|
||||
|
||||
if (unsavedChanges) {
|
||||
newUnsavedFiles.add(currentDocument.filePath);
|
||||
} else {
|
||||
newUnsavedFiles.delete(currentDocument.filePath);
|
||||
}
|
||||
|
||||
this.unsavedFiles.set(newUnsavedFiles);
|
||||
}
|
||||
}
|
||||
|
||||
setCurrentDocumentScrollPosition(position: ScrollPosition) {
|
||||
const editorDocument = this.currentDocument.get();
|
||||
|
||||
if (!editorDocument) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { filePath } = editorDocument;
|
||||
|
||||
this.#editorStore.updateScrollPosition(filePath, position);
|
||||
}
|
||||
|
||||
setSelectedFile(filePath: string | undefined) {
|
||||
this.#editorStore.setSelectedFile(filePath);
|
||||
}
|
||||
|
||||
async saveFile(filePath: string) {
|
||||
const documents = this.#editorStore.documents.get();
|
||||
const document = documents[filePath];
|
||||
|
||||
if (document === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.#filesStore.saveFile(filePath, document.value);
|
||||
|
||||
const newUnsavedFiles = new Set(this.unsavedFiles.get());
|
||||
newUnsavedFiles.delete(filePath);
|
||||
|
||||
this.unsavedFiles.set(newUnsavedFiles);
|
||||
}
|
||||
|
||||
async saveFileContents(filePath: string, contents: string) {
|
||||
await this.#filesStore.saveFile(filePath, contents);
|
||||
}
|
||||
|
||||
async saveCurrentDocument() {
|
||||
const currentDocument = this.currentDocument.get();
|
||||
|
||||
if (currentDocument === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.saveFile(currentDocument.filePath);
|
||||
}
|
||||
|
||||
resetCurrentDocument() {
|
||||
const currentDocument = this.currentDocument.get();
|
||||
|
||||
if (currentDocument === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { filePath } = currentDocument;
|
||||
const file = this.#filesStore.getFile(filePath);
|
||||
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setCurrentDocumentContent(file.content);
|
||||
}
|
||||
|
||||
async saveAllFiles() {
|
||||
for (const filePath of this.unsavedFiles.get()) {
|
||||
await this.saveFile(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
getFileModifcations() {
|
||||
return this.#filesStore.getFileModifications();
|
||||
}
|
||||
|
||||
resetAllFileModifications() {
|
||||
this.#filesStore.resetFileModifications();
|
||||
}
|
||||
|
||||
abortAllActions() {
|
||||
// TODO: what do we wanna do and how do we wanna recover from this?
|
||||
}
|
||||
|
||||
addArtifact({ messageId, title, id, type }: ArtifactCallbackData) {
|
||||
const artifact = this.#getArtifact(messageId);
|
||||
|
||||
if (artifact) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.artifactIdList.includes(messageId)) {
|
||||
this.artifactIdList.push(messageId);
|
||||
}
|
||||
|
||||
this.artifacts.setKey(messageId, {
|
||||
id,
|
||||
title,
|
||||
closed: false,
|
||||
type,
|
||||
});
|
||||
}
|
||||
|
||||
updateArtifact({ messageId }: ArtifactCallbackData, state: Partial<ArtifactUpdateState>) {
|
||||
const artifact = this.#getArtifact(messageId);
|
||||
|
||||
if (!artifact) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.artifacts.setKey(messageId, { ...artifact, ...state });
|
||||
}
|
||||
|
||||
runAction(data: ActionCallbackData) {
|
||||
this.addToExecutionQueue(() => this._runAction(data));
|
||||
}
|
||||
|
||||
async _runAction(data: ActionCallbackData) {
|
||||
const { messageId } = data;
|
||||
|
||||
const artifact = this.#getArtifact(messageId);
|
||||
|
||||
if (!artifact) {
|
||||
unreachable('Artifact not found');
|
||||
}
|
||||
|
||||
if (data.action.type === 'file') {
|
||||
const { filePath, content } = data.action;
|
||||
|
||||
const existingFiles = this.files.get();
|
||||
this.files.set({
|
||||
...existingFiles,
|
||||
[filePath]: {
|
||||
path: filePath,
|
||||
content,
|
||||
},
|
||||
});
|
||||
|
||||
onRepositoryFileWritten();
|
||||
|
||||
if (this.selectedFile.value !== filePath) {
|
||||
this.setSelectedFile(filePath);
|
||||
}
|
||||
|
||||
if (this.currentView.value !== 'code') {
|
||||
this.currentView.set('code');
|
||||
}
|
||||
|
||||
this.#editorStore.updateFile(filePath, content);
|
||||
}
|
||||
}
|
||||
|
||||
#getArtifact(id: string) {
|
||||
const artifacts = this.artifacts.get();
|
||||
return artifacts[id];
|
||||
}
|
||||
|
||||
private async _generateZip(injectRecordingMessageHandler = false) {
|
||||
const zip = new JSZip();
|
||||
const files = this.files.get();
|
||||
|
||||
// Get the project name from the description input, or use a default name
|
||||
const projectName = (description.value ?? 'project').toLocaleLowerCase().split(' ').join('_');
|
||||
|
||||
// Generate a simple 6-character hash based on the current timestamp
|
||||
const timestampHash = Date.now().toString(36).slice(-6);
|
||||
const uniqueProjectName = `${projectName}_${timestampHash}`;
|
||||
|
||||
for (const [filePath, dirent] of Object.entries(files)) {
|
||||
if (dirent) {
|
||||
let content = dirent.content;
|
||||
|
||||
if (injectRecordingMessageHandler && filePath == 'index.html') {
|
||||
content = doInjectRecordingMessageHandler(content);
|
||||
}
|
||||
|
||||
// split the path into segments
|
||||
const pathSegments = filePath.split('/');
|
||||
|
||||
// if there's more than one segment, we need to create folders
|
||||
if (pathSegments.length > 1) {
|
||||
let currentFolder = zip;
|
||||
|
||||
for (let i = 0; i < pathSegments.length - 1; i++) {
|
||||
currentFolder = currentFolder.folder(pathSegments[i])!;
|
||||
}
|
||||
currentFolder.file(pathSegments[pathSegments.length - 1], content);
|
||||
} else {
|
||||
// if there's only one segment, it's a file in the root
|
||||
zip.file(filePath, content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate the zip file and save it
|
||||
const content = await zip.generateAsync({ type: 'blob' });
|
||||
|
||||
return { content, uniqueProjectName };
|
||||
}
|
||||
|
||||
async downloadZip() {
|
||||
const { content, uniqueProjectName } = await this._generateZip();
|
||||
saveAs(content, `${uniqueProjectName}.zip`);
|
||||
}
|
||||
|
||||
async generateZipBase64(injectRecordingMessageHandler = false) {
|
||||
const { content, uniqueProjectName } = await this._generateZip(injectRecordingMessageHandler);
|
||||
const buf = await content.arrayBuffer();
|
||||
const contentBase64 = uint8ArrayToBase64(new Uint8Array(buf));
|
||||
|
||||
return { contentBase64, uniqueProjectName };
|
||||
}
|
||||
|
||||
async restoreProjectContentsBase64(messageId: string, contentBase64: string) {
|
||||
const fileArtifacts = await extractFileArtifactsFromRepositoryContents(contentBase64);
|
||||
|
||||
const modifiedFilePaths = new Set<string>();
|
||||
|
||||
// Check if any files we know about have different contents in the artifacts.
|
||||
const files = this.files.get();
|
||||
const fileRelativePaths = new Set<string>();
|
||||
|
||||
for (const [filePath, dirent] of Object.entries(files)) {
|
||||
if (dirent) {
|
||||
fileRelativePaths.add(filePath);
|
||||
|
||||
const content = dirent.content;
|
||||
|
||||
const artifact = fileArtifacts.find((artifact) => artifact.path === filePath);
|
||||
const artifactContent = artifact?.content ?? '';
|
||||
|
||||
if (content != artifactContent) {
|
||||
modifiedFilePaths.add(filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also create any new files in the artifacts.
|
||||
for (const artifact of fileArtifacts) {
|
||||
if (!fileRelativePaths.has(artifact.path)) {
|
||||
modifiedFilePaths.add(artifact.path);
|
||||
}
|
||||
}
|
||||
|
||||
const actionArtifactId = `restore-contents-artifact-id-${messageId}`;
|
||||
|
||||
for (const filePath of modifiedFilePaths) {
|
||||
console.log('RestoreModifiedFile', filePath);
|
||||
|
||||
const artifact = fileArtifacts.find((artifact) => artifact.path === filePath);
|
||||
const artifactContent = artifact?.content ?? '';
|
||||
|
||||
const actionId = `restore-contents-action-${messageId}-${filePath}-${Math.random().toString()}`;
|
||||
const data: ActionCallbackData = {
|
||||
actionId,
|
||||
messageId,
|
||||
artifactId: actionArtifactId,
|
||||
action: {
|
||||
type: 'file',
|
||||
filePath,
|
||||
content: artifactContent,
|
||||
},
|
||||
};
|
||||
|
||||
this.runAction(data);
|
||||
}
|
||||
}
|
||||
|
||||
async syncFiles(targetHandle: FileSystemDirectoryHandle) {
|
||||
const files = this.files.get();
|
||||
const syncedFiles = [];
|
||||
|
||||
for (const [filePath, dirent] of Object.entries(files)) {
|
||||
if (dirent) {
|
||||
const pathSegments = filePath.split('/');
|
||||
let currentHandle = targetHandle;
|
||||
|
||||
for (let i = 0; i < pathSegments.length - 1; i++) {
|
||||
currentHandle = await currentHandle.getDirectoryHandle(pathSegments[i], { create: true });
|
||||
}
|
||||
|
||||
// create or get the file
|
||||
const fileHandle = await currentHandle.getFileHandle(pathSegments[pathSegments.length - 1], {
|
||||
create: true,
|
||||
});
|
||||
|
||||
// write the file content
|
||||
const writable = await fileHandle.createWritable();
|
||||
await writable.write(dirent.content);
|
||||
await writable.close();
|
||||
|
||||
syncedFiles.push(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
return syncedFiles;
|
||||
}
|
||||
|
||||
async pushToGitHub(repoName: string, githubUsername?: string, ghToken?: string) {
|
||||
try {
|
||||
// Use cookies if username and token are not provided
|
||||
const githubToken = ghToken || Cookies.get('githubToken');
|
||||
const owner = githubUsername || Cookies.get('githubUsername');
|
||||
|
||||
if (!githubToken || !owner) {
|
||||
throw new Error('GitHub token or username is not set in cookies or provided.');
|
||||
}
|
||||
|
||||
// Initialize Octokit with the auth token
|
||||
const octokit = new Octokit({ auth: githubToken });
|
||||
|
||||
// Check if the repository already exists before creating it
|
||||
let repo: RestEndpointMethodTypes['repos']['get']['response']['data'];
|
||||
|
||||
try {
|
||||
const resp = await octokit.repos.get({ owner, repo: repoName });
|
||||
repo = resp.data;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && 'status' in error && error.status === 404) {
|
||||
// Repository doesn't exist, so create a new one
|
||||
const { data: newRepo } = await octokit.repos.createForAuthenticatedUser({
|
||||
name: repoName,
|
||||
private: false,
|
||||
auto_init: true,
|
||||
});
|
||||
repo = newRepo;
|
||||
} else {
|
||||
console.log('cannot create repo!');
|
||||
throw error; // Some other error occurred
|
||||
}
|
||||
}
|
||||
|
||||
// Get all files
|
||||
const files = this.files.get();
|
||||
|
||||
if (!files || Object.keys(files).length === 0) {
|
||||
throw new Error('No files found to push');
|
||||
}
|
||||
|
||||
// Create blobs for each file
|
||||
const blobs = await Promise.all(
|
||||
Object.entries(files).map(async ([filePath, dirent]) => {
|
||||
if (dirent) {
|
||||
const { data: blob } = await octokit.git.createBlob({
|
||||
owner: repo.owner.login,
|
||||
repo: repo.name,
|
||||
content: Buffer.from(dirent.content).toString('base64'),
|
||||
encoding: 'base64',
|
||||
});
|
||||
return { path: filePath, sha: blob.sha };
|
||||
}
|
||||
|
||||
return null;
|
||||
}),
|
||||
);
|
||||
|
||||
const validBlobs = blobs.filter(Boolean); // Filter out any undefined blobs
|
||||
|
||||
if (validBlobs.length === 0) {
|
||||
throw new Error('No valid files to push');
|
||||
}
|
||||
|
||||
// Get the latest commit SHA (assuming main branch, update dynamically if needed)
|
||||
const { data: ref } = await octokit.git.getRef({
|
||||
owner: repo.owner.login,
|
||||
repo: repo.name,
|
||||
ref: `heads/${repo.default_branch || 'main'}`, // Handle dynamic branch
|
||||
});
|
||||
const latestCommitSha = ref.object.sha;
|
||||
|
||||
// Create a new tree
|
||||
const { data: newTree } = await octokit.git.createTree({
|
||||
owner: repo.owner.login,
|
||||
repo: repo.name,
|
||||
base_tree: latestCommitSha,
|
||||
tree: validBlobs.map((blob) => ({
|
||||
path: blob!.path,
|
||||
mode: '100644',
|
||||
type: 'blob',
|
||||
sha: blob!.sha,
|
||||
})),
|
||||
});
|
||||
|
||||
// Create a new commit
|
||||
const { data: newCommit } = await octokit.git.createCommit({
|
||||
owner: repo.owner.login,
|
||||
repo: repo.name,
|
||||
message: 'Initial commit from your app',
|
||||
tree: newTree.sha,
|
||||
parents: [latestCommitSha],
|
||||
});
|
||||
|
||||
// Update the reference
|
||||
await octokit.git.updateRef({
|
||||
owner: repo.owner.login,
|
||||
repo: repo.name,
|
||||
ref: `heads/${repo.default_branch || 'main'}`, // Handle dynamic branch
|
||||
sha: newCommit.sha,
|
||||
});
|
||||
|
||||
alert(`Repository created and code pushed: ${repo.html_url}`);
|
||||
} catch (error) {
|
||||
console.error('Error pushing to GitHub:', error);
|
||||
throw error; // Rethrow the error for further handling
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const workbenchStore = new WorkbenchStore();
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { LoaderFunctionArgs } from '~/lib/remix-types';
|
||||
import { json, type MetaFunction } from '~/lib/remix-types';
|
||||
import { ClientOnly } from 'remix-utils/client-only';
|
||||
import { BaseChat } from '~/components/chat/BaseChat';
|
||||
import { GitUrlImport } from '~/components/git/GitUrlImport.client';
|
||||
import { Header } from '~/components/header/Header';
|
||||
import BackgroundRays from '~/components/ui/BackgroundRays';
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [{ title: 'Bolt' }, { name: 'description', content: 'Talk with Bolt, an AI assistant from StackBlitz' }];
|
||||
};
|
||||
|
||||
export async function loader(args: LoaderFunctionArgs) {
|
||||
return json({ url: args.params.url });
|
||||
}
|
||||
|
||||
export default function Index() {
|
||||
return (
|
||||
<div className="flex flex-col h-full w-full bg-bolt-elements-background-depth-1">
|
||||
<BackgroundRays />
|
||||
<Header />
|
||||
<ClientOnly fallback={<BaseChat />}>{() => <GitUrlImport />}</ClientOnly>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
import { createTwoFilesPatch } from 'diff';
|
||||
import type { FileMap } from '~/lib/stores/files';
|
||||
import { MODIFICATIONS_TAG_NAME } from './constants';
|
||||
|
||||
export const modificationsRegex = new RegExp(
|
||||
`^<${MODIFICATIONS_TAG_NAME}>[\\s\\S]*?<\\/${MODIFICATIONS_TAG_NAME}>\\s+`,
|
||||
'g',
|
||||
);
|
||||
|
||||
interface ModifiedFile {
|
||||
type: 'diff' | 'file';
|
||||
content: string;
|
||||
}
|
||||
|
||||
type FileModifications = Record<string, ModifiedFile>;
|
||||
|
||||
export function computeFileModifications(files: FileMap, modifiedFiles: Map<string, string>) {
|
||||
const modifications: FileModifications = {};
|
||||
|
||||
let hasModifiedFiles = false;
|
||||
|
||||
for (const [filePath, originalContent] of modifiedFiles) {
|
||||
const file = files[filePath];
|
||||
|
||||
if (!file) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const unifiedDiff = diffFiles(filePath, originalContent, file.content);
|
||||
|
||||
if (!unifiedDiff) {
|
||||
// files are identical
|
||||
continue;
|
||||
}
|
||||
|
||||
hasModifiedFiles = true;
|
||||
|
||||
if (unifiedDiff.length > file.content.length) {
|
||||
// if there are lots of changes we simply grab the current file content since it's smaller than the diff
|
||||
modifications[filePath] = { type: 'file', content: file.content };
|
||||
} else {
|
||||
// otherwise we use the diff since it's smaller
|
||||
modifications[filePath] = { type: 'diff', content: unifiedDiff };
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasModifiedFiles) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return modifications;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes a diff in the unified format. The only difference is that the header is omitted
|
||||
* because it will always assume that you're comparing two versions of the same file and
|
||||
* it allows us to avoid the extra characters we send back to the llm.
|
||||
*
|
||||
* @see https://www.gnu.org/software/diffutils/manual/html_node/Unified-Format.html
|
||||
*/
|
||||
export function diffFiles(fileName: string, oldFileContent: string, newFileContent: string) {
|
||||
let unifiedDiff = createTwoFilesPatch(fileName, fileName, oldFileContent, newFileContent);
|
||||
|
||||
const patchHeaderEnd = `--- ${fileName}\n+++ ${fileName}\n`;
|
||||
const headerEndIndex = unifiedDiff.indexOf(patchHeaderEnd);
|
||||
|
||||
if (headerEndIndex >= 0) {
|
||||
unifiedDiff = unifiedDiff.slice(headerEndIndex + patchHeaderEnd.length);
|
||||
}
|
||||
|
||||
if (unifiedDiff === '') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return unifiedDiff;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the unified diff to HTML.
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* ```html
|
||||
* <bolt_file_modifications>
|
||||
* <diff path="/home/project/index.js">
|
||||
* - console.log('Hello, World!');
|
||||
* + console.log('Hello, Bolt!');
|
||||
* </diff>
|
||||
* </bolt_file_modifications>
|
||||
* ```
|
||||
*/
|
||||
export function fileModificationsToHTML(modifications: FileModifications) {
|
||||
const entries = Object.entries(modifications);
|
||||
|
||||
if (entries.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const result: string[] = [`<${MODIFICATIONS_TAG_NAME}>`];
|
||||
|
||||
for (const [filePath, { type, content }] of entries) {
|
||||
result.push(`<${type} path=${JSON.stringify(filePath)}>`, content, `</${type}>`);
|
||||
}
|
||||
|
||||
result.push(`</${MODIFICATIONS_TAG_NAME}>`);
|
||||
|
||||
return result.join('\n');
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
import type { Message } from 'ai';
|
||||
import { generateId, shouldIncludeFile } from './fileUtils';
|
||||
import type { Message } from '~/lib/persistence/useChatHistory';
|
||||
import { generateId } from './fileUtils';
|
||||
import JSZip from 'jszip';
|
||||
|
||||
export interface FileArtifact {
|
||||
interface FileArtifact {
|
||||
content: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export async function getFileArtifacts(files: File[]): Promise<FileArtifact[]> {
|
||||
return Promise.all(
|
||||
export async function getFileRepositoryContents(files: File[]): Promise<string> {
|
||||
const artifacts: FileArtifact[] = await Promise.all(
|
||||
files.map(async (file) => {
|
||||
return new Promise<FileArtifact>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
@@ -25,34 +26,19 @@ export async function getFileArtifacts(files: File[]): Promise<FileArtifact[]> {
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const zip = new JSZip();
|
||||
for (const { path, content } of artifacts) {
|
||||
zip.file(path, content);
|
||||
}
|
||||
return await zip.generateAsync({ type: "base64" });
|
||||
}
|
||||
|
||||
export const createChatFromFolder = async (
|
||||
fileArtifacts: FileArtifact[],
|
||||
binaryFiles: string[],
|
||||
export function createChatFromFolder(
|
||||
folderName: string,
|
||||
): Promise<Message[]> => {
|
||||
const binaryFilesMessage =
|
||||
binaryFiles.length > 0
|
||||
? `\n\nSkipped ${binaryFiles.length} binary files:\n${binaryFiles.map((f) => `- ${f}`).join('\n')}`
|
||||
: '';
|
||||
|
||||
let filesContent = `I've imported the contents of the "${folderName}" folder.${binaryFilesMessage}`;
|
||||
filesContent += `<boltArtifact id="imported-files" title="Imported Files">`;
|
||||
|
||||
for (const file of fileArtifacts) {
|
||||
if (shouldIncludeFile(file.path)) {
|
||||
filesContent += `<boltAction type="file" filePath="${file.path}">${file.content}</boltAction>\n\n`;
|
||||
}
|
||||
}
|
||||
filesContent += `</boltArtifact>`;
|
||||
|
||||
const filesMessage: Message = {
|
||||
role: 'assistant',
|
||||
content: filesContent,
|
||||
id: generateId(),
|
||||
createdAt: new Date(),
|
||||
};
|
||||
repositoryId: string
|
||||
): Message[] {
|
||||
let filesContent = `I've imported the contents of the "${folderName}" folder.`;
|
||||
|
||||
const userMessage: Message = {
|
||||
role: 'user',
|
||||
@@ -61,7 +47,15 @@ export const createChatFromFolder = async (
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
const filesMessage: Message = {
|
||||
role: 'assistant',
|
||||
content: filesContent,
|
||||
id: generateId(),
|
||||
createdAt: new Date(),
|
||||
repositoryId,
|
||||
};
|
||||
|
||||
const messages = [userMessage, filesMessage];
|
||||
|
||||
return messages;
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user