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
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');
});
});