feat(workbench): sync file changes back to webcontainer (#5)
This commit is contained in:
@@ -167,7 +167,7 @@ export class ActionRunner {
|
||||
await webcontainer.fs.mkdir(folder, { recursive: true });
|
||||
logger.debug('Created folder', folder);
|
||||
} catch (error) {
|
||||
logger.error('Failed to create folder\n', error);
|
||||
logger.error('Failed to create folder\n\n', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,7 +175,7 @@ export class ActionRunner {
|
||||
await webcontainer.fs.writeFile(action.filePath, action.content);
|
||||
logger.debug(`File written ${action.filePath}`);
|
||||
} catch (error) {
|
||||
logger.error('Failed to write file\n', error);
|
||||
logger.error('Failed to write file\n\n', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user