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:
@@ -32,6 +32,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand
|
||||
| `src/runtime/apps/emailServer.ts` | Java `EmailServer`, `SMTPServer`, `SMTPMitarbeiter`, `POP3Server`, `POP3Mitarbeiter`, `EmailKonto` | neu nach Java (F3.3a): Konten einer Domain, SMTP auf Port 25 speichert Mails an eigene Konten (wie Java neu gelesen) und schickt bei unbekannten Empfängern eine Benachrichtigung an einen Absender der Domain, POP3 auf Port 110 gibt Mails ab 0 nummeriert aus; beide zusammen ein- und ausgeschaltet, POP3 zuerst; SMTP-Antworten in der Sprache des Projekts; Fensterzeilen wie Java. An Java-Filius gemessen (`EmailRun`, `MailCases`). Abweichend von Java: nach einer Mail ist der Umschlag neu (Java antwortet dem nächsten Befehl 503 und behält Absender, Empfänger und Text); ein Befehl, den der Zustand nicht nimmt, bekommt 503 (Java schweigt), HELO beginnt neu; in DATA ist alles Text, auch `quit`; die Domain gilt in jeder Schreibweise; ein Empfänger ohne `@` ist unbekannt, ein leerer Absender bekommt keine Benachrichtigung (Java wirft), eine Benachrichtigung an eine Mail ohne From geht an den Absender des Umschlags (Java: `To: null`); POP3: eine Mail lässt sich mehrmals abrufen, eine fehlende Mail, `LIST` ohne Mails und falsche Argumente geben `-ERR` (Java: leere Mail, leere Antwort, Abbruch), `LIST n` listet die eine Mail, Nummern bleiben nach `DELE` (Javas `LIST` zählt neu), vor der Anmeldung `-ERR unknown command`, `QUIT` immer `+OK`. Seit F3.3b leitet der Server Mails an fremde Domains per MX weiter (in seinem eigenen Thread, je Weiterleitung ein neuer SMTP-Client) und schickt eine Benachrichtigung an einen fremden Absender mit leerem Absender an dessen Domain. Noch nicht: die Kontodatei `/mailserver/konten.txt` zur Laufzeit |
|
||||
| `src/runtime/apps/emailClient.ts` | Java `EmailAnwendung`, `SMTPClient`, `POP3Client`, das Senden aus `GUIApplicationEmailAnwendungWindow` | neu nach Java (F3.3b): das E-Mail-Programm sendet eine Mail an den SMTP-Server seines Kontos, eine Verbindung je Empfänger (An, Cc, Bcc), und holt die Mails seines Kontos vom POP3-Server (jede abgerufen, dann gelöscht); die Prüfungen des Fensters (kein Konto, Adresse, die Javas Muster nicht nimmt, kein Empfänger); Meldungen wie Javas Beobachter (Verbindung, versendet, getrennt, Ausnahmen); der SMTP-Client dient auch dem Server zum Weiterleiten. An Java-Filius gemessen (`EmailClientRun`). Abweichend von Java: der SMTP-Port des Kontos gilt (Java verbindet immer zu 25); drei SYN statt einem |
|
||||
| `src/runtime/apps/filePrograms.ts` | Java `FileExplorer`, `TextEditor`, `ImageViewer` und ihre Fenster, `config/filetypes.txt`, die Meldungen `sw_fileexplorer_msg1`–`3`, `fileexplorer_msg19` | neu nach Java (F3.5b): die Dateioperationen hinter den Knöpfen von Datei-Explorer (Ordnerliste, neuer Ordner, löschen, kopieren/ausschneiden/einfügen, umbenennen, importieren), Texteditor (speichern, speichern unter) und Bildbetrachter, auf den Dateien des Knotens; an Java-Filius gemessen (`FileCases`). Abweichend von Java: ein Ordnername, der zu keinem Namen wird, legt nichts an; Einfügen auf einen vorhandenen Namen wird abgelehnt (Java: zweiter Eintrag gleichen Namens); Umbenennen auf einen Namen mit `/` oder mit Leerzeichen am Rand wird abgelehnt, eine reine Änderung der Groß-/Kleinschreibung ausgeführt (Java lehnt sie ab); „Speichern unter“ und Import auf einen Ordnernamen werden abgelehnt (Java wirft); die Größe einer Textdatei sind ihre Zeichen |
|
||||
| `src/runtime/apps/filePrograms.ts` (F4.6f2), `test/filePrograms.test.ts` | Java `GUIApplicationTextEditorWindow.oeffnen/speichernUnter`, `Dateisystem.holeDatei/speicherDatei` (gemessen mit `TextEditorWindowRun`) | erweitert (F4.6f2): `openText` öffnet nach dem Ordner und Namen des Dialogs (der Name als Pfad, in jeder Schreibweise) und nur eine Textdatei (Java zeigt ein Bild als Base64-Text), ohne Fund `notFound` (Java vergisst die offene Datei); „Speichern unter“ nimmt `ordner/d.txt` als Datei d.txt in ordner (Java legt eine Datei mit `/` im Namen an) und lehnt einen leeren Namen und einen mit Leerzeichen am Rand ab (`invalidName`; Java legt solche Dateien an, das Dateisystem warf), einen fehlenden Ordner auch unterwegs (`folderMissing`) |
|
||||
| `src/runtime/apps/gnutella.ts` | Java `filius.software.dateiaustausch` (`PeerToPeerAnwendung`, `PeerToPeerServer`, `PeerToPeerServerMitarbeiter`, `PeerToPeerClient`, `PeerToPeerClientMitarbeiter`, `PingPaket`, `PongPaket`) | neu nach Java (F3.6a): Beitreten mit Ping und Pong in Javas Textformat über Port 6346, Weiterleiten an die eigenen Teilnehmer, Pongs auf dem Weg des Pings zurück, Höchstzahl der Teilnehmer, Zurücksetzen, Ordner `/peer2peer`; an Java-Filius gemessen (`GnutellaRun`). Die Swift-Fassung (`TopologyRuntimeGnutella.swift`, eigenes Protokoll) nicht übernommen. Seit F3.6b Suche mit Query und Treffern (weitergeleitet wie Pings, Treffer auf dem Weg der Query zurück), Ergebnisse, Download per HTTP über 6346 (`QueryPaket`, `QueryHitPaket`, `httpAnfrageVerarbeiten`, `herunterladenDatei`). Abweichend von Java: ein zweites Mal ankommender Ping bekommt keinen Pong, und seine Verbindung bleibt (Java wirft und schließt sie); wer Treffer geschickt hat, hält die Verbindung, damit auch die Treffer der weiter entfernten Teilnehmer zurückkommen (Java schließt sie, die gehen verloren); Größen in Treffer und Pong in Zeichen (Java: Base64-dekodiert) |
|
||||
| `src/runtime/apps/terminal.ts`, `src/runtime/apps/terminalTexts.ts` | Java `filius.software.lokal.Terminal`, die Eingabe aus `GUIApplicationTerminalWindow`, die Texte `sw_terminal_*` und `terminal_msg*` des Bündels | neu nach Java (F3.4a): die Befehlszeile mit Javas Zerlegung der Eingabe, Verteilung nach dem Befehlsnamen und den lokalen Befehlen `ls`/`dir`, `cd`, `pwd`, `mkdir`, `touch`, `cp`/`copy`, `mv`/`move`, `rm`/`del`, `cat`/`type`, `test`, `help`, `ipconfig`, `route`; Texte in der Sprache des Projekts, aus dem von Java geladenen Bündel erzeugt. An Java-Filius gemessen (`TerminalCases`). Abweichend von Java: die Größe einer Textdatei sind ihre Zeichen (Java dekodiert Base64); ein fehlender Ordner ist für `ls` „nicht vorhanden“, `ls /` nennt die Wurzel; `cd` auf eine Datei, `touch`/`mkdir` in fehlenden Ordnern und `cp` auf einen Ordnernamen scheitern; `..` bleibt an der Wurzel; `rm` und `mv` nehmen absolute Pfade absolut, die Wurzel ist nicht löschbar, nach dem Löschen des Arbeitsordners gilt der nächste verbliebene darüber; `mv` auf die Datei selbst benennt sie um. Seit F3.4b die Netzbefehle `ping` (Strg+C über `interrupt`), `host`, `nslookup`, `arp` und `netstat`, mit Javas Laufmarken; ein unbekannter Befehl wird wie bei Java sofort gemeldet, auch während ein anderer läuft; seit F3.4c `traceroute` und `tcpdump` (`arpsend` nicht gebaut, der Befehl ist unbekannt); an Java-Filius gemessen (`TerminalRun`). Abweichend von Java: `ping` nennt den Host, wo Java seinen ersten Parameter nennt (`-b` in der Statistik), und nimmt vor dem Host nur `-b` (Java nimmt jedes Wort und pingt einen Broadcast); `arp` und `netstat` listen in der Reihenfolge des Lernens bzw. Anlegens (Java: Reihenfolge einer `HashMap`); `netstat` zeigt von einem UDP-Server nur den lauschenden Socket (Java auch einen für jede je beantwortete Adresse, nie geschlossen); `traceroute` sagt sein Fazit vor der Endmarke (Java danach, wo sein Fenster keinen Text mehr zeigt) und nennt ein Ziel beim 20. Sprung erreicht (Java: nicht erreicht); `tcpdump` meldet jeden Frame sofort (Java schaut alle 100 ms); seit F4.6b `continueIn` — Javas Arbeitsverzeichnis überdauert Stopp und Start (`TerminalWindowRun`), das Fenster des Raums reicht es an das Programm des nächsten Starts weiter |
|
||||
| `src/runtime/apps/echo.ts` | Java `filius.software.clientserver` (`ServerBaustein`, `ServerBausteinMitarbeiter`, `ClientAnwendung`, `ClientBaustein`) | neu nach Java statt nach Swift (F3.1): Echo-Server (auf `server.ts`, sendet jede Nachricht zurück) und einfacher Client (verbinden über Adresse oder Namen, senden, empfangen, trennen), mit Javas Fensterzeilen. An Java-Filius gemessen. Abweichend von Java: Der Server merkt das Ausschalten, der Client die Verbindung sofort (Java alle 100 ms); ohne Route scheitert der Client sofort (Java wartet ewig ohne Meldung); ausgeschaltet bestätigt der Server noch das FIN seiner Verbindungen (Java gibt den Port frei, das FIN bleibt unbeantwortet) |
|
||||
@@ -139,6 +140,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand
|
||||
| `src/view.ts` (`desktopView`) | Java `GUIDesktopWindow.updateTitle`, `GUIDesktopPanel.updateAppPane` | neu (F4.6a): was der Desktop eines Rechners vom Dokument braucht — Art, Beschriftung, ob sie die Adresse ist (Javas Titel lässt die Adresse dann weg), installierte Programme |
|
||||
| `src/software.ts`, `test/install.test.ts`, `test/fixtures/installed-programs.fls` | Java `GUIInstallationsDialog.aenderungenSpeichern` (gemessen mit `InstallRun`), `config/Desktop_*.txt` | neu (F4.6c): `setSoftware` — installieren (aus, Javas Voreinstellungen, fehlende Dateien; DNS-Einträge, E-Mail-Konten und das Konto des E-Mail-Programms aus vorhandenen Dateien) und entfernen (mit Einstellungen, Dateien bleiben) in einem Schritt, in der Reihenfolge von Javas Liste der Sprache (`SOFTWARE_ORDER`). Gegen Javas gespeicherte Installation geprüft; benannt: Javas Gnutella würfelt 3–5 Teilnehmer, hier 3 wie in einer Datei ohne Angabe |
|
||||
| `src/simulation.ts` | Plan §6.1 (`origin: 'sim'`); Java `SzenarioVerwaltung.speichern` nach einem Lauf (gemessen mit `SaveAfterRunRun`) | neu (F4.6f1): `writeRunState` schreibt, was ein Lauf von jedem Knoten hinterlässt, und nur, was sich unterscheidet — Dateien (neue hinter die anderen, geänderte an ihrem Platz, ein Text nur an der geänderten Stelle, verschwundene entfernt), ob Programme laufen, Einstellungen, die der Knoten hat (Feldreihenfolge egal); zweimal geschrieben ändert es nichts; `SIM_ORIGIN` landet in keinem Rückgängig-Stapel |
|
||||
| `src/files.ts`, `src/simulation.ts`, `test/files.test.ts` | Plan §7.3 (Texteditor auf `Y.Text`) | neu (F4.6f2): die Dateien eines Geräts im Dokument — als Dateisystem des Kerns gelesen (`nodeFiles`), über die Operationen des Kerns geändert und nur mit dem Geänderten zurückgeschrieben (`changeFiles`), der `Y.Text` einer Textdatei in jeder Schreibweise (`fileText`), und die Dateien des Dokuments in das Dateisystem einer laufenden Engine, nur was sich unterscheidet, nach ihrer Schreibweise, und nach einem Wechsel zwischen Datei und Ordner alles neu in der Reihenfolge des Dokuments (`readFilesInto`, duldsam wie `fileSystemOf`); `writeFiles` und `replaceText` aus `simulation.ts` hierher gezogen |
|
||||
| `test/sheet.test.ts`, `test/sheet.cases.txt`, `test/sheet.java.json` | — | neu (F4.5a): 77 in Javas Felder getippte Texte, jede Regel dagegen samt der benannten Abweichungen; die Befehle, die Funkverbindungen, Rückgängig je Änderung, alles über eine gespeicherte `.fls` |
|
||||
| `test/devices.test.ts`, `test/fixtures/new-devices.fls` | — | neu (F4.4a): neue Geräte jeder Art gegen Javas gespeicherte (Zufall maskiert), Kabelregeln, ein selbst gebautes Netz pingt im Kern, Rückgängig nur der eigenen Änderungen über zwei Replikate |
|
||||
| `test/projectDoc.test.ts` | — | neu (F4.1): alle 23 Korpusdateien → Dokument → Yjs-Update → Projekt: dasselbe Projekt, derselbe Schnappschuss, dieselbe `.fls`, dieselbe Reihenfolge je Ordner; Kennungen absichtlich gegen die Dateireihenfolge |
|
||||
@@ -169,6 +171,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand
|
||||
| `src/browserWindow.ts`, `src/room.ts` (Browser, Webserver), `test/browserWindow.test.ts`, `test/browserWindow.java.json` | Java `GUIApplicationWebBrowserWindow`, `GUIApplicationWebServerWindow` (gemessen mit `BrowserWindowRun`) | neu (F4.6d): das Browserfenster im Raum, eines je Rechner für alle, über Stopps behalten — Adresse öffnen (keine Adresse: die Zeile zurück auf „http://“), Links und Formulare der gezeigten Seite folgen, was das Programm bekam, in den Bildschirmstand; der Raum schickt Bildschirmstände (Browser statt Zeilen, Webserver zu seinen Zeilen), nimmt die Aktionen `open`/`follow` und `setActive`/`useVirtualHosts`/`setVirtualHost` an und startet in der Sprache der Person. Gegen Javas Fenster geprüft; benannt: Javas Fehlerseite findet ihre Vorlage nie, Bilder und Links einer Seite im Ordner, eine Marke lädt in Java die Startseite, die Datei der virtuellen Hosts |
|
||||
| `src/room.ts` (E-Mail), `test/mailWindow.test.ts`, `test/mailWindow.java.json` | Java `GUIApplicationEmailAnwendungWindow`, `GUIApplicationEmailServerWindow` (gemessen mit `MailWindowRun`) | erweitert (F4.6e): der Raum schickt die Bildschirmstände von E-Mail-Programm und -Server zu ihren Zeilen und nimmt `setAccount`/`send`/`fetch`/`removeMail` und `setActive`/`setDomain` (nur bei angehaltenem Server)/`addAccount`/`removeAccount` an. Gegen Javas Fenster geprüft; benannt: ein vorhandener Benutzername, Löschen im falschen Ordner, die Domain in der Kontendatei |
|
||||
| `src/room.ts` (Rückweg), `test/writeBack.test.ts`, `test/fixtures/after-run-*.fls`, `test/terminalWindow.test.ts` | Java `GUIMainMenu.stopSimulation`, `SzenarioVerwaltung.speichern` (gemessen mit `SaveAfterRunRun`) | erweitert (F4.6f1): der Raum schreibt, was ein Lauf hinterlässt (Dateien, laufende Server, virtuelle Hosts, Domain und Konten der E-Mail-Server, Konto und Ordner der E-Mail-Programme, Firewalls, die Adresse eines DHCP-Clients), als `origin: 'sim'` ins Dokument: höchstens einmal je Sekunde (ab dem Start gezählt), nach jeder Aktion in einem Fenster und beim Stopp. Gegen Javas nach dem Lauf gespeichertes Projekt geprüft; die Befehlszeile geht nach einem Neustart im vorher angelegten Ordner weiter wie Javas (die benannte Abweichung aus F4.6b ist weg) |
|
||||
| `src/room.ts` (Dateien in beide Richtungen), `test/writeBack.test.ts` | Plan §7.3 | erweitert (F4.6f2): was eine Person ins Dokument schreibt (der Texteditor), geht im Lauf sofort in die Dateien der Engine; was die Engine an ihren Dateien ändert, geht im Takt der Änderung ins Dokument (nicht erst nach einer Sekunde), damit das Dokument jede Änderung der Engine hat, bevor die nächste einer Person kommt — so behält ein Text, den beide zugleich ändern, beides. Ein Rechner, dessen Programme keine Dateien benutzen, liest seine beim nächsten Start |
|
||||
| `browser/m9.spec.ts`, `browser/traceDigest.ts`, `browser/*Entry.ts`, `/playwright.config.ts` | Plan §13 M9 | neu (F4.2): M9 in Chromium — 17 Beispiele, je 30 s mit Eingaben, ergeben Zeichen für Zeichen denselben Mitschnitt wie in Node; der Raum in einem echten Worker |
|
||||
|
||||
## packages/editor
|
||||
@@ -198,11 +201,13 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand
|
||||
| `src/desktop/BrowserPanel.tsx`, `src/desktop/browserPage.ts`, `src/assets/desktop/browser_waterwolf_logo.png` | Java `GUIApplicationWebBrowserWindow` (`webbrowser_msg2`, `sw_webbrowser_msg1`, `tmpl/http_fehler_*`), Javas Logo (`gfx/desktop`) | neu (F4.6d): der Webbrowser — Adresszeile mit „Start“, Javas Startseite mit Logo, die Seite in einem Rahmen mit Sandbox und Richtlinie (kein Skript, nichts lädt außer den gekommenen Bildern als Daten, kein Formular geht ab), Links und Formulare gehen an den Raum (ein Formular wie Javas Fenster: seine Felder in Reihenfolge samt Knopf), eine Marke springt in der Seite; der Titel des Fensters aus der Seite mit aufgelösten Entities (Java zeigt „&amp;“), die Fehlerseite aus Javas Vorlage (Java findet sie nie und zeigt „Fehler“), Text als Text und ein Bild als Bild (Java zeigt beides als HTML) |
|
||||
| `src/desktop/WebServerPanel.tsx`, `src/desktop/serverLines.ts` | Java `GUIApplicationWebServerWindow` (`webserver_msg1`–`6`), `ServerAnwendung`/`ServerMitarbeiter`/`TCPSocket` (`sw_serveranwendung_msg1`–`6`, `sw_servermitarbeiter_msg1`/`2`, `sw_tcpsocket_msg6`–`8`) | neu (F4.6d): der Webserver — „Starten“/„Anhalten“, „Verwende virtuelle Hosts“ mit der Tabelle (eine Zelle wird beim Verlassen oder mit Enter geschrieben, Escape nimmt sie zurück), darunter die Zeilen des Servers in Javas Worten |
|
||||
| `src/desktop/EmailClientPanel.tsx`, `src/desktop/EmailServerPanel.tsx`, `src/desktop/mailLines.ts`, `src/assets/desktop/email_*.png` | Java `GUIApplicationEmailAnwendungWindow` (`emailanwendung_msg1`–`47`), `GUIApplicationEmailServerWindow` (`emailserver_msg1`–`29`), `SMTPMitarbeiter`/`SMTPClient` (`sw_smtpmitarbeiter_msg12`–`14`, `sw_smtpclient_msg1`–`4`), Javas Symbole (`gfx/desktop`, `img/email_icon.png`) | neu (F4.6e): das E-Mail-Programm — Abrufen, Verfassen, Antworten, Löschen, Konto einrichten, Posteingang und Gesendete, darunter die gewählte Mail (sonst Javas Willkommen), Verfassen und Konto als Fenster über dem Programm, Javas Dialoge als Hinweis mit OK, ein Fehler des Programms wie Javas Dialog; die gewählte Mail gehört zu ihrem Ordner (Java löschte die zuvor im anderen Ordner gewählte). Der E-Mail-Server — Starten/Anhalten, Maildomain (nur angehalten, rot, wo Javas Muster sie nicht nimmt), Neues Konto mit Namensfeld (Java hat das Etikett ohne Feld; ohne Namen Javas Platzhalter), Konten-Liste mit Javas Frage vor dem Entfernen, Log Fenster in Javas Worten |
|
||||
| `src/desktop/textEditor.ts`, `src/desktop/TextEditorPanel.tsx`, `src/desktop/FileDialog.tsx`, `src/desktop/textBinding.ts`, `src/assets/desktop/datei.png`, `src/assets/desktop/ordner.png` | Java `GUIApplicationTextEditorWindow` (`texteditor_msg3`–`6`), `DMTNFileChooser` (`dmtnfilechooser_msg1`–`11`), `OrdnerInhaltListRenderer` (Javas Symbole aus `gfx/desktop`), gemessen mit `TextEditorWindowRun` | neu (F4.6f2): der Texteditor — „Neu“, „Öffnen“, „Speichern“, „Speichern unter“ über Javas Dateidialog, der Titel mit dem Namen der offenen Datei, Javas Courier; der Text der offenen Datei ist ihr `Y.Text` im Dokument (Plan §7.3): Getipptes steht sofort in der Datei, für alle, die sie offen haben, und für die laufende Simulation (Java hält eine Kopie bis „Speichern“). Strg+Z/Strg+Y nehmen das eigene Getippte zurück. Besser als Java: „Speichern unter“ auf eine vorhandene Datei schreibt sie und bleibt in ihr (Java bleibt in einer Kopie, die kein Ordner hält, jedes spätere „Speichern“ ging verloren); eine gelöschte offene Datei lässt ihren Text als neuen Text, „Speichern“ fragt nach einem Namen (Javas ging verloren); was der Dialog nicht findet oder kein Text ist, sagt er, die offene Datei bleibt; der Dialog zeigt den Pfad des Ordners (Java nur den Namen, die Wurzel als „root“), ein Tippen öffnet einen Ordner oder nimmt einen Dateinamen (Java will einen Doppelklick) |
|
||||
| `src/Editor.tsx`, `src/canvas/Canvas.tsx` (Aktionsmodus) | Java `GUIContainer.setActiveSite/showDesktop/closeDialogs/reopenDialogs`, `GUIKnotenItem` (Klick im Aktionsmodus), `GUIEvents.kontextMenueAktionsmodus` (`guievents_msg3`), `JCablePanel` (`farbeBlinken`) | erweitert (F4.6a): im Aktionsmodus ist der Entwurf gesperrt wie bei Java — keine Palette, kein Blatt, nichts bewegt sich, kein Rückgängig, kein Löschen —, Kabel mit Rahmen leuchten grün, ein Tipp auf einen Rechner öffnet seinen Desktop (Langdruck: „Desktop anzeigen“); offene Desktops überstehen den Entwurf und erscheinen beim nächsten Start wieder, die eines entfernten Rechners nicht |
|
||||
| `browser/simulation.spec.ts`, `test/programs.test.ts` | — | neu (F4.6a): Moduswechsel, Sperren, Geschwindigkeit, Kabelaktivität, Desktop mit Titel, Programmen, Netzwerk-Info, Verschieben und Stapeln, Handy, Englisch; die Programmliste gegen Javas `Desktop_*.txt`; seit F4.6b die Befehlszeile (Ping über drei Router, Verlauf, Chips, Strg+C, Behalten, exit) und der Fehlerfall des Raums; seit F4.6c die Software-Installation (sofort lauffähig, Doppelklick, Abwählen) |
|
||||
| `browser/web.spec.ts`, `browser/fixtures/web-pages.fls`, `test/webPagesFixture.test.ts`, `test/serverLines.test.ts`, `src/simulation/useSimulation.ts` (`views`), `src/Editor.tsx` (Sprache beim Start), `src/desktop/DesktopWindow.tsx` | — | neu (F4.6d): Browser und Webserver in Chromium auf einer eigenen Projektdatei (mit den Befehlen des Dokuments gebaut und bei jedem Lauf verglichen): Adresszeile, Titel, Sandbox, Bilder, Marke, Links im Ordner, beide Formulare bis in die Zeilen des Servers, Fehlerseite, nicht erreichbarer Server, Knopf, Kästchen und Tabelle, die Routerseiten auf Englisch; die Zeilen des Servers gegen Javas Fenster |
|
||||
| `browser/mail.spec.ts`, `test/mailLines.test.ts` | — | neu (F4.6e): E-Mail in Chromium auf Javas Beispiel `email_komplett` — Prüfungen und Dialoge, Senden, Abrufen, Antworten, Löschen im gewählten Ordner, Konto-Dialog, ein Fehler als Dialog; Konten anlegen und entfernen, Domain nur angehalten; die Zeilen des Server-Fensters gegen Javas Fenster |
|
||||
| `browser/simulation.spec.ts` (Rückweg) | — | erweitert (F4.6f1): ein in der Befehlszeile angelegter Ordner ist nach Entwurf und Neustart da, ein entfernter weg |
|
||||
| `browser/text.spec.ts`, `test/textEditor.test.ts`, `test/textBinding.test.ts`, `src/main.tsx` (`filiusDev.insertText`) | — | neu (F4.6f2): Javas Schritte am Fenster des Texteditors nachgespielt (gleich, wo Java recht hat, benannt, wo es irrt); in Chromium: im Lauf installiert, eine Seite über Javas Dialog in den Webserver gespeichert, vom anderen Rechner abgerufen, im Lauf geändert und wieder abgerufen, der Cursor bleibt, wo man tippt, Rückgängig, Ablehnungen im Dialog, eine von der Befehlszeile gelöschte offene Datei, „Neu“, Weitertippen nach einer fremden Änderung vor dem Cursor (die Entwicklungsseite schreibt dafür wie eine andere Person in eine Datei); wohin Cursor und Auswahl bei fremden Änderungen gehen |
|
||||
| `src/sheet/ServicePanels.tsx`, `src/sheet/RowsEditor.tsx`, `src/sheet/SheetTabs.tsx`, `src/sheet/DeviceSheet.tsx` (Seiten) | Java `JDHCPKonfiguration` (`jdhcpkonfiguration_msg1`–`15`), `JPortForwardingDialog` (`jportforwarding_msg4`–`7`, `jgatewayconfiguration_msg19`) | neu (F4.5c): Javas Dialoge als Seiten des Blatts mit „Zurück“ — DHCP-Server (Grundeinstellungen, statische Adresszuweisung; beim Rechner, gesperrt während DHCP die Karte einrichtet, und auf dem LAN-Reiter des Heimrouters), Portfreigaben (Heimrouter); jedes Feld beim Verlassen übernommen statt mit Javas OK-Knopf. Die Zeilentabellen (Weiterleitung, Zuordnungen, Portfreigaben) teilen sich `RowsEditor`: eine neue Zeile, sobald alle Felder gültig sind, Zeile wählen, bearbeiten, löschen. Javas Tooltip zu „Manuelle Einstellungen“ als Text unter dem Kästchen (ein Finger hat kein Überfahren) |
|
||||
| `src/sheet/RouterSheets.tsx`, `src/sheet/NameField.tsx`, `src/canvas/Canvas.tsx` (`highlightedLink`), `src/theme/tokens.css` (`--canvas-cable-active`) | Java `JVermittlungsrechnerKonfiguration`, `JGatewayConfiguration`, `JWeiterleitungsTabelle` (Wörter `jvermittlungsrechnerkonfiguration_msg6`–`26`, `jweiterleitungstabelle_msg3`–`6`), `JCablePanel.farbeBlinken`, `Kabel.setAktiv` | neu (F4.5b): Blatt des Vermittlungsrechners — Reiter Allgemein (Name, Gateway, Automatisches Routing, IP-Weiterleitung nur angezeigt), je Karte (Reiter mit ihrer Adresse, „Verbunden mit …“, Adresse, Maske, MAC) und Weiterleitungstabelle (alle Einträge oder nur die manuellen, Javas Zeilen grau, eine neue Zeile, die mit vier gültigen Feldern übernommen wird, Zelle bearbeiten, Eintrag löschen; bei RIP gesperrt); Blatt des Heimrouters mit Allgemein, LAN, WAN (DHCP sperrt die WAN-Adresse). Das Kabel der offenen Karte leuchtet grün wie bei Java. Javas „Eintrag Löschen“ klein geschrieben |
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1393,3 +1393,37 @@ Gemessen (03.10.2026):
|
||||
* Die Konten der Programme in email_komplett liegen nur in der alten
|
||||
`kontoListe`, die FILIUS 2.14 nicht liest; das Orakel setzt sie, wie man
|
||||
sie in den Dialog tippt (`setzeKonto`, `speichern`).
|
||||
|
||||
### Der Texteditor (F4.6f2)
|
||||
|
||||
`TextEditorWindowRun` öffnet Javas `GUIApplicationTextEditorWindow` auf
|
||||
einem `GUIDesktopPanel` (erster Rechner von mail-lan, mit Befehlszeile und
|
||||
Webserver) und benutzt ihn wie eine Person: tippen, „Neu“, „Öffnen“,
|
||||
„Speichern“, „Speichern unter“, dazu Javas Dateidialog (`DMTNFileChooser`:
|
||||
die Liste des Ordners, ein Doppelklick auf einen Ordner oder eine Datei,
|
||||
„Ordner aufwärts“, das Namensfeld, der eigene Knopf, „Abbrechen“). Der
|
||||
modale Dialog wird gesucht, während der Knopf des Editors auf ihn wartet.
|
||||
Daneben ändert die Befehlszeile die Dateien. Je Schritt: Titel und Text des
|
||||
Fensters, was der Dialog zeigte, alle Dateien
|
||||
(`packages/sim/test/textEditorWindow.java.json`).
|
||||
|
||||
Gemessen (03.10.2026):
|
||||
|
||||
* Der Titel ist „Text-Editor“, mit offener Datei „Text-Editor - name“; nach
|
||||
„Öffnen“ in anderer Schreibweise der Name der Datei, nach „Speichern
|
||||
unter“ der getippte.
|
||||
* „Speichern“ ohne Datei fragt wie „Speichern unter“; der Dialog beginnt in
|
||||
der Wurzel („Ordner: root“, sonst nur der Name des Ordners, nicht sein
|
||||
Pfad), listet Ordner und Dateien in ihrer Entstehungsreihenfolge, ein
|
||||
Doppelklick öffnet einen Ordner oder setzt den Namen einer Datei.
|
||||
„Speichern unter“ legt eine Datei vom Typ `text` an.
|
||||
* Ändert ein anderes Programm die offene Datei, übernimmt das Fenster sie,
|
||||
solange nichts getippt ist; danach behält es seinen Text.
|
||||
* „Öffnen“ nimmt den Namen als Pfad vom Ordner aus (`ordner/b.txt`), in jeder
|
||||
Schreibweise; ein Bild erscheint als Base64-Text. Ohne Fund (auch ohne
|
||||
Namen) vergisst das Fenster die offene Datei und behält den Text.
|
||||
* Javas Fehler: „Speichern unter“ auf eine vorhandene Datei schreibt sie,
|
||||
das Fenster bleibt aber in einer Kopie, die kein Ordner hält — jedes
|
||||
spätere „Speichern“ geht verloren. Ebenso nach dem Löschen der offenen
|
||||
Datei. „Speichern unter“ mit `ordner/d.txt` legt in der Wurzel eine Datei
|
||||
mit `/` im Namen an, ohne Namen eine namenlose.
|
||||
|
||||
@@ -21,7 +21,7 @@ if [ ! -f "$upstream/target/filius.jar" ]; then
|
||||
fi
|
||||
classpath="$upstream/target/filius.jar:$upstream/target/lib/*"
|
||||
mkdir -p "$here/.build"
|
||||
javac -encoding UTF-8 -d "$here/.build" -cp "$classpath" "$here/src/FlsOracle.java" "$here/src/GetterDefaults.java" "$here/src/IpCheckCases.java" "$here/src/DnsHostsCases.java" "$here/src/EmailFileCases.java" "$here/src/BeansDump.java" "$here/src/Base64Cases.java" "$here/src/ScenarioNetwork.java" "$here/src/DhcpRun.java" "$here/src/TcpRun.java" "$here/src/DnsRun.java" "$here/src/ArpRun.java" "$here/src/RipRun.java" "$here/src/NatRun.java" "$here/src/WlanRun.java" "$here/src/ExampleRun.java" "$here/src/EchoRun.java" "$here/src/WebRun.java" "$here/src/RouterWebRun.java" "$here/src/KonfigCases.java" "$here/src/EmailRun.java" "$here/src/MailCases.java" "$here/src/EmailClientRun.java" "$here/src/TerminalCases.java" "$here/src/TerminalRun.java" "$here/src/FileCases.java" "$here/src/GnutellaRun.java" "$here/src/NewDeviceRun.java" "$here/src/SheetRun.java" "$here/src/TerminalWindowRun.java" "$here/src/InstallRun.java" "$here/src/BrowserWindowRun.java" "$here/src/MailWindowRun.java" "$here/src/SaveAfterRunRun.java"
|
||||
javac -encoding UTF-8 -d "$here/.build" -cp "$classpath" "$here/src/FlsOracle.java" "$here/src/GetterDefaults.java" "$here/src/IpCheckCases.java" "$here/src/DnsHostsCases.java" "$here/src/EmailFileCases.java" "$here/src/BeansDump.java" "$here/src/Base64Cases.java" "$here/src/ScenarioNetwork.java" "$here/src/DhcpRun.java" "$here/src/TcpRun.java" "$here/src/DnsRun.java" "$here/src/ArpRun.java" "$here/src/RipRun.java" "$here/src/NatRun.java" "$here/src/WlanRun.java" "$here/src/ExampleRun.java" "$here/src/EchoRun.java" "$here/src/WebRun.java" "$here/src/RouterWebRun.java" "$here/src/KonfigCases.java" "$here/src/EmailRun.java" "$here/src/MailCases.java" "$here/src/EmailClientRun.java" "$here/src/TerminalCases.java" "$here/src/TerminalRun.java" "$here/src/FileCases.java" "$here/src/GnutellaRun.java" "$here/src/NewDeviceRun.java" "$here/src/SheetRun.java" "$here/src/TerminalWindowRun.java" "$here/src/InstallRun.java" "$here/src/BrowserWindowRun.java" "$here/src/MailWindowRun.java" "$here/src/SaveAfterRunRun.java" "$here/src/TextEditorWindowRun.java"
|
||||
# FILIUS writes its settings below $HOME; keep them out of the real one.
|
||||
mkdir -p "$here/.build/home"
|
||||
# Removed first: a crashed run must not leave the previous result standing.
|
||||
@@ -401,6 +401,16 @@ for run in email:email_komplett dhcp:einfaches_rechnernetz_komplett; do
|
||||
test -s "$output"
|
||||
done
|
||||
|
||||
# FILIUS' text editor window used as a person does — typing, „Neu“,
|
||||
# „Öffnen“, „Speichern“, „Speichern unter“ over its file dialog, the command
|
||||
# line changing the files beside it (TextEditorWindowRun): the editor's text
|
||||
# editor (packages/editor/test/textEditor.test.ts).
|
||||
output="$root/packages/sim/test/textEditorWindow.java.json"
|
||||
rm -f "$output"
|
||||
HOME="$here/.build/home" xvfb-run -a java -Duser.language=de -Duser.country=DE -cp "$classpath:$here/.build" TextEditorWindowRun \
|
||||
"$root/packages/core/test/scenarios/mail-lan.scenario.json" "$output" 2>&1 | grep -v 'logback\|^$\|^\s*at ' || true
|
||||
test -s "$output"
|
||||
|
||||
# FILIUS' own examples as Java FILIUS runs them, opened and started as FILIUS
|
||||
# does (ExampleRun): the acceptance of F2 (packages/fls/test/examplesJava.test.ts)
|
||||
# and the classics of F3 (web, email, gnutella; packages/fls/test/classicsJava.test.ts).
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
// Uses FILIUS' own text editor window (GUIApplicationTextEditorWindow on a
|
||||
// GUIDesktopPanel) on mail-lan's first computer as a person does: typing,
|
||||
// its buttons „Neu“, „Öffnen“, „Speichern“, „Speichern unter“, and its file
|
||||
// dialog (DMTNFileChooser: the folder's list, a double click on a folder or
|
||||
// a file, „Ordner aufwärts“, the name field, the dialog's own button), with
|
||||
// the command line beside it changing the files. After each step it writes
|
||||
// what the window shows (its title, its text), what the dialog showed, and
|
||||
// the computer's files. The reference for the editor's text editor
|
||||
// (packages/editor/test/textEditor.test.ts).
|
||||
//
|
||||
// Usage: java -cp filius.jar:lib/*:classes TextEditorWindowRun <scenario.json> <output json>
|
||||
|
||||
import filius.gui.anwendungssicht.GUIApplicationTextEditorWindow;
|
||||
import filius.gui.anwendungssicht.GUIApplicationWindow;
|
||||
import filius.gui.anwendungssicht.GUIDesktopPanel;
|
||||
import filius.software.lokal.Terminal;
|
||||
import filius.software.system.Betriebssystem;
|
||||
import filius.software.system.Datei;
|
||||
|
||||
import java.awt.Component;
|
||||
import java.awt.Container;
|
||||
import java.awt.Point;
|
||||
import java.awt.Window;
|
||||
import java.awt.event.MouseEvent;
|
||||
import java.awt.event.MouseListener;
|
||||
import java.io.FileOutputStream;
|
||||
import java.lang.reflect.Field;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Enumeration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.StringTokenizer;
|
||||
import javax.swing.AbstractButton;
|
||||
import javax.swing.JDialog;
|
||||
import javax.swing.JFrame;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JList;
|
||||
import javax.swing.JTextArea;
|
||||
import javax.swing.JTextField;
|
||||
import javax.swing.ListModel;
|
||||
import javax.swing.SwingUtilities;
|
||||
import javax.swing.tree.DefaultMutableTreeNode;
|
||||
|
||||
public class TextEditorWindowRun {
|
||||
static final String CLIENT = "00000000-0000-0000-0000-000000000201";
|
||||
|
||||
static final List<Object> steps = new ArrayList<>();
|
||||
static GUIApplicationTextEditorWindow window;
|
||||
static GUIDesktopPanel desktop;
|
||||
static Betriebssystem system;
|
||||
static Terminal terminal;
|
||||
/** What the last dialog showed, for the next step. */
|
||||
static List<Object> shown = new ArrayList<>();
|
||||
|
||||
static Object field(Class<?> owner, Object object, String name) throws Exception {
|
||||
Field field = owner.getDeclaredField(name);
|
||||
field.setAccessible(true);
|
||||
return field.get(object);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
static <T> T find(Container container, Class<T> kind) {
|
||||
for (Component component : container.getComponents()) {
|
||||
if (kind.isInstance(component)) return (T) component;
|
||||
if (component instanceof Container) {
|
||||
T found = find((Container) component, kind);
|
||||
if (found != null) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static <T> List<T> all(Container container, Class<T> kind, List<T> found) {
|
||||
for (Component component : container.getComponents()) {
|
||||
if (kind.isInstance(component)) found.add(kind.cast(component));
|
||||
if (component instanceof Container) all((Container) component, kind, found);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
static AbstractButton button(Container container, String text) {
|
||||
for (AbstractButton each : all(container, AbstractButton.class, new ArrayList<>())) if (text.equals(each.getText())) return each;
|
||||
throw new IllegalStateException("no button " + text);
|
||||
}
|
||||
|
||||
static void onEdt(Runnable action) throws Exception {
|
||||
SwingUtilities.invokeLater(action);
|
||||
Thread.sleep(400);
|
||||
}
|
||||
|
||||
static JTextArea editor() throws Exception {
|
||||
return (JTextArea) field(GUIApplicationTextEditorWindow.class, window, "editorField");
|
||||
}
|
||||
|
||||
/** The file dialog Java shows (modal, so the button's press waits for it). */
|
||||
static JDialog dialog() throws Exception {
|
||||
long until = System.currentTimeMillis() + 5000;
|
||||
while (System.currentTimeMillis() < until) {
|
||||
for (Window each : Window.getWindows()) {
|
||||
if (each instanceof JDialog && each.isVisible() && find((Container) each, JList.class) != null) return (JDialog) each;
|
||||
}
|
||||
Thread.sleep(100);
|
||||
}
|
||||
throw new IllegalStateException("no file dialog");
|
||||
}
|
||||
|
||||
/** What the dialog shows: its title, its folder's label and its list. */
|
||||
static void look(JDialog dialog) {
|
||||
Map<String, Object> look = new LinkedHashMap<>();
|
||||
look.put("title", dialog.getTitle());
|
||||
List<String> labels = new ArrayList<>();
|
||||
for (JLabel label : all(dialog.getContentPane(), JLabel.class, new ArrayList<>())) labels.add(label.getText());
|
||||
look.put("labels", labels);
|
||||
List<String> entries = new ArrayList<>();
|
||||
ListModel<?> model = find(dialog.getContentPane(), JList.class).getModel();
|
||||
for (int index = 0; index < model.getSize(); index++) entries.add(String.valueOf(model.getElementAt(index)));
|
||||
look.put("entries", entries);
|
||||
look.put("name", find(dialog.getContentPane(), JTextField.class).getText());
|
||||
List<String> buttons = new ArrayList<>();
|
||||
for (AbstractButton each : all(dialog.getContentPane(), AbstractButton.class, new ArrayList<>())) buttons.add(each.getText());
|
||||
look.put("buttons", buttons);
|
||||
shown.add(look);
|
||||
}
|
||||
|
||||
/** A double click on the list's entry, as Java's dialog listens (mouseClicked, two clicks). */
|
||||
static void doubleClick(JDialog dialog, String entry) throws Exception {
|
||||
JList<?> list = find(dialog.getContentPane(), JList.class);
|
||||
int index = -1;
|
||||
for (int each = 0; each < list.getModel().getSize(); each++) if (entry.equals(String.valueOf(list.getModel().getElementAt(each)))) index = each;
|
||||
if (index < 0) throw new IllegalStateException("no entry " + entry);
|
||||
int found = index;
|
||||
onEdt(() -> {
|
||||
Point at = list.indexToLocation(found);
|
||||
MouseEvent event = new MouseEvent(list, MouseEvent.MOUSE_CLICKED, System.currentTimeMillis(), 0, at.x + 2, at.y + 2, 2, false, MouseEvent.BUTTON1);
|
||||
for (MouseListener listener : list.getMouseListeners()) listener.mouseClicked(event);
|
||||
});
|
||||
look(dialog);
|
||||
}
|
||||
|
||||
/**
|
||||
* A file dialog after the editor's button: what it shows, then the
|
||||
* person's moves (">name" a double click on an entry, "^" folder up,
|
||||
* "=name" the name typed), then its own button ("!") or „Abbrechen“ ("x").
|
||||
*/
|
||||
static void inDialog(String editorButton, String... moves) throws Exception {
|
||||
onEdt(button(window, editorButton)::doClick);
|
||||
JDialog dialog = dialog();
|
||||
look(dialog);
|
||||
for (String move : moves) {
|
||||
if (move.startsWith(">")) doubleClick(dialog, move.substring(1));
|
||||
else if (move.equals("^")) {
|
||||
onEdt(button(dialog.getContentPane(), "Ordner aufwärts")::doClick);
|
||||
look(dialog);
|
||||
} else if (move.startsWith("=")) {
|
||||
JTextField name = find(dialog.getContentPane(), JTextField.class);
|
||||
onEdt(() -> name.setText(move.substring(1)));
|
||||
} else if (move.equals("!")) {
|
||||
String action = editorButton.equals("Öffnen") ? "Öffnen" : "Speichern";
|
||||
onEdt(button(dialog.getContentPane(), action)::doClick);
|
||||
} else if (move.equals("x")) onEdt(button(dialog.getContentPane(), "Abbrechen")::doClick);
|
||||
}
|
||||
Thread.sleep(300);
|
||||
}
|
||||
|
||||
static void type(String text) throws Exception {
|
||||
JTextArea editor = editor();
|
||||
onEdt(() -> editor.setText(text));
|
||||
}
|
||||
|
||||
/** GUIApplicationTerminalWindow on Enter: the line handed to the program. */
|
||||
static void command(String line) throws Exception {
|
||||
StringTokenizer tokens = new StringTokenizer(line, " ");
|
||||
String name = tokens.nextToken();
|
||||
String[] parameters = new String[3 + tokens.countTokens()];
|
||||
Arrays.fill(parameters, "");
|
||||
for (int index = 0; tokens.hasMoreTokens(); index++) parameters[index] = tokens.nextToken();
|
||||
terminal.terminalEingabeAuswerten(name, parameters);
|
||||
Thread.sleep(500);
|
||||
}
|
||||
|
||||
/** The computer's files, each folder's entries in their order: path, type, content (an image's only its length). */
|
||||
static List<List<String>> files() {
|
||||
List<List<String>> files = new ArrayList<>();
|
||||
walk(system.getDateisystem().getRoot(), "", files);
|
||||
return files;
|
||||
}
|
||||
|
||||
static void walk(DefaultMutableTreeNode folder, String path, List<List<String>> files) {
|
||||
for (Enumeration<?> each = folder.children(); each.hasMoreElements();) {
|
||||
DefaultMutableTreeNode node = (DefaultMutableTreeNode) each.nextElement();
|
||||
if (node.getUserObject() instanceof Datei) {
|
||||
Datei datei = (Datei) node.getUserObject();
|
||||
String content = datei.getDateiInhalt();
|
||||
boolean text = "text".equals(datei.getDateiTyp());
|
||||
files.add(Arrays.asList(path + "/" + datei.getName(), String.valueOf(datei.getDateiTyp()), text ? content : "(" + (content == null ? 0 : content.length()) + " characters)"));
|
||||
} else {
|
||||
files.add(Arrays.asList(path + "/" + node + "/", "folder", ""));
|
||||
walk(node, path + "/" + node, files);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void step(String name) throws Exception {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("step", name);
|
||||
result.put("title", ((JLabel) field(GUIApplicationWindow.class, window, "titleLabel")).getText());
|
||||
String text = editor().getText();
|
||||
result.put("text", text.length() > 200 ? text.substring(0, 200) + "… (" + text.length() + " characters)" : text);
|
||||
if (!shown.isEmpty()) result.put("dialog", shown);
|
||||
shown = new ArrayList<>();
|
||||
result.put("files", files());
|
||||
steps.add(result);
|
||||
}
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
try {
|
||||
run(args);
|
||||
} catch (Throwable error) {
|
||||
error.printStackTrace();
|
||||
System.exit(1);
|
||||
}
|
||||
System.exit(0);
|
||||
}
|
||||
|
||||
static void run(String[] args) throws Exception {
|
||||
ScenarioNetwork network = ScenarioNetwork.read(args[0]);
|
||||
system = (Betriebssystem) network.nodes.get(CLIENT).getSystemSoftware();
|
||||
system.installApp("filius.software.lokal.TextEditor", false);
|
||||
system.installApp("filius.software.lokal.Terminal", false);
|
||||
system.installApp("filius.software.www.WebServer", false);
|
||||
network.start();
|
||||
Thread.sleep(1000);
|
||||
terminal = (Terminal) system.holeSoftware("filius.software.lokal.Terminal");
|
||||
|
||||
JFrame frame = new JFrame();
|
||||
desktop = new GUIDesktopPanel(system);
|
||||
frame.getContentPane().add(desktop);
|
||||
window = (GUIApplicationTextEditorWindow) desktop.starteAnwendung("filius.software.lokal.TextEditor");
|
||||
SwingUtilities.invokeAndWait(() -> {
|
||||
frame.pack();
|
||||
frame.setVisible(true);
|
||||
});
|
||||
Thread.sleep(500);
|
||||
step("open");
|
||||
|
||||
// A new text saved: „Speichern“ without a file asks for a name.
|
||||
type("Erste Zeile\nZweite Zeile");
|
||||
inDialog("Speichern", "=notiz.txt", "!");
|
||||
step("save new as notiz.txt");
|
||||
type("Erste Zeile\nZweite Zeile\nDritte Zeile");
|
||||
onEdt(button(window, "Speichern")::doClick);
|
||||
step("save");
|
||||
|
||||
// Another program changes the open file: the editor takes it while nothing is typed, keeps its own text after typing.
|
||||
Datei notiz = system.getDateisystem().holeDatei("/notiz.txt");
|
||||
onEdt(() -> notiz.setDateiInhalt("von außen"));
|
||||
step("changed outside, nothing typed");
|
||||
type("getippt");
|
||||
onEdt(() -> notiz.setDateiInhalt("wieder von außen"));
|
||||
step("changed outside after typing");
|
||||
|
||||
// Folders in the dialog: a double click into one, a file chosen there, folder up.
|
||||
command("mkdir ordner");
|
||||
command("touch ordner/b.txt");
|
||||
inDialog("Öffnen", ">Ordner;ordner", ">Datei;b.txt", "!");
|
||||
step("open ordner/b.txt by double clicks");
|
||||
inDialog("Öffnen", ">Ordner;ordner", "^", "x");
|
||||
step("dialog: folder and up, cancelled");
|
||||
inDialog("Öffnen", "=NOTIZ.TXT", "!");
|
||||
step("open NOTIZ.TXT");
|
||||
inDialog("Öffnen", "=ordner/b.txt", "!");
|
||||
step("open ordner/b.txt by name");
|
||||
inDialog("Öffnen", "=fehlt.txt", "!");
|
||||
step("open fehlt.txt");
|
||||
inDialog("Speichern", "x");
|
||||
step("save without a file, cancelled");
|
||||
inDialog("Öffnen", "=", "!");
|
||||
step("open without a name");
|
||||
|
||||
// An image opened as text.
|
||||
inDialog("Öffnen", ">Ordner;webserver", ">Datei;splashscreen-mini.png", "!");
|
||||
step("open the web server's image");
|
||||
|
||||
// „Speichern unter“ onto a file there (in other case), then „Speichern“ again.
|
||||
inDialog("Öffnen", "=notiz.txt", "!");
|
||||
type("vor dem Speichern unter");
|
||||
inDialog("Speichern unter", "=NOTIZ.TXT", "!");
|
||||
step("save as NOTIZ.TXT over notiz.txt");
|
||||
type("nach dem Speichern unter");
|
||||
onEdt(button(window, "Speichern")::doClick);
|
||||
step("save after save as over a file");
|
||||
|
||||
// „Speichern unter“ a new name in a folder, then „Speichern“ again.
|
||||
type("neu im Ordner");
|
||||
inDialog("Speichern unter", ">Ordner;ordner", "=c.txt", "!");
|
||||
step("save as ordner/c.txt");
|
||||
type("neu im Ordner, geändert");
|
||||
onEdt(button(window, "Speichern")::doClick);
|
||||
step("save after save as a new file");
|
||||
|
||||
// The open file removed by the command line, then saved.
|
||||
command("rm ordner/c.txt");
|
||||
step("open file removed");
|
||||
type("nach dem Löschen");
|
||||
onEdt(button(window, "Speichern")::doClick);
|
||||
step("save after the file was removed");
|
||||
|
||||
// A name with a folder in it, and none.
|
||||
inDialog("Speichern unter", "=ordner/d.txt", "!");
|
||||
step("save as ordner/d.txt");
|
||||
inDialog("Speichern unter", "=", "!");
|
||||
step("save as without a name");
|
||||
|
||||
onEdt(button(window, "Neu")::doClick);
|
||||
step("new");
|
||||
|
||||
Map<String, Object> output = new LinkedHashMap<>();
|
||||
output.put("steps", steps);
|
||||
try (FileOutputStream out = new FileOutputStream(args[1])) {
|
||||
out.write((FlsOracle.Json.write(output, "") + "\n").getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
network.stop();
|
||||
System.out.println("text editor window: " + steps.size() + " steps");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user