Spaces:
Build error
Build error
| import type { PathWatcherEvent, WebContainer } from '@webcontainer/api'; | |
| import { getEncoding } from 'istextorbinary'; | |
| import { map, type MapStore } from 'nanostores'; | |
| import { Buffer } from 'node:buffer'; | |
| import { path } from '~/utils/path'; | |
| import { bufferWatchEvents } from '~/utils/buffer'; | |
| import { WORK_DIR } from '~/utils/constants'; | |
| import { computeFileModifications } from '~/utils/diff'; | |
| import { createScopedLogger } from '~/utils/logger'; | |
| import { unreachable } from '~/utils/unreachable'; | |
| const logger = createScopedLogger('FilesStore'); | |
| const utf8TextDecoder = new TextDecoder('utf8', { fatal: true }); | |
| export interface File { | |
| type: 'file'; | |
| content: string; | |
| isBinary: boolean; | |
| } | |
| export interface Folder { | |
| type: 'folder'; | |
| } | |
| type Dirent = File | Folder; | |
| export type FileMap = Record<string, Dirent | undefined>; | |
| export class FilesStore { | |
| #webcontainer: Promise<WebContainer>; | |
| /** | |
| * 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(webcontainerPromise: Promise<WebContainer>) { | |
| this.#webcontainer = webcontainerPromise; | |
| if (import.meta.hot) { | |
| import.meta.hot.data.files = this.files; | |
| import.meta.hot.data.modifiedFiles = this.#modifiedFiles; | |
| } | |
| this.#init(); | |
| } | |
| getFile(filePath: string) { | |
| const dirent = this.files.get()[filePath]; | |
| if (dirent?.type !== 'file') { | |
| return undefined; | |
| } | |
| return dirent; | |
| } | |
| getFileModifications() { | |
| return computeFileModifications(this.files.get(), this.#modifiedFiles); | |
| } | |
| getModifiedFiles() { | |
| let modifiedFiles: { [path: string]: File } | undefined = undefined; | |
| for (const [filePath, originalContent] of this.#modifiedFiles) { | |
| const file = this.files.get()[filePath]; | |
| if (file?.type !== 'file') { | |
| continue; | |
| } | |
| if (file.content === originalContent) { | |
| continue; | |
| } | |
| if (!modifiedFiles) { | |
| modifiedFiles = {}; | |
| } | |
| modifiedFiles[filePath] = file; | |
| } | |
| return modifiedFiles; | |
| } | |
| resetFileModifications() { | |
| this.#modifiedFiles.clear(); | |
| } | |
| async saveFile(filePath: string, content: string) { | |
| const webcontainer = await this.#webcontainer; | |
| try { | |
| const relativePath = path.relative(webcontainer.workdir, filePath); | |
| if (!relativePath) { | |
| throw new Error(`EINVAL: invalid file path, write '${relativePath}'`); | |
| } | |
| const oldContent = this.getFile(filePath)?.content; | |
| if (!oldContent && oldContent !== '') { | |
| unreachable('Expected content to be defined'); | |
| } | |
| await webcontainer.fs.writeFile(relativePath, content); | |
| if (!this.#modifiedFiles.has(filePath)) { | |
| this.#modifiedFiles.set(filePath, oldContent); | |
| } | |
| // we immediately update the file and don't rely on the `change` event coming from the watcher | |
| this.files.setKey(filePath, { type: 'file', content, isBinary: false }); | |
| logger.info('File updated'); | |
| } catch (error) { | |
| logger.error('Failed to update file content\n\n', error); | |
| throw error; | |
| } | |
| } | |
| async #init() { | |
| const webcontainer = await this.#webcontainer; | |
| webcontainer.internal.watchPaths( | |
| { include: [`${WORK_DIR}/**`], exclude: ['**/node_modules', '.git'], includeContent: true }, | |
| bufferWatchEvents(100, this.#processEventBuffer.bind(this)), | |
| ); | |
| } | |
| #processEventBuffer(events: Array<[events: PathWatcherEvent[]]>) { | |
| const watchEvents = events.flat(2); | |
| for (const { type, path, buffer } of watchEvents) { | |
| // remove any trailing slashes | |
| const sanitizedPath = path.replace(/\/+$/g, ''); | |
| switch (type) { | |
| case 'add_dir': { | |
| // we intentionally add a trailing slash so we can distinguish files from folders in the file tree | |
| this.files.setKey(sanitizedPath, { type: 'folder' }); | |
| break; | |
| } | |
| case 'remove_dir': { | |
| this.files.setKey(sanitizedPath, undefined); | |
| for (const [direntPath] of Object.entries(this.files)) { | |
| if (direntPath.startsWith(sanitizedPath)) { | |
| this.files.setKey(direntPath, undefined); | |
| } | |
| } | |
| break; | |
| } | |
| case 'add_file': | |
| case 'change': { | |
| if (type === 'add_file') { | |
| this.#size++; | |
| } | |
| let content = ''; | |
| /** | |
| * @note This check is purely for the editor. The way we detect this is not | |
| * bullet-proof and it's a best guess so there might be false-positives. | |
| * The reason we do this is because we don't want to display binary files | |
| * in the editor nor allow to edit them. | |
| */ | |
| const isBinary = isBinaryFile(buffer); | |
| if (!isBinary) { | |
| content = this.#decodeFileContent(buffer); | |
| } | |
| this.files.setKey(sanitizedPath, { type: 'file', content, isBinary }); | |
| break; | |
| } | |
| case 'remove_file': { | |
| this.#size--; | |
| this.files.setKey(sanitizedPath, undefined); | |
| break; | |
| } | |
| case 'update_directory': { | |
| // we don't care about these events | |
| break; | |
| } | |
| } | |
| } | |
| } | |
| #decodeFileContent(buffer?: Uint8Array) { | |
| if (!buffer || buffer.byteLength === 0) { | |
| return ''; | |
| } | |
| try { | |
| return utf8TextDecoder.decode(buffer); | |
| } catch (error) { | |
| console.log(error); | |
| return ''; | |
| } | |
| } | |
| } | |
| function isBinaryFile(buffer: Uint8Array | undefined) { | |
| if (buffer === undefined) { | |
| return false; | |
| } | |
| return getEncoding(convertToBuffer(buffer), { chunkLength: 100 }) === 'binary'; | |
| } | |
| /** | |
| * Converts a `Uint8Array` into a Node.js `Buffer` by copying the prototype. | |
| * The goal is to avoid expensive copies. It does create a new typed array | |
| * but that's generally cheap as long as it uses the same underlying | |
| * array buffer. | |
| */ | |
| function convertToBuffer(view: Uint8Array): Buffer { | |
| return Buffer.from(view.buffer, view.byteOffset, view.byteLength); | |
| } | |