import { atom, map, type MapStore, type ReadableAtom, type WritableAtom } from 'nanostores'; import type { EditorDocument, ScrollPosition } from '~/components/editor/codemirror/CodeMirrorEditor'; import { ActionRunner } from '~/lib/runtime/action-runner'; import type { ActionCallbackData, ArtifactCallbackData } from '~/lib/runtime/message-parser'; import { webcontainer } from '~/lib/webcontainer'; import type { ITerminal } from '~/types/terminal'; import { unreachable } from '~/utils/unreachable'; import { EditorStore } from './editor'; import { FilesStore, type FileMap } from './files'; import { PreviewsStore } from './previews'; import { TerminalStore } from './terminal'; import JSZip from 'jszip'; import { saveAs } from 'file-saver'; import { Octokit, type RestEndpointMethodTypes } from '@octokit/rest'; import * as nodePath from 'node:path'; import { extractRelativePath } from '~/utils/diff'; import { description } from '~/lib/persistence'; import Cookies from 'js-cookie'; export interface ArtifactState { id: string; title: string; type?: string; closed: boolean; runner: ActionRunner; } export type ArtifactUpdateState = Pick; type Artifacts = MapStore>; export type WorkbenchViewType = 'code' | 'preview'; export class WorkbenchStore { #previewsStore = new PreviewsStore(webcontainer); #filesStore = new FilesStore(webcontainer); #editorStore = new EditorStore(this.#filesStore); #terminalStore = new TerminalStore(webcontainer); artifacts: Artifacts = import.meta.hot?.data.artifacts ?? map({}); showWorkbench: WritableAtom = import.meta.hot?.data.showWorkbench ?? atom(false); currentView: WritableAtom = import.meta.hot?.data.currentView ?? atom('code'); unsavedFiles: WritableAtom> = import.meta.hot?.data.unsavedFiles ?? atom(new Set()); modifiedFiles = new Set(); 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; } } addToExecutionQueue(callback: () => Promise) { this.#globalExecutionQueue = this.#globalExecutionQueue.then(() => callback()); } get previews() { return this.#previewsStore.previews; } get files() { return this.#filesStore.files; } get currentDocument(): ReadableAtom { return this.#editorStore.currentDocument; } get selectedFile(): ReadableAtom { return this.#editorStore.selectedFile; } get firstArtifact(): ArtifactState | undefined { return this.#getArtifact(this.artifactIdList[0]); } get filesCount(): number { return this.#filesStore.filesCount; } get showTerminal() { return this.#terminalStore.showTerminal; } get boltTerminal() { return this.#terminalStore.boltTerminal; } toggleTerminal(value?: boolean) { this.#terminalStore.toggleTerminal(value); } attachTerminal(terminal: ITerminal) { this.#terminalStore.attachTerminal(terminal); } attachBoltTerminal(terminal: ITerminal) { this.#terminalStore.attachBoltTerminal(terminal); } onTerminalResize(cols: number, rows: number) { this.#terminalStore.onTerminalResize(cols, rows); } setDocuments(files: FileMap) { this.#editorStore.setDocuments(files); if (this.#filesStore.filesCount > 0 && this.currentDocument.get() === undefined) { // we find the first file and select it for (const [filePath, dirent] of Object.entries(files)) { if (dirent?.type === 'file') { 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 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, runner: new ActionRunner(webcontainer, () => this.boltTerminal), }); } updateArtifact({ messageId }: ArtifactCallbackData, state: Partial) { const artifact = this.#getArtifact(messageId); if (!artifact) { return; } this.artifacts.setKey(messageId, { ...artifact, ...state }); } addAction(data: ActionCallbackData) { this._addAction(data); // this.addToExecutionQueue(()=>this._addAction(data)) } async _addAction(data: ActionCallbackData) { const { messageId } = data; const artifact = this.#getArtifact(messageId); if (!artifact) { unreachable('Artifact not found'); } return artifact.runner.addAction(data); } runAction(data: ActionCallbackData, isStreaming: boolean = false) { if (isStreaming) { this._runAction(data, isStreaming); } else { this.addToExecutionQueue(() => this._runAction(data, isStreaming)); } } async _runAction(data: ActionCallbackData, isStreaming: boolean = false) { const { messageId } = data; const artifact = this.#getArtifact(messageId); if (!artifact) { unreachable('Artifact not found'); } if (data.action.type === 'file') { const wc = await webcontainer; const fullPath = nodePath.join(wc.workdir, data.action.filePath); if (this.selectedFile.value !== fullPath) { this.setSelectedFile(fullPath); } if (this.currentView.value !== 'code') { this.currentView.set('code'); } const doc = this.#editorStore.documents.get()[fullPath]; if (!doc) { await artifact.runner.runAction(data, isStreaming); } this.#editorStore.updateFile(fullPath, data.action.content); if (!isStreaming) { await artifact.runner.runAction(data); this.resetAllFileModifications(); } } else { await artifact.runner.runAction(data); } } #getArtifact(id: string) { const artifacts = this.artifacts.get(); return artifacts[id]; } async downloadZip() { 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?.type === 'file' && !dirent.isBinary) { const relativePath = extractRelativePath(filePath); // split the path into segments const pathSegments = relativePath.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], dirent.content); } else { // if there's only one segment, it's a file in the root zip.file(relativePath, dirent.content); } } } // Generate the zip file and save it const content = await zip.generateAsync({ type: 'blob' }); saveAs(content, `${uniqueProjectName}.zip`); } async syncFiles(targetHandle: FileSystemDirectoryHandle) { const files = this.files.get(); const syncedFiles = []; for (const [filePath, dirent] of Object.entries(files)) { if (dirent?.type === 'file' && !dirent.isBinary) { const relativePath = extractRelativePath(filePath); const pathSegments = relativePath.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(relativePath); } } 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?.type === 'file' && dirent.content) { const { data: blob } = await octokit.git.createBlob({ owner: repo.owner.login, repo: repo.name, content: Buffer.from(dirent.content).toString('base64'), encoding: 'base64', }); return { path: extractRelativePath(filePath), sha: blob.sha }; } return 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();