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:
@@ -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);
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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 |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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