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:
@@ -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
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user