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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 17:50:27 +00:00
co-authored by Claude Opus 5.5
parent 3d6655e736
commit 2c7733b575
29 changed files with 3268 additions and 76 deletions
+168
View File
@@ -0,0 +1,168 @@
// The text editor on a computer's desktop in Chromium (F4.6f2), on
// browser/fixtures/web-pages.fls (test/webPagesFixture.test.ts): installed
// while the simulation runs, a page written and saved through Java's file
// dialog into the web server's folder, fetched by the other computer's
// browser, changed while it runs and fetched again; the caret where one
// types, the window's own undo, what the dialog refuses, a file removed by
// the command line while it is open, „Neu“.
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { expect, test, type Page } from '@playwright/test';
import { drawing, onDevice, serveEditor } from './editorPage.js';
let server: { readonly base: string; readonly close: () => Promise<void> };
test.beforeAll(async () => {
server = await serveEditor();
});
test.afterAll(async () => {
await server.close();
});
/** What the development page lets a test do: write into a file as another would. */
interface DevPage {
readonly insertText: (id: string, path: string, index: number, text: string) => void;
}
const FIXTURE = join(dirname(fileURLToPath(import.meta.url)), 'fixtures/web-pages.fls');
async function openPages(page: Page): Promise<void> {
await page.goto(`${server.base}?example=new`);
await page.waitForSelector('svg.filius-canvas');
await page.locator('input[type="file"]').setInputFiles(FIXTURE);
await expect.poll(async () => (await drawing(page).catch(() => ({ nodes: [] }))).nodes.length).toBe(4);
await page.getByRole('button', { name: 'Wechselt in den Aktionsmodus' }).click();
await expect(page.locator('svg.filius-canvas')).toHaveAttribute('data-mode', 'simulation');
}
/** A computer's desktop, opened with a tap. */
async function desktopOf(page: Page, name: string) {
const id = (await drawing(page)).nodes.find((node) => node.name === name)!.id;
const at = await onDevice(page, id);
await page.mouse.click(at.x, at.y);
return page.locator(`section[data-desktop="${id}"]`);
}
test('writes a page into the web server, which serves it while the simulation runs, and changes it there', async ({ page }) => {
test.setTimeout(120_000);
await openPages(page);
const desktop = await desktopOf(page, 'Server');
// Installed while it runs, with the command line beside it.
await desktop.locator('[data-program="installation"]').click();
const installation = desktop.locator('[data-program-window="installation"]');
const available = installation.locator('[data-list="available"] [role="option"]');
await available.filter({ hasText: 'Text-Editor' }).click();
await available.filter({ hasText: 'Befehlszeile' }).click();
await installation.getByRole('button', { name: 'Installieren' }).click();
await installation.getByRole('button', { name: 'Änderungen annehmen' }).click();
await desktop.locator('[data-program="textEditor"]').click();
const window = desktop.locator('[data-program-window="textEditor"]');
const title = window.locator('.program-titlebar h3');
const text = window.getByRole('textbox', { name: 'Text' });
await expect(title).toHaveText('Text-Editor');
await expect(window.locator('.text-editor-bar button')).toHaveText(['Neu', 'Öffnen', 'Speichern', 'Speichern unter']);
await expect(text).toHaveCSS('font-size', '16px');
// A new text: „Speichern“ asks for a name in Java's dialog; a tap opens a folder.
await text.fill('<html><head><title>Neu</title></head><body>Erster Stand</body></html>');
await window.getByRole('button', { name: 'Speichern', exact: true }).click();
const dialog = window.getByRole('dialog', { name: 'Datei Speichern' });
await expect(dialog.locator('.file-dialog-folder span')).toHaveText('Ordner: /');
await expect(dialog.getByRole('button', { name: 'Ordner aufwärts' })).toBeDisabled();
await expect(dialog.locator('.file-dialog-entries button')).toHaveText(['webserver']);
await dialog.locator('.file-dialog-entries button', { hasText: 'webserver' }).click();
await expect(dialog.locator('.file-dialog-folder span')).toHaveText('Ordner: /webserver');
await expect(dialog.locator('.file-dialog-entries button')).toHaveText(['ordner', 'index.html', 'splashscreen-mini.png', 'ziel.html', 'zwei.html']);
// No name: said in the dialog, which stays.
await dialog.getByRole('button', { name: 'Speichern' }).click();
await expect(dialog.getByRole('alert')).toHaveText('Geben Sie einen Dateinamen ein.');
await dialog.getByRole('textbox', { name: 'Dateiname:' }).fill('neu.html');
await dialog.getByRole('button', { name: 'Speichern' }).click();
await expect(dialog).toHaveCount(0);
await expect(title).toHaveText('Text-Editor - neu.html');
// Saved as it is typed: „Speichern“ has nothing left to do.
await expect(window.getByRole('button', { name: 'Speichern', exact: true })).toBeDisabled();
// The other computer's browser fetches it from the running web server (the desktops closed in between, which covered the computers).
const fetchPage = async (expected: string) => {
await desktop.locator('.desktop-close').click();
const browserDesktop = await desktopOf(page, 'Browser');
await browserDesktop.locator('[data-program="webBrowser"]').click();
const browser = browserDesktop.locator('[data-program-window="webBrowser"]');
const address = browser.getByRole('textbox', { name: 'Adresse' });
await address.fill('192.168.0.20/neu.html');
await address.press('Enter');
await expect(browser.frameLocator('iframe[title="Seite"]').locator('body')).toHaveText(expected, { timeout: 30_000 });
await browserDesktop.locator('.desktop-close').click();
await desktopOf(page, 'Server');
await desktop.locator('[data-program="textEditor"]').click();
};
await fetchPage('Erster Stand');
// The window is kept over the desktop closed, as Java's desktop keeps it.
await expect(title).toHaveText('Text-Editor - neu.html');
// Changed in the middle, the caret where one types: the server serves it at once.
const at = (await text.inputValue()).indexOf('Erster');
await text.evaluate((area: HTMLTextAreaElement, index) => {
area.focus();
area.setSelectionRange(index, index + 'Erster'.length);
}, at);
await page.keyboard.type('Zweiter');
await expect(text).toHaveValue('<html><head><title>Neu</title></head><body>Zweiter Stand</body></html>');
await fetchPage('Zweiter Stand');
// Another writes before the caret: the caret moves with its text, and one types on where one was.
const id = (await drawing(page)).nodes.find((node) => node.name === 'Server')!.id;
await text.evaluate((area: HTMLTextAreaElement, index) => {
area.focus();
area.setSelectionRange(index, index);
}, at + 'Zweiter'.length);
await page.evaluate(([node]) => (window as unknown as { filiusDev: DevPage }).filiusDev.insertText(node!, '/webserver/neu.html', 0, '<!-- x -->'), [id]);
await expect(text).toHaveValue('<!-- x --><html><head><title>Neu</title></head><body>Zweiter Stand</body></html>');
await page.keyboard.type('!');
await expect(text).toHaveValue('<!-- x --><html><head><title>Neu</title></head><body>Zweiter! Stand</body></html>');
await page.keyboard.press('Backspace');
await text.evaluate((area: HTMLTextAreaElement) => area.blur());
await expect(text).toHaveValue('<!-- x --><html><head><title>Neu</title></head><body>Zweiter Stand</body></html>');
// The window's own undo takes back its typing.
await text.press('Control+z');
await expect(text).toHaveValue('<!-- x --><html><head><title>Neu</title></head><body>Erster Stand</body></html>');
await text.press('Control+y');
await expect(text).toHaveValue('<!-- x --><html><head><title>Neu</title></head><body>Zweiter Stand</body></html>');
// „Öffnen“: a name that is not there and an image are refused, the open file stays.
await window.getByRole('button', { name: 'Öffnen' }).click();
const opening = window.getByRole('dialog', { name: 'Datei Öffnen' });
await opening.getByRole('textbox', { name: 'Dateiname:' }).fill('fehlt.txt');
await opening.getByRole('button', { name: 'Öffnen' }).click();
await expect(opening.getByRole('alert')).toHaveText('Die Datei „fehlt.txt“ gibt es nicht.');
await opening.locator('.file-dialog-entries button', { hasText: 'webserver' }).click();
await opening.locator('.file-dialog-entries button', { hasText: 'splashscreen-mini.png' }).click();
await expect(opening.getByRole('textbox', { name: 'Dateiname:' })).toHaveValue('splashscreen-mini.png');
await opening.getByRole('button', { name: 'Öffnen' }).click();
await expect(opening.getByRole('alert')).toHaveText('„splashscreen-mini.png“ ist keine Textdatei.');
await opening.getByRole('button', { name: 'Abbrechen' }).click();
await expect(title).toHaveText('Text-Editor - neu.html');
// Removed by the command line while open: the text stays as a new text, „Speichern“ asks for a name.
await desktop.locator('.desktop-home').click();
await desktop.locator('[data-program="commandPrompt"]').click();
const input = desktop.locator('input[name="terminalInput"]');
await input.fill('rm /webserver/neu.html');
await input.press('Enter');
await desktop.locator('.desktop-home').click();
await desktop.locator('[data-program="textEditor"]').click();
await expect(title).toHaveText('Text-Editor');
await expect(window.getByRole('status')).toHaveText('Die Datei „neu.html“ wurde gelöscht. Der Text ist noch da; „Speichern“ legt ihn als neue Datei ab.');
await expect(text).toHaveValue('<!-- x --><html><head><title>Neu</title></head><body>Zweiter Stand</body></html>');
await expect(window.getByRole('button', { name: 'Speichern', exact: true })).toBeEnabled();
// „Neu“: empty, no file.
await window.getByRole('button', { name: 'Neu' }).click();
await expect(text).toHaveValue('');
await expect(window.getByRole('status')).toHaveCount(0);
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 369 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 480 B

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