feat(workbench): sync file changes back to webcontainer (#5)

This commit is contained in:
Dominic Elm
2024-07-24 16:10:39 +02:00
committed by GitHub
parent df25c678d1
commit d45b95dd11
18 changed files with 491 additions and 129 deletions

View File

@@ -1,15 +1,16 @@
import type { WebContainer } from '@webcontainer/api';
import { atom, computed, map } from 'nanostores';
import { atom, computed, map, type MapStore, type WritableAtom } from 'nanostores';
import type { EditorDocument, ScrollPosition } from '../../components/editor/codemirror/CodeMirrorEditor';
import type { FileMap } from './files';
import type { FileMap, FilesStore } from './files';
export type EditorDocuments = Record<string, EditorDocument>;
export class EditorStore {
#webcontainer: Promise<WebContainer>;
type SelectedFile = WritableAtom<string | undefined>;
selectedFile = atom<string | undefined>();
documents = map<EditorDocuments>({});
export class EditorStore {
#filesStore: FilesStore;
selectedFile: SelectedFile = import.meta.hot?.data.selectedFile ?? atom<string | undefined>();
documents: MapStore<EditorDocuments> = import.meta.hot?.data.documents ?? map<EditorDocuments>({});
currentDocument = computed([this.documents, this.selectedFile], (documents, selectedFile) => {
if (!selectedFile) {
@@ -19,12 +20,13 @@ export class EditorStore {
return documents[selectedFile];
});
constructor(webcontainerPromise: Promise<WebContainer>) {
this.#webcontainer = webcontainerPromise;
}
constructor(filesStore: FilesStore) {
this.#filesStore = filesStore;
commitFileContent(_filePath: string) {
// TODO
if (import.meta.hot) {
import.meta.hot.data.documents = this.documents;
import.meta.hot.data.selectedFile = this.selectedFile;
}
}
setDocuments(files: FileMap) {
@@ -38,13 +40,14 @@ export class EditorStore {
return undefined;
}
const previousDocument = previousDocuments?.[filePath];
return [
filePath,
{
value: dirent.content,
commitPending: false,
filePath,
scroll: previousDocuments?.[filePath]?.scroll,
scroll: previousDocument?.scroll,
},
] as [string, EditorDocument];
})
@@ -71,26 +74,22 @@ export class EditorStore {
});
}
updateFile(filePath: string, content: string): boolean {
updateFile(filePath: string, newContent: string | Uint8Array) {
const documents = this.documents.get();
const documentState = documents[filePath];
if (!documentState) {
return false;
return;
}
const currentContent = documentState.value;
const contentChanged = currentContent !== content;
const contentChanged = currentContent !== newContent;
if (contentChanged) {
this.documents.setKey(filePath, {
...documentState,
previousValue: !documentState.commitPending ? currentContent : documentState.previousValue,
commitPending: documentState.previousValue ? documentState.previousValue !== content : true,
value: content,
value: newContent,
});
}
return contentChanged;
}
}

View File

@@ -1,16 +1,20 @@
import type { PathWatcherEvent, WebContainer } from '@webcontainer/api';
import { map } from 'nanostores';
import { map, type MapStore } from 'nanostores';
import * as nodePath from 'node:path';
import { bufferWatchEvents } from '../../utils/buffer';
import { WORK_DIR } from '../../utils/constants';
import { createScopedLogger } from '../../utils/logger';
const logger = createScopedLogger('FilesStore');
const textDecoder = new TextDecoder('utf8', { fatal: true });
interface File {
export interface File {
type: 'file';
content: string;
content: string | Uint8Array;
}
interface Folder {
export interface Folder {
type: 'folder';
}
@@ -21,14 +25,59 @@ export type FileMap = Record<string, Dirent | undefined>;
export class FilesStore {
#webcontainer: Promise<WebContainer>;
files = map<FileMap>({});
/**
* Tracks the number of files without folders.
*/
#size = 0;
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;
}
this.#init();
}
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;
}
}
async #init() {
const webcontainer = await this.#webcontainer;
@@ -64,10 +113,16 @@ export class FilesStore {
}
case 'add_file':
case 'change': {
if (type === 'add_file') {
this.#size++;
}
this.files.setKey(sanitizedPath, { type: 'file', content: this.#decodeFileContent(buffer) });
break;
}
case 'remove_file': {
this.#size--;
this.files.setKey(sanitizedPath, undefined);
break;
}

View File

@@ -21,11 +21,20 @@ type Artifacts = MapStore<Record<string, ArtifactState>>;
export class WorkbenchStore {
#previewsStore = new PreviewsStore(webcontainer);
#filesStore = new FilesStore(webcontainer);
#editorStore = new EditorStore(webcontainer);
#editorStore = new EditorStore(this.#filesStore);
artifacts: Artifacts = import.meta.hot?.data.artifacts ?? map({});
showWorkbench: WritableAtom<boolean> = import.meta.hot?.data.showWorkbench ?? atom(false);
unsavedFiles: WritableAtom<Set<string>> = import.meta.hot?.data.unsavedFiles ?? atom(new Set<string>());
modifiedFiles = new Set<string>();
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;
}
}
get previews() {
return this.#previewsStore.previews;
@@ -45,20 +54,53 @@ export class WorkbenchStore {
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) {
setCurrentDocumentContent(newContent: string | Uint8Array) {
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) {
@@ -77,6 +119,40 @@ export class WorkbenchStore {
this.#editorStore.setSelectedFile(filePath);
}
async saveCurrentDocument() {
const currentDocument = this.currentDocument.get();
if (currentDocument === undefined) {
return;
}
const { filePath } = currentDocument;
await this.#filesStore.saveFile(filePath, currentDocument.value);
const newUnsavedFiles = new Set(this.unsavedFiles.get());
newUnsavedFiles.delete(filePath);
this.unsavedFiles.set(newUnsavedFiles);
}
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);
}
abortAllActions() {
// TODO: what do we wanna do and how do we wanna recover from this?
}
@@ -136,8 +212,3 @@ export class WorkbenchStore {
}
export const workbenchStore = new WorkbenchStore();
if (import.meta.hot) {
import.meta.hot.data.artifacts = workbenchStore.artifacts;
import.meta.hot.data.showWorkbench = workbenchStore.showWorkbench;
}