feat: F4.6f2 the text editor on the document's Y.Text, files both ways while the simulation runs

Oracle TextEditorWindowRun uses Java's text editor window and its file
dialog as a person does. The open file's text is its Y.Text in the
document (plan 7.3): typed is in the file at once, for everyone and for
the running engine. Core openText/saveTextAs without Java's mistakes
(lost saves after save-as onto a file or a removed file, files named
'ordner/d.txt' or ''); doc files.ts (changeFiles, fileText, nodeFiles,
readFilesInto); the room takes a person's files into the engine at once
and writes the engine's files back at the tick they changed; the editor's
window, Java's dialog, the text area following others' changes with the
caret in place, its own undo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 17:50:27 +00:00
co-authored by Claude Opus 5.5
parent 3d6655e736
commit 2c7733b575
29 changed files with 3268 additions and 76 deletions
+30 -3
View File
@@ -16,7 +16,11 @@
// file system's rule), and one that changes only the case is done (Java
// finds the entry itself and refuses);
// - "save as" onto a folder's name, and an import onto it, is refused (Java
// throws);
// throws); "save as" with an empty name, or one with blanks at its ends,
// is refused (Java makes such a file), a name with "/" leads into a
// folder (Java makes a file whose name holds the separator);
// - "open" opens text files only (Java shows an image as its Base64 text)
// and keeps the open file when it finds none (Java forgets it);
// - a text file's size is its length in characters (as the command line's).
import type { VirtualFileContent, VirtualFileSystem } from '../../model/virtualFileSystem.js';
@@ -223,15 +227,38 @@ export function saveText(files: VirtualFileSystem, path: string, text: string):
/**
* The editor's "save as" (Dateisystem.speicherDatei): the text as a file of
* the folder, over one of that name in any case (its name kept as it is).
* The name may lead into a folder of the folder ("ordner/d.txt", as "open"
* takes it; Java makes a file with "/" in its name); a name the file system
* does not take (empty, or with blanks at its ends; Java makes such a file)
* is invalidName, one whose folder is not there folderMissing.
*/
export function saveTextAs(files: VirtualFileSystem, folder: string, name: string, text: string): 'saved' | 'folderMissing' | 'isFolder' {
export function saveTextAs(files: VirtualFileSystem, folder: string, name: string, text: string): 'saved' | 'folderMissing' | 'isFolder' | 'invalidName' {
if (name.split('/').some((part) => part.trim() !== part) || evaluatedPath(name) === '/') return 'invalidName';
const target = fileTarget(files, folder, name);
if (target === undefined) return 'folderMissing';
if (target.isFolder) return 'isFolder';
files.writeTextFile(target.path, text);
try {
files.writeTextFile(target.path, text);
} catch {
return 'folderMissing';
}
return 'saved';
}
/**
* The editor's "open" (holeDatei of the dialog's folder and name): the name
* taken as a path from the folder, in any case. Java opens whatever file it
* finds, an image as its Base64 text, and with none forgets the open file;
* here a file that is not text is notText, none notFound (the open file
* stays open).
*/
export function openText(files: VirtualFileSystem, folder: string, name: string): { readonly path: string; readonly text: string } | 'notFound' | 'notText' {
// No name is the folder itself: not found.
const entry = found(files, evaluatedPath(name.startsWith('/') ? name : `${folder}/${name}`));
if (entry === undefined || entry.content.kind === 'directory') return 'notFound';
return entry.content.kind === 'text' ? { path: entry.path, text: entry.content.text } : 'notText';
}
/** The image viewer's "open": an image file's bytes and media type; none for any other file. */
export function imageFile(files: VirtualFileSystem, path: string): { readonly data: Uint8Array; readonly mediaType: string } | undefined {
const entry = found(files, path);
+27
View File
@@ -23,6 +23,7 @@ import {
renameEntry,
saveText,
saveTextAs,
openText,
VirtualFileSystem,
type Clipboard,
type PageLanguage,
@@ -238,4 +239,30 @@ describe('the file programs beyond the cases', () => {
expect(imageFile(files, '/text.txt')).toBeUndefined();
expect(imageFile(files, '/fehlt.png')).toBeUndefined();
});
// Measured with TextEditorWindowRun (packages/sim/test/textEditorWindow.java.json).
it("opens a text file by the dialog's folder and name, in any case, and neither an image nor what is not there", () => {
const files = new VirtualFileSystem();
files.writeTextFile('/notiz.txt', 'Hallo');
files.createDirectory('/ordner');
files.writeTextFile('/ordner/b.txt', '');
files.writeImageFile('/bild.png', new Uint8Array([1]), 'image/png');
expect(openText(files, '/', 'NOTIZ.TXT')).toEqual({ path: '/notiz.txt', text: 'Hallo' });
expect(openText(files, '/', 'ordner/b.txt')).toEqual({ path: '/ordner/b.txt', text: '' });
expect(openText(files, '/ordner', '../notiz.txt')).toEqual({ path: '/notiz.txt', text: 'Hallo' });
expect(openText(files, '/ordner', '/notiz.txt')).toEqual({ path: '/notiz.txt', text: 'Hallo' });
expect(['fehlt.txt', '', 'ordner'].map((name) => openText(files, '/', name))).toEqual(['notFound', 'notFound', 'notFound']);
expect(openText(files, '/', 'bild.png')).toBe('notText');
});
it('saves as a name leading into a folder, and not as an empty name, one with blanks at its ends or one whose folder is missing', () => {
const files = new VirtualFileSystem();
files.createDirectory('/ordner');
expect(saveTextAs(files, '/', 'ordner/d.txt', 'd')).toBe('saved');
expect(files.textFile('/ordner/d.txt')).toBe('d');
expect(['', ' x.txt', 'x.txt ', 'ordner/ e.txt', '..'].map((name) => saveTextAs(files, '/ordner', name, 'x'))).toEqual(['invalidName', 'invalidName', 'invalidName', 'invalidName', 'invalidName']);
expect(saveTextAs(files, '/', 'fehlt/e.txt', 'e')).toBe('folderMissing');
expect(saveTextAs(files, '/', 'ORDNER', 'e')).toBe('isFolder');
expect(files.allEntries().map((entry) => entry.path)).toEqual(['/', '/ordner', '/ordner/d.txt']);
});
});
+170
View File
@@ -0,0 +1,170 @@
// 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, 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. */
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);
}
}
}
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;
}
+1
View File
@@ -1,6 +1,7 @@
// Public surface of the shared document.
export * from './commands.js';
export * from './fields.js';
export * from './files.js';
export * from './janitor.js';
export * from './projectDoc.js';
export * from './settings.js';
+1 -1
View File
@@ -224,7 +224,7 @@ export function inOrder(map: Y.Map<Y.Map<unknown>>): [string, Y.Map<unknown>][]
* system refuses (its folder missing before the janitor ran, or a name two
* people gave at once in different case) is left out, not the project.
*/
function fileSystemOf(files: Y.Map<Y.Map<unknown>>): VirtualFileSystem {
export function fileSystemOf(files: Y.Map<Y.Map<unknown>>): VirtualFileSystem {
const fileSystem = new VirtualFileSystem();
for (const [path, entry] of inOrder(files)) {
const kind = entry.get('kind') as VirtualFileContent['kind'];
+3 -56
View File
@@ -9,8 +9,9 @@
// the second time and a text edited meanwhile keeps what it did not touch.
import * as Y from 'yjs';
import type { Id, InstallableProgram, VirtualFileContent, VirtualFileSystem } from '@mdg-filius/core';
import { fileEntry, NODE_SETTINGS, ROOTS } from './projectDoc.js';
import type { Id, InstallableProgram, VirtualFileSystem } from '@mdg-filius/core';
import { sameBytes, writeFiles } from './files.js';
import { NODE_SETTINGS, ROOTS } from './projectDoc.js';
export { emailServerAfterRun } from '@mdg-filius/fls';
import { fromY, toY, type PlainValue } from './values.js';
@@ -43,60 +44,6 @@ function same(left: unknown, right: unknown): boolean {
return left === right;
}
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). */
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 run left them: new ones after the others, changed ones in their place, gone ones removed. */
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);
}
}
}
/** Writes what the runs left of the nodes, what differs only, in one transaction; nodes the document no longer has are left out. */
export function writeRunState(doc: Y.Doc, states: ReadonlyMap<Id, NodeRunState>, origin: unknown = SIM_ORIGIN): void {
const nodes = doc.getMap<Y.Map<unknown>>(ROOTS.nodes);
+157
View File
@@ -0,0 +1,157 @@
// A device's files in the document: read as a file system, changed through
// the core's operations, a text file's Y.Text found in any case, and the
// document's files into a running engine's — what differs only.
import { describe, expect, it } from 'vitest';
import * as Y from 'yjs';
import { saveTextAs, VirtualFileSystem } from '@mdg-filius/core';
import { addDevice, changeFiles, emptyProject, fileText, nodeFiles, readFilesInto, ROOTS, writeProject } from '../src/index.js';
function setUp() {
const doc = new Y.Doc();
writeProject(doc, emptyProject());
let counter = 0;
addDevice(doc, { kind: 'pc', id: 'pc', name: 'PC', x: 0, y: 0, newId: () => `port-${++counter}` }, 'me');
const files = () => doc.getMap<Y.Map<unknown>>(ROOTS.nodes).get('pc')!.get('files') as Y.Map<Y.Map<unknown>> | undefined;
return { doc, files };
}
const paths = (fileSystem: VirtualFileSystem) => fileSystem.allEntries().map((entry) => [entry.path, entry.content.kind === 'text' ? entry.content.text : entry.content.kind]);
describe("a device's files in the document", () => {
it('are changed through the core, written with the origin, what changed only', () => {
const { doc, files } = setUp();
expect(paths(nodeFiles(doc, 'pc')!)).toEqual([['/', 'directory']]);
expect(nodeFiles(doc, 'none')).toBeUndefined();
const origins: unknown[] = [];
doc.on('update', (_update: Uint8Array, origin: unknown) => origins.push(origin));
expect(changeFiles(doc, 'pc', 'me', (fileSystem) => saveTextAs(fileSystem, '/', 'a.txt', 'Anfang Ende'))).toBe('saved');
expect(origins).toEqual(['me']);
expect((files()!.get('/a.txt')!.get('text') as Y.Text).toString()).toBe('Anfang Ende');
// Nothing changed: nothing written.
expect(changeFiles(doc, 'pc', 'me', (fileSystem) => fileSystem.contains('/a.txt'))).toBe(true);
expect(origins).toHaveLength(1);
expect(changeFiles(doc, 'none', 'me', () => 'ran')).toBeUndefined();
});
it("finds a text file's Y.Text in any case, and none for a folder, an image or what is not there", () => {
const { doc } = setUp();
changeFiles(doc, 'pc', 'me', (fileSystem) => {
fileSystem.writeTextFile('/Notiz.txt', 'x');
fileSystem.createDirectory('/ordner');
fileSystem.writeImageFile('/bild.png', new Uint8Array([1]), 'image/png');
});
const text = fileText(doc, 'pc', '/Notiz.txt');
expect(text?.toString()).toBe('x');
expect(fileText(doc, 'pc', '/NOTIZ.TXT')).toBe(text);
expect([fileText(doc, 'pc', '/ordner'), fileText(doc, 'pc', '/bild.png'), fileText(doc, 'pc', '/fehlt.txt'), fileText(doc, 'none', '/Notiz.txt')]).toEqual([
undefined,
undefined,
undefined,
undefined,
]);
const empty = new Y.Doc();
writeProject(empty, emptyProject());
addDevice(empty, { kind: 'pc', id: 'pc', name: 'PC', x: 0, y: 0, newId: () => 'port' }, 'me');
expect(fileText(empty, 'pc', '/Notiz.txt')).toBeUndefined();
});
it("go into a running engine's file system, what differs only: new ones after the others, changed ones in their place, gone ones removed", () => {
const { doc } = setUp();
changeFiles(doc, 'pc', 'me', (fileSystem) => {
fileSystem.createDirectory('/ordner');
fileSystem.writeTextFile('/ordner/a.txt', 'a');
fileSystem.writeTextFile('/b.txt', 'b');
fileSystem.writeTextFile('/Gross.txt', 'G');
fileSystem.writeImageFile('/bild.png', new Uint8Array([1]), 'image/png');
fileSystem.writeBinaryFile('/daten.bin', new Uint8Array([2]));
});
const engine = nodeFiles(doc, 'pc')!;
engine.writeTextFile('/eigen.txt', 'the engine wrote it');
// Alike but for the engine's own file: it goes, the others stay as they are.
expect(readFilesInto(doc, 'pc', engine)).toBe(true);
expect(paths(engine)).toEqual(paths(nodeFiles(doc, 'pc')!));
const revision = engine.revision;
expect(readFilesInto(doc, 'pc', engine)).toBe(false);
expect(engine.revision).toBe(revision);
// A text changed, an image, a binary file, a folder turned into a file and a file into a folder, a new one.
changeFiles(doc, 'pc', 'me', (fileSystem) => {
fileSystem.writeTextFile('/b.txt', 'b2');
fileSystem.writeImageFile('/bild.png', new Uint8Array([3]), 'image/png');
fileSystem.writeBinaryFile('/daten.bin', new Uint8Array([2]), 'application/octet-stream');
fileSystem.deleteItem('/ordner', true);
fileSystem.writeTextFile('/ordner', 'now a file');
fileSystem.deleteItem('/b.txt');
fileSystem.createDirectory('/b.txt');
fileSystem.writeTextFile('/neu.txt', 'neu');
});
expect(readFilesInto(doc, 'pc', engine)).toBe(true);
expect(paths(engine)).toEqual(paths(nodeFiles(doc, 'pc')!));
expect(
engine
.entriesInCreationOrder('/')
.map((entry) => entry.path)
.at(-1),
).toBe('/neu.txt');
expect(engine.imageFile('/bild.png').data).toEqual(new Uint8Array([3]));
expect(engine.entry('/daten.bin').content).toEqual({ kind: 'binary', data: new Uint8Array([2]), mediaType: 'application/octet-stream' });
expect(readFilesInto(doc, 'none', engine)).toBe(false);
// A file in a new folder; then a folder with all in it removed; a name changed only in case comes with its spelling.
changeFiles(doc, 'pc', 'me', (fileSystem) => {
fileSystem.createDirectory('/neu');
fileSystem.createDirectory('/neu/tiefer');
fileSystem.writeTextFile('/neu/tiefer/c.txt', 'c');
});
expect(readFilesInto(doc, 'pc', engine)).toBe(true);
expect(engine.textFile('/neu/tiefer/c.txt')).toBe('c');
changeFiles(doc, 'pc', 'me', (fileSystem) => {
fileSystem.deleteItem('/neu', true);
fileSystem.renameInPlace('/neu.txt', 'NEU.txt');
});
expect(readFilesInto(doc, 'pc', engine)).toBe(true);
expect(paths(engine)).toEqual(paths(nodeFiles(doc, 'pc')!));
expect(engine.contains('/neu')).toBe(false);
expect(engine.entry('/neu.txt').path).toBe('/NEU.txt');
// What stayed keeps its place, a name in capitals too.
const order = (fileSystem: VirtualFileSystem) => fileSystem.entriesInCreationOrder('/').map((entry) => entry.path);
expect(order(engine)).toEqual(order(nodeFiles(doc, 'pc')!));
expect(order(engine).indexOf('/Gross.txt')).toBeLessThan(order(engine).indexOf('/NEU.txt'));
// So also in a folder.
changeFiles(doc, 'pc', 'me', (fileSystem) => {
fileSystem.createDirectory('/tief');
fileSystem.writeTextFile('/tief/x', 'x');
fileSystem.writeTextFile('/tief/y.txt', 'y');
});
readFilesInto(doc, 'pc', engine);
changeFiles(doc, 'pc', 'me', (fileSystem) => {
fileSystem.deleteItem('/tief/x');
fileSystem.createDirectory('/tief/x');
});
readFilesInto(doc, 'pc', engine);
expect(engine.entriesInCreationOrder('/tief').map((entry) => entry.path)).toEqual(['/tief/x', '/tief/y.txt']);
});
it('leave out what the file system refuses, as the editor draws it', () => {
const { doc, files } = setUp();
changeFiles(doc, 'pc', 'me', (fileSystem) => fileSystem.writeTextFile('/a.txt', 'a'));
// Two people gave a name at once in different case: one file, the first name, the later text (as fileSystemOf reads it); one in a folder not there is left out.
doc.transact(() => {
const entry = new Y.Map<unknown>();
entry.set('order', 5);
entry.set('kind', 'text');
entry.set('text', new Y.Text('A'));
files()!.set('/A.TXT', entry);
const lost = new Y.Map<unknown>();
lost.set('order', 6);
lost.set('kind', 'text');
lost.set('text', new Y.Text('lost'));
files()!.set('/fehlt/x.txt', lost);
});
const engine = new VirtualFileSystem();
expect(readFilesInto(doc, 'pc', engine)).toBe(true);
expect(paths(engine)).toEqual([
['/', 'directory'],
['/a.txt', 'A'],
]);
});
});
+168
View File
@@ -0,0 +1,168 @@
// The text editor on a computer's desktop in Chromium (F4.6f2), on
// browser/fixtures/web-pages.fls (test/webPagesFixture.test.ts): installed
// while the simulation runs, a page written and saved through Java's file
// dialog into the web server's folder, fetched by the other computer's
// browser, changed while it runs and fetched again; the caret where one
// types, the window's own undo, what the dialog refuses, a file removed by
// the command line while it is open, „Neu“.
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { expect, test, type Page } from '@playwright/test';
import { drawing, onDevice, serveEditor } from './editorPage.js';
let server: { readonly base: string; readonly close: () => Promise<void> };
test.beforeAll(async () => {
server = await serveEditor();
});
test.afterAll(async () => {
await server.close();
});
/** What the development page lets a test do: write into a file as another would. */
interface DevPage {
readonly insertText: (id: string, path: string, index: number, text: string) => void;
}
const FIXTURE = join(dirname(fileURLToPath(import.meta.url)), 'fixtures/web-pages.fls');
async function openPages(page: Page): Promise<void> {
await page.goto(`${server.base}?example=new`);
await page.waitForSelector('svg.filius-canvas');
await page.locator('input[type="file"]').setInputFiles(FIXTURE);
await expect.poll(async () => (await drawing(page).catch(() => ({ nodes: [] }))).nodes.length).toBe(4);
await page.getByRole('button', { name: 'Wechselt in den Aktionsmodus' }).click();
await expect(page.locator('svg.filius-canvas')).toHaveAttribute('data-mode', 'simulation');
}
/** A computer's desktop, opened with a tap. */
async function desktopOf(page: Page, name: string) {
const id = (await drawing(page)).nodes.find((node) => node.name === name)!.id;
const at = await onDevice(page, id);
await page.mouse.click(at.x, at.y);
return page.locator(`section[data-desktop="${id}"]`);
}
test('writes a page into the web server, which serves it while the simulation runs, and changes it there', async ({ page }) => {
test.setTimeout(120_000);
await openPages(page);
const desktop = await desktopOf(page, 'Server');
// Installed while it runs, with the command line beside it.
await desktop.locator('[data-program="installation"]').click();
const installation = desktop.locator('[data-program-window="installation"]');
const available = installation.locator('[data-list="available"] [role="option"]');
await available.filter({ hasText: 'Text-Editor' }).click();
await available.filter({ hasText: 'Befehlszeile' }).click();
await installation.getByRole('button', { name: 'Installieren' }).click();
await installation.getByRole('button', { name: 'Änderungen annehmen' }).click();
await desktop.locator('[data-program="textEditor"]').click();
const window = desktop.locator('[data-program-window="textEditor"]');
const title = window.locator('.program-titlebar h3');
const text = window.getByRole('textbox', { name: 'Text' });
await expect(title).toHaveText('Text-Editor');
await expect(window.locator('.text-editor-bar button')).toHaveText(['Neu', 'Öffnen', 'Speichern', 'Speichern unter']);
await expect(text).toHaveCSS('font-size', '16px');
// A new text: „Speichern“ asks for a name in Java's dialog; a tap opens a folder.
await text.fill('<html><head><title>Neu</title></head><body>Erster Stand</body></html>');
await window.getByRole('button', { name: 'Speichern', exact: true }).click();
const dialog = window.getByRole('dialog', { name: 'Datei Speichern' });
await expect(dialog.locator('.file-dialog-folder span')).toHaveText('Ordner: /');
await expect(dialog.getByRole('button', { name: 'Ordner aufwärts' })).toBeDisabled();
await expect(dialog.locator('.file-dialog-entries button')).toHaveText(['webserver']);
await dialog.locator('.file-dialog-entries button', { hasText: 'webserver' }).click();
await expect(dialog.locator('.file-dialog-folder span')).toHaveText('Ordner: /webserver');
await expect(dialog.locator('.file-dialog-entries button')).toHaveText(['ordner', 'index.html', 'splashscreen-mini.png', 'ziel.html', 'zwei.html']);
// No name: said in the dialog, which stays.
await dialog.getByRole('button', { name: 'Speichern' }).click();
await expect(dialog.getByRole('alert')).toHaveText('Geben Sie einen Dateinamen ein.');
await dialog.getByRole('textbox', { name: 'Dateiname:' }).fill('neu.html');
await dialog.getByRole('button', { name: 'Speichern' }).click();
await expect(dialog).toHaveCount(0);
await expect(title).toHaveText('Text-Editor - neu.html');
// Saved as it is typed: „Speichern“ has nothing left to do.
await expect(window.getByRole('button', { name: 'Speichern', exact: true })).toBeDisabled();
// The other computer's browser fetches it from the running web server (the desktops closed in between, which covered the computers).
const fetchPage = async (expected: string) => {
await desktop.locator('.desktop-close').click();
const browserDesktop = await desktopOf(page, 'Browser');
await browserDesktop.locator('[data-program="webBrowser"]').click();
const browser = browserDesktop.locator('[data-program-window="webBrowser"]');
const address = browser.getByRole('textbox', { name: 'Adresse' });
await address.fill('192.168.0.20/neu.html');
await address.press('Enter');
await expect(browser.frameLocator('iframe[title="Seite"]').locator('body')).toHaveText(expected, { timeout: 30_000 });
await browserDesktop.locator('.desktop-close').click();
await desktopOf(page, 'Server');
await desktop.locator('[data-program="textEditor"]').click();
};
await fetchPage('Erster Stand');
// The window is kept over the desktop closed, as Java's desktop keeps it.
await expect(title).toHaveText('Text-Editor - neu.html');
// Changed in the middle, the caret where one types: the server serves it at once.
const at = (await text.inputValue()).indexOf('Erster');
await text.evaluate((area: HTMLTextAreaElement, index) => {
area.focus();
area.setSelectionRange(index, index + 'Erster'.length);
}, at);
await page.keyboard.type('Zweiter');
await expect(text).toHaveValue('<html><head><title>Neu</title></head><body>Zweiter Stand</body></html>');
await fetchPage('Zweiter Stand');
// Another writes before the caret: the caret moves with its text, and one types on where one was.
const id = (await drawing(page)).nodes.find((node) => node.name === 'Server')!.id;
await text.evaluate((area: HTMLTextAreaElement, index) => {
area.focus();
area.setSelectionRange(index, index);
}, at + 'Zweiter'.length);
await page.evaluate(([node]) => (window as unknown as { filiusDev: DevPage }).filiusDev.insertText(node!, '/webserver/neu.html', 0, '<!-- x -->'), [id]);
await expect(text).toHaveValue('<!-- x --><html><head><title>Neu</title></head><body>Zweiter Stand</body></html>');
await page.keyboard.type('!');
await expect(text).toHaveValue('<!-- x --><html><head><title>Neu</title></head><body>Zweiter! Stand</body></html>');
await page.keyboard.press('Backspace');
await text.evaluate((area: HTMLTextAreaElement) => area.blur());
await expect(text).toHaveValue('<!-- x --><html><head><title>Neu</title></head><body>Zweiter Stand</body></html>');
// The window's own undo takes back its typing.
await text.press('Control+z');
await expect(text).toHaveValue('<!-- x --><html><head><title>Neu</title></head><body>Erster Stand</body></html>');
await text.press('Control+y');
await expect(text).toHaveValue('<!-- x --><html><head><title>Neu</title></head><body>Zweiter Stand</body></html>');
// „Öffnen“: a name that is not there and an image are refused, the open file stays.
await window.getByRole('button', { name: 'Öffnen' }).click();
const opening = window.getByRole('dialog', { name: 'Datei Öffnen' });
await opening.getByRole('textbox', { name: 'Dateiname:' }).fill('fehlt.txt');
await opening.getByRole('button', { name: 'Öffnen' }).click();
await expect(opening.getByRole('alert')).toHaveText('Die Datei „fehlt.txt“ gibt es nicht.');
await opening.locator('.file-dialog-entries button', { hasText: 'webserver' }).click();
await opening.locator('.file-dialog-entries button', { hasText: 'splashscreen-mini.png' }).click();
await expect(opening.getByRole('textbox', { name: 'Dateiname:' })).toHaveValue('splashscreen-mini.png');
await opening.getByRole('button', { name: 'Öffnen' }).click();
await expect(opening.getByRole('alert')).toHaveText('„splashscreen-mini.png“ ist keine Textdatei.');
await opening.getByRole('button', { name: 'Abbrechen' }).click();
await expect(title).toHaveText('Text-Editor - neu.html');
// Removed by the command line while open: the text stays as a new text, „Speichern“ asks for a name.
await desktop.locator('.desktop-home').click();
await desktop.locator('[data-program="commandPrompt"]').click();
const input = desktop.locator('input[name="terminalInput"]');
await input.fill('rm /webserver/neu.html');
await input.press('Enter');
await desktop.locator('.desktop-home').click();
await desktop.locator('[data-program="textEditor"]').click();
await expect(title).toHaveText('Text-Editor');
await expect(window.getByRole('status')).toHaveText('Die Datei „neu.html“ wurde gelöscht. Der Text ist noch da; „Speichern“ legt ihn als neue Datei ab.');
await expect(text).toHaveValue('<!-- x --><html><head><title>Neu</title></head><body>Zweiter Stand</body></html>');
await expect(window.getByRole('button', { name: 'Speichern', exact: true })).toBeEnabled();
// „Neu“: empty, no file.
await window.getByRole('button', { name: 'Neu' }).click();
await expect(text).toHaveValue('');
await expect(window.getByRole('status')).toHaveCount(0);
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 369 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 480 B

+14 -5
View File
@@ -12,9 +12,9 @@
// bar, as Java's windows do; on a phone it fills the surface. A program's
// icon opens its window over the desktop (ProgramFrame), the home button
// leaves it; so far the command line (F4.6b), the web browser and the web
// server (F4.6d), the e-mail program and server (F4.6e) have one, the others
// follow. The browser's window takes its
// title from its page, as Java's does.
// server (F4.6d), the e-mail program and server (F4.6e), the text editor
// (F4.6f2) have one, the others follow. The browser's window takes its title
// from its page, the text editor's from its open file, as Java's do.
import { useCallback, useEffect, useState, type PointerEvent as ReactPointerEvent } from 'react';
import type * as Y from 'yjs';
@@ -34,6 +34,7 @@ import { EmailServerPanel } from './EmailServerPanel.js';
import { pageTitle } from './browserPage.js';
import { InstallationPanel } from './InstallationPanel.js';
import { TerminalPanel } from './TerminalPanel.js';
import { TextEditorPanel, useTextEditorWindow } from './TextEditorPanel.js';
import { WebServerPanel } from './WebServerPanel.js';
import './desktop.css';
@@ -63,7 +64,7 @@ export interface DesktopWindowProps {
export type DesktopWindowName = DesktopProgram | 'installation';
/** The programs whose windows are built so far. */
const WITH_WINDOW: ReadonlySet<DesktopProgram> = new Set(['commandPrompt', 'webBrowser', 'webServer', 'emailClient', 'emailServer']);
const WITH_WINDOW: ReadonlySet<DesktopProgram> = new Set(['commandPrompt', 'webBrowser', 'webServer', 'emailClient', 'emailServer', 'textEditor']);
/** The document's desktop of a computer, read anew after every change (a program installed, the label changed). */
function useDesktopView(doc: Y.Doc, id: string) {
@@ -81,6 +82,7 @@ export function DesktopWindow({ doc, id, simulation, active, x, y, z, program: o
const t = useTranslate();
const language = useLanguage();
const view = useDesktopView(doc, id);
const textEditor = useTextEditorWindow(doc, id);
const { watch } = simulation;
useEffect(() => watch([`node:${id}`]), [watch, id]);
const settings = simulation.nodes.get(id);
@@ -157,7 +159,13 @@ export function DesktopWindow({ doc, id, simulation, active, x, y, z, program: o
) : shown === null ? null : (
<ProgramFrame
icon={JAVA_PROGRAMS[shown].icon}
title={shown === 'webBrowser' ? browserTitle(browserView(simulation, id), t('program.webBrowser'), (page) => pageTitle(page, t)) : t(`program.${shown}`)}
title={
shown === 'webBrowser'
? browserTitle(browserView(simulation, id), t('program.webBrowser'), (page) => pageTitle(page, t))
: shown === 'textEditor'
? textEditor.title(t('program.textEditor'))
: t(`program.${shown}`)
}
program={shown}
>
{shown === 'commandPrompt' ? <TerminalPanel node={id} simulation={simulation} onExit={home} /> : null}
@@ -165,6 +173,7 @@ export function DesktopWindow({ doc, id, simulation, active, x, y, z, program: o
{shown === 'webServer' ? <WebServerPanel node={id} simulation={simulation} /> : null}
{shown === 'emailClient' ? <EmailClientPanel node={id} simulation={simulation} /> : null}
{shown === 'emailServer' ? <EmailServerPanel node={id} simulation={simulation} /> : null}
{shown === 'textEditor' ? <TextEditorPanel doc={doc} node={id} /> : null}
</ProgramFrame>
)}
{info ? (
@@ -0,0 +1,94 @@
// Java's file dialog for a program's "open" and "save" (DMTNFileChooser),
// over the program's window: its title, the folder and „Ordner aufwärts“,
// the folder's entries in the order they were made with Java's icons, the
// name field, the dialog's own button and „Abbrechen“. It starts in the
// root, as Java's does.
//
// Better than Java: the folder is shown by its path (Java's label shows its
// name only, the root as "root"); a single tap opens a folder or takes a
// file's name (Java wants a double click, which a finger rarely makes); what
// the program refuses is said in the dialog, which stays open.
import { useState, type FormEvent } from 'react';
import { folderEntries, parentPath, type VirtualFileSystem } from '@mdg-filius/core';
import fileIcon from '../assets/desktop/datei.png';
import folderIcon from '../assets/desktop/ordner.png';
import { useTranslate } from '../i18n/i18n.js';
export interface FileDialogProps {
readonly kind: 'open' | 'save';
/** The computer's files, as they are now. */
readonly files: VirtualFileSystem;
/** The dialog's folder and name chosen: what the program says against it, or null when it is done. */
readonly onChoose: (folder: string, name: string) => string | null;
readonly onCancel: () => void;
}
export function FileDialog({ kind, files, onChoose, onCancel }: FileDialogProps) {
const t = useTranslate();
const [folder, setFolder] = useState('/');
const [name, setName] = useState('');
const [problem, setProblem] = useState<string | null>(null);
// A folder removed meanwhile: the root.
const shown = files.contains(folder) && files.entry(folder).content.kind === 'directory' ? files.entry(folder).path : '/';
const entries = folderEntries(files, shown);
const choose = (event: FormEvent) => {
event.preventDefault();
setProblem(onChoose(shown, name));
};
const title = t(kind === 'open' ? 'fileDialog.openTitle' : 'fileDialog.saveTitle');
return (
<form className="mail-dialog file-dialog" role="dialog" aria-label={title} onSubmit={choose}>
<h4>{title}</h4>
<div className="file-dialog-folder">
<span>{`${t('fileDialog.folder')} ${shown}`}</span>
<button type="button" disabled={shown === '/'} onClick={() => setFolder(parentPath(shown))}>
{t('fileDialog.up')}
</button>
</div>
<ul className="file-dialog-entries" aria-label={t('fileDialog.entries')}>
{entries.map((entry) => (
<li key={entry.path}>
<button
type="button"
data-kind={entry.kind}
aria-pressed={entry.kind === 'file' ? entry.name === name : undefined}
onClick={() => {
setProblem(null);
if (entry.kind === 'folder') setFolder(entry.path);
else setName(entry.name);
}}
>
<img src={entry.kind === 'folder' ? folderIcon : fileIcon} alt="" />
<span>{entry.name}</span>
</button>
</li>
))}
</ul>
<label>
<span>{t('fileDialog.name')}</span>
<input
value={name}
autoCapitalize="off"
autoCorrect="off"
spellCheck={false}
onChange={(event) => {
setName(event.target.value);
setProblem(null);
}}
/>
</label>
{problem === null ? null : (
<p className="mail-notice" role="alert">
{problem}
</p>
)}
<div className="mail-dialog-buttons">
<button type="submit">{t(kind === 'open' ? 'fileDialog.open' : 'fileDialog.save')}</button>
<button type="button" onClick={onCancel}>
{t('fileDialog.cancel')}
</button>
</div>
</form>
);
}
@@ -0,0 +1,126 @@
// A computer's text editor, after Java's GUIApplicationTextEditorWindow: the
// buttons „Neu“, „Öffnen“, „Speichern“, „Speichern unter“ above, the text
// below in Java's Courier, Java's file dialog over it (FileDialog). What it
// does is the window's (TextEditorWindow, textEditor.ts, with what it does
// better than Java); the text area follows the open file's Y.Text, so that
// two who have it open type into the same text. Ctrl+Z and Ctrl+Y (or
// Ctrl+Shift+Z) take back and redo this window's own typing in the file.
//
// The file is the document's, so it opens without the simulation's room;
// while the simulation runs, the room takes what is typed into the engine's
// files at once.
import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore, type KeyboardEvent } from 'react';
import type * as Y from 'yjs';
import { entryName } from '@mdg-filius/core';
import { nodeFiles } from '@mdg-filius/doc';
import { useTranslate } from '../i18n/i18n.js';
import { FileDialog } from './FileDialog.js';
import { followText } from './textBinding.js';
import { textEditorWindow, type TextEditorWindow } from './textEditor.js';
/** A computer's text editor window, drawn anew with each change of it. */
export function useTextEditorWindow(doc: Y.Doc, node: string): TextEditorWindow {
const window = textEditorWindow(doc, node);
useSyncExternalStore(
(listener) => window.subscribe(listener),
() => window.version,
);
return window;
}
/** The document's files of the computer, read anew after every change while the dialog is open. */
function useFiles(doc: Y.Doc, node: string, reading: boolean) {
const [, setVersion] = useState(0);
useEffect(() => {
if (!reading) return;
const update = () => setVersion((version) => version + 1);
doc.on('update', update);
return () => doc.off('update', update);
}, [doc, reading]);
return reading ? nodeFiles(doc, node) : undefined;
}
export function TextEditorPanel({ doc, node }: { readonly doc: Y.Doc; readonly node: string }) {
const t = useTranslate();
const window = useTextEditorWindow(doc, node);
const [dialog, setDialog] = useState<'open' | 'save' | null>(null);
const files = useFiles(doc, node, dialog !== null);
const area = useRef<HTMLTextAreaElement>(null);
const file = window.file;
const path = window.path;
// The open file followed (bound once per file: what one types is in the area already).
useLayoutEffect(() => {
const element = area.current;
return element === null || file === undefined ? undefined : followText(element, file, window.origin);
}, [file, window]);
// A new text set when it changes (Neu, a file gone), not while it is typed.
useLayoutEffect(() => {
const element = area.current;
if (element !== null && file === undefined && element.value !== window.text) element.value = window.text;
}, [file, window, window.version]);
const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
if (!(event.ctrlKey || event.metaKey) || window.file === undefined) return;
const key = event.key.toLowerCase();
if (key === 'z' && !event.shiftKey) window.undo();
else if (key === 'y' || (key === 'z' && event.shiftKey)) window.redo();
else return;
event.preventDefault();
};
const open = (folder: string, name: string): string | null => {
const result = window.open(folder, name);
if (result === 'opened') {
setDialog(null);
return null;
}
return t(`textEditor.${result}`, { name });
};
const saveAs = (folder: string, name: string): string | null => {
if (name === '') return t('textEditor.noName');
const result = window.saveAs(folder, name);
if (result === 'saved') {
setDialog(null);
return null;
}
return t(`textEditor.${result}`, { name });
};
return (
<div className="text-editor">
<div className="text-editor-bar">
<button type="button" onClick={() => window.newText()}>
{t('textEditor.new')}
</button>
<button type="button" onClick={() => setDialog('open')}>
{t('textEditor.open')}
</button>
<button type="button" disabled={path !== null} title={path === null ? undefined : t('textEditor.savedLive')} onClick={() => window.save() === 'askName' && setDialog('save')}>
{t('textEditor.save')}
</button>
<button type="button" onClick={() => setDialog('save')}>
{t('textEditor.saveAs')}
</button>
</div>
{window.gone === null ? null : (
<p className="mail-notice text-editor-notice" role="status">
{t('textEditor.gone', { name: entryName(window.gone) })}
</p>
)}
<textarea
ref={area}
className="text-editor-text"
aria-label={t('textEditor.text')}
spellCheck={false}
autoCapitalize="off"
autoCorrect="off"
defaultValue={window.text}
onInput={(event) => window.input(event.currentTarget.value)}
onKeyDown={onKeyDown}
/>
{dialog === null || files === undefined ? null : <FileDialog kind={dialog} files={files} onChoose={dialog === 'open' ? open : saveAs} onCancel={() => setDialog(null)} />}
</div>
);
}
+96
View File
@@ -809,3 +809,99 @@
.mail-question button + button {
margin-left: 8px;
}
/* The text editor (GUIApplicationTextEditorWindow): Java's four buttons
* above, the text below in Courier; 16 pixels, as Safari on the iPad zooms
* into smaller fields. */
.text-editor {
position: relative;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 5px;
padding: 5px;
background: #eeeeee;
}
.text-editor-bar {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 5px;
flex: none;
}
.text-editor-bar button,
.file-dialog-folder button {
min-width: 44px;
min-height: 40px;
padding: 0 12px;
border: 1px solid #a0a0a0;
border-radius: 6px;
background: #f8f8f8;
color: #000000;
font: 13px Dialog, sans-serif;
cursor: pointer;
}
.text-editor-bar button:disabled,
.file-dialog-folder button:disabled {
color: #a0a0a0;
cursor: default;
}
.text-editor-text {
flex: 1;
min-height: 0;
resize: none;
padding: 4px;
border: 1px solid #a0a0a0;
background: #ffffff;
color: #000000;
font: 16px 'Courier New', Courier, monospace;
}
/* Java's file dialog (DMTNFileChooser) over the program. */
.file-dialog-folder {
display: flex;
align-items: center;
gap: 10px;
flex: none;
}
.file-dialog-folder span {
flex: 1;
min-width: 0;
overflow-wrap: anywhere;
}
.file-dialog-entries {
flex: 1;
min-height: 96px;
margin: 0;
padding: 0;
overflow: auto;
border: 1px solid #a0a0a0;
background: #ffffff;
list-style: none;
}
.file-dialog-entries button {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
min-height: 32px;
padding: 0 6px;
border: 0;
background: transparent;
color: #000000;
font: 13px Dialog, sans-serif;
text-align: left;
cursor: pointer;
}
.file-dialog-entries button[aria-pressed='true'] {
background: #b8cfe5;
}
@@ -0,0 +1,50 @@
// A text area that follows a document's Y.Text (the text editor's open
// file, plan §7.3): what another wrote — another person, the simulation,
// an undo — comes into the area, the caret and the selection kept where they
// were in the text around them. What one types goes the other way through
// the window (TextEditorWindow.input), with the origin this leaves out.
import type * as Y from 'yjs';
/** A change of a Y.Text as its event gives it (retain, insert, delete). */
export type TextDelta = readonly {
readonly retain?: number;
readonly insert?: unknown;
readonly delete?: number;
}[];
/**
* Where a place in the text is after another's change: moved by what was
* inserted before it and by what was deleted before it; an insertion just
* at it leaves it before the new text (the other's typing goes on after the
* caret, not under it).
*/
export function mapIndex(index: number, delta: TextDelta): number {
let old = 0;
let shift = 0;
for (const op of delta) {
if (old >= index) break;
if (op.retain !== undefined) old += op.retain;
else if (op.insert !== undefined) shift += typeof op.insert === 'string' ? op.insert.length : 1;
else if (op.delete !== undefined) {
shift -= Math.min(op.delete, index - old);
old += op.delete;
}
}
return index + shift;
}
/** Lets the area follow the text, but for the changes of the origin (its own typing); returns the end of it. */
export function followText(area: HTMLTextAreaElement, text: Y.Text, origin: object): () => void {
area.value = text.toString();
const onChange = (event: Y.YTextEvent, transaction: Y.Transaction) => {
if (transaction.origin === origin) return;
const delta = event.delta as TextDelta;
const { selectionStart, selectionEnd, selectionDirection } = area;
const focused = area.ownerDocument.activeElement === area;
area.value = text.toString();
if (focused) area.setSelectionRange(mapIndex(selectionStart, delta), mapIndex(selectionEnd, delta), selectionDirection);
};
text.observe(onChange);
return () => text.unobserve(onChange);
}
+191
View File
@@ -0,0 +1,191 @@
// A computer's text editor window, after Java's GUIApplicationTextEditorWindow
// (measured with tools/java-oracle's TextEditorWindowRun): „Neu“, „Öffnen“,
// „Speichern“, „Speichern unter“ over Java's file dialog, the title with the
// open file's name. Without React, so that the window's steps can be played
// against Java's (test/textEditor.test.ts); the panel draws it.
//
// The open file's text is its Y.Text in the document (plan §7.3): what one
// types is in the file at once, for everyone who has it open and for the
// running simulation — Java's editor keeps a copy until „Speichern“ and takes
// what another program wrote only while nothing is typed. A new text not yet
// saved is the window's own.
//
// Better than Java (each a mistake of Java's, measured):
// - „Speichern unter“ onto a file that is there writes that file and goes on
// in it (Java goes on in a copy no folder holds: every later „Speichern“
// is lost), the title shows the file's own name (Java's the name typed);
// - a file removed while it is open leaves its text as a new text, and
// „Speichern“ asks for a name (Java's „Speichern“ is lost);
// - „Öffnen“ of a name that is not there, or no name, says so and keeps the
// open file (Java forgets it silently), and opens no image (Java shows its
// Base64 text);
// - „Speichern unter“ refuses an empty name and one with blanks at its ends,
// and takes "ordner/d.txt" as the file d.txt in ordner (Java makes files
// of such names).
import * as Y from 'yjs';
import { entryName, openText, saveTextAs, type Id } from '@mdg-filius/core';
import { changeFiles, fileText, nodeFiles, replaceText } from '@mdg-filius/doc';
export type OpenResult = 'opened' | 'notFound' | 'notText';
export type SaveResult = 'saved' | 'askName';
export type SaveAsResult = 'saved' | 'folderMissing' | 'isFolder' | 'invalidName';
export class TextEditorWindow {
private pathValue: string | null = null;
private draft = '';
private goneValue: string | null = null;
private bound: Y.Text | undefined;
private lastText = '';
private undoManager: Y.UndoManager | undefined;
private readonly listeners = new Set<() => void>();
private versionValue = 0;
/** The window's origin: what it writes into the document, its own undo takes back. */
readonly origin: object = {};
constructor(
private readonly doc: Y.Doc,
private readonly node: Id,
) {
doc.on('update', () => this.refresh());
}
/** The open file's path; null for a new text. */
get path(): string | null {
return this.pathValue;
}
/** The open file's text in the document; none for a new text. */
get file(): Y.Text | undefined {
return this.bound;
}
/** The path of the open file that was removed while it was open, until the window opens or saves another or starts anew. */
get gone(): string | null {
return this.goneValue;
}
get text(): string {
return this.bound === undefined ? this.draft : this.bound.toString();
}
/** Changes with each change of what the window shows (for React). */
get version(): number {
return this.versionValue;
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private changed(): void {
this.versionValue += 1;
for (const listener of this.listeners) listener();
}
/** GUIApplicationWindow.setTitle: the program's name, " - " and the open file's name. */
title(programName: string): string {
return this.pathValue === null ? programName : `${programName} - ${entryName(this.pathValue)}`;
}
private readonly remember = () => {
this.lastText = this.bound?.toString() ?? '';
};
private bind(text: Y.Text | undefined, path: string | null, gone: string | null = null): void {
this.goneValue = gone;
this.bound?.unobserve(this.remember);
this.undoManager?.destroy();
this.bound = text;
this.pathValue = path;
this.undoManager = text === undefined ? undefined : new Y.UndoManager(text, { trackedOrigins: new Set([this.origin]), captureTimeout: 500 });
text?.observe(this.remember);
this.remember();
}
/** After a change of the document: the open file found again (another may have replaced its entry), or gone. */
private refresh(): void {
if (this.pathValue === null) return;
const now = fileText(this.doc, this.node, this.pathValue);
if (now === this.bound) return;
if (now === undefined) {
this.draft = this.lastText;
this.bind(undefined, null, this.pathValue);
} else {
this.bind(now, this.pathValue);
}
this.changed();
}
/** What the person typed: into the open file (where it differs), or the new text. */
input(next: string): void {
if (this.bound === undefined) {
if (this.draft === next) return;
this.draft = next;
} else {
const text = this.bound;
this.doc.transact(() => replaceText(text, next), this.origin);
}
this.changed();
}
/** „Neu“: an empty new text. */
newText(): void {
this.draft = '';
this.bind(undefined, null);
this.changed();
}
/** „Öffnen“ with the dialog's folder and name. */
open(folder: string, name: string): OpenResult {
const files = nodeFiles(this.doc, this.node);
const found = files === undefined ? 'notFound' : openText(files, folder, name);
if (typeof found === 'string') return found;
this.bind(fileText(this.doc, this.node, found.path), found.path);
this.changed();
return 'opened';
}
/** „Speichern“: the open file has the text already; a new text asks for a name („Speichern unter“). */
save(): SaveResult {
return this.pathValue === null ? 'askName' : 'saved';
}
/** „Speichern unter“ with the dialog's folder and name: the text as that file, which is then the open one. */
saveAs(folder: string, name: string): SaveAsResult {
const text = this.text;
let path: string | undefined;
const result =
changeFiles(this.doc, this.node, this.origin, (files) => {
const saved = saveTextAs(files, folder, name, text);
if (saved === 'saved') path = files.entry(`${folder === '/' ? '' : folder}/${name}`).path;
return saved;
}) ?? 'folderMissing';
if (path !== undefined) {
this.bind(fileText(this.doc, this.node, path), path);
this.changed();
}
return result;
}
/** The window's own undo in the open file (what it typed). */
undo(): void {
this.undoManager?.undo();
}
redo(): void {
this.undoManager?.redo();
}
}
const windows = new WeakMap<Y.Doc, Map<Id, TextEditorWindow>>();
/** A computer's text editor window, the same while the editor runs (Java's desktop keeps its windows). */
export function textEditorWindow(doc: Y.Doc, node: Id): TextEditorWindow {
let ofDoc = windows.get(doc);
if (ofDoc === undefined) windows.set(doc, (ofDoc = new Map()));
let window = ofDoc.get(node);
if (window === undefined) ofDoc.set(node, (window = new TextEditorWindow(doc, node)));
return window;
}
+22
View File
@@ -122,6 +122,15 @@
"emailServer.username": "Benutzername:",
"emailServer.whiteSpace": "Der Benutzername darf keine Leerzeichen enthalten!",
"emailServer.yes": "Ja",
"fileDialog.cancel": "Abbrechen",
"fileDialog.entries": "Inhalt des Ordners",
"fileDialog.folder": "Ordner:",
"fileDialog.name": "Dateiname:",
"fileDialog.open": "Öffnen",
"fileDialog.openTitle": "Datei Öffnen",
"fileDialog.save": "Speichern",
"fileDialog.saveTitle": "Datei Speichern",
"fileDialog.up": "Ordner aufwärts",
"firewall.accept": "akzeptieren",
"firewall.action": "Aktion",
"firewall.active": "Firewall aktivieren",
@@ -257,6 +266,19 @@
"terminal.interrupt": "Befehl abbrechen (Strg+C)",
"terminal.next": "Nächster Befehl",
"terminal.previous": "Vorheriger Befehl",
"textEditor.folderMissing": "Den Ordner für „{name}“ gibt es nicht.",
"textEditor.gone": "Die Datei „{name}“ wurde gelöscht. Der Text ist noch da; „Speichern“ legt ihn als neue Datei ab.",
"textEditor.invalidName": "„{name}“ ist kein zulässiger Dateiname.",
"textEditor.isFolder": "„{name}“ ist ein Ordner.",
"textEditor.new": "Neu",
"textEditor.noName": "Geben Sie einen Dateinamen ein.",
"textEditor.notFound": "Die Datei „{name}“ gibt es nicht.",
"textEditor.notText": "„{name}“ ist keine Textdatei.",
"textEditor.open": "Öffnen",
"textEditor.save": "Speichern",
"textEditor.saveAs": "Speichern unter",
"textEditor.savedLive": "Was Sie tippen, steht sofort in der Datei.",
"textEditor.text": "Text",
"webServer.folder": "Unterverzeichnis",
"webServer.hostName": "Hostname",
"webServer.useVirtualHosts": "Verwende virtuelle Hosts",
+22
View File
@@ -122,6 +122,15 @@
"emailServer.username": "User:",
"emailServer.whiteSpace": "User names may not contain whitespace!",
"emailServer.yes": "Yes",
"fileDialog.cancel": "Cancel",
"fileDialog.entries": "Contents of the folder",
"fileDialog.folder": "Folder:",
"fileDialog.name": "File:",
"fileDialog.open": "Open",
"fileDialog.openTitle": "Open file",
"fileDialog.save": "Save",
"fileDialog.saveTitle": "Save file",
"fileDialog.up": "Folder up",
"firewall.accept": "accept",
"firewall.action": "Action",
"firewall.active": "Activate firewall",
@@ -257,6 +266,19 @@
"terminal.interrupt": "Stop the command (Ctrl+C)",
"terminal.next": "Next command",
"terminal.previous": "Previous command",
"textEditor.folderMissing": "There is no folder for “{name}”.",
"textEditor.gone": "The file “{name}” was deleted. The text is still here; “Save” stores it as a new file.",
"textEditor.invalidName": "“{name}” is not a valid file name.",
"textEditor.isFolder": "“{name}” is a folder.",
"textEditor.new": "New",
"textEditor.noName": "Enter a file name.",
"textEditor.notFound": "There is no file “{name}”.",
"textEditor.notText": "“{name}” is not a text file.",
"textEditor.open": "Open",
"textEditor.save": "Save",
"textEditor.saveAs": "Save as",
"textEditor.savedLive": "What you type is in the file at once.",
"textEditor.text": "Text",
"webServer.folder": "Subdirectory",
"webServer.hostName": "Hostname",
"webServer.useVirtualHosts": "Enable virtual hosts",
+11 -3
View File
@@ -7,7 +7,7 @@
import { StrictMode, useEffect, useState } from 'react';
import { createRoot } from 'react-dom/client';
import * as Y from 'yjs';
import { desktopView, deviceSheet, emptyProject, topologyView, writeProject } from '@mdg-filius/doc';
import { desktopView, deviceSheet, emptyProject, fileText, topologyView, writeProject } from '@mdg-filius/doc';
import { openProjectArchive } from '@mdg-filius/fls';
import { startBridge } from './bridge/dashboardBridge.js';
import { Editor } from './Editor.js';
@@ -60,9 +60,17 @@ function App() {
.then((buffer) => setDoc(docOf(new Uint8Array(buffer))));
}, [example]);
// The development page lets its browser tests read the document's drawing (never the editor embedded in the dashboard).
// The development page lets its browser tests read the document's drawing and write into a file as another would (never the editor embedded in the dashboard).
useEffect(() => {
(window as unknown as { filiusDev?: unknown }).filiusDev = doc === null ? undefined : { view: () => topologyView(doc), sheet: (id: string) => deviceSheet(doc, id), desktop: (id: string) => desktopView(doc, id) };
(window as unknown as { filiusDev?: unknown }).filiusDev =
doc === null
? undefined
: {
view: () => topologyView(doc),
sheet: (id: string) => deviceSheet(doc, id),
desktop: (id: string) => desktopView(doc, id),
insertText: (id: string, path: string, index: number, text: string) => doc.transact(() => fileText(doc, id, path)?.insert(index, text), 'other'),
};
}, [doc]);
const t = (key: string) => translate(language, key);
+12
View File
@@ -23,6 +23,18 @@ function properties(file: string): Map<string, string> {
/** The editor's keys whose words are Java's, and the key of each in Java's bundle. */
const FROM_JAVA: Readonly<Record<string, string>> = {
'device.pc': 'hw_rechner_msg1',
'textEditor.new': 'texteditor_msg3',
'textEditor.open': 'texteditor_msg4',
'textEditor.save': 'texteditor_msg5',
'textEditor.saveAs': 'texteditor_msg6',
'fileDialog.up': 'dmtnfilechooser_msg1',
'fileDialog.name': 'dmtnfilechooser_msg2',
'fileDialog.cancel': 'dmtnfilechooser_msg4',
'fileDialog.saveTitle': 'dmtnfilechooser_msg5',
'fileDialog.save': 'dmtnfilechooser_msg6',
'fileDialog.openTitle': 'dmtnfilechooser_msg7',
'fileDialog.open': 'dmtnfilechooser_msg8',
'fileDialog.folder': 'dmtnfilechooser_msg11',
'device.notebook': 'hw_notebook_msg1',
'device.networkSwitch': 'hw_switch_msg1',
'device.router': 'hw_vermittlungsrechner_msg1',
+56
View File
@@ -0,0 +1,56 @@
// Where the caret and the selection go when another changes the open file's
// text (textBinding.ts): moved by what was inserted or deleted before them,
// left before what another inserts just at them — the deltas as Yjs gives them.
import { describe, expect, it } from 'vitest';
import * as Y from 'yjs';
import { mapIndex, type TextDelta } from '../src/desktop/textBinding.js';
/** The delta of a change to a text that held `before`. */
function deltaOf(before: string, change: (text: Y.Text) => void): TextDelta {
const doc = new Y.Doc();
const text = doc.getText('t');
text.insert(0, before);
let delta: TextDelta = [];
text.observe((event) => (delta = event.delta as TextDelta));
change(text);
return delta;
}
describe('a place in the text after another’s change', () => {
it('moves by what was inserted before it, not by what came after it or just at it', () => {
const insert = deltaOf('Hallo Welt', (text) => text.insert(6, 'schöne '));
expect([0, 5, 6, 7, 10].map((index) => mapIndex(index, insert))).toEqual([0, 5, 6, 14, 17]);
expect(
mapIndex(
3,
deltaOf('abc', (text) => text.insert(0, 'xy')),
),
).toBe(5);
expect(
mapIndex(
0,
deltaOf('abc', (text) => text.insert(0, 'xy')),
),
).toBe(0);
});
it('moves back by what was deleted before it, to where the deletion began when it lay inside', () => {
const removed = deltaOf('Hallo schöne Welt', (text) => text.delete(6, 7));
expect([0, 6, 9, 13, 17].map((index) => mapIndex(index, removed))).toEqual([0, 6, 6, 6, 10]);
});
it('takes a replacement as its deletion and then its insertion: a place at the end of what was replaced stays before the new text', () => {
const replaced = deltaOf('Erster Stand', (text) => {
text.doc!.transact(() => {
text.delete(0, 6);
text.insert(0, 'Zweiter');
});
});
expect(replaced).toEqual([{ delete: 6 }, { insert: 'Zweiter' }]);
expect([0, 3, 6, 7, 12].map((index) => mapIndex(index, replaced))).toEqual([0, 0, 0, 8, 13]);
// An embedded object counts as one place.
expect(mapIndex(2, [{ insert: { image: 'x' } }])).toBe(3);
expect(mapIndex(2, [{ retain: 2 }, { insert: { image: 'x' } }])).toBe(2);
});
});
+231
View File
@@ -0,0 +1,231 @@
// The text editor window against Java's (packages/sim/test/
// textEditorWindow.java.json, TextEditorWindowRun): Java's steps played on a
// computer with a web server, the same files, the dialog's lists, the title
// and the text after each; where Java errs, what the window does instead
// (textEditor.ts names each), and where the text is the document's — typed
// is written — what follows from it.
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import * as Y from 'yjs';
import { folderEntries } from '@mdg-filius/core';
import { addDevice, changeFiles, emptyProject, fileText, nodeFiles, replaceText, ROOTS, setSoftware, writeProject } from '@mdg-filius/doc';
import { TextEditorWindow, textEditorWindow } from '../src/desktop/textEditor.js';
import { translate } from '../src/i18n/i18n.js';
interface JavaStep {
readonly step: string;
readonly title: string;
readonly text: string;
readonly dialog?: readonly {
readonly title: string;
readonly labels: readonly string[];
readonly entries: readonly string[];
}[];
readonly files: readonly (readonly [string, string, string])[];
}
const java = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '../../sim/test/textEditorWindow.java.json'), 'utf8')) as { steps: JavaStep[] };
const at = (name: string) => {
const found = java.steps.find((each) => each.step === name);
if (found === undefined) throw new Error(`no step ${name}`);
return found;
};
/** The files outside the web server's folder: a folder as null, a text as it is (Java's oracle gives a file of another type by its length). */
type Files = Record<string, string | null>;
const outsideWebServer = (path: string) => !path.startsWith('/webserver');
function javaFiles(step: JavaStep): Files {
return Object.fromEntries(
step.files
.filter(([path]) => outsideWebServer(path))
.map(([path, type, content]) => [path === '/' ? '/' : path.replace(/\/$/, ''), type === 'folder' ? null : content === '(0 characters)' ? '' : content]),
);
}
const PROGRAM = translate('de', 'program.textEditor');
function setUp() {
const doc = new Y.Doc();
writeProject(doc, emptyProject());
let counter = 0;
addDevice(doc, { kind: 'pc', id: 'pc', name: 'PC', x: 0, y: 0, newId: () => `port-${++counter}` }, 'me');
setSoftware(doc, 'pc', { install: ['webServer', 'textEditor', 'commandPrompt'], remove: [] }, 'de', 'me');
const window = new TextEditorWindow(doc, 'pc');
const files = (): Files =>
Object.fromEntries(
nodeFiles(doc, 'pc')!
.allEntries()
.filter((entry) => entry.path !== '/' && outsideWebServer(entry.path))
.map((entry) => [entry.path, entry.content.kind === 'directory' ? null : entry.content.kind === 'text' ? entry.content.text : entry.content.kind]),
);
const shown = () => ({ title: window.title(PROGRAM), text: window.text, files: files() });
/** Another program writes a file (Java's setDateiInhalt). */
const outside = (path: string, text: string) => doc.transact(() => replaceText(fileText(doc, 'pc', path)!, text), 'other');
/** The command line beside the window. */
const command = (change: Parameters<typeof changeFiles>[3]) => changeFiles(doc, 'pc', 'terminal', change);
/** The dialog's list of a folder, as Java's list writes its entries. */
const entries = (folder: string) => folderEntries(nodeFiles(doc, 'pc')!, folder).map((entry) => `${entry.kind === 'folder' ? 'Ordner' : 'Datei'};${entry.name}`);
return { doc, window, files, shown, outside, command, entries };
}
/** Java's title, text and files after a step, with what differs here. */
function expected(name: string, change: { title?: string; text?: string; files?: Files } = {}) {
const step = at(name);
return { title: change.title ?? step.title, text: change.text ?? step.text, files: { ...javaFiles(step), ...change.files } };
}
describe("the text editor window, against Java's", () => {
it("plays Java's steps: the same where Java is right, as named where it errs", () => {
const { window, files, shown, outside, command, entries } = setUp();
expect(shown()).toEqual(expected('open'));
// A new text: „Speichern“ asks for a name, and the dialog lists the folder.
window.input('Erste Zeile\nZweite Zeile');
expect(window.save()).toBe('askName');
expect(entries('/')).toEqual(at('save new as notiz.txt').dialog![0]!.entries);
expect(window.saveAs('/', 'notiz.txt')).toBe('saved');
expect(shown()).toEqual(expected('save new as notiz.txt'));
window.input('Erste Zeile\nZweite Zeile\nDritte Zeile');
expect(window.save()).toBe('saved');
expect(shown()).toEqual(expected('save'));
// Another program writes the open file: the window shows it.
outside('/notiz.txt', 'von außen');
expect(shown()).toEqual(expected('changed outside, nothing typed'));
// Typed is written: another's change meets it in the file, and the window shows the file (Java keeps its copy).
window.input('getippt');
expect(files()['/notiz.txt']).toBe('getippt');
outside('/notiz.txt', 'wieder von außen');
expect(shown()).toEqual(expected('changed outside after typing', { text: 'wieder von außen' }));
// The dialog: a folder's list, a file opened there, by name in any case, by a path.
command((fileSystem) => {
fileSystem.createDirectory('/ordner');
fileSystem.writeTextFile('/ordner/b.txt', '');
});
const dialog = at('open ordner/b.txt by double clicks').dialog!;
expect([entries('/'), entries('/ordner')]).toEqual([dialog[0]!.entries, dialog[1]!.entries]);
expect(window.open('/ordner', 'b.txt')).toBe('opened');
expect(shown()).toEqual(expected('open ordner/b.txt by double clicks'));
expect(window.open('/', 'NOTIZ.TXT')).toBe('opened');
expect(shown()).toEqual(expected('open NOTIZ.TXT'));
expect(window.open('/', 'ordner/b.txt')).toBe('opened');
expect(shown()).toEqual(expected('open ordner/b.txt by name'));
// Not there, no name, an image: said so, b.txt stays open (Java forgets it, shows the image's Base64 text).
expect(window.open('/', 'fehlt.txt')).toBe('notFound');
expect(shown()).toEqual(expected('open fehlt.txt', { title: `${PROGRAM} - b.txt` }));
expect(window.save()).toBe('saved');
expect(window.open('/', '')).toBe('notFound');
expect(entries('/webserver')).toEqual(at("open the web server's image").dialog![1]!.entries);
expect(window.open('/webserver', 'splashscreen-mini.png')).toBe('notText');
expect(shown()).toEqual(expected('open without a name', { title: `${PROGRAM} - b.txt` }));
// „Speichern unter“ onto notiz.txt in other case: that file, its name in the title, and later typing still goes there.
expect(window.open('/', 'notiz.txt')).toBe('opened');
window.input('vor dem Speichern unter');
expect(window.saveAs('/', 'NOTIZ.TXT')).toBe('saved');
expect(shown()).toEqual(expected('save as NOTIZ.TXT over notiz.txt', { title: `${PROGRAM} - notiz.txt` }));
window.input('nach dem Speichern unter');
const noteText = { '/notiz.txt': 'nach dem Speichern unter' };
expect(shown()).toEqual(expected('save after save as over a file', { title: `${PROGRAM} - notiz.txt`, files: noteText }));
// „Speichern unter“ a new file in a folder: typed goes into the open file first (here notiz.txt), then the new one has it.
window.input('neu im Ordner');
noteText['/notiz.txt'] = 'neu im Ordner';
expect(window.saveAs('/ordner', 'c.txt')).toBe('saved');
expect(shown()).toEqual(expected('save as ordner/c.txt', { files: noteText }));
window.input('neu im Ordner, geändert');
expect(shown()).toEqual(expected('save after save as a new file', { files: noteText }));
// The open file removed: its text stays as a new text, and „Speichern“ asks for a name (Java's is lost).
command((fileSystem) => fileSystem.deleteItem('/ordner/c.txt'));
expect(shown()).toEqual(expected('open file removed', { title: PROGRAM, files: noteText }));
expect(window.gone).toBe('/ordner/c.txt');
window.input('nach dem Löschen');
expect(window.save()).toBe('askName');
expect(shown()).toEqual(expected('save after the file was removed', { title: PROGRAM, files: noteText }));
// A name with a folder in it is that folder's file (Java's file is named "ordner/d.txt" in the root); no name is refused.
expect(window.saveAs('/', 'ordner/d.txt')).toBe('saved');
expect(window.gone).toBeNull();
expect(shown()).toEqual(expected('save as ordner/d.txt', { title: `${PROGRAM} - d.txt`, files: noteText }));
expect(entries('/')).not.toContain('Datei;ordner/d.txt');
expect(window.saveAs('/', '')).toBe('invalidName');
const withoutNameless = Object.fromEntries(Object.entries(expected('save as without a name').files).filter(([path]) => path !== '/'));
expect(shown()).toEqual({ ...expected('save as without a name', { title: `${PROGRAM} - d.txt` }), files: { ...withoutNameless, ...noteText } });
window.newText();
expect({ ...shown(), files: {} }).toEqual({ ...expected('new'), files: {} });
});
it("names Java's dialog, its folder's label aside", () => {
const dialog = at('save new as notiz.txt').dialog![0]!;
expect(dialog.title).toBe(translate('de', 'fileDialog.saveTitle'));
expect(dialog.labels).toEqual([`${translate('de', 'fileDialog.folder')} root`, translate('de', 'fileDialog.name')]);
expect(at('open ordner/b.txt by double clicks').dialog![0]!.title).toBe(translate('de', 'fileDialog.openTitle'));
});
});
describe('the text editor window beyond the steps', () => {
it("follows the open file when another's write replaces its entry, and takes back only its own typing", () => {
const { doc, window } = setUp();
window.input('eins');
window.saveAs('/', 'a.txt');
const files = doc.getMap<Y.Map<unknown>>(ROOTS.nodes).get('pc')!.get('files') as Y.Map<Y.Map<unknown>>;
let seen = window.version;
const stop = window.subscribe(() => (seen = window.version));
// Another replaces the entry (a text again): the window follows to the new text.
doc.transact(() => {
const entry = new Y.Map<unknown>();
entry.set('order', files.get('/a.txt')!.get('order'));
entry.set('kind', 'text');
entry.set('text', new Y.Text('ersetzt'));
files.set('/a.txt', entry);
}, 'other');
expect([window.path, window.text, window.file]).toEqual(['/a.txt', 'ersetzt', fileText(doc, 'pc', '/a.txt')]);
expect(seen).toBe(window.version);
// Own typing is taken back, another's not.
window.input('ersetzt und getippt');
doc.transact(() => fileText(doc, 'pc', '/a.txt')!.insert(0, '>'), 'other');
window.undo();
expect(window.text).toBe('>ersetzt');
window.redo();
expect(window.text).toBe('>ersetzt und getippt');
stop();
window.input('x');
expect(seen).toBeLessThan(window.version);
});
it('keeps a new text and changes nothing for the same typing, and is the same window for a computer while the editor runs', () => {
const { doc, window } = setUp();
const before = window.version;
window.input('');
expect(window.version).toBe(before);
window.input('neu');
window.newText();
expect([window.text, window.path, window.file]).toEqual(['', null, undefined]);
expect(textEditorWindow(doc, 'pc')).toBe(textEditorWindow(doc, 'pc'));
expect(textEditorWindow(doc, 'pc')).not.toBe(textEditorWindow(doc, 'other'));
expect(textEditorWindow(new Y.Doc(), 'pc')).not.toBe(textEditorWindow(doc, 'pc'));
// A file removed while open is gone until another opens; „Neu“ ends it too.
window.input('a');
window.saveAs('/', 'a.txt');
window.saveAs('/', 'b.txt');
changeFiles(doc, 'pc', 'terminal', (files) => files.deleteItem('/b.txt'));
expect([window.gone, window.text]).toEqual(['/b.txt', 'a']);
expect(window.open('/', 'a.txt')).toBe('opened');
expect(window.gone).toBeNull();
changeFiles(doc, 'pc', 'terminal', (files) => files.deleteItem('/a.txt'));
expect(window.gone).toBe('/a.txt');
window.newText();
expect(window.gone).toBeNull();
// A computer the document does not have: nothing opens, nothing is saved.
const missing = new TextEditorWindow(doc, 'none');
expect(missing.open('/', 'a.txt')).toBe('notFound');
expect(missing.saveAs('/', 'a.txt')).toBe('folderMissing');
});
});
+46 -5
View File
@@ -33,6 +33,13 @@
// as `origin: 'sim'`: at most once a second while it runs, after each action
// in a window and when it stops (writeRunState). So the next start, and every
// client, sees it — as Java's project saved after a run holds it.
//
// Files go both ways while it runs (plan §7.3: the text editor writes the
// document's Y.Text): what a person writes into the document goes into the
// engine's files at once (readFilesInto), and what the engine changed in its
// files goes into the document at the tick it changed, not a second later —
// so the document holds every change of the engine before a person's next
// one comes in, and a text two write at once keeps both.
import * as Y from 'yjs';
import {
@@ -50,7 +57,7 @@ import {
type PageLanguage,
type TopologySnapshot,
} from '@mdg-filius/core';
import { docSnapshot, emailServerAfterRun, fromY, installedSoftware, ROOTS, SIM_ORIGIN, writeRunState, type NodeRunState } from '@mdg-filius/doc';
import { docSnapshot, emailServerAfterRun, fromY, installedSoftware, readFilesInto, ROOTS, SIM_ORIGIN, writeRunState, type NodeRunState } from '@mdg-filius/doc';
import {
parseClientMessage,
SIM_PROTOCOL_VERSION,
@@ -136,6 +143,35 @@ export class SimulationRoom {
doc.on('update', () => {
if (this.mode === 'simulation') this.guarded(() => this.syncSoftware());
});
doc.getMap<Y.Map<unknown>>(ROOTS.nodes).observeDeep((events, transaction) => {
// The room's own writes hold what the engine has.
if (transaction.origin === SIM_ORIGIN) return;
const nodes = new Set<Id>();
for (const event of events) {
const [node, key] = event.path as string[];
// A device added or removed comes in at the next start.
if (node !== undefined && (key === 'files' || (key === undefined && event.keys.has('files')))) nodes.add(node);
}
if (nodes.size > 0) this.guarded(() => this.syncFiles(nodes));
});
}
/** The document's files of the nodes into the running engine's (a person wrote them). */
private syncFiles(nodes: ReadonlySet<Id>): void {
const fileSystems = this.engine.state.topologySnapshot.fileSystemsByNodeId;
for (const id of nodes) {
const files = fileSystems.get(id);
// A node whose programs use no files has none in the engine; it reads the document's at the next start.
if (files !== undefined && readFilesInto(this.doc, id, files)) this.writtenFiles.set(id, { files, revision: files.revision });
}
}
/** Whether a node's files changed since they were last written back; marked written if so. */
private takeChangedFiles(id: Id, files: { readonly revision: number } | undefined): boolean {
const written = this.writtenFiles.get(id);
const changed = files !== undefined && (written?.files !== files || written.revision !== files.revision);
if (files !== undefined && changed) this.writtenFiles.set(id, { files, revision: files.revision });
return changed;
}
/** The computers' programs in the document. */
@@ -206,10 +242,7 @@ export class SimulationRoom {
settings.firewall = snapshot.firewallConfigurationsByNodeId.get(id);
if (snapshot.dhcpClientConfigurationsByNodeId.get(id)?.isEnabled === true) settings.device = snapshot.deviceConfigurations.get(id);
const files = snapshot.fileSystemsByNodeId.get(id);
const written = this.writtenFiles.get(id);
const changed = files !== undefined && (written?.files !== files || written.revision !== files.revision);
if (files !== undefined && changed) this.writtenFiles.set(id, { files, revision: files.revision });
states.set(id, { ...(changed ? { files } : {}), active, settings });
states.set(id, { ...(this.takeChangedFiles(id, files) && files !== undefined ? { files } : {}), active, settings });
}
return states;
}
@@ -221,6 +254,13 @@ export class SimulationRoom {
writeRunState(this.doc, this.runState(), SIM_ORIGIN);
}
/** Writes the files the engine changed since they were last written (origin 'sim'); nothing when none changed. */
private writeFilesBack(): void {
const states = new Map<Id, NodeRunState>();
for (const [id, files] of this.engine.state.topologySnapshot.fileSystemsByNodeId) if (this.takeChangedFiles(id, files)) states.set(id, { files });
if (states.size > 0) writeRunState(this.doc, states, SIM_ORIGIN);
}
/** The document as the engine takes it, in the language of the start. */
private snapshot(): TopologySnapshot {
const snapshot = docSnapshot(this.doc);
@@ -364,6 +404,7 @@ export class SimulationRoom {
if (!this.guarded(() => this.engine.handle({ type: 'advance', toMilliseconds: this.engine.state.currentTimeMilliseconds + elapsed }))) return;
this.flush([...this.clients.values()], false);
if (now - this.lastWriteBack >= (this.options.writeBackMilliseconds ?? WRITE_BACK_MILLISECONDS)) this.guarded(() => this.writeBack());
else this.guarded(() => this.writeFilesBack());
}
/**
File diff suppressed because it is too large Load Diff
+87 -2
View File
@@ -11,7 +11,7 @@ import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import * as Y from 'yjs';
import { VirtualFileSystem as VirtualFileSystemOf, type Id, type VirtualFileSystem } from '@mdg-filius/core';
import { createUndoManager, readProject, ROOTS, setSoftware, SIM_ORIGIN, writeProject, writeRunState } from '@mdg-filius/doc';
import { addDevice, changeFiles, createUndoManager, emptyProject, fileText, readProject, ROOTS, setSoftware, SIM_ORIGIN, writeProject, writeRunState } from '@mdg-filius/doc';
import { openProjectArchive, type ImportedProject } from '@mdg-filius/fls';
import type { HostMessage } from '@mdg-filius/protocol';
import { SimulationRoom, TICK_MILLISECONDS, WRITE_BACK_MILLISECONDS } from '../src/index.js';
@@ -39,7 +39,9 @@ function setUp(file: string, writeBackMilliseconds?: number) {
room.tick();
}
};
return { doc, room, got, id, act, run, send: (message: object) => room.receive(client, { v: 1, ...message }) };
/** What the room sent of a window since the last call, as text. */
const sent = (app: string) => JSON.stringify(got.splice(0).filter((message) => message.kind === 'app' && message.app === app));
return { doc, room, got, id, act, run, sent, send: (message: object) => room.receive(client, { v: 1, ...message }) };
}
/** A computer's files: text as it is, the rest by kind. */
@@ -240,3 +242,86 @@ describe("the room's write-back", () => {
expect(readProject(doc).webServerConfigurations.has(nodeId)).toBe(false);
});
});
describe('files both ways while the simulation runs', () => {
it("takes what a person writes into the document at once, and writes the engine's files at the tick they changed", () => {
const { doc, id, act, run, send, sent } = setUp('email_komplett.fls', 60_000);
const one = id('Rechner 1');
setSoftware(doc, one, { install: ['commandPrompt'], remove: [] }, 'de', 'me');
send({ kind: 'sub', topics: [`app:${one}/terminal`] });
send({ kind: 'start' });
run(TICK_MILLISECONDS);
// A file saved by a person (the text editor), then typed into: the command line reads it.
changeFiles(doc, one, 'me', (files) => files.writeTextFile('/notiz.txt', 'Hallo'));
act('Rechner 1', 'terminal', 'enter', { line: 'cat notiz.txt' });
expect(sent('terminal')).toContain('Hallo');
doc.transact(() => fileText(doc, one, '/notiz.txt')!.insert(5, ' Welt'), 'me');
act('Rechner 1', 'terminal', 'enter', { line: 'cat notiz.txt' });
expect(sent('terminal')).toContain('Hallo Welt');
// Removed by a person: gone for the command line.
changeFiles(doc, one, 'me', (files) => files.deleteItem('/notiz.txt'));
act('Rechner 1', 'terminal', 'enter', { line: 'cat notiz.txt' });
expect(sent('terminal')).not.toContain('Hallo');
// A mail taken by the server changes its accounts file in a tick: in the document then, the settings a minute later.
const server = id('Mail-Server (filius.de)');
const accountsFile = () => fileText(doc, server, '/mailserver/konten.txt')!.toString();
const before = accountsFile();
const mailboxes = () => readProject(doc).emailServerConfigurations.get(server)!.accounts.map((account) => account.mailbox.length);
const settings = mailboxes();
act('Rechner 1', 'emailClient', 'setAccount', ACCOUNT('rechner1', '1'));
act('Rechner 1', 'emailClient', 'send', { to: 'rechner2@filius.de', cc: '', bcc: '', subject: 'Im Lauf', text: 'Text' });
const afterAction = accountsFile();
let tick = 0;
while (accountsFile() === afterAction && tick++ < 50) run(TICK_MILLISECONDS);
expect(accountsFile()).not.toBe(before);
expect(accountsFile()).toContain('Im Lauf');
expect(mailboxes()).toEqual(settings);
// A person types into the file the engine just wrote: both stay.
doc.transact(() => fileText(doc, server, '/mailserver/konten.txt')!.insert(0, '# '), 'me');
expect(accountsFile().startsWith('# ')).toBe(true);
expect(accountsFile()).toContain('Im Lauf');
});
it('takes the first file of a computer that had none, leaves out one whose engine has no files, and writes nothing back of what it took', () => {
const doc = new Y.Doc();
writeProject(doc, emptyProject());
let counter = 0;
addDevice(doc, { kind: 'pc', id: 'pc', name: 'PC', x: 0, y: 0, newId: () => `port-${++counter}` }, 'me');
addDevice(doc, { kind: 'pc', id: 'bare', name: 'Bare', x: 100, y: 0, newId: () => `port-${++counter}` }, 'me');
const programs = doc.getMap<Y.Map<unknown>>(ROOTS.nodes).get('pc')!.get('programs') as Y.Map<Y.Map<unknown>>;
const entry = new Y.Map<unknown>();
entry.set('active', true);
programs.set('commandPrompt', entry);
// Two computers without files (a new one has a few).
for (const id of ['pc', 'bare']) doc.getMap<Y.Map<unknown>>(ROOTS.nodes).get(id)!.delete('files');
let now = 0;
const room = new SimulationRoom(doc, { now: () => now, seed: () => 7 });
const got: HostMessage[] = [];
const client = room.connect((message) => got.push(message));
const send = (fields: object) => room.receive(client, { v: 1, ...fields });
const run = (milliseconds: number) => {
for (let elapsed = 0; elapsed < milliseconds; elapsed += TICK_MILLISECONDS) {
now += TICK_MILLISECONDS;
room.tick();
}
};
send({ kind: 'sub', topics: ['app:pc/terminal'] });
send({ kind: 'start' });
run(TICK_MILLISECONDS);
expect(doc.getMap<Y.Map<unknown>>(ROOTS.nodes).get('pc')!.has('files')).toBe(false);
const writes: unknown[] = [];
doc.on('afterTransaction', (transaction: Y.Transaction) => transaction.origin === SIM_ORIGIN && transaction.changed.size > 0 && writes.push(transaction));
changeFiles(doc, 'pc', 'me', (files) => files.writeTextFile('/erste.txt', 'Erste Datei'));
// A computer whose programs use no files: its file waits for the next start, the simulation runs on.
changeFiles(doc, 'bare', 'me', (files) => files.writeTextFile('/x.txt', 'x'));
run(3 * WRITE_BACK_MILLISECONDS);
expect(room.currentMode).toBe('simulation');
expect(got.filter((message) => message.kind === 'error')).toEqual([]);
// What it took it does not write back: the document has it.
expect(writes).toHaveLength(0);
send({ kind: 'input', node: 'pc', app: 'terminal', action: 'enter', args: { line: 'cat erste.txt' }, reqId: 'cat' });
expect(JSON.stringify(got)).toContain('Erste Datei');
});
});