Oracle FileExplorerWindowRun uses Java's explorer and viewer windows as a person does (the right-click menu, Java's questions answered, the import window, the viewer's open). Both windows work on the document's files through the core's operations: the tree of folders and files, the chosen folder's entries, Java's menu (also as buttons, no right click needed), copy that stays and cut that goes once pasted, the import; the viewer's picture and title. Not taken from Java: a file chosen in the tree taken for a folder, pictures laid over each other, a text file as an empty picture. writeFiles numbers the entries in the file system's order again when a rename made a new key. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
179 lines
9.0 KiB
TypeScript
179 lines
9.0 KiB
TypeScript
// 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 { 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. */
|
|
function holds(entry: Y.Map<unknown>, content: VirtualFileContent): boolean {
|
|
if (entry.get('kind') !== content.kind) return false;
|
|
if (content.kind === 'directory' || content.kind === 'text') return true;
|
|
return sameBytes(entry.get('data') as Uint8Array, content.data) && entry.get('mediaType') === content.mediaType;
|
|
}
|
|
|
|
/**
|
|
* 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<unknown>, fileSystem: VirtualFileSystem): void {
|
|
let files = node.get('files') as Y.Map<Y.Map<unknown>> | 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<Y.Map<unknown>>();
|
|
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));
|
|
} else if (!holds(entry, content)) {
|
|
files.set(path, fileEntry(entry.get('order') as number, content));
|
|
} 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<unknown> | undefined {
|
|
return doc.getMap<Y.Map<unknown>>(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<Y.Map<unknown>> | 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<T>(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<Y.Map<unknown>> | 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<Y.Map<unknown>> | 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;
|
|
}
|