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