diff --git a/CHANGES.md b/CHANGES.md index ee45df3..da51641 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -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 | diff --git a/packages/core/src/runtime/apps/filePrograms.ts b/packages/core/src/runtime/apps/filePrograms.ts index 999f6a2..fce9a66 100644 --- a/packages/core/src/runtime/apps/filePrograms.ts +++ b/packages/core/src/runtime/apps/filePrograms.ts @@ -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); diff --git a/packages/core/test/filePrograms.test.ts b/packages/core/test/filePrograms.test.ts index 7285f1c..d9cc71d 100644 --- a/packages/core/test/filePrograms.test.ts +++ b/packages/core/test/filePrograms.test.ts @@ -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']); + }); }); diff --git a/packages/doc/src/files.ts b/packages/doc/src/files.ts new file mode 100644 index 0000000..50b56f3 --- /dev/null +++ b/packages/doc/src/files.ts @@ -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, 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, fileSystem: VirtualFileSystem): void { + let files = node.get('files') as Y.Map> | 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>(); + 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 | undefined { + return doc.getMap>(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> | 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(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> | 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> | 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; +} diff --git a/packages/doc/src/index.ts b/packages/doc/src/index.ts index 21fec60..7807302 100644 --- a/packages/doc/src/index.ts +++ b/packages/doc/src/index.ts @@ -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'; diff --git a/packages/doc/src/projectDoc.ts b/packages/doc/src/projectDoc.ts index e6cab81..d844e28 100644 --- a/packages/doc/src/projectDoc.ts +++ b/packages/doc/src/projectDoc.ts @@ -224,7 +224,7 @@ export function inOrder(map: Y.Map>): [string, Y.Map][] * 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>): VirtualFileSystem { +export function fileSystemOf(files: Y.Map>): VirtualFileSystem { const fileSystem = new VirtualFileSystem(); for (const [path, entry] of inOrder(files)) { const kind = entry.get('kind') as VirtualFileContent['kind']; diff --git a/packages/doc/src/simulation.ts b/packages/doc/src/simulation.ts index ef10239..e9f0723 100644 --- a/packages/doc/src/simulation.ts +++ b/packages/doc/src/simulation.ts @@ -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, 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, fileSystem: VirtualFileSystem): void { - let files = node.get('files') as Y.Map> | 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>(); - 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, origin: unknown = SIM_ORIGIN): void { const nodes = doc.getMap>(ROOTS.nodes); diff --git a/packages/doc/test/files.test.ts b/packages/doc/test/files.test.ts new file mode 100644 index 0000000..e1ef115 --- /dev/null +++ b/packages/doc/test/files.test.ts @@ -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>(ROOTS.nodes).get('pc')!.get('files') as Y.Map> | 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(); + 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(); + 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'], + ]); + }); +}); diff --git a/packages/editor/browser/text.spec.ts b/packages/editor/browser/text.spec.ts new file mode 100644 index 0000000..917267c --- /dev/null +++ b/packages/editor/browser/text.spec.ts @@ -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 }; + +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 { + 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('NeuErster Stand'); + 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('NeuZweiter Stand'); + 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, ''), [id]); + await expect(text).toHaveValue('NeuZweiter Stand'); + await page.keyboard.type('!'); + await expect(text).toHaveValue('NeuZweiter! Stand'); + await page.keyboard.press('Backspace'); + await text.evaluate((area: HTMLTextAreaElement) => area.blur()); + await expect(text).toHaveValue('NeuZweiter Stand'); + + // The window's own undo takes back its typing. + await text.press('Control+z'); + await expect(text).toHaveValue('NeuErster Stand'); + await text.press('Control+y'); + await expect(text).toHaveValue('NeuZweiter Stand'); + + // „Ö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('NeuZweiter Stand'); + 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); +}); diff --git a/packages/editor/src/assets/desktop/datei.png b/packages/editor/src/assets/desktop/datei.png new file mode 100644 index 0000000..478eafa Binary files /dev/null and b/packages/editor/src/assets/desktop/datei.png differ diff --git a/packages/editor/src/assets/desktop/ordner.png b/packages/editor/src/assets/desktop/ordner.png new file mode 100644 index 0000000..41ea5c8 Binary files /dev/null and b/packages/editor/src/assets/desktop/ordner.png differ diff --git a/packages/editor/src/desktop/DesktopWindow.tsx b/packages/editor/src/desktop/DesktopWindow.tsx index fd6f9d6..3182f82 100644 --- a/packages/editor/src/desktop/DesktopWindow.tsx +++ b/packages/editor/src/desktop/DesktopWindow.tsx @@ -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 = new Set(['commandPrompt', 'webBrowser', 'webServer', 'emailClient', 'emailServer']); +const WITH_WINDOW: ReadonlySet = 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 : ( 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' ? : null} @@ -165,6 +173,7 @@ export function DesktopWindow({ doc, id, simulation, active, x, y, z, program: o {shown === 'webServer' ? : null} {shown === 'emailClient' ? : null} {shown === 'emailServer' ? : null} + {shown === 'textEditor' ? : null} )} {info ? ( diff --git a/packages/editor/src/desktop/FileDialog.tsx b/packages/editor/src/desktop/FileDialog.tsx new file mode 100644 index 0000000..7c8e4d7 --- /dev/null +++ b/packages/editor/src/desktop/FileDialog.tsx @@ -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(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 ( +
+

{title}

+
+ {`${t('fileDialog.folder')} ${shown}`} + +
+
    + {entries.map((entry) => ( +
  • + +
  • + ))} +
+ + {problem === null ? null : ( +

+ {problem} +

+ )} +
+ + +
+
+ ); +} diff --git a/packages/editor/src/desktop/TextEditorPanel.tsx b/packages/editor/src/desktop/TextEditorPanel.tsx new file mode 100644 index 0000000..54ea203 --- /dev/null +++ b/packages/editor/src/desktop/TextEditorPanel.tsx @@ -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(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) => { + 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 ( +
+
+ + + + +
+ {window.gone === null ? null : ( +

+ {t('textEditor.gone', { name: entryName(window.gone) })} +

+ )} +