2024-07-18 21:07:04 +00:00
|
|
|
import type { PathWatcherEvent, WebContainer } from '@webcontainer/api';
|
2024-07-24 14:10:39 +00:00
|
|
|
import { map, type MapStore } from 'nanostores';
|
|
|
|
import * as nodePath from 'node:path';
|
2024-07-24 15:43:32 +00:00
|
|
|
import { bufferWatchEvents } from '~/utils/buffer';
|
|
|
|
import { WORK_DIR } from '~/utils/constants';
|
|
|
|
import { createScopedLogger } from '~/utils/logger';
|
2024-07-24 14:10:39 +00:00
|
|
|
|
|
|
|
const logger = createScopedLogger('FilesStore');
|
2024-07-18 21:07:04 +00:00
|
|
|
|
|
|
|
const textDecoder = new TextDecoder('utf8', { fatal: true });
|
|
|
|
|
2024-07-24 14:10:39 +00:00
|
|
|
export interface File {
|
2024-07-18 21:07:04 +00:00
|
|
|
type: 'file';
|
2024-07-24 14:10:39 +00:00
|
|
|
content: string | Uint8Array;
|
2024-07-18 21:07:04 +00:00
|
|
|
}
|
|
|
|
|
2024-07-24 14:10:39 +00:00
|
|
|
export interface Folder {
|
2024-07-18 21:07:04 +00:00
|
|
|
type: 'folder';
|
|
|
|
}
|
|
|
|
|
|
|
|
type Dirent = File | Folder;
|
|
|
|
|
|
|
|
export type FileMap = Record<string, Dirent | undefined>;
|
|
|
|
|
|
|
|
export class FilesStore {
|
|
|
|
#webcontainer: Promise<WebContainer>;
|
|
|
|
|
2024-07-24 14:10:39 +00:00
|
|
|
/**
|
|
|
|
* Tracks the number of files without folders.
|
|
|
|
*/
|
|
|
|
#size = 0;
|
|
|
|
|
|
|
|
files: MapStore<FileMap> = import.meta.hot?.data.files ?? map({});
|
|
|
|
|
|
|
|
get filesCount() {
|
|
|
|
return this.#size;
|
|
|
|
}
|
2024-07-18 21:07:04 +00:00
|
|
|
|
|
|
|
constructor(webcontainerPromise: Promise<WebContainer>) {
|
|
|
|
this.#webcontainer = webcontainerPromise;
|
|
|
|
|
2024-07-24 14:10:39 +00:00
|
|
|
if (import.meta.hot) {
|
|
|
|
import.meta.hot.data.files = this.files;
|
|
|
|
}
|
|
|
|
|
2024-07-18 21:07:04 +00:00
|
|
|
this.#init();
|
|
|
|
}
|
|
|
|
|
2024-07-24 14:10:39 +00:00
|
|
|
getFile(filePath: string) {
|
|
|
|
const dirent = this.files.get()[filePath];
|
|
|
|
|
|
|
|
if (dirent?.type !== 'file') {
|
|
|
|
return undefined;
|
|
|
|
}
|
|
|
|
|
|
|
|
return dirent;
|
|
|
|
}
|
|
|
|
|
|
|
|
async saveFile(filePath: string, content: string | Uint8Array) {
|
|
|
|
const webcontainer = await this.#webcontainer;
|
|
|
|
|
|
|
|
try {
|
|
|
|
const relativePath = nodePath.relative(webcontainer.workdir, filePath);
|
|
|
|
|
|
|
|
if (!relativePath) {
|
|
|
|
throw new Error(`EINVAL: invalid file path, write '${relativePath}'`);
|
|
|
|
}
|
|
|
|
|
|
|
|
await webcontainer.fs.writeFile(relativePath, content);
|
|
|
|
|
|
|
|
this.files.setKey(filePath, { type: 'file', content });
|
|
|
|
|
|
|
|
logger.info('File updated');
|
|
|
|
} catch (error) {
|
|
|
|
logger.error('Failed to update file content\n\n', error);
|
|
|
|
|
|
|
|
throw error;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-07-18 21:07:04 +00:00
|
|
|
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': {
|
2024-07-24 14:10:39 +00:00
|
|
|
if (type === 'add_file') {
|
|
|
|
this.#size++;
|
|
|
|
}
|
|
|
|
|
2024-07-18 21:07:04 +00:00
|
|
|
this.files.setKey(sanitizedPath, { type: 'file', content: this.#decodeFileContent(buffer) });
|
2024-07-24 14:10:39 +00:00
|
|
|
|
2024-07-18 21:07:04 +00:00
|
|
|
break;
|
|
|
|
}
|
|
|
|
case 'remove_file': {
|
2024-07-24 14:10:39 +00:00
|
|
|
this.#size--;
|
2024-07-18 21:07:04 +00:00
|
|
|
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 '';
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|