feat(workbench): add file tree and hook up editor
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
export const getSystemPrompt = (cwd: string = '/home/project') => `
|
||||
import { WORK_DIR } from '../../../utils/constants';
|
||||
|
||||
export const getSystemPrompt = (cwd: string = WORK_DIR) => `
|
||||
You are Bolt, an expert AI assistant and exceptional senior software developer with vast knowledge across multiple programming languages, frameworks, and best practices.
|
||||
|
||||
<system_constraints>
|
||||
|
||||
@@ -20,7 +20,7 @@ const messageParser = new StreamingMessageParser({
|
||||
workbenchStore.updateArtifact(data, { closed: true });
|
||||
},
|
||||
onActionOpen: (data) => {
|
||||
logger.debug('onActionOpen', data.action);
|
||||
logger.trace('onActionOpen', data.action);
|
||||
|
||||
// we only add shell actions when when the close tag got parsed because only then we have the content
|
||||
if (data.action.type !== 'shell') {
|
||||
@@ -28,7 +28,7 @@ const messageParser = new StreamingMessageParser({
|
||||
}
|
||||
},
|
||||
onActionClose: (data) => {
|
||||
logger.debug('onActionClose', data.action);
|
||||
logger.trace('onActionClose', data.action);
|
||||
|
||||
if (data.action.type === 'shell') {
|
||||
workbenchStore.addAction(data);
|
||||
|
||||
96
packages/bolt/app/lib/stores/editor.ts
Normal file
96
packages/bolt/app/lib/stores/editor.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import type { WebContainer } from '@webcontainer/api';
|
||||
import { atom, computed, map } from 'nanostores';
|
||||
import type { EditorDocument, ScrollPosition } from '../../components/editor/codemirror/CodeMirrorEditor';
|
||||
import type { FileMap } from './files';
|
||||
|
||||
export type EditorDocuments = Record<string, EditorDocument>;
|
||||
|
||||
export class EditorStore {
|
||||
#webcontainer: Promise<WebContainer>;
|
||||
|
||||
selectedFile = atom<string | undefined>();
|
||||
documents = map<EditorDocuments>({});
|
||||
|
||||
currentDocument = computed([this.documents, this.selectedFile], (documents, selectedFile) => {
|
||||
if (!selectedFile) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return documents[selectedFile];
|
||||
});
|
||||
|
||||
constructor(webcontainerPromise: Promise<WebContainer>) {
|
||||
this.#webcontainer = webcontainerPromise;
|
||||
}
|
||||
|
||||
commitFileContent(_filePath: string) {
|
||||
// TODO
|
||||
}
|
||||
|
||||
setDocuments(files: FileMap) {
|
||||
const previousDocuments = this.documents.value;
|
||||
|
||||
this.documents.set(
|
||||
Object.fromEntries<EditorDocument>(
|
||||
Object.entries(files)
|
||||
.map(([filePath, dirent]) => {
|
||||
if (dirent === undefined || dirent.type === 'folder') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return [
|
||||
filePath,
|
||||
{
|
||||
value: dirent.content,
|
||||
commitPending: false,
|
||||
filePath,
|
||||
scroll: previousDocuments?.[filePath]?.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, content: string): boolean {
|
||||
const documents = this.documents.get();
|
||||
const documentState = documents[filePath];
|
||||
|
||||
if (!documentState) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentContent = documentState.value;
|
||||
const contentChanged = currentContent !== content;
|
||||
|
||||
if (contentChanged) {
|
||||
this.documents.setKey(filePath, {
|
||||
...documentState,
|
||||
previousValue: !documentState.commitPending ? currentContent : documentState.previousValue,
|
||||
commitPending: documentState.previousValue ? documentState.previousValue !== content : true,
|
||||
value: content,
|
||||
});
|
||||
}
|
||||
|
||||
return contentChanged;
|
||||
}
|
||||
}
|
||||
94
packages/bolt/app/lib/stores/files.ts
Normal file
94
packages/bolt/app/lib/stores/files.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import type { PathWatcherEvent, WebContainer } from '@webcontainer/api';
|
||||
import { map } from 'nanostores';
|
||||
import { bufferWatchEvents } from '../../utils/buffer';
|
||||
import { WORK_DIR } from '../../utils/constants';
|
||||
|
||||
const textDecoder = new TextDecoder('utf8', { fatal: true });
|
||||
|
||||
interface File {
|
||||
type: 'file';
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface Folder {
|
||||
type: 'folder';
|
||||
}
|
||||
|
||||
type Dirent = File | Folder;
|
||||
|
||||
export type FileMap = Record<string, Dirent | undefined>;
|
||||
|
||||
export class FilesStore {
|
||||
#webcontainer: Promise<WebContainer>;
|
||||
|
||||
files = map<FileMap>({});
|
||||
|
||||
constructor(webcontainerPromise: Promise<WebContainer>) {
|
||||
this.#webcontainer = webcontainerPromise;
|
||||
|
||||
this.#init();
|
||||
}
|
||||
|
||||
async #init() {
|
||||
const webcontainer = await this.#webcontainer;
|
||||
|
||||
webcontainer.watchPaths(
|
||||
{ include: [`${WORK_DIR}/**`], exclude: ['**/node_modules', '.git'], includeContent: true },
|
||||
bufferWatchEvents(100, this.#processEventBuffer.bind(this)),
|
||||
);
|
||||
}
|
||||
|
||||
#processEventBuffer(events: Array<[events: PathWatcherEvent[]]>) {
|
||||
const watchEvents = events.flat(2);
|
||||
|
||||
for (const { type, path, buffer } of watchEvents) {
|
||||
// remove any trailing slashes
|
||||
const sanitizedPath = path.replace(/\/+$/g, '');
|
||||
|
||||
switch (type) {
|
||||
case 'add_dir': {
|
||||
// we intentionally add a trailing slash so we can distinguish files from folders in the file tree
|
||||
this.files.setKey(sanitizedPath, { type: 'folder' });
|
||||
break;
|
||||
}
|
||||
case 'remove_dir': {
|
||||
this.files.setKey(sanitizedPath, undefined);
|
||||
|
||||
for (const [direntPath] of Object.entries(this.files)) {
|
||||
if (direntPath.startsWith(sanitizedPath)) {
|
||||
this.files.setKey(direntPath, undefined);
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case 'add_file':
|
||||
case 'change': {
|
||||
this.files.setKey(sanitizedPath, { type: 'file', content: this.#decodeFileContent(buffer) });
|
||||
break;
|
||||
}
|
||||
case 'remove_file': {
|
||||
this.files.setKey(sanitizedPath, undefined);
|
||||
break;
|
||||
}
|
||||
case 'update_directory': {
|
||||
// we don't care about these events
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#decodeFileContent(buffer?: Uint8Array) {
|
||||
if (!buffer || buffer.byteLength === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
return textDecoder.decode(buffer);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
import { atom, map, type MapStore, type WritableAtom } from 'nanostores';
|
||||
import { atom, map, type MapStore, type ReadableAtom, type WritableAtom } from 'nanostores';
|
||||
import type { EditorDocument, ScrollPosition } from '../../components/editor/codemirror/CodeMirrorEditor';
|
||||
import type { BoltAction } from '../../types/actions';
|
||||
import { unreachable } from '../../utils/unreachable';
|
||||
import { ActionRunner } from '../runtime/action-runner';
|
||||
import type { ActionCallbackData, ArtifactCallbackData } from '../runtime/message-parser';
|
||||
import { webcontainer } from '../webcontainer';
|
||||
import { chatStore } from './chat';
|
||||
import { EditorStore } from './editor';
|
||||
import { FilesStore, type FileMap } from './files';
|
||||
import { PreviewsStore } from './previews';
|
||||
|
||||
const MIN_SPINNER_TIME = 200;
|
||||
@@ -41,6 +44,8 @@ type Artifacts = MapStore<Record<string, ArtifactState>>;
|
||||
export class WorkbenchStore {
|
||||
#actionRunner = new ActionRunner(webcontainer);
|
||||
#previewsStore = new PreviewsStore(webcontainer);
|
||||
#filesStore = new FilesStore(webcontainer);
|
||||
#editorStore = new EditorStore(webcontainer);
|
||||
|
||||
artifacts: Artifacts = import.meta.hot?.data.artifacts ?? map({});
|
||||
|
||||
@@ -50,10 +55,52 @@ export class WorkbenchStore {
|
||||
return this.#previewsStore.previews;
|
||||
}
|
||||
|
||||
get files() {
|
||||
return this.#filesStore.files;
|
||||
}
|
||||
|
||||
get currentDocument(): ReadableAtom<EditorDocument | undefined> {
|
||||
return this.#editorStore.currentDocument;
|
||||
}
|
||||
|
||||
get selectedFile(): ReadableAtom<string | undefined> {
|
||||
return this.#editorStore.selectedFile;
|
||||
}
|
||||
|
||||
setDocuments(files: FileMap) {
|
||||
this.#editorStore.setDocuments(files);
|
||||
}
|
||||
|
||||
setShowWorkbench(show: boolean) {
|
||||
this.showWorkbench.set(show);
|
||||
}
|
||||
|
||||
setCurrentDocumentContent(newContent: string) {
|
||||
const filePath = this.currentDocument.get()?.filePath;
|
||||
|
||||
if (!filePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#editorStore.updateFile(filePath, newContent);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
abortAllActions() {
|
||||
for (const [, artifact] of Object.entries(this.artifacts.get())) {
|
||||
for (const [, action] of Object.entries(artifact.actions.get())) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { WebContainer } from '@webcontainer/api';
|
||||
import { WORK_DIR_NAME } from '../../utils/constants';
|
||||
|
||||
interface WebContainerContext {
|
||||
loaded: boolean;
|
||||
@@ -20,7 +21,7 @@ if (!import.meta.env.SSR) {
|
||||
webcontainer =
|
||||
import.meta.hot?.data.webcontainer ??
|
||||
Promise.resolve()
|
||||
.then(() => WebContainer.boot({ workdirName: 'project' }))
|
||||
.then(() => WebContainer.boot({ workdirName: WORK_DIR_NAME }))
|
||||
.then((webcontainer) => {
|
||||
webcontainerContext.loaded = true;
|
||||
return webcontainer;
|
||||
|
||||
Reference in New Issue
Block a user