// A device's files in the document (`files`: path → { order, kind, text | // data, mediaType? }): read as the core's file system, changed through the // core's operations (what the text editor's, and later the explorer's, // buttons do), and the two ways between the document and a running engine — // what a run wrote goes into the document (writeFiles, for writeRunState), // what a person wrote meanwhile into the engine (readFilesInto). Only what // differs is written either way, a text where it changed, so that what two // write at once is kept (plan §7.3: the text editor's content is the // document's Y.Text). import * as Y from 'yjs'; import type { Id, VirtualFileContent, VirtualFileSystem } from '@mdg-filius/core'; import { VirtualFileSystem as FileSystem } from '@mdg-filius/core'; import { blobSha, blobSideOf } from './blobs.js'; import { fileEntry, fileSystemOf, inOrder, ROOTS } from './projectDoc.js'; export function sameBytes(left: Uint8Array, right: Uint8Array): boolean { return left.length === right.length && left.every((byte, index) => byte === right[index]); } /** A text changed where it changed: the common start and end stay (and with them what another person typed there). */ export function replaceText(text: Y.Text, next: string): void { const current = text.toString(); if (current === next) return; let start = 0; while (start < current.length && start < next.length && current[start] === next[start]) start += 1; let end = 0; while (end < current.length - start && end < next.length - start && current[current.length - 1 - end] === next[next.length - 1 - end]) end += 1; if (current.length - start - end > 0) text.delete(start, current.length - start - end); if (next.length - start - end > 0) text.insert(start, next.slice(start, next.length - end)); } /** * Whether a document entry holds the content (a blob by its bytes where they * are here, else by its name). */ function holds(entry: Y.Map, content: VirtualFileContent): boolean { if (entry.get('kind') !== content.kind) return false; if (content.kind === 'directory' || content.kind === 'text') return true; if (entry.get('mediaType') !== content.mediaType) return false; const blob = entry.get('blob'); if (typeof blob !== 'string') return sameBytes(entry.get('data') as Uint8Array, content.data); const held = blobSideOf(entry.doc)?.bytes(blob); // A blob whose bytes are not here was read as no bytes: written back so, that is no change (it would empty the file). if (held === undefined && content.data.length === 0) return true; if (entry.get('size') !== content.data.length) return false; return held === undefined ? blobSha(content.data) === blob : sameBytes(held, content.data); } /** * A node's files as the file system has them: new ones after the others, * changed ones in their place, gone ones removed; when the order is no * longer the file system's (a renamed entry), numbered anew. */ export function writeFiles(node: Y.Map, fileSystem: VirtualFileSystem): void { const blobs = blobSideOf(node.doc); let files = node.get('files') as Y.Map> | undefined; const wanted: [string, VirtualFileContent][] = []; const walk = (folder: string): void => { for (const entry of fileSystem.entriesInCreationOrder(folder)) { wanted.push([entry.path, entry.content]); if (entry.content.kind === 'directory') walk(entry.path); } }; walk('/'); if (files === undefined) { if (wanted.length === 0) return; files = new Y.Map>(); node.set('files', files); } const paths = new Set(wanted.map(([path]) => path)); for (const path of [...files.keys()]) if (!paths.has(path)) files.delete(path); let order = Math.max(-1, ...[...files.values()].map((entry) => entry.get('order') as number)) + 1; for (const [path, content] of wanted) { const entry = files.get(path); if (entry === undefined) { files.set(path, fileEntry(order++, content, blobs)); } else if (!holds(entry, content)) { files.set(path, fileEntry(entry.get('order') as number, content, blobs)); } else if (content.kind === 'text') { replaceText(entry.get('text') as Y.Text, content.text); } } // An entry renamed is new to the document: the order is the file system's again (a rename keeps the entry's place). if (inOrder(files).some(([path], index) => path !== wanted[index]![0])) { for (const [index, [path]] of wanted.entries()) if (files.get(path)!.get('order') !== index) files.get(path)!.set('order', index); } } function nodeMap(doc: Y.Doc, id: Id): Y.Map | undefined { return doc.getMap>(ROOTS.nodes).get(id); } /** A device's files as the core's file system; none for a device the document does not have. */ export function nodeFiles(doc: Y.Doc, id: Id): VirtualFileSystem | undefined { const node = nodeMap(doc, id); if (node === undefined) return undefined; const files = node.get('files') as Y.Map> | undefined; return files === undefined ? new FileSystem() : fileSystemOf(files); } /** * Changes a device's files through the core's operations (filePrograms): the * change gets them as a file system, and what it changed is written into * the document, in one transaction with the origin. What the change * returned; undefined for a device the document does not have. */ export function changeFiles(doc: Y.Doc, id: Id, origin: unknown, change: (files: VirtualFileSystem) => T): T | undefined { const node = nodeMap(doc, id); const files = nodeFiles(doc, id); if (node === undefined || files === undefined) return undefined; const result = change(files); // What did not change writes nothing (writeFiles writes what differs only). doc.transact(() => writeFiles(node, files), origin); return result; } /** A text file's text in the document, the entry found in any case; none when there is no text file at the path. */ export function fileText(doc: Y.Doc, id: Id, path: string): Y.Text | undefined { const files = nodeMap(doc, id)?.get('files') as Y.Map> | undefined; if (files === undefined) return undefined; const lower = path.toLowerCase(); const entry = files.get(path) ?? [...files.entries()].find(([key]) => key.toLowerCase() === lower)?.[1]; // Only a text file's entry has a text. return entry?.get('text') as Y.Text | undefined; } /** * The document's files of a device into a file system (a running engine's): * what differs only, an entry the document no longer has removed, a new one * after the others; when a file turned into a folder or back, all anew in the * document's order (the command line lists them so). Read tolerantly, as fileSystemOf: an entry the file * system refuses is left out. Whether anything changed. */ export function readFilesInto(doc: Y.Doc, id: Id, fileSystem: VirtualFileSystem): boolean { const node = nodeMap(doc, id); if (node === undefined) return false; const revision = fileSystem.revision; const wanted = fileSystemOf((node.get('files') as Y.Map> | undefined) ?? new Y.Map()); // By their spelling: a name changed only in case is the entry anew. const kept = new Set(wanted.allEntries().map((entry) => entry.path)); // Deepest first, so that a folder's entries go before it. for (const entry of fileSystem.allEntries().reverse()) { if (entry.path !== '/' && !kept.has(entry.path)) fileSystem.deleteItem(entry.path, true); } const walk = (folder: string): void => { for (const { path, content } of wanted.entriesInCreationOrder(folder)) { const have = fileSystem.contains(path) ? fileSystem.entry(path).content : undefined; if (have === undefined || !sameContent(have, content)) { try { // A folder for a file, or a file for a folder: the old one goes first. if (have !== undefined && (have.kind === 'directory' || content.kind === 'directory')) fileSystem.deleteItem(path, true); write(fileSystem, path, content); } catch { // Left out (see above). } } if (content.kind === 'directory') walk(path); } }; walk('/'); // A file turned into a folder, or a folder into a file, came in after the others: built anew in the document's order. if (preorder(fileSystem).join('\n') !== preorder(wanted).join('\n')) { for (const entry of fileSystem.entriesInCreationOrder('/')) fileSystem.deleteItem(entry.path, true); for (const path of preorder(wanted)) write(fileSystem, path, wanted.entry(path).content); } return fileSystem.revision !== revision; } /** Every entry's path, each folder's entries in the order they were made after the folder. */ function preorder(fileSystem: VirtualFileSystem): string[] { return fileSystem.entriesInCreationOrder('/').flatMap(function below(entry): string[] { return [entry.path, ...(entry.content.kind === 'directory' ? fileSystem.entriesInCreationOrder(entry.path).flatMap(below) : [])]; }); } function write(fileSystem: VirtualFileSystem, path: string, content: VirtualFileContent): void { if (content.kind === 'directory') fileSystem.createDirectory(path); else if (content.kind === 'text') fileSystem.writeTextFile(path, content.text); else if (content.kind === 'image') fileSystem.writeImageFile(path, content.data, content.mediaType); else fileSystem.writeBinaryFile(path, content.data, content.mediaType); } function sameContent(left: VirtualFileContent, right: VirtualFileContent): boolean { if (left.kind !== right.kind) return false; if (left.kind === 'directory') return true; if (left.kind === 'text') return left.text === (right as typeof left).text; const other = right as typeof left; return sameBytes(left.data, other.data) && left.mediaType === other.mediaType; }