feat: F4.6f3 the file explorer and the image viewer
Oracle FileExplorerWindowRun uses Java's explorer and viewer windows as a person does (the right-click menu, Java's questions answered, the import window, the viewer's open). Both windows work on the document's files through the core's operations: the tree of folders and files, the chosen folder's entries, Java's menu (also as buttons, no right click needed), copy that stays and cut that goes once pasted, the import; the viewer's picture and title. Not taken from Java: a file chosen in the tree taken for a folder, pictures laid over each other, a text file as an empty picture. writeFiles numbers the entries in the file system's order again when a rename made a new key. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -33,6 +33,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand
|
|||||||
| `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/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` | 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/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/filePrograms.ts` (F4.6f3), `test/filePrograms.test.ts` | Java `GUIApplicationImageViewerWindow.oeffnen` (gemessen mit `FileExplorerWindowRun`) | erweitert (F4.6f3): `openImage` öffnet nach dem Ordner und Namen des Dialogs nur ein Bild (Java zeigt eine andere Datei als leeres Bild), ohne Fund `notFound` |
|
||||||
| `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/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/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) |
|
| `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) |
|
||||||
@@ -141,6 +142,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand
|
|||||||
| `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/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/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 |
|
| `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 |
|
||||||
|
| `src/files.ts` (Reihenfolge), `test/files.test.ts` | Java `GUIApplicationFileExplorerWindow` (Umbenennen an Ort und Stelle) | erweitert (F4.6f3): `writeFiles` nummeriert die Einträge nach der Reihenfolge des Dateisystems neu, wenn sie davon abweicht — ein umbenannter Eintrag ist im Dokument ein neuer Schlüssel und rückte sonst ans Ende |
|
||||||
| `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/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/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 |
|
| `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 |
|
||||||
@@ -202,12 +204,14 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand
|
|||||||
| `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/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/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/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/desktop/fileExplorer.ts`, `src/desktop/windowState.ts`, `src/desktop/FileExplorerPanel.tsx`, `src/desktop/ImageViewerPanel.tsx` | Java `GUIApplicationFileExplorerWindow` (`fileexplorer_msg2`–`19`, `sw_fileexplorer_msg1`–`3`, `sw_fileimporter_msg3`), `GUIApplicationImageViewerWindow` (`imageviewer_msg2`), gemessen mit `FileExplorerWindowRun` | neu (F4.6f3): der Datei-Explorer — Baum aus Ordnern und Dateien, die Einträge des gewählten Ordners mit Javas Symbolen, Javas Menü per Rechtsklick (auf einem Eintrag Löschen, Ausschneiden, Kopieren, Umbenennen; auf der freien Fläche Neuer Ordner, Einfügen), Javas Fragen und das Importfenster; Kopiertes bleibt zum nächsten Einfügen, Ausgeschnittenes nicht. Der Bildbetrachter — „Öffnen“ über Javas Dateidialog, das Bild, sein Name als Titel. Die Dateien sind die des Dokuments, geändert über die Operationen des Kerns. Besser als Java: Baum und Liste folgen den Dateien sofort („Aktualisieren“ entfällt); die Aktionen des Menüs sind auch Knöpfe, ein Finger braucht keinen Rechtsklick; ein Ordner der Liste öffnet mit Doppelklick; eine im Baum gewählte Datei wird in ihrem Ordner gewählt (Java hält sie für einen Ordner, ein neuer Ordner landete unter ihr); was eine Aktion ablehnt, wird gesagt; der neue Name beginnt mit dem alten; der Bildbetrachter zeigt ein Bild statt alle übereinander und sagt, was kein Bild ist. `windowState.ts`: was die Fenster der lokalen Programme teilen (auch der Texteditor) |
|
||||||
| `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 |
|
| `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/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/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/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/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 |
|
| `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 |
|
||||||
|
| `browser/files.spec.ts`, `test/fileExplorer.test.ts` | — | neu (F4.6f3): Javas Schritte an Explorer und Bildbetrachter nachgespielt (gleich, wo Java recht hat, benannt, wo es irrt); in Chromium: im Lauf installiert, Ordner angelegt, über Javas Rechtsklick-Menü kopiert und eingefügt, Javas Meldung beim Einfügen in sich selbst, per Knopf umbenannt und nach „Sicher?“ gelöscht, was die Befehlszeile anlegt sofort sichtbar, ein lokales Bild importiert und im Bildbetrachter gezeigt, eine Textdatei dort abgelehnt |
|
||||||
| `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/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 |
|
| `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 |
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,8 @@
|
|||||||
// is refused (Java makes such a file), a name with "/" leads into a
|
// is refused (Java makes such a file), a name with "/" leads into a
|
||||||
// folder (Java makes a file whose name holds the separator);
|
// folder (Java makes a file whose name holds the separator);
|
||||||
// - "open" opens text files only (Java shows an image as its Base64 text)
|
// - "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);
|
// and keeps the open file when it finds none (Java forgets it); the image
|
||||||
|
// viewer's opens images only (Java shows another file as an empty picture);
|
||||||
// - a text file's size is its length in characters (as the command line's).
|
// - a text file's size is its length in characters (as the command line's).
|
||||||
|
|
||||||
import type { VirtualFileContent, VirtualFileSystem } from '../../model/virtualFileSystem.js';
|
import type { VirtualFileContent, VirtualFileSystem } from '../../model/virtualFileSystem.js';
|
||||||
@@ -259,6 +260,19 @@ export function openText(files: VirtualFileSystem, folder: string, name: string)
|
|||||||
return entry.content.kind === 'text' ? { path: entry.path, text: entry.content.text } : 'notText';
|
return entry.content.kind === 'text' ? { path: entry.path, text: entry.content.text } : 'notText';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The image viewer's "open" (holeDatei of the dialog's folder and name, as
|
||||||
|
* the editor's): an image file's path, bytes and media type. Java shows any
|
||||||
|
* file it finds — another as an empty picture —, and with none keeps what it
|
||||||
|
* shows; here a file that is not an image is notImage, none notFound.
|
||||||
|
*/
|
||||||
|
export function openImage(files: VirtualFileSystem, folder: string, name: string): { readonly path: string; readonly data: Uint8Array; readonly mediaType: string } | 'notFound' | 'notImage' {
|
||||||
|
// 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 === 'image' ? { path: entry.path, data: entry.content.data, mediaType: entry.content.mediaType } : 'notImage';
|
||||||
|
}
|
||||||
|
|
||||||
/** The image viewer's "open": an image file's bytes and media type; none for any other file. */
|
/** 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 {
|
export function imageFile(files: VirtualFileSystem, path: string): { readonly data: Uint8Array; readonly mediaType: string } | undefined {
|
||||||
const entry = found(files, path);
|
const entry = found(files, path);
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
saveText,
|
saveText,
|
||||||
saveTextAs,
|
saveTextAs,
|
||||||
openText,
|
openText,
|
||||||
|
openImage,
|
||||||
VirtualFileSystem,
|
VirtualFileSystem,
|
||||||
type Clipboard,
|
type Clipboard,
|
||||||
type PageLanguage,
|
type PageLanguage,
|
||||||
@@ -265,4 +266,18 @@ describe('the file programs beyond the cases', () => {
|
|||||||
expect(saveTextAs(files, '/', 'ORDNER', 'e')).toBe('isFolder');
|
expect(saveTextAs(files, '/', 'ORDNER', 'e')).toBe('isFolder');
|
||||||
expect(files.allEntries().map((entry) => entry.path)).toEqual(['/', '/ordner', '/ordner/d.txt']);
|
expect(files.allEntries().map((entry) => entry.path)).toEqual(['/', '/ordner', '/ordner/d.txt']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Measured with FileExplorerWindowRun (packages/sim/test/fileExplorerWindow.java.json).
|
||||||
|
it("opens an image by the dialog's folder and name, in any case, and neither another file nor what is not there", () => {
|
||||||
|
const files = new VirtualFileSystem();
|
||||||
|
files.createDirectory('/webserver');
|
||||||
|
files.writeImageFile('/webserver/Bild.png', new Uint8Array([1, 2]), 'image/png');
|
||||||
|
files.writeTextFile('/text.txt', 'x');
|
||||||
|
expect(openImage(files, '/webserver', 'bild.PNG')).toEqual({ path: '/webserver/Bild.png', data: new Uint8Array([1, 2]), mediaType: 'image/png' });
|
||||||
|
expect(openImage(files, '/', 'webserver/Bild.png')).toEqual(openImage(files, '/text', '/webserver/Bild.png'));
|
||||||
|
expect(['fehlt.png', '', 'webserver'].map((name) => openImage(files, '/', name))).toEqual(['notFound', 'notFound', 'notFound']);
|
||||||
|
expect(openImage(files, '/', 'text.txt')).toBe('notImage');
|
||||||
|
files.writeBinaryFile('/daten.bin', new Uint8Array([3]));
|
||||||
|
expect(openImage(files, '/', 'daten.bin')).toBe('notImage');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
import * as Y from 'yjs';
|
import * as Y from 'yjs';
|
||||||
import type { Id, VirtualFileContent, VirtualFileSystem } from '@mdg-filius/core';
|
import type { Id, VirtualFileContent, VirtualFileSystem } from '@mdg-filius/core';
|
||||||
import { VirtualFileSystem as FileSystem } from '@mdg-filius/core';
|
import { VirtualFileSystem as FileSystem } from '@mdg-filius/core';
|
||||||
import { fileEntry, fileSystemOf, ROOTS } from './projectDoc.js';
|
import { fileEntry, fileSystemOf, inOrder, ROOTS } from './projectDoc.js';
|
||||||
|
|
||||||
export function sameBytes(left: Uint8Array, right: Uint8Array): boolean {
|
export function sameBytes(left: Uint8Array, right: Uint8Array): boolean {
|
||||||
return left.length === right.length && left.every((byte, index) => byte === right[index]);
|
return left.length === right.length && left.every((byte, index) => byte === right[index]);
|
||||||
@@ -36,7 +36,11 @@ function holds(entry: Y.Map<unknown>, content: VirtualFileContent): boolean {
|
|||||||
return sameBytes(entry.get('data') as Uint8Array, content.data) && entry.get('mediaType') === content.mediaType;
|
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. */
|
/**
|
||||||
|
* A node's files as the file system has them: new ones after the others,
|
||||||
|
* changed ones in their place, gone ones removed; when the order is no
|
||||||
|
* longer the file system's (a renamed entry), numbered anew.
|
||||||
|
*/
|
||||||
export function writeFiles(node: Y.Map<unknown>, fileSystem: VirtualFileSystem): void {
|
export function writeFiles(node: Y.Map<unknown>, fileSystem: VirtualFileSystem): void {
|
||||||
let files = node.get('files') as Y.Map<Y.Map<unknown>> | undefined;
|
let files = node.get('files') as Y.Map<Y.Map<unknown>> | undefined;
|
||||||
const wanted: [string, VirtualFileContent][] = [];
|
const wanted: [string, VirtualFileContent][] = [];
|
||||||
@@ -65,6 +69,10 @@ export function writeFiles(node: Y.Map<unknown>, fileSystem: VirtualFileSystem):
|
|||||||
replaceText(entry.get('text') as Y.Text, content.text);
|
replaceText(entry.get('text') as Y.Text, content.text);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// An entry renamed is new to the document: the order is the file system's again (a rename keeps the entry's place).
|
||||||
|
if (inOrder(files).some(([path], index) => path !== wanted[index]![0])) {
|
||||||
|
for (const [index, [path]] of wanted.entries()) if (files.get(path)!.get('order') !== index) files.get(path)!.set('order', index);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function nodeMap(doc: Y.Doc, id: Id): Y.Map<unknown> | undefined {
|
function nodeMap(doc: Y.Doc, id: Id): Y.Map<unknown> | undefined {
|
||||||
|
|||||||
@@ -116,19 +116,17 @@ describe("a device's files in the document", () => {
|
|||||||
const order = (fileSystem: VirtualFileSystem) => fileSystem.entriesInCreationOrder('/').map((entry) => entry.path);
|
const order = (fileSystem: VirtualFileSystem) => fileSystem.entriesInCreationOrder('/').map((entry) => entry.path);
|
||||||
expect(order(engine)).toEqual(order(nodeFiles(doc, 'pc')!));
|
expect(order(engine)).toEqual(order(nodeFiles(doc, 'pc')!));
|
||||||
expect(order(engine).indexOf('/Gross.txt')).toBeLessThan(order(engine).indexOf('/NEU.txt'));
|
expect(order(engine).indexOf('/Gross.txt')).toBeLessThan(order(engine).indexOf('/NEU.txt'));
|
||||||
// So also in a folder.
|
// So also in a folder: a file renamed there keeps its place, in the document and in the engine.
|
||||||
changeFiles(doc, 'pc', 'me', (fileSystem) => {
|
changeFiles(doc, 'pc', 'me', (fileSystem) => {
|
||||||
fileSystem.createDirectory('/tief');
|
fileSystem.createDirectory('/tief');
|
||||||
fileSystem.writeTextFile('/tief/x', 'x');
|
fileSystem.writeTextFile('/tief/x', 'x');
|
||||||
fileSystem.writeTextFile('/tief/y.txt', 'y');
|
fileSystem.writeTextFile('/tief/y.txt', 'y');
|
||||||
});
|
});
|
||||||
readFilesInto(doc, 'pc', engine);
|
readFilesInto(doc, 'pc', engine);
|
||||||
changeFiles(doc, 'pc', 'me', (fileSystem) => {
|
changeFiles(doc, 'pc', 'me', (fileSystem) => fileSystem.renameInPlace('/tief/x', 'x.txt'));
|
||||||
fileSystem.deleteItem('/tief/x');
|
expect(nodeFiles(doc, 'pc')!.entriesInCreationOrder('/tief').map((entry) => entry.path)).toEqual(['/tief/x.txt', '/tief/y.txt']);
|
||||||
fileSystem.createDirectory('/tief/x');
|
|
||||||
});
|
|
||||||
readFilesInto(doc, 'pc', engine);
|
readFilesInto(doc, 'pc', engine);
|
||||||
expect(engine.entriesInCreationOrder('/tief').map((entry) => entry.path)).toEqual(['/tief/x', '/tief/y.txt']);
|
expect(engine.entriesInCreationOrder('/tief').map((entry) => entry.path)).toEqual(['/tief/x.txt', '/tief/y.txt']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('leave out what the file system refuses, as the editor draws it', () => {
|
it('leave out what the file system refuses, as the editor draws it', () => {
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
// The file explorer and the image viewer on a computer's desktop in
|
||||||
|
// Chromium (F4.6f3), on browser/fixtures/web-pages.fls
|
||||||
|
// (test/webPagesFixture.test.ts): installed while the simulation runs; a
|
||||||
|
// folder made, an entry copied and pasted through Java's right-click menu,
|
||||||
|
// renamed and deleted through the buttons (after Java's questions), what the
|
||||||
|
// command line makes shown at once, a local file imported; a picture
|
||||||
|
// imported and opened in the viewer, a text file refused there.
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const FIXTURE = join(here, 'fixtures/web-pages.fls');
|
||||||
|
const PICTURE = join(here, '../src/assets/desktop/ordner.png');
|
||||||
|
|
||||||
|
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"]').first().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');
|
||||||
|
}
|
||||||
|
|
||||||
|
test('makes, copies, pastes, renames, deletes and imports as Java’s explorer does, and shows a picture in the viewer', async ({ page }) => {
|
||||||
|
test.setTimeout(120_000);
|
||||||
|
await openPages(page);
|
||||||
|
const id = (await drawing(page)).nodes.find((node) => node.name === 'Server')!.id;
|
||||||
|
const at = await onDevice(page, id);
|
||||||
|
await page.mouse.click(at.x, at.y);
|
||||||
|
const desktop = page.locator(`section[data-desktop="${id}"]`);
|
||||||
|
await desktop.locator('[data-program="installation"]').click();
|
||||||
|
const installation = desktop.locator('[data-program-window="installation"]');
|
||||||
|
const available = installation.locator('[data-list="available"] [role="option"]');
|
||||||
|
for (const program of ['Datei-Explorer', 'Bildbetrachter', 'Befehlszeile']) await available.filter({ hasText: program }).click();
|
||||||
|
await installation.getByRole('button', { name: 'Installieren' }).click();
|
||||||
|
await installation.getByRole('button', { name: 'Änderungen annehmen' }).click();
|
||||||
|
|
||||||
|
await desktop.locator('[data-program="fileExplorer"]').click();
|
||||||
|
const window = desktop.locator('[data-program-window="fileExplorer"]');
|
||||||
|
await expect(window.locator('.program-titlebar h3')).toHaveText('Datei-Explorer');
|
||||||
|
const bar = window.getByRole('toolbar', { name: 'Aktionen' });
|
||||||
|
await expect(bar.getByRole('button')).toHaveText(['Importieren', 'Neuer Ordner', 'Löschen', 'Ausschneiden', 'Kopieren', 'Einfügen', 'Umbenennen']);
|
||||||
|
await expect(bar.getByRole('button', { name: 'Löschen' })).toBeDisabled();
|
||||||
|
await expect(bar.getByRole('button', { name: 'Einfügen' })).toBeDisabled();
|
||||||
|
const tree = window.getByRole('list', { name: 'Ordner und Dateien' }).getByRole('button');
|
||||||
|
const entries = window.getByRole('list', { name: 'Inhalt des Ordners' }).getByRole('button');
|
||||||
|
await expect(entries).toHaveText(['webserver']);
|
||||||
|
await expect(tree.first()).toHaveText('/');
|
||||||
|
|
||||||
|
// A new folder, named in Java's question.
|
||||||
|
await bar.getByRole('button', { name: 'Neuer Ordner' }).click();
|
||||||
|
const naming = window.getByRole('dialog', { name: 'Neuer Ordner' });
|
||||||
|
await naming.getByRole('textbox', { name: 'Neuer Name:' }).fill('ordner');
|
||||||
|
await naming.getByRole('button', { name: 'OK' }).click();
|
||||||
|
await expect(entries).toHaveText(['webserver', 'ordner']);
|
||||||
|
|
||||||
|
// Java's right-click menu: on an entry, then on the free space with something copied.
|
||||||
|
await entries.filter({ hasText: 'webserver' }).click({ button: 'right' });
|
||||||
|
await expect(window.getByRole('menuitem')).toHaveText(['Löschen', 'Ausschneiden', 'Kopieren', 'Umbenennen']);
|
||||||
|
await window.getByRole('menuitem', { name: 'Kopieren' }).click();
|
||||||
|
await window.locator('.file-explorer-tree [data-path="/ordner"]').click();
|
||||||
|
await expect(entries).toHaveCount(0);
|
||||||
|
await window.getByRole('list', { name: 'Inhalt des Ordners' }).click({ button: 'right', position: { x: 20, y: 20 } });
|
||||||
|
await expect(window.getByRole('menuitem')).toHaveText(['Neuer Ordner', 'Einfügen']);
|
||||||
|
await window.getByRole('menuitem', { name: 'Einfügen' }).click();
|
||||||
|
await expect(entries).toHaveText(['webserver']);
|
||||||
|
// A folder of the list opens with a double click; into the copied folder itself: Java's message.
|
||||||
|
await entries.filter({ hasText: 'webserver' }).dblclick();
|
||||||
|
await expect(window.locator('.file-explorer-tree [data-path="/ordner/webserver"]')).toHaveAttribute('aria-current', 'true');
|
||||||
|
await window.locator('.file-explorer-tree [data-path="/webserver"]').click();
|
||||||
|
await bar.getByRole('button', { name: 'Einfügen' }).click();
|
||||||
|
await expect(window.getByRole('alert')).toHaveText('Unzulässiger Zielordner!');
|
||||||
|
|
||||||
|
// Renamed through the button: the old name first, a name there already is said.
|
||||||
|
await window.locator('.file-explorer-tree [data-path="/"]').click();
|
||||||
|
await entries.filter({ hasText: 'ordner' }).click();
|
||||||
|
await bar.getByRole('button', { name: 'Umbenennen' }).click();
|
||||||
|
const renaming = window.getByRole('dialog', { name: 'Umbenennen' });
|
||||||
|
await expect(renaming.getByRole('textbox', { name: 'Neuer Name:' })).toHaveValue('ordner');
|
||||||
|
await renaming.getByRole('textbox', { name: 'Neuer Name:' }).fill('webserver');
|
||||||
|
await renaming.getByRole('button', { name: 'OK' }).click();
|
||||||
|
await expect(renaming.getByRole('alert')).toHaveText('Den Namen „webserver“ gibt es in diesem Ordner schon.');
|
||||||
|
await renaming.getByRole('textbox', { name: 'Neuer Name:' }).fill('Kopie');
|
||||||
|
await renaming.getByRole('button', { name: 'OK' }).click();
|
||||||
|
await expect(entries).toHaveText(['webserver', 'Kopie']);
|
||||||
|
|
||||||
|
// Deleted after Java's "Sicher?": no, then yes.
|
||||||
|
await bar.getByRole('button', { name: 'Löschen' }).click();
|
||||||
|
await window.getByRole('alertdialog', { name: 'Sicher?' }).getByRole('button', { name: 'Nein' }).click();
|
||||||
|
await expect(entries).toHaveText(['webserver', 'Kopie']);
|
||||||
|
await bar.getByRole('button', { name: 'Löschen' }).click();
|
||||||
|
await window.getByRole('alertdialog', { name: 'Sicher?' }).getByRole('button', { name: 'Ja' }).click();
|
||||||
|
await expect(entries).toHaveText(['webserver']);
|
||||||
|
|
||||||
|
// What the command line makes is there at once.
|
||||||
|
await desktop.locator('.desktop-home').click();
|
||||||
|
await desktop.locator('[data-program="commandPrompt"]').click();
|
||||||
|
const input = desktop.locator('input[name="terminalInput"]');
|
||||||
|
await input.fill('mkdir extern');
|
||||||
|
await input.press('Enter');
|
||||||
|
await desktop.locator('.desktop-home').click();
|
||||||
|
await desktop.locator('[data-program="fileExplorer"]').click();
|
||||||
|
await expect(entries).toHaveText(['webserver', 'extern']);
|
||||||
|
|
||||||
|
// A local picture imported: the new name is the local file's at first.
|
||||||
|
await bar.getByRole('button', { name: 'Importieren' }).click();
|
||||||
|
const importing = window.getByRole('dialog', { name: 'Datei importieren' });
|
||||||
|
await importing.getByRole('button', { name: 'Datei importieren' }).click();
|
||||||
|
await expect(importing.getByRole('status')).toHaveText('Eingabe- und Ausgabedatei müssen angegeben werden!');
|
||||||
|
await importing.locator('input[type="file"]').setInputFiles(PICTURE);
|
||||||
|
await expect(importing.getByRole('textbox', { name: 'Neuer Name:' })).toHaveValue('ordner.png');
|
||||||
|
await importing.getByRole('textbox', { name: 'Neuer Name:' }).fill('bild.png');
|
||||||
|
await importing.getByRole('button', { name: 'Datei importieren' }).click();
|
||||||
|
await expect(importing.getByRole('status')).toHaveText('Datei erfolgreich importiert!');
|
||||||
|
await importing.getByRole('button', { name: 'Schließen' }).click();
|
||||||
|
await expect(entries).toHaveText(['webserver', 'extern', 'bild.png']);
|
||||||
|
|
||||||
|
// The viewer: the picture, its name as the title; a text file is said.
|
||||||
|
await desktop.locator('.desktop-home').click();
|
||||||
|
await desktop.locator('[data-program="imageViewer"]').click();
|
||||||
|
const viewer = desktop.locator('[data-program-window="imageViewer"]');
|
||||||
|
await expect(viewer.locator('.program-titlebar h3')).toHaveText('Bildbetrachter');
|
||||||
|
await viewer.getByRole('button', { name: 'Öffnen' }).click();
|
||||||
|
const dialog = viewer.getByRole('dialog', { name: 'Datei Öffnen' });
|
||||||
|
await dialog.locator('.file-dialog-entries button', { hasText: 'bild.png' }).click();
|
||||||
|
await dialog.getByRole('button', { name: 'Öffnen' }).click();
|
||||||
|
await expect(viewer.locator('.program-titlebar h3')).toHaveText('bild.png');
|
||||||
|
await expect.poll(() => viewer.getByRole('img', { name: 'Bild' }).evaluate((image: HTMLImageElement) => image.naturalWidth)).toBe(16);
|
||||||
|
await viewer.getByRole('button', { name: 'Öffnen' }).click();
|
||||||
|
await dialog.locator('.file-dialog-entries button', { hasText: 'webserver' }).click();
|
||||||
|
await dialog.locator('.file-dialog-entries button', { hasText: 'index.html' }).click();
|
||||||
|
await dialog.getByRole('button', { name: 'Öffnen' }).click();
|
||||||
|
await expect(dialog.getByRole('alert')).toHaveText('„index.html“ ist keine Bilddatei.');
|
||||||
|
});
|
||||||
@@ -13,8 +13,10 @@
|
|||||||
// icon opens its window over the desktop (ProgramFrame), the home button
|
// 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
|
// 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), the text editor
|
// 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
|
// (F4.6f2), the file explorer and the image viewer (F4.6f3) have one, the
|
||||||
// from its page, the text editor's from its open file, as Java's do.
|
// others follow. The browser's window takes its title from its page, the
|
||||||
|
// text editor's from its open file, the image viewer's from its picture, as
|
||||||
|
// Java's do.
|
||||||
|
|
||||||
import { useCallback, useEffect, useState, type PointerEvent as ReactPointerEvent } from 'react';
|
import { useCallback, useEffect, useState, type PointerEvent as ReactPointerEvent } from 'react';
|
||||||
import type * as Y from 'yjs';
|
import type * as Y from 'yjs';
|
||||||
@@ -35,6 +37,8 @@ import { pageTitle } from './browserPage.js';
|
|||||||
import { InstallationPanel } from './InstallationPanel.js';
|
import { InstallationPanel } from './InstallationPanel.js';
|
||||||
import { TerminalPanel } from './TerminalPanel.js';
|
import { TerminalPanel } from './TerminalPanel.js';
|
||||||
import { TextEditorPanel, useTextEditorWindow } from './TextEditorPanel.js';
|
import { TextEditorPanel, useTextEditorWindow } from './TextEditorPanel.js';
|
||||||
|
import { FileExplorerPanel } from './FileExplorerPanel.js';
|
||||||
|
import { ImageViewerPanel, useImageViewer } from './ImageViewerPanel.js';
|
||||||
import { WebServerPanel } from './WebServerPanel.js';
|
import { WebServerPanel } from './WebServerPanel.js';
|
||||||
import './desktop.css';
|
import './desktop.css';
|
||||||
|
|
||||||
@@ -64,7 +68,7 @@ export interface DesktopWindowProps {
|
|||||||
export type DesktopWindowName = DesktopProgram | 'installation';
|
export type DesktopWindowName = DesktopProgram | 'installation';
|
||||||
|
|
||||||
/** The programs whose windows are built so far. */
|
/** The programs whose windows are built so far. */
|
||||||
const WITH_WINDOW: ReadonlySet<DesktopProgram> = new Set(['commandPrompt', 'webBrowser', 'webServer', 'emailClient', 'emailServer', 'textEditor']);
|
const WITH_WINDOW: ReadonlySet<DesktopProgram> = new Set(['commandPrompt', 'webBrowser', 'webServer', 'emailClient', 'emailServer', 'textEditor', 'fileExplorer', 'imageViewer']);
|
||||||
|
|
||||||
/** The document's desktop of a computer, read anew after every change (a program installed, the label changed). */
|
/** 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) {
|
function useDesktopView(doc: Y.Doc, id: string) {
|
||||||
@@ -83,6 +87,7 @@ export function DesktopWindow({ doc, id, simulation, active, x, y, z, program: o
|
|||||||
const language = useLanguage();
|
const language = useLanguage();
|
||||||
const view = useDesktopView(doc, id);
|
const view = useDesktopView(doc, id);
|
||||||
const textEditor = useTextEditorWindow(doc, id);
|
const textEditor = useTextEditorWindow(doc, id);
|
||||||
|
const imageViewer = useImageViewer(doc, id);
|
||||||
const { watch } = simulation;
|
const { watch } = simulation;
|
||||||
useEffect(() => watch([`node:${id}`]), [watch, id]);
|
useEffect(() => watch([`node:${id}`]), [watch, id]);
|
||||||
const settings = simulation.nodes.get(id);
|
const settings = simulation.nodes.get(id);
|
||||||
@@ -164,7 +169,9 @@ export function DesktopWindow({ doc, id, simulation, active, x, y, z, program: o
|
|||||||
? browserTitle(browserView(simulation, id), t('program.webBrowser'), (page) => pageTitle(page, t))
|
? browserTitle(browserView(simulation, id), t('program.webBrowser'), (page) => pageTitle(page, t))
|
||||||
: shown === 'textEditor'
|
: shown === 'textEditor'
|
||||||
? textEditor.title(t('program.textEditor'))
|
? textEditor.title(t('program.textEditor'))
|
||||||
: t(`program.${shown}`)
|
: shown === 'imageViewer'
|
||||||
|
? imageViewer.title(t('program.imageViewer'))
|
||||||
|
: t(`program.${shown}`)
|
||||||
}
|
}
|
||||||
program={shown}
|
program={shown}
|
||||||
>
|
>
|
||||||
@@ -174,6 +181,8 @@ export function DesktopWindow({ doc, id, simulation, active, x, y, z, program: o
|
|||||||
{shown === 'emailClient' ? <EmailClientPanel node={id} simulation={simulation} /> : null}
|
{shown === 'emailClient' ? <EmailClientPanel node={id} simulation={simulation} /> : null}
|
||||||
{shown === 'emailServer' ? <EmailServerPanel node={id} simulation={simulation} /> : null}
|
{shown === 'emailServer' ? <EmailServerPanel node={id} simulation={simulation} /> : null}
|
||||||
{shown === 'textEditor' ? <TextEditorPanel doc={doc} node={id} /> : null}
|
{shown === 'textEditor' ? <TextEditorPanel doc={doc} node={id} /> : null}
|
||||||
|
{shown === 'fileExplorer' ? <FileExplorerPanel doc={doc} node={id} /> : null}
|
||||||
|
{shown === 'imageViewer' ? <ImageViewerPanel doc={doc} node={id} /> : null}
|
||||||
</ProgramFrame>
|
</ProgramFrame>
|
||||||
)}
|
)}
|
||||||
{info ? (
|
{info ? (
|
||||||
|
|||||||
@@ -0,0 +1,263 @@
|
|||||||
|
// A computer's file explorer, after Java's GUIApplicationFileExplorerWindow:
|
||||||
|
// the tree of folders and files on the left, the chosen folder's entries on
|
||||||
|
// the right with Java's icons, Java's menu on a right click (on an entry:
|
||||||
|
// delete, cut, copy, rename; on the free space: new folder, paste), its
|
||||||
|
// questions — a name for a new folder, the new name, "Sicher?" before a
|
||||||
|
// delete — and its import window. What it does is the window's
|
||||||
|
// (FileExplorerWindow, fileExplorer.ts, with what it does better than Java).
|
||||||
|
//
|
||||||
|
// Better than Java: the menu's actions are buttons above the list too, so a
|
||||||
|
// finger needs no right click (plan F4: no place needs one); a folder of the
|
||||||
|
// list opens with a double click; the new name starts as the old one; what
|
||||||
|
// an action refuses is said.
|
||||||
|
|
||||||
|
import { useRef, useState, useSyncExternalStore, type FormEvent, type MouseEvent as ReactMouseEvent } from 'react';
|
||||||
|
import type * as Y from 'yjs';
|
||||||
|
import { entryName } from '@mdg-filius/core';
|
||||||
|
import fileIcon from '../assets/desktop/datei.png';
|
||||||
|
import folderIcon from '../assets/desktop/ordner.png';
|
||||||
|
import { ContextMenu } from '../design/ContextMenu.js';
|
||||||
|
import { useTranslate } from '../i18n/i18n.js';
|
||||||
|
import { fileExplorerWindow, type ExplorerItem, type FileExplorerWindow } from './fileExplorer.js';
|
||||||
|
|
||||||
|
const ITEM_KEYS: Readonly<Record<ExplorerItem, string>> = {
|
||||||
|
newFolder: 'explorer.newFolder',
|
||||||
|
delete: 'explorer.delete',
|
||||||
|
cut: 'explorer.cut',
|
||||||
|
copy: 'explorer.copy',
|
||||||
|
paste: 'explorer.paste',
|
||||||
|
rename: 'explorer.rename',
|
||||||
|
};
|
||||||
|
|
||||||
|
function useExplorer(doc: Y.Doc, node: string): FileExplorerWindow {
|
||||||
|
const window = fileExplorerWindow(doc, node);
|
||||||
|
useSyncExternalStore(
|
||||||
|
(listener) => window.subscribe(listener),
|
||||||
|
() => window.version,
|
||||||
|
);
|
||||||
|
return window;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Asking = { readonly kind: 'newFolder' | 'rename'; readonly name: string; readonly problem: string | null } | { readonly kind: 'delete' } | { readonly kind: 'import' };
|
||||||
|
|
||||||
|
export function FileExplorerPanel({ doc, node }: { readonly doc: Y.Doc; readonly node: string }) {
|
||||||
|
const t = useTranslate();
|
||||||
|
const explorer = useExplorer(doc, node);
|
||||||
|
const panel = useRef<HTMLDivElement>(null);
|
||||||
|
const [menu, setMenu] = useState<{ readonly x: number; readonly y: number; readonly onEntry: boolean } | null>(null);
|
||||||
|
const [asking, setAsking] = useState<Asking | null>(null);
|
||||||
|
const [notice, setNotice] = useState<string | null>(null);
|
||||||
|
const folder = explorer.folder;
|
||||||
|
const selected = explorer.selected;
|
||||||
|
|
||||||
|
const run = (item: ExplorerItem) => {
|
||||||
|
setNotice(null);
|
||||||
|
switch (item) {
|
||||||
|
case 'newFolder':
|
||||||
|
setAsking({ kind: 'newFolder', name: '', problem: null });
|
||||||
|
return;
|
||||||
|
case 'rename':
|
||||||
|
if (selected !== null) setAsking({ kind: 'rename', name: entryName(selected), problem: null });
|
||||||
|
return;
|
||||||
|
case 'delete':
|
||||||
|
if (selected !== null) setAsking({ kind: 'delete' });
|
||||||
|
return;
|
||||||
|
case 'cut':
|
||||||
|
case 'copy':
|
||||||
|
explorer.take(item === 'cut');
|
||||||
|
return;
|
||||||
|
case 'paste': {
|
||||||
|
const clipboard = explorer.clipboard;
|
||||||
|
const result = explorer.paste();
|
||||||
|
if (result === 'invalidTarget') setNotice(t('explorer.invalidTarget'));
|
||||||
|
else if (result === 'nameTaken') setNotice(t('explorer.pasteTaken', { name: clipboard === null ? '' : entryName(clipboard.path) }));
|
||||||
|
else if (result === 'missing') setNotice(t('explorer.pasteMissing'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const enabled = (item: ExplorerItem) => (item === 'newFolder' ? true : item === 'paste' ? explorer.clipboard !== null : selected !== null);
|
||||||
|
|
||||||
|
const openMenu = (event: ReactMouseEvent, path: string | null) => {
|
||||||
|
event.preventDefault();
|
||||||
|
explorer.choose(path);
|
||||||
|
const box = panel.current!.getBoundingClientRect();
|
||||||
|
setMenu({ x: event.clientX - box.left, y: event.clientY - box.top, onEntry: path !== null });
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitName = (event: FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (asking === null || (asking.kind !== 'newFolder' && asking.kind !== 'rename')) return;
|
||||||
|
const result = asking.kind === 'newFolder' ? explorer.newFolder(asking.name) : explorer.rename(asking.name);
|
||||||
|
if (result === 'made' || result === 'renamed') setAsking(null);
|
||||||
|
else setAsking({ ...asking, problem: t(`explorer.${result}`, { name: asking.name }) });
|
||||||
|
};
|
||||||
|
|
||||||
|
const tree = explorer.tree();
|
||||||
|
const entries = explorer.entries();
|
||||||
|
return (
|
||||||
|
<div className="file-explorer" ref={panel}>
|
||||||
|
<div className="file-explorer-bar" role="toolbar" aria-label={t('explorer.menu')}>
|
||||||
|
<button type="button" onClick={() => setAsking({ kind: 'import' })}>
|
||||||
|
{t('explorer.import')}
|
||||||
|
</button>
|
||||||
|
{(['newFolder', 'delete', 'cut', 'copy', 'paste', 'rename'] as const).map((item) => (
|
||||||
|
<button key={item} type="button" data-item={item} disabled={!enabled(item)} onClick={() => run(item)}>
|
||||||
|
{t(ITEM_KEYS[item])}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{notice === null ? null : (
|
||||||
|
<p className="mail-notice" role="alert">
|
||||||
|
{notice}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="file-explorer-body">
|
||||||
|
<ul className="file-explorer-tree" aria-label={t('explorer.tree')}>
|
||||||
|
{tree.map((row) => (
|
||||||
|
<li key={row.path}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-path={row.path}
|
||||||
|
style={{ paddingLeft: 6 + row.depth * 16 }}
|
||||||
|
aria-current={row.path === folder ? 'true' : undefined}
|
||||||
|
aria-pressed={row.kind === 'file' ? row.path === selected : undefined}
|
||||||
|
onClick={() => explorer.chooseInTree(row.path)}
|
||||||
|
>
|
||||||
|
<img src={row.kind === 'folder' ? folderIcon : fileIcon} alt="" />
|
||||||
|
<span>{row.name}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<ul
|
||||||
|
className="file-explorer-entries"
|
||||||
|
aria-label={t('explorer.entries')}
|
||||||
|
onClick={(event) => event.target === event.currentTarget && explorer.choose(null)}
|
||||||
|
onContextMenu={(event) => event.target === event.currentTarget && openMenu(event, null)}
|
||||||
|
>
|
||||||
|
{entries.map((entry) => (
|
||||||
|
<li key={entry.path}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-kind={entry.kind}
|
||||||
|
aria-pressed={entry.path === selected}
|
||||||
|
onClick={() => explorer.choose(entry.path)}
|
||||||
|
onDoubleClick={() => entry.kind === 'folder' && explorer.open(entry.path)}
|
||||||
|
onContextMenu={(event) => openMenu(event, entry.path)}
|
||||||
|
>
|
||||||
|
<img src={entry.kind === 'folder' ? folderIcon : fileIcon} alt="" />
|
||||||
|
<span>{entry.name}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{menu === null ? null : (
|
||||||
|
<ContextMenu
|
||||||
|
at={{ x: menu.x, y: menu.y }}
|
||||||
|
items={explorer.menu(menu.onEntry).map((item) => ({ key: item, label: t(ITEM_KEYS[item]), onChoose: () => run(item) }))}
|
||||||
|
onClose={() => setMenu(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{asking?.kind === 'newFolder' || asking?.kind === 'rename' ? (
|
||||||
|
<form className="mail-dialog" role="dialog" aria-label={t(ITEM_KEYS[asking.kind])} onSubmit={submitName}>
|
||||||
|
<h4>{t(ITEM_KEYS[asking.kind])}</h4>
|
||||||
|
<label>
|
||||||
|
<span>{t('explorer.newName')}</span>
|
||||||
|
<input value={asking.name} autoFocus autoCapitalize="off" autoCorrect="off" spellCheck={false} onChange={(event) => setAsking({ ...asking, name: event.target.value, problem: null })} />
|
||||||
|
</label>
|
||||||
|
{asking.problem === null ? null : (
|
||||||
|
<p className="mail-notice" role="alert">
|
||||||
|
{asking.problem}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="mail-dialog-buttons">
|
||||||
|
<button type="submit">{t('explorer.ok')}</button>
|
||||||
|
<button type="button" onClick={() => setAsking(null)}>
|
||||||
|
{t('explorer.cancel')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
|
{asking?.kind === 'delete' ? (
|
||||||
|
<div className="mail-question" role="alertdialog" aria-label={t('explorer.sure')}>
|
||||||
|
<p>{t('explorer.sure')}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
explorer.delete();
|
||||||
|
setAsking(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('explorer.yes')}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => setAsking(null)}>
|
||||||
|
{t('explorer.no')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{asking?.kind === 'import' ? <ImportDialog explorer={explorer} onClose={() => setAsking(null)} /> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Java's import window (fileImport): the local file (read only) and "Datei
|
||||||
|
* wählen", the new name (the local file's at first), "Datei importieren",
|
||||||
|
* the result, "Schließen".
|
||||||
|
*/
|
||||||
|
function ImportDialog({ explorer, onClose }: { readonly explorer: FileExplorerWindow; readonly onClose: () => void }) {
|
||||||
|
const t = useTranslate();
|
||||||
|
const picker = useRef<HTMLInputElement>(null);
|
||||||
|
const [local, setLocal] = useState<{ readonly name: string; readonly bytes: Uint8Array } | null>(null);
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [output, setOutput] = useState('');
|
||||||
|
const importNow = (event: FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (local === null || name === '') {
|
||||||
|
setOutput(t('explorer.importMissing'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = explorer.importLocal(name, local.name, local.bytes);
|
||||||
|
setOutput(result === 'imported' ? t('explorer.imported') : result === 'tooLarge' ? `${t('explorer.notImported')}\n${t('explorer.tooLarge')}` : t('explorer.notImported'));
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<form className="mail-dialog file-import" role="dialog" aria-label={t('explorer.importTitle')} onSubmit={importNow}>
|
||||||
|
<h4>{t('explorer.importTitle')}</h4>
|
||||||
|
<label>
|
||||||
|
<span>{t('explorer.localFile')}</span>
|
||||||
|
<input value={local?.name ?? ''} readOnly />
|
||||||
|
<button type="button" onClick={() => picker.current?.click()}>
|
||||||
|
{t('explorer.chooseFile')}
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
ref={picker}
|
||||||
|
type="file"
|
||||||
|
hidden
|
||||||
|
onChange={async (event) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (file === undefined) return;
|
||||||
|
setLocal({ name: file.name, bytes: new Uint8Array(await file.arrayBuffer()) });
|
||||||
|
setName(file.name);
|
||||||
|
setOutput('');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<label>
|
||||||
|
<span>{t('explorer.newName')}</span>
|
||||||
|
<input value={name} autoCapitalize="off" autoCorrect="off" spellCheck={false} onChange={(event) => setName(event.target.value)} />
|
||||||
|
<button type="submit">{t('explorer.importButton')}</button>
|
||||||
|
</label>
|
||||||
|
<p className="file-import-output" role="status">
|
||||||
|
{output}
|
||||||
|
</p>
|
||||||
|
<div className="mail-dialog-buttons">
|
||||||
|
<button type="button" onClick={onClose}>
|
||||||
|
{t('explorer.close')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// A computer's image viewer, after Java's GUIApplicationImageViewerWindow:
|
||||||
|
// "Öffnen" above, Java's file dialog over it (FileDialog), the picture below
|
||||||
|
// as the document has it. What it does is the window's (ImageViewerWindow,
|
||||||
|
// fileExplorer.ts, with what it does better than Java).
|
||||||
|
|
||||||
|
import { useEffect, useState, useSyncExternalStore } from 'react';
|
||||||
|
import type * as Y from 'yjs';
|
||||||
|
import { base64Encode } from '@mdg-filius/core';
|
||||||
|
import { nodeFiles } from '@mdg-filius/doc';
|
||||||
|
import { useTranslate } from '../i18n/i18n.js';
|
||||||
|
import { FileDialog } from './FileDialog.js';
|
||||||
|
import { imageViewerWindow, type ImageViewerWindow } from './fileExplorer.js';
|
||||||
|
|
||||||
|
/** A computer's image viewer window, drawn anew with each change of it. */
|
||||||
|
export function useImageViewer(doc: Y.Doc, node: string): ImageViewerWindow {
|
||||||
|
const window = imageViewerWindow(doc, node);
|
||||||
|
useSyncExternalStore(
|
||||||
|
(listener) => window.subscribe(listener),
|
||||||
|
() => window.version,
|
||||||
|
);
|
||||||
|
return window;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImageViewerPanel({ doc, node }: { readonly doc: Y.Doc; readonly node: string }) {
|
||||||
|
const t = useTranslate();
|
||||||
|
const viewer = useImageViewer(doc, node);
|
||||||
|
const [opening, setOpening] = useState(false);
|
||||||
|
const [, setVersion] = useState(0);
|
||||||
|
// The dialog lists the files as they are now.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!opening) return;
|
||||||
|
const update = () => setVersion((version) => version + 1);
|
||||||
|
doc.on('update', update);
|
||||||
|
return () => doc.off('update', update);
|
||||||
|
}, [doc, opening]);
|
||||||
|
const picture = viewer.picture;
|
||||||
|
const files = opening ? nodeFiles(doc, node) : undefined;
|
||||||
|
return (
|
||||||
|
<div className="image-viewer">
|
||||||
|
<div className="text-editor-bar">
|
||||||
|
<button type="button" onClick={() => setOpening(true)}>
|
||||||
|
{t('viewer.open')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="image-viewer-picture">{picture === undefined ? null : <img src={`data:${picture.mediaType};base64,${base64Encode(picture.data)}`} alt={t('viewer.picture')} />}</div>
|
||||||
|
{files === undefined ? null : (
|
||||||
|
<FileDialog
|
||||||
|
kind="open"
|
||||||
|
files={files}
|
||||||
|
onChoose={(folder, name) => {
|
||||||
|
const result = viewer.open(folder, name);
|
||||||
|
if (result === 'opened') {
|
||||||
|
setOpening(false);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return t(`viewer.${result}`, { name });
|
||||||
|
}}
|
||||||
|
onCancel={() => setOpening(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -905,3 +905,124 @@
|
|||||||
.file-dialog-entries button[aria-pressed='true'] {
|
.file-dialog-entries button[aria-pressed='true'] {
|
||||||
background: #b8cfe5;
|
background: #b8cfe5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The file explorer (GUIApplicationFileExplorerWindow): the menu's actions
|
||||||
|
* as buttons, the tree beside the folder's entries (one above the other on
|
||||||
|
* a narrow screen). */
|
||||||
|
.file-explorer {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 5px;
|
||||||
|
background: #eeeeee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-explorer-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 5px;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-explorer-bar button {
|
||||||
|
min-width: 44px;
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid #a0a0a0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #f8f8f8;
|
||||||
|
color: #000000;
|
||||||
|
font: 13px Dialog, sans-serif;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-explorer-bar button:disabled {
|
||||||
|
color: #a0a0a0;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-explorer-body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-explorer-tree,
|
||||||
|
.file-explorer-entries {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid #a0a0a0;
|
||||||
|
background: #ffffff;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-explorer-tree button,
|
||||||
|
.file-explorer-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-explorer-tree button[aria-current='true'],
|
||||||
|
.file-explorer-tree button[aria-pressed='true'],
|
||||||
|
.file-explorer-entries button[aria-pressed='true'] {
|
||||||
|
background: #b8cfe5;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 639px) {
|
||||||
|
.file-explorer-body {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-import label button {
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-import-output {
|
||||||
|
min-height: 1.5em;
|
||||||
|
margin: 0;
|
||||||
|
font-weight: bold;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The image viewer (GUIApplicationImageViewerWindow): "Öffnen" above, the picture below. */
|
||||||
|
.image-viewer {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 5px;
|
||||||
|
background: #eeeeee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-viewer-picture {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-viewer-picture img {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
// A computer's file explorer and image viewer windows, after Java's
|
||||||
|
// GUIApplicationFileExplorerWindow and GUIApplicationImageViewerWindow
|
||||||
|
// (measured with tools/java-oracle's FileExplorerWindowRun). Without React,
|
||||||
|
// so that the windows' steps can be played against Java's
|
||||||
|
// (test/fileExplorer.test.ts); the panels draw them. The files are the
|
||||||
|
// document's, changed through the core's operations (filePrograms) with the
|
||||||
|
// window's origin; while the simulation runs, the room takes them into the
|
||||||
|
// engine.
|
||||||
|
//
|
||||||
|
// The explorer: the tree of all folders and files, the chosen folder's
|
||||||
|
// entries, the menu of an entry (delete, cut, copy, rename) and of the free
|
||||||
|
// space (new folder, paste while something is copied or cut — a copy stays
|
||||||
|
// to paste again, a cut is gone once pasted), the import of a local file.
|
||||||
|
// Better than Java (each measured):
|
||||||
|
// - the tree and the list follow the files at once (Java's list shows what
|
||||||
|
// the command line did only after "Aktualisieren", which is left out);
|
||||||
|
// - a file chosen in the tree chooses it in its folder (Java takes the file
|
||||||
|
// for a folder: the list is empty, and a new folder lands below the file);
|
||||||
|
// - what an operation refuses is said (Java's rename of a name there already
|
||||||
|
// does nothing); what the core does better (filePrograms.ts) holds here
|
||||||
|
// too — a paste where the name is there already, a rename to "/".
|
||||||
|
//
|
||||||
|
// The image viewer: "Öffnen" over Java's file dialog, the picture, its name
|
||||||
|
// as the title (Java's title is the file's name alone). Better than Java:
|
||||||
|
// one picture (Java lays each one opened over the last), a file that is no
|
||||||
|
// picture is said (Java shows an empty one), as one that is not there.
|
||||||
|
|
||||||
|
import type * as Y from 'yjs';
|
||||||
|
import {
|
||||||
|
deleteEntry,
|
||||||
|
entryName,
|
||||||
|
folderEntries,
|
||||||
|
importFile,
|
||||||
|
makeFolder,
|
||||||
|
openImage,
|
||||||
|
parentPath,
|
||||||
|
pasteEntry,
|
||||||
|
renameEntry,
|
||||||
|
VirtualFileSystem,
|
||||||
|
type Clipboard,
|
||||||
|
type FolderEntry,
|
||||||
|
type Id,
|
||||||
|
} from '@mdg-filius/core';
|
||||||
|
import { changeFiles, nodeFiles } from '@mdg-filius/doc';
|
||||||
|
import { WindowState, windowOf } from './windowState.js';
|
||||||
|
|
||||||
|
/** A row of the explorer's tree: every folder and file, each folder's entries after it in the order they were made. */
|
||||||
|
export interface TreeRow {
|
||||||
|
readonly path: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly kind: 'file' | 'folder';
|
||||||
|
readonly depth: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The items of the explorer's menu (Java's popup): on an entry, or on the free space. */
|
||||||
|
export type ExplorerItem = 'newFolder' | 'delete' | 'cut' | 'copy' | 'paste' | 'rename';
|
||||||
|
|
||||||
|
export type NewFolderResult = 'made' | 'noName' | 'invalidName';
|
||||||
|
export type PasteResult = 'pasted' | 'invalidTarget' | 'nameTaken' | 'missing';
|
||||||
|
export type RenameResult = 'renamed' | 'noName' | 'nameTaken' | 'invalidName';
|
||||||
|
export type ImportResult = 'imported' | 'tooLarge' | 'notImported';
|
||||||
|
|
||||||
|
export class FileExplorerWindow extends WindowState {
|
||||||
|
private folderValue = '/';
|
||||||
|
private selectedValue: string | null = null;
|
||||||
|
private clipboardValue: Clipboard | null = null;
|
||||||
|
/** The window's origin for what it changes in the document. */
|
||||||
|
readonly origin: object = {};
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly doc: Y.Doc,
|
||||||
|
private readonly node: Id,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
doc.on('update', () => this.changed());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The computer's files as they are now. */
|
||||||
|
files(): VirtualFileSystem {
|
||||||
|
return nodeFiles(this.doc, this.node) ?? new VirtualFileSystem();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The chosen folder; the nearest folder above it that is there when another removed it. */
|
||||||
|
get folder(): string {
|
||||||
|
const files = this.files();
|
||||||
|
let folder = this.folderValue;
|
||||||
|
while (folder !== '/' && !(files.contains(folder) && files.entry(folder).content.kind === 'directory')) folder = parentPath(folder);
|
||||||
|
return folder === '/' ? '/' : files.entry(folder).path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The chosen entry of the folder; none when another removed it. */
|
||||||
|
get selected(): string | null {
|
||||||
|
const selected = this.selectedValue;
|
||||||
|
if (selected === null) return null;
|
||||||
|
const files = this.files();
|
||||||
|
return files.contains(selected) ? files.entry(selected).path : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
get clipboard(): Clipboard | null {
|
||||||
|
return this.clipboardValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
tree(): TreeRow[] {
|
||||||
|
const files = this.files();
|
||||||
|
const below = (folder: string, depth: number): TreeRow[] =>
|
||||||
|
folderEntries(files, folder).flatMap((entry) => [{ path: entry.path, name: entry.name, kind: entry.kind, depth }, ...(entry.kind === 'folder' ? below(entry.path, depth + 1) : [])]);
|
||||||
|
return [{ path: '/', name: '/', kind: 'folder', depth: 0 }, ...below('/', 1)];
|
||||||
|
}
|
||||||
|
|
||||||
|
entries(): FolderEntry[] {
|
||||||
|
return folderEntries(this.files(), this.folder);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A row of the tree chosen: a folder is the folder then, a file is chosen in its folder. */
|
||||||
|
chooseInTree(path: string): void {
|
||||||
|
const files = this.files();
|
||||||
|
if (!files.contains(path)) return;
|
||||||
|
const entry = files.entry(path);
|
||||||
|
if (entry.content.kind === 'directory') {
|
||||||
|
this.folderValue = entry.path;
|
||||||
|
this.selectedValue = null;
|
||||||
|
} else {
|
||||||
|
this.folderValue = parentPath(entry.path);
|
||||||
|
this.selectedValue = entry.path;
|
||||||
|
}
|
||||||
|
this.changed();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An entry of the list chosen, or none (the free space). */
|
||||||
|
choose(path: string | null): void {
|
||||||
|
this.selectedValue = path;
|
||||||
|
this.changed();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A folder of the list opened (a double click; Java's list opens nothing). */
|
||||||
|
open(path: string): void {
|
||||||
|
this.chooseInTree(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Java's menu: on an entry delete, cut, copy, rename; on the free space a new folder and, with something to paste, paste. */
|
||||||
|
menu(onEntry: boolean): ExplorerItem[] {
|
||||||
|
return onEntry ? ['delete', 'cut', 'copy', 'rename'] : ['newFolder', ...(this.clipboardValue === null ? [] : (['paste'] as const))];
|
||||||
|
}
|
||||||
|
|
||||||
|
private change<T>(operation: (files: VirtualFileSystem) => T): T {
|
||||||
|
return changeFiles(this.doc, this.node, this.origin, operation) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Neuer Ordner": the name as a path from the folder, nothing for a name there already (makeFolder). */
|
||||||
|
newFolder(name: string): NewFolderResult {
|
||||||
|
if (name.trim() === '') return 'noName';
|
||||||
|
const folder = this.folder;
|
||||||
|
return this.change((files) => makeFolder(files, folder, name)) ? 'made' : 'invalidName';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Löschen" of the chosen entry, a folder with all in it (after Java's question, which the panel asks). */
|
||||||
|
delete(): boolean {
|
||||||
|
const selected = this.selected;
|
||||||
|
if (selected === null) return false;
|
||||||
|
const folder = this.folder;
|
||||||
|
const done = this.change((files) => deleteEntry(files, folder, entryName(selected)));
|
||||||
|
this.changed();
|
||||||
|
return done;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Ausschneiden" and "Kopieren": the chosen entry to paste. */
|
||||||
|
take(cut: boolean): void {
|
||||||
|
const selected = this.selected;
|
||||||
|
if (selected === null) return;
|
||||||
|
this.clipboardValue = { path: selected, cut };
|
||||||
|
this.changed();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Einfügen" into the folder; what was cut is gone from the clipboard once pasted, a copy stays. */
|
||||||
|
paste(): PasteResult {
|
||||||
|
const clipboard = this.clipboardValue;
|
||||||
|
if (clipboard === null) return 'missing';
|
||||||
|
const folder = this.folder;
|
||||||
|
const result = this.change((files) => pasteEntry(files, clipboard, folder));
|
||||||
|
if (result === 'pasted' && clipboard.cut) this.clipboardValue = null;
|
||||||
|
this.changed();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Umbenennen" of the chosen entry: not to no name, not to a name another entry of the folder has. */
|
||||||
|
rename(newName: string): RenameResult {
|
||||||
|
const selected = this.selected;
|
||||||
|
if (selected === null) return 'invalidName';
|
||||||
|
if (newName.trim() === '') return 'noName';
|
||||||
|
const folder = this.folder;
|
||||||
|
const taken = this.entries().some((entry) => entry.path !== selected && entry.name.toLowerCase() === newName.toLowerCase());
|
||||||
|
if (taken) return 'nameTaken';
|
||||||
|
const done = this.change((files) => renameEntry(files, folder, entryName(selected), newName));
|
||||||
|
if (done) this.selectedValue = `${folder === '/' ? '' : folder}/${newName}`;
|
||||||
|
this.changed();
|
||||||
|
return done ? 'renamed' : 'invalidName';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Datei importieren": a local file into the folder under the new name (importFile). */
|
||||||
|
importLocal(newName: string, localName: string, bytes: Uint8Array): ImportResult {
|
||||||
|
const folder = this.folder;
|
||||||
|
return this.change((files) => importFile(files, folder, newName, localName, bytes));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ViewerOpenResult = 'opened' | 'notFound' | 'notImage';
|
||||||
|
|
||||||
|
export class ImageViewerWindow extends WindowState {
|
||||||
|
private pathValue: string | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly doc: Y.Doc,
|
||||||
|
private readonly node: Id,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
doc.on('update', () => this.pathValue !== null && this.changed());
|
||||||
|
}
|
||||||
|
|
||||||
|
private files(): VirtualFileSystem {
|
||||||
|
return nodeFiles(this.doc, this.node) ?? new VirtualFileSystem();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The picture shown, as the document has it now; none before one is opened or when another removed it. */
|
||||||
|
get picture(): { readonly path: string; readonly data: Uint8Array; readonly mediaType: string } | undefined {
|
||||||
|
if (this.pathValue === null) return undefined;
|
||||||
|
const found = openImage(this.files(), '/', this.pathValue);
|
||||||
|
return typeof found === 'string' ? undefined : found;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The program's name, the picture's name once one is shown (Java's setTitle with the file's name). */
|
||||||
|
title(programName: string): string {
|
||||||
|
const picture = this.picture;
|
||||||
|
return picture === undefined ? programName : entryName(picture.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Öffnen" with the dialog's folder and name. */
|
||||||
|
open(folder: string, name: string): ViewerOpenResult {
|
||||||
|
const found = openImage(this.files(), folder, name);
|
||||||
|
if (typeof found === 'string') return found;
|
||||||
|
this.pathValue = found.path;
|
||||||
|
this.changed();
|
||||||
|
return 'opened';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fileExplorerWindow(doc: Y.Doc, node: Id): FileExplorerWindow {
|
||||||
|
return windowOf(doc, node, 'fileExplorer', () => new FileExplorerWindow(doc, node));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function imageViewerWindow(doc: Y.Doc, node: Id): ImageViewerWindow {
|
||||||
|
return windowOf(doc, node, 'imageViewer', () => new ImageViewerWindow(doc, node));
|
||||||
|
}
|
||||||
@@ -26,20 +26,19 @@
|
|||||||
import * as Y from 'yjs';
|
import * as Y from 'yjs';
|
||||||
import { entryName, openText, saveTextAs, type Id } from '@mdg-filius/core';
|
import { entryName, openText, saveTextAs, type Id } from '@mdg-filius/core';
|
||||||
import { changeFiles, fileText, nodeFiles, replaceText } from '@mdg-filius/doc';
|
import { changeFiles, fileText, nodeFiles, replaceText } from '@mdg-filius/doc';
|
||||||
|
import { WindowState, windowOf } from './windowState.js';
|
||||||
|
|
||||||
export type OpenResult = 'opened' | 'notFound' | 'notText';
|
export type OpenResult = 'opened' | 'notFound' | 'notText';
|
||||||
export type SaveResult = 'saved' | 'askName';
|
export type SaveResult = 'saved' | 'askName';
|
||||||
export type SaveAsResult = 'saved' | 'folderMissing' | 'isFolder' | 'invalidName';
|
export type SaveAsResult = 'saved' | 'folderMissing' | 'isFolder' | 'invalidName';
|
||||||
|
|
||||||
export class TextEditorWindow {
|
export class TextEditorWindow extends WindowState {
|
||||||
private pathValue: string | null = null;
|
private pathValue: string | null = null;
|
||||||
private draft = '';
|
private draft = '';
|
||||||
private goneValue: string | null = null;
|
private goneValue: string | null = null;
|
||||||
private bound: Y.Text | undefined;
|
private bound: Y.Text | undefined;
|
||||||
private lastText = '';
|
private lastText = '';
|
||||||
private undoManager: Y.UndoManager | undefined;
|
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. */
|
/** The window's origin: what it writes into the document, its own undo takes back. */
|
||||||
readonly origin: object = {};
|
readonly origin: object = {};
|
||||||
|
|
||||||
@@ -47,6 +46,7 @@ export class TextEditorWindow {
|
|||||||
private readonly doc: Y.Doc,
|
private readonly doc: Y.Doc,
|
||||||
private readonly node: Id,
|
private readonly node: Id,
|
||||||
) {
|
) {
|
||||||
|
super();
|
||||||
doc.on('update', () => this.refresh());
|
doc.on('update', () => this.refresh());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,21 +69,6 @@ export class TextEditorWindow {
|
|||||||
return this.bound === undefined ? this.draft : this.bound.toString();
|
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. */
|
/** GUIApplicationWindow.setTitle: the program's name, " - " and the open file's name. */
|
||||||
title(programName: string): string {
|
title(programName: string): string {
|
||||||
return this.pathValue === null ? programName : `${programName} - ${entryName(this.pathValue)}`;
|
return this.pathValue === null ? programName : `${programName} - ${entryName(this.pathValue)}`;
|
||||||
@@ -179,13 +164,7 @@ export class TextEditorWindow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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). */
|
/** 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 {
|
export function textEditorWindow(doc: Y.Doc, node: Id): TextEditorWindow {
|
||||||
let ofDoc = windows.get(doc);
|
return windowOf(doc, node, 'textEditor', () => new TextEditorWindow(doc, node));
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
// What the windows of a computer's local programs share (text editor, file
|
||||||
|
// explorer, image viewer): their state is the window's own, without React,
|
||||||
|
// so that their steps can be played against Java's; React draws them anew
|
||||||
|
// with each change (version). One window of each program per computer while
|
||||||
|
// the editor runs, as Java's desktop keeps its windows.
|
||||||
|
|
||||||
|
import type * as Y from 'yjs';
|
||||||
|
import type { Id } from '@mdg-filius/core';
|
||||||
|
|
||||||
|
export class WindowState {
|
||||||
|
private readonly listeners = new Set<() => void>();
|
||||||
|
private versionValue = 0;
|
||||||
|
|
||||||
|
/** 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected changed(): void {
|
||||||
|
this.versionValue += 1;
|
||||||
|
for (const listener of this.listeners) listener();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const windows = new WeakMap<Y.Doc, Map<string, WindowState>>();
|
||||||
|
|
||||||
|
/** A computer's window of a program, the same while the editor runs. */
|
||||||
|
export function windowOf<T extends WindowState>(doc: Y.Doc, node: Id, program: string, make: () => T): T {
|
||||||
|
let ofDoc = windows.get(doc);
|
||||||
|
if (ofDoc === undefined) windows.set(doc, (ofDoc = new Map()));
|
||||||
|
const key = `${node}/${program}`;
|
||||||
|
let window = ofDoc.get(key);
|
||||||
|
if (window === undefined) ofDoc.set(key, (window = make()));
|
||||||
|
return window as T;
|
||||||
|
}
|
||||||
@@ -122,6 +122,39 @@
|
|||||||
"emailServer.username": "Benutzername:",
|
"emailServer.username": "Benutzername:",
|
||||||
"emailServer.whiteSpace": "Der Benutzername darf keine Leerzeichen enthalten!",
|
"emailServer.whiteSpace": "Der Benutzername darf keine Leerzeichen enthalten!",
|
||||||
"emailServer.yes": "Ja",
|
"emailServer.yes": "Ja",
|
||||||
|
"explorer.cancel": "Abbrechen",
|
||||||
|
"explorer.chooseFile": "Datei wählen",
|
||||||
|
"explorer.close": "Schließen",
|
||||||
|
"explorer.copy": "Kopieren",
|
||||||
|
"explorer.cut": "Ausschneiden",
|
||||||
|
"explorer.delete": "Löschen",
|
||||||
|
"explorer.entries": "Inhalt des Ordners",
|
||||||
|
"explorer.file": "Datei",
|
||||||
|
"explorer.folder": "Ordner",
|
||||||
|
"explorer.import": "Importieren",
|
||||||
|
"explorer.importButton": "Datei importieren",
|
||||||
|
"explorer.importMissing": "Eingabe- und Ausgabedatei müssen angegeben werden!",
|
||||||
|
"explorer.importTitle": "Datei importieren",
|
||||||
|
"explorer.imported": "Datei erfolgreich importiert!",
|
||||||
|
"explorer.invalidName": "„{name}“ ist kein zulässiger Name.",
|
||||||
|
"explorer.invalidTarget": "Unzulässiger Zielordner!",
|
||||||
|
"explorer.localFile": "Lokale Datei:",
|
||||||
|
"explorer.menu": "Aktionen",
|
||||||
|
"explorer.nameTaken": "Den Namen „{name}“ gibt es in diesem Ordner schon.",
|
||||||
|
"explorer.newFolder": "Neuer Ordner",
|
||||||
|
"explorer.newName": "Neuer Name:",
|
||||||
|
"explorer.no": "Nein",
|
||||||
|
"explorer.noName": "Geben Sie einen Namen ein.",
|
||||||
|
"explorer.notImported": "Datei konnte nicht importiert werden",
|
||||||
|
"explorer.ok": "OK",
|
||||||
|
"explorer.paste": "Einfügen",
|
||||||
|
"explorer.pasteMissing": "Was eingefügt werden sollte, gibt es nicht mehr.",
|
||||||
|
"explorer.pasteTaken": "In diesem Ordner gibt es „{name}“ schon.",
|
||||||
|
"explorer.rename": "Umbenennen",
|
||||||
|
"explorer.sure": "Sicher?",
|
||||||
|
"explorer.tooLarge": "Datei existiert nicht oder ist zu groß (> 150KB)",
|
||||||
|
"explorer.tree": "Ordner und Dateien",
|
||||||
|
"explorer.yes": "Ja",
|
||||||
"fileDialog.cancel": "Abbrechen",
|
"fileDialog.cancel": "Abbrechen",
|
||||||
"fileDialog.entries": "Inhalt des Ordners",
|
"fileDialog.entries": "Inhalt des Ordners",
|
||||||
"fileDialog.folder": "Ordner:",
|
"fileDialog.folder": "Ordner:",
|
||||||
@@ -279,6 +312,10 @@
|
|||||||
"textEditor.saveAs": "Speichern unter",
|
"textEditor.saveAs": "Speichern unter",
|
||||||
"textEditor.savedLive": "Was Sie tippen, steht sofort in der Datei.",
|
"textEditor.savedLive": "Was Sie tippen, steht sofort in der Datei.",
|
||||||
"textEditor.text": "Text",
|
"textEditor.text": "Text",
|
||||||
|
"viewer.notFound": "Die Datei „{name}“ gibt es nicht.",
|
||||||
|
"viewer.notImage": "„{name}“ ist keine Bilddatei.",
|
||||||
|
"viewer.open": "Öffnen",
|
||||||
|
"viewer.picture": "Bild",
|
||||||
"webServer.folder": "Unterverzeichnis",
|
"webServer.folder": "Unterverzeichnis",
|
||||||
"webServer.hostName": "Hostname",
|
"webServer.hostName": "Hostname",
|
||||||
"webServer.useVirtualHosts": "Verwende virtuelle Hosts",
|
"webServer.useVirtualHosts": "Verwende virtuelle Hosts",
|
||||||
|
|||||||
@@ -122,6 +122,39 @@
|
|||||||
"emailServer.username": "User:",
|
"emailServer.username": "User:",
|
||||||
"emailServer.whiteSpace": "User names may not contain whitespace!",
|
"emailServer.whiteSpace": "User names may not contain whitespace!",
|
||||||
"emailServer.yes": "Yes",
|
"emailServer.yes": "Yes",
|
||||||
|
"explorer.cancel": "Cancel",
|
||||||
|
"explorer.chooseFile": "Select file",
|
||||||
|
"explorer.close": "Close",
|
||||||
|
"explorer.copy": "Copy",
|
||||||
|
"explorer.cut": "Cut",
|
||||||
|
"explorer.delete": "Delete",
|
||||||
|
"explorer.entries": "Contents of the folder",
|
||||||
|
"explorer.file": "File",
|
||||||
|
"explorer.folder": "Folder",
|
||||||
|
"explorer.import": "Import",
|
||||||
|
"explorer.importButton": "Import file",
|
||||||
|
"explorer.importMissing": "Select input and output file!",
|
||||||
|
"explorer.importTitle": "Import file",
|
||||||
|
"explorer.imported": "File successfully imported!",
|
||||||
|
"explorer.invalidName": "“{name}” is not a valid name.",
|
||||||
|
"explorer.invalidTarget": "Invalid target folder!",
|
||||||
|
"explorer.localFile": "Local file:",
|
||||||
|
"explorer.menu": "Actions",
|
||||||
|
"explorer.nameTaken": "This folder already has an entry “{name}”.",
|
||||||
|
"explorer.newFolder": "New folder",
|
||||||
|
"explorer.newName": "New name:",
|
||||||
|
"explorer.no": "No",
|
||||||
|
"explorer.noName": "Enter a name.",
|
||||||
|
"explorer.notImported": "File could not be imported.",
|
||||||
|
"explorer.ok": "OK",
|
||||||
|
"explorer.paste": "Paste",
|
||||||
|
"explorer.pasteMissing": "What was to be pasted is no longer there.",
|
||||||
|
"explorer.pasteTaken": "This folder already has an entry “{name}”.",
|
||||||
|
"explorer.rename": "Rename",
|
||||||
|
"explorer.sure": "Sure?",
|
||||||
|
"explorer.tooLarge": "File does not exist or is too large (> 150KB)",
|
||||||
|
"explorer.tree": "Folders and files",
|
||||||
|
"explorer.yes": "Yes",
|
||||||
"fileDialog.cancel": "Cancel",
|
"fileDialog.cancel": "Cancel",
|
||||||
"fileDialog.entries": "Contents of the folder",
|
"fileDialog.entries": "Contents of the folder",
|
||||||
"fileDialog.folder": "Folder:",
|
"fileDialog.folder": "Folder:",
|
||||||
@@ -279,6 +312,10 @@
|
|||||||
"textEditor.saveAs": "Save as",
|
"textEditor.saveAs": "Save as",
|
||||||
"textEditor.savedLive": "What you type is in the file at once.",
|
"textEditor.savedLive": "What you type is in the file at once.",
|
||||||
"textEditor.text": "Text",
|
"textEditor.text": "Text",
|
||||||
|
"viewer.notFound": "There is no file “{name}”.",
|
||||||
|
"viewer.notImage": "“{name}” is not a picture file.",
|
||||||
|
"viewer.open": "Open",
|
||||||
|
"viewer.picture": "Picture",
|
||||||
"webServer.folder": "Subdirectory",
|
"webServer.folder": "Subdirectory",
|
||||||
"webServer.hostName": "Hostname",
|
"webServer.hostName": "Hostname",
|
||||||
"webServer.useVirtualHosts": "Enable virtual hosts",
|
"webServer.useVirtualHosts": "Enable virtual hosts",
|
||||||
|
|||||||
@@ -0,0 +1,276 @@
|
|||||||
|
// The file explorer and image viewer windows against Java's (packages/sim/
|
||||||
|
// test/fileExplorerWindow.java.json, FileExplorerWindowRun): Java's steps
|
||||||
|
// played on a computer with a web server, the same tree, list, menus and
|
||||||
|
// files after each; where Java errs, what the windows do instead
|
||||||
|
// (fileExplorer.ts names each).
|
||||||
|
|
||||||
|
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 { addDevice, changeFiles, emptyProject, nodeFiles, setSoftware, writeProject } from '@mdg-filius/doc';
|
||||||
|
import { FileExplorerWindow, fileExplorerWindow, ImageViewerWindow, imageViewerWindow, type ExplorerItem } from '../src/desktop/fileExplorer.js';
|
||||||
|
import { translate } from '../src/i18n/i18n.js';
|
||||||
|
|
||||||
|
interface JavaStep {
|
||||||
|
readonly step: string;
|
||||||
|
readonly title?: string;
|
||||||
|
readonly buttons?: readonly string[];
|
||||||
|
readonly tree?: readonly string[];
|
||||||
|
readonly list?: readonly string[];
|
||||||
|
readonly menu?: readonly string[];
|
||||||
|
readonly dialogs?: readonly string[];
|
||||||
|
readonly shown?: readonly unknown[];
|
||||||
|
readonly pictures?: readonly string[];
|
||||||
|
readonly files?: readonly (readonly [string, string, string])[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const java = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '../../sim/test/fileExplorerWindow.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;
|
||||||
|
};
|
||||||
|
const de = (key: string, values?: Readonly<Record<string, string | number>>) => translate('de', key, values);
|
||||||
|
|
||||||
|
/** The files but the web server's picture: a folder as null, a text as it is (the oracle gives one of another type by its length). */
|
||||||
|
type Files = Record<string, string | null>;
|
||||||
|
const kept = (path: string) => path !== '/webserver/splashscreen-mini.png' && path !== '/webserver/index.html';
|
||||||
|
|
||||||
|
function javaFiles(step: JavaStep): Files {
|
||||||
|
return Object.fromEntries(
|
||||||
|
step.files!.filter(([path]) => kept(path)).map(([path, type, content]) => [path.replace(/\/$/, ''), type === 'folder' ? null : content === '(0 characters)' ? '' : content]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ITEMS: Readonly<Record<ExplorerItem, string>> = {
|
||||||
|
newFolder: 'explorer.newFolder',
|
||||||
|
delete: 'explorer.delete',
|
||||||
|
cut: 'explorer.cut',
|
||||||
|
copy: 'explorer.copy',
|
||||||
|
paste: 'explorer.paste',
|
||||||
|
rename: 'explorer.rename',
|
||||||
|
};
|
||||||
|
|
||||||
|
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', 'fileExplorer', 'imageViewer', 'commandPrompt'], remove: [] }, 'de', 'me');
|
||||||
|
const explorer = new FileExplorerWindow(doc, 'pc');
|
||||||
|
const files = (): Files =>
|
||||||
|
Object.fromEntries(
|
||||||
|
nodeFiles(doc, 'pc')!
|
||||||
|
.allEntries()
|
||||||
|
.filter((entry) => entry.path !== '/' && kept(entry.path))
|
||||||
|
.map((entry) => [entry.path, entry.content.kind === 'directory' ? null : entry.content.kind === 'text' ? entry.content.text : entry.content.kind]),
|
||||||
|
);
|
||||||
|
/** The tree's rows and the list's entries as Java's window writes them (the root as "root"). */
|
||||||
|
const tree = () => explorer.tree().map((row) => `${row.depth}:${row.depth === 0 ? 'root' : row.name}`);
|
||||||
|
const list = () => explorer.entries().map((entry) => `${de(entry.kind === 'folder' ? 'explorer.folder' : 'explorer.file')};${entry.name}`);
|
||||||
|
const menu = (onEntry: boolean) => explorer.menu(onEntry).map((item) => de(ITEMS[item]));
|
||||||
|
const shown = () => ({ tree: tree(), list: list(), files: files() });
|
||||||
|
const command = (change: Parameters<typeof changeFiles>[3]) => changeFiles(doc, 'pc', 'terminal', change);
|
||||||
|
const choose = (name: string) => explorer.choose(explorer.entries().find((entry) => entry.name === name)!.path);
|
||||||
|
return { doc, explorer, files, tree, list, menu, shown, command, choose };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Java's tree, list and files after a step, with what differs here. */
|
||||||
|
function expected(name: string, change: { tree?: readonly string[]; list?: readonly string[]; files?: Files } = {}) {
|
||||||
|
const step = at(name);
|
||||||
|
return { tree: change.tree ?? step.tree, list: change.list ?? step.list, files: change.files ?? javaFiles(step) };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("the file explorer window, against Java's", () => {
|
||||||
|
it("plays Java's steps: the same where Java is right, as named where it errs", () => {
|
||||||
|
const { explorer, shown, menu, command, choose, files, list } = setUp();
|
||||||
|
expect([de('program.fileExplorer'), de('explorer.import')]).toEqual([at('explorer title').title, at('explorer title').buttons![1]]);
|
||||||
|
expect(shown()).toEqual(expected('open'));
|
||||||
|
|
||||||
|
// The free space's menu: a new folder.
|
||||||
|
expect(menu(false)).toEqual(at('new folder ordner').menu);
|
||||||
|
expect(explorer.newFolder('ordner')).toBe('made');
|
||||||
|
expect(shown()).toEqual(expected('new folder ordner'));
|
||||||
|
// What the command line does is in the tree and the list at once (Java's only after "Aktualisieren").
|
||||||
|
command((fileSystem) => {
|
||||||
|
fileSystem.createDirectory('/extern');
|
||||||
|
fileSystem.writeTextFile('/notiz.txt', '');
|
||||||
|
});
|
||||||
|
expect(shown()).toEqual(expected('Aktualisieren'));
|
||||||
|
expect(at('command line made extern and notiz.txt').list).not.toEqual(list());
|
||||||
|
|
||||||
|
// An entry's menu; a copy pastes into ordner, and again into extern.
|
||||||
|
choose('notiz.txt');
|
||||||
|
expect(menu(true)).toEqual(at('menu of an entry').menu);
|
||||||
|
explorer.take(false);
|
||||||
|
expect(menu(false)).toEqual(at('menu after copy').menu);
|
||||||
|
explorer.chooseInTree('/ordner');
|
||||||
|
expect(explorer.paste()).toBe('pasted');
|
||||||
|
expect(shown()).toEqual(expected('paste the copy into ordner'));
|
||||||
|
explorer.chooseInTree('/extern');
|
||||||
|
expect(menu(false)).toEqual(at('menu after the paste of a copy').menu);
|
||||||
|
expect(explorer.paste()).toBe('pasted');
|
||||||
|
expect(shown()).toEqual(expected('paste the copy again, into extern'));
|
||||||
|
|
||||||
|
// Cut and pasted: gone from the clipboard.
|
||||||
|
explorer.chooseInTree('/');
|
||||||
|
choose('notiz.txt');
|
||||||
|
explorer.take(true);
|
||||||
|
explorer.chooseInTree('/webserver');
|
||||||
|
expect(explorer.paste()).toBe('pasted');
|
||||||
|
expect(menu(false)).toEqual(at('cut notiz.txt and paste it into webserver, then the menu').menu);
|
||||||
|
expect(shown()).toEqual(expected('cut notiz.txt and paste it into webserver, then the menu'));
|
||||||
|
|
||||||
|
// Rename: a name there already and a blank one are refused (and said), a new one taken.
|
||||||
|
explorer.chooseInTree('/');
|
||||||
|
choose('ordner');
|
||||||
|
expect(at('rename ordner to extern').dialogs).toEqual([de('explorer.newName')]);
|
||||||
|
expect(explorer.rename('extern')).toBe('nameTaken');
|
||||||
|
expect(shown()).toEqual(expected('rename ordner to extern'));
|
||||||
|
expect(explorer.rename(' ')).toBe('noName');
|
||||||
|
expect(explorer.rename('Ordner Neu')).toBe('renamed');
|
||||||
|
expect(explorer.selected).toBe('/Ordner Neu');
|
||||||
|
expect(shown()).toEqual(expected('rename ordner to Ordner Neu'));
|
||||||
|
|
||||||
|
// Delete (after Java's question, which the panel asks).
|
||||||
|
expect(at('delete extern, yes').dialogs).toEqual([de('explorer.sure')]);
|
||||||
|
choose('extern');
|
||||||
|
expect(explorer.delete()).toBe(true);
|
||||||
|
expect(shown()).toEqual(expected('delete extern, yes'));
|
||||||
|
|
||||||
|
// A file chosen in the tree is chosen in its folder; a new folder goes into that folder (Java's below the file).
|
||||||
|
explorer.chooseInTree('/webserver/index.html');
|
||||||
|
expect([explorer.folder, explorer.selected]).toEqual(['/webserver', '/webserver/index.html']);
|
||||||
|
expect(list()).not.toEqual(at('a file chosen in the tree').list);
|
||||||
|
expect(explorer.newFolder('unter')).toBe('made');
|
||||||
|
const javaUnder = javaFiles(at('new folder in a file chosen in the tree'));
|
||||||
|
expect(javaUnder['/webserver/index.html/unter']).toBeNull();
|
||||||
|
const { ['/webserver/index.html/unter']: _below, ...rest } = javaUnder;
|
||||||
|
expect(files()).toEqual({ ...rest, '/webserver/unter': null });
|
||||||
|
|
||||||
|
// The import: Java's texts, the local file line by line with CR LF.
|
||||||
|
const shownImport = at('import window').shown!;
|
||||||
|
expect(shownImport[0]).toEqual([
|
||||||
|
`label:${de('explorer.localFile')}`,
|
||||||
|
`label:${de('explorer.newName')}`,
|
||||||
|
`label:${de('explorer.importTitle')}`,
|
||||||
|
`button:${de('explorer.chooseFile')}`,
|
||||||
|
`button:${de('explorer.importButton')}`,
|
||||||
|
`button:${de('explorer.close')}`,
|
||||||
|
'field: (read only)',
|
||||||
|
'field:',
|
||||||
|
]);
|
||||||
|
expect(shownImport[1]).toBe(`output:${de('explorer.importMissing')}`);
|
||||||
|
explorer.chooseInTree('/');
|
||||||
|
expect(explorer.importLocal('importiert.txt', 'import123.txt', new TextEncoder().encode('Zeile 1\nZeile 2'))).toBe('imported');
|
||||||
|
expect(shownImport[2]).toBe(`output:${de('explorer.imported')}`);
|
||||||
|
expect(files()['/importiert.txt']).toBe(javaFiles(at('import window'))['/importiert.txt']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the file explorer window beyond the steps', () => {
|
||||||
|
it('falls back to the folder above when another removes the chosen one, and forgets a removed choice', () => {
|
||||||
|
const { explorer, command, choose } = setUp();
|
||||||
|
command((fileSystem) => {
|
||||||
|
fileSystem.createDirectory('/a');
|
||||||
|
fileSystem.createDirectory('/a/b');
|
||||||
|
fileSystem.writeTextFile('/a/b/c.txt', 'c');
|
||||||
|
});
|
||||||
|
explorer.chooseInTree('/A/B');
|
||||||
|
expect(explorer.folder).toBe('/a/b');
|
||||||
|
choose('c.txt');
|
||||||
|
expect(explorer.selected).toBe('/a/b/c.txt');
|
||||||
|
command((fileSystem) => fileSystem.deleteItem('/a/b', true));
|
||||||
|
expect([explorer.folder, explorer.selected]).toEqual(['/a', null]);
|
||||||
|
command((fileSystem) => fileSystem.deleteItem('/a', true));
|
||||||
|
expect(explorer.folder).toBe('/');
|
||||||
|
// Nothing chosen: nothing to delete, cut or rename; nothing taken: nothing to paste; a tree row not there: nothing.
|
||||||
|
expect(explorer.delete()).toBe(false);
|
||||||
|
explorer.take(true);
|
||||||
|
expect(explorer.clipboard).toBeNull();
|
||||||
|
expect(explorer.rename('x')).toBe('invalidName');
|
||||||
|
expect(explorer.paste()).toBe('missing');
|
||||||
|
explorer.chooseInTree('/fehlt');
|
||||||
|
expect(explorer.folder).toBe('/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says what the core refuses: a paste into the copied folder or onto a name there, a name for no folder, a rename to "/"', () => {
|
||||||
|
const { explorer, command, choose, files } = setUp();
|
||||||
|
command((fileSystem) => {
|
||||||
|
fileSystem.createDirectory('/a');
|
||||||
|
fileSystem.writeTextFile('/b.txt', 'b');
|
||||||
|
fileSystem.writeTextFile('/a/b.txt', 'other');
|
||||||
|
});
|
||||||
|
choose('a');
|
||||||
|
explorer.take(false);
|
||||||
|
explorer.chooseInTree('/a');
|
||||||
|
expect(explorer.paste()).toBe('invalidTarget');
|
||||||
|
explorer.chooseInTree('/');
|
||||||
|
choose('b.txt');
|
||||||
|
explorer.take(true);
|
||||||
|
explorer.chooseInTree('/a');
|
||||||
|
expect(explorer.paste()).toBe('nameTaken');
|
||||||
|
// A refused cut stays to paste elsewhere.
|
||||||
|
expect(explorer.clipboard).toEqual({ path: '/b.txt', cut: true });
|
||||||
|
expect([explorer.newFolder(''), explorer.newFolder('..'), explorer.newFolder('neu')]).toEqual(['noName', 'invalidName', 'made']);
|
||||||
|
explorer.chooseInTree('/a/neu');
|
||||||
|
expect(explorer.newFolder('../../..')).toBe('invalidName');
|
||||||
|
explorer.chooseInTree('/b.txt');
|
||||||
|
expect(explorer.rename('x/y')).toBe('invalidName');
|
||||||
|
expect(explorer.rename('B.TXT')).toBe('renamed');
|
||||||
|
expect(explorer.selected).toBe('/B.TXT');
|
||||||
|
expect(files()['/B.TXT']).toBe('b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is the same window for a computer while the editor runs, and follows the document', () => {
|
||||||
|
const { doc, explorer, command } = setUp();
|
||||||
|
expect(fileExplorerWindow(doc, 'pc')).toBe(fileExplorerWindow(doc, 'pc'));
|
||||||
|
expect(fileExplorerWindow(doc, 'pc')).not.toBe(fileExplorerWindow(doc, 'other'));
|
||||||
|
const before = explorer.version;
|
||||||
|
let seen = before;
|
||||||
|
const stop = explorer.subscribe(() => (seen = explorer.version));
|
||||||
|
command((fileSystem) => fileSystem.createDirectory('/neu'));
|
||||||
|
expect(seen).toBeGreaterThan(before);
|
||||||
|
expect(seen).toBe(explorer.version);
|
||||||
|
stop();
|
||||||
|
// The chosen folder renamed by another in case only: its new spelling.
|
||||||
|
explorer.chooseInTree('/neu');
|
||||||
|
command((fileSystem) => fileSystem.renameInPlace('/neu', 'NEU'));
|
||||||
|
expect(explorer.folder).toBe('/NEU');
|
||||||
|
// A computer the document does not have: no files.
|
||||||
|
expect(new FileExplorerWindow(doc, 'none').tree()).toEqual([{ path: '/', name: '/', kind: 'folder', depth: 0 }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the image viewer window, against Java's", () => {
|
||||||
|
it('shows one picture with its name as the title, and says what is no picture or not there', () => {
|
||||||
|
const { doc, command } = setUp();
|
||||||
|
const viewer = new ImageViewerWindow(doc, 'pc');
|
||||||
|
const program = de('program.imageViewer');
|
||||||
|
expect(viewer.title(program)).toBe(at('viewer open').title);
|
||||||
|
expect(viewer.picture).toBeUndefined();
|
||||||
|
expect(viewer.open('/webserver', 'splashscreen-mini.png')).toBe('opened');
|
||||||
|
expect(viewer.title(program)).toBe(at("viewer: the web server's picture").title);
|
||||||
|
expect(viewer.picture?.mediaType).toBe('image/png');
|
||||||
|
// Opened again: one picture (Java lays a second over the first).
|
||||||
|
expect(at('viewer: the picture again').pictures).toHaveLength(2);
|
||||||
|
expect(viewer.open('/webserver', 'splashscreen-mini.png')).toBe('opened');
|
||||||
|
// A text file: said, the picture stays (Java shows an empty one, titled with the text's name).
|
||||||
|
command((fileSystem) => fileSystem.writeTextFile('/importiert.txt', 'x'));
|
||||||
|
expect(viewer.open('/', 'importiert.txt')).toBe('notImage');
|
||||||
|
expect(at('viewer: a text file').title).toBe('importiert.txt');
|
||||||
|
expect(viewer.title(program)).toBe('splashscreen-mini.png');
|
||||||
|
expect(viewer.open('/', 'fehlt.png')).toBe('notFound');
|
||||||
|
expect(viewer.title(program)).toBe('splashscreen-mini.png');
|
||||||
|
// Removed by another: no picture, the program's name.
|
||||||
|
const before = viewer.version;
|
||||||
|
let seen = before;
|
||||||
|
viewer.subscribe(() => (seen = viewer.version));
|
||||||
|
command((fileSystem) => fileSystem.deleteItem('/webserver/splashscreen-mini.png'));
|
||||||
|
expect([viewer.picture, viewer.title(program), seen]).toEqual([undefined, program, viewer.version]);
|
||||||
|
expect(seen).toBeGreaterThan(before);
|
||||||
|
expect(imageViewerWindow(doc, 'pc')).toBe(imageViewerWindow(doc, 'pc'));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -23,6 +23,28 @@ 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. */
|
/** 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>> = {
|
const FROM_JAVA: Readonly<Record<string, string>> = {
|
||||||
'device.pc': 'hw_rechner_msg1',
|
'device.pc': 'hw_rechner_msg1',
|
||||||
|
'explorer.newFolder': 'fileexplorer_msg3',
|
||||||
|
'explorer.delete': 'fileexplorer_msg4',
|
||||||
|
'explorer.cut': 'fileexplorer_msg5',
|
||||||
|
'explorer.copy': 'fileexplorer_msg6',
|
||||||
|
'explorer.paste': 'fileexplorer_msg7',
|
||||||
|
'explorer.rename': 'fileexplorer_msg8',
|
||||||
|
'explorer.newName': 'fileexplorer_msg9',
|
||||||
|
'explorer.file': 'fileexplorer_msg10',
|
||||||
|
'explorer.folder': 'fileexplorer_msg11',
|
||||||
|
'explorer.import': 'fileexplorer_msg2',
|
||||||
|
'explorer.importTitle': 'fileexplorer_msg12',
|
||||||
|
'explorer.localFile': 'fileexplorer_msg13',
|
||||||
|
'explorer.chooseFile': 'fileexplorer_msg14',
|
||||||
|
'explorer.importButton': 'fileexplorer_msg15',
|
||||||
|
'explorer.importMissing': 'fileexplorer_msg16',
|
||||||
|
'explorer.sure': 'fileexplorer_msg18',
|
||||||
|
'explorer.invalidTarget': 'fileexplorer_msg19',
|
||||||
|
'explorer.close': 'sw_fileimporter_msg3',
|
||||||
|
'explorer.imported': 'sw_fileexplorer_msg2',
|
||||||
|
'explorer.notImported': 'sw_fileexplorer_msg1',
|
||||||
|
'explorer.tooLarge': 'sw_fileexplorer_msg3',
|
||||||
|
'viewer.open': 'imageviewer_msg2',
|
||||||
'textEditor.new': 'texteditor_msg3',
|
'textEditor.new': 'texteditor_msg3',
|
||||||
'textEditor.open': 'texteditor_msg4',
|
'textEditor.open': 'texteditor_msg4',
|
||||||
'textEditor.save': 'texteditor_msg5',
|
'textEditor.save': 'texteditor_msg5',
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1427,3 +1427,36 @@ Gemessen (03.10.2026):
|
|||||||
spätere „Speichern“ geht verloren. Ebenso nach dem Löschen der offenen
|
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
|
Datei. „Speichern unter“ mit `ordner/d.txt` legt in der Wurzel eine Datei
|
||||||
mit `/` im Namen an, ohne Namen eine namenlose.
|
mit `/` im Namen an, ohne Namen eine namenlose.
|
||||||
|
|
||||||
|
### Datei-Explorer und Bildbetrachter (F4.6f3)
|
||||||
|
|
||||||
|
`FileExplorerWindowRun` öffnet Javas `GUIApplicationFileExplorerWindow` und
|
||||||
|
`GUIApplicationImageViewerWindow` auf einem `GUIDesktopPanel` (erster
|
||||||
|
Rechner von mail-lan, mit Befehlszeile und Webserver) und benutzt sie wie
|
||||||
|
eine Person: den Baum, die Liste mit dem Menü eines Rechtsklicks (auf einem
|
||||||
|
Eintrag, auf der freien Fläche; ein gezeigtes Menü liegt in der Ebene des
|
||||||
|
Rahmens, nicht im Fenster), Javas Fragen — ein Wächter tippt in die
|
||||||
|
Eingabedialoge und sagt ja oder nein —, „Aktualisieren“, das Importfenster
|
||||||
|
(die Felder, wie „Datei wählen“ sie füllt); im Bildbetrachter „Öffnen“ über
|
||||||
|
den Dateidialog. Je Schritt: Zeilen des Baums, Liste, Menü, Texte der
|
||||||
|
Dialoge, Titel, Bilder des Betrachters, alle Dateien
|
||||||
|
(`packages/sim/test/fileExplorerWindow.java.json`).
|
||||||
|
|
||||||
|
Gemessen (03.10.2026):
|
||||||
|
|
||||||
|
* Der Baum zeigt Ordner und Dateien in Entstehungsreihenfolge, die Wurzel
|
||||||
|
als „root“; die Liste den gewählten Ordner, ohne „Aktualisieren“ nicht,
|
||||||
|
was die Befehlszeile anlegte.
|
||||||
|
* Das Menü auf einem Eintrag: Löschen, Ausschneiden, Kopieren, Umbenennen;
|
||||||
|
auf der freien Fläche: Neuer Ordner, mit etwas zum Einfügen auch
|
||||||
|
Einfügen. Kopiertes bleibt zum nächsten Einfügen, Ausgeschnittenes ist
|
||||||
|
nach dem Einfügen weg.
|
||||||
|
* Neuer Ordner fragt ohne Text, Umbenennen mit „Neuer Name:“ (ein
|
||||||
|
vorhandener oder leerer Name ändert still nichts), Löschen mit „Sicher?“.
|
||||||
|
* Das Importfenster: „Lokale Datei:“ (nur lesbar) mit „Datei wählen“, „Neuer
|
||||||
|
Name:“, „Datei importieren“, die Meldung darunter, „Schließen“.
|
||||||
|
* Javas Fehler: Eine im Baum gewählte Datei gilt als Ordner — die Liste ist
|
||||||
|
leer, ein neuer Ordner landet unter der Datei.
|
||||||
|
* Der Bildbetrachter heißt nach dem Öffnen wie die Datei; jedes geöffnete
|
||||||
|
Bild legt Java über das vorige, eine Textdatei zeigt es als leeres Bild,
|
||||||
|
ohne Fund bleibt alles.
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ if [ ! -f "$upstream/target/filius.jar" ]; then
|
|||||||
fi
|
fi
|
||||||
classpath="$upstream/target/filius.jar:$upstream/target/lib/*"
|
classpath="$upstream/target/filius.jar:$upstream/target/lib/*"
|
||||||
mkdir -p "$here/.build"
|
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" "$here/src/TextEditorWindowRun.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" "$here/src/FileExplorerWindowRun.java"
|
||||||
# FILIUS writes its settings below $HOME; keep them out of the real one.
|
# FILIUS writes its settings below $HOME; keep them out of the real one.
|
||||||
mkdir -p "$here/.build/home"
|
mkdir -p "$here/.build/home"
|
||||||
# Removed first: a crashed run must not leave the previous result standing.
|
# Removed first: a crashed run must not leave the previous result standing.
|
||||||
@@ -411,6 +411,16 @@ HOME="$here/.build/home" xvfb-run -a java -Duser.language=de -Duser.country=DE -
|
|||||||
"$root/packages/core/test/scenarios/mail-lan.scenario.json" "$output" 2>&1 | grep -v 'logback\|^$\|^\s*at ' || true
|
"$root/packages/core/test/scenarios/mail-lan.scenario.json" "$output" 2>&1 | grep -v 'logback\|^$\|^\s*at ' || true
|
||||||
test -s "$output"
|
test -s "$output"
|
||||||
|
|
||||||
|
# FILIUS' file explorer and image viewer windows used as a person does —
|
||||||
|
# the tree, the right-click menu, Java's questions answered, the import
|
||||||
|
# window, the viewer's "Öffnen" (FileExplorerWindowRun): the editor's
|
||||||
|
# explorer and viewer (packages/editor/test/fileExplorer.test.ts).
|
||||||
|
output="$root/packages/sim/test/fileExplorerWindow.java.json"
|
||||||
|
rm -f "$output"
|
||||||
|
HOME="$here/.build/home" xvfb-run -a java -Duser.language=de -Duser.country=DE -cp "$classpath:$here/.build" FileExplorerWindowRun \
|
||||||
|
"$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
|
# 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)
|
# 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).
|
# and the classics of F3 (web, email, gnutella; packages/fls/test/classicsJava.test.ts).
|
||||||
|
|||||||
@@ -0,0 +1,490 @@
|
|||||||
|
// Uses FILIUS' own file explorer and image viewer windows
|
||||||
|
// (GUIApplicationFileExplorerWindow, GUIApplicationImageViewerWindow on a
|
||||||
|
// GUIDesktopPanel) on mail-lan's first computer as a person does: the
|
||||||
|
// explorer's tree, its list with the menu of a right click (on an entry, on
|
||||||
|
// the free space), Java's questions answered as given (a watcher types into
|
||||||
|
// the input dialogs, says yes or no), "Aktualisieren", the import window;
|
||||||
|
// the image viewer's "Öffnen" over the file dialog. The command line beside
|
||||||
|
// them changes the files. After each step it writes what the windows show
|
||||||
|
// (the tree's rows, the list, the menu, the dialogs' texts, the title, the
|
||||||
|
// viewer's pictures) and the computer's files. The reference for the
|
||||||
|
// editor's explorer and viewer (packages/editor/test/fileExplorer.test.ts).
|
||||||
|
//
|
||||||
|
// Usage: java -cp filius.jar:lib/*:classes FileExplorerWindowRun <scenario.json> <output json>
|
||||||
|
|
||||||
|
import filius.gui.anwendungssicht.GUIApplicationFileExplorerWindow;
|
||||||
|
import filius.gui.anwendungssicht.GUIApplicationImageViewerWindow;
|
||||||
|
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.ArrayDeque;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Deque;
|
||||||
|
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.ImageIcon;
|
||||||
|
import javax.swing.JButton;
|
||||||
|
import javax.swing.JDialog;
|
||||||
|
import javax.swing.JFrame;
|
||||||
|
import javax.swing.JLabel;
|
||||||
|
import javax.swing.JList;
|
||||||
|
import javax.swing.JMenuItem;
|
||||||
|
import javax.swing.JOptionPane;
|
||||||
|
import javax.swing.JPanel;
|
||||||
|
import javax.swing.JPopupMenu;
|
||||||
|
import javax.swing.JTextArea;
|
||||||
|
import javax.swing.JTextField;
|
||||||
|
import javax.swing.JTree;
|
||||||
|
import javax.swing.ListModel;
|
||||||
|
import javax.swing.SwingUtilities;
|
||||||
|
import javax.swing.tree.DefaultMutableTreeNode;
|
||||||
|
import javax.swing.tree.TreePath;
|
||||||
|
|
||||||
|
public class FileExplorerWindowRun {
|
||||||
|
static final String CLIENT = "00000000-0000-0000-0000-000000000201";
|
||||||
|
|
||||||
|
static final List<Object> steps = new ArrayList<>();
|
||||||
|
/** The answers to Java's next dialogs: a text typed into an input dialog, or "yes"/"no". */
|
||||||
|
static final Deque<String> answers = new ArrayDeque<>();
|
||||||
|
/** The texts of the dialogs since the last step. */
|
||||||
|
static final List<String> dialogs = new ArrayList<>();
|
||||||
|
static GUIApplicationFileExplorerWindow explorer;
|
||||||
|
static GUIApplicationImageViewerWindow viewer;
|
||||||
|
static GUIDesktopPanel desktop;
|
||||||
|
static Betriebssystem system;
|
||||||
|
static Terminal terminal;
|
||||||
|
static List<String> menu = null;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void setField(Class<?> owner, Object object, String name, Object value) throws Exception {
|
||||||
|
Field field = owner.getDeclaredField(name);
|
||||||
|
field.setAccessible(true);
|
||||||
|
field.set(object, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Answers each of Java's option dialogs from the answers given, writes down its text. */
|
||||||
|
static void watchDialogs() {
|
||||||
|
Thread watcher = new Thread(() -> {
|
||||||
|
while (true) {
|
||||||
|
for (Window window : Window.getWindows()) {
|
||||||
|
if (!(window instanceof JDialog) || !window.isVisible()) continue;
|
||||||
|
JOptionPane pane = find((Container) window, JOptionPane.class);
|
||||||
|
if (pane == null) continue;
|
||||||
|
String answer;
|
||||||
|
synchronized (dialogs) {
|
||||||
|
dialogs.add(String.valueOf(pane.getMessage()));
|
||||||
|
answer = answers.isEmpty() ? "yes" : answers.pop();
|
||||||
|
}
|
||||||
|
if (pane.getWantsInput()) {
|
||||||
|
pane.setInputValue(answer);
|
||||||
|
pane.setValue(JOptionPane.OK_OPTION);
|
||||||
|
} else {
|
||||||
|
pane.setValue(answer.equals("no") ? JOptionPane.NO_OPTION : pane.getOptionType() == JOptionPane.DEFAULT_OPTION ? JOptionPane.OK_OPTION : JOptionPane.YES_OPTION);
|
||||||
|
}
|
||||||
|
window.setVisible(false);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Thread.sleep(50);
|
||||||
|
} catch (InterruptedException error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
watcher.setDaemon(true);
|
||||||
|
watcher.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
static JList<String> list() throws Exception {
|
||||||
|
return (JList<String>) field(GUIApplicationFileExplorerWindow.class, explorer, "dateiListe");
|
||||||
|
}
|
||||||
|
|
||||||
|
static JTree tree() throws Exception {
|
||||||
|
return (JTree) field(GUIApplicationFileExplorerWindow.class, explorer, "tv");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A right click on the list: on the entry's row, or below the entries; the menu Java shows. */
|
||||||
|
static void rightClick(String entry) throws Exception {
|
||||||
|
JList<String> list = list();
|
||||||
|
int index = -1;
|
||||||
|
for (int each = 0; each < list.getModel().getSize(); each++) if (list.getModel().getElementAt(each).endsWith(";" + entry)) index = each;
|
||||||
|
if (entry != null && index < 0) throw new IllegalStateException("no entry " + entry);
|
||||||
|
int at = index;
|
||||||
|
List<JPopupMenu> before = popups();
|
||||||
|
onEdt(() -> {
|
||||||
|
Point point = at >= 0 ? list.indexToLocation(at) : new Point(5, list.getModel().getSize() * 16 + 40);
|
||||||
|
MouseEvent event = new MouseEvent(list, MouseEvent.MOUSE_CLICKED, System.currentTimeMillis(), 0, point.x + 2, point.y + 2, 1, true, MouseEvent.BUTTON3);
|
||||||
|
for (MouseListener listener : list.getMouseListeners()) listener.mouseClicked(event);
|
||||||
|
});
|
||||||
|
JPopupMenu popup = null;
|
||||||
|
for (JPopupMenu each : popups()) if (!before.contains(each) && each.isVisible()) popup = each;
|
||||||
|
if (popup == null) throw new IllegalStateException("no menu");
|
||||||
|
menu = new ArrayList<>();
|
||||||
|
for (Component item : popup.getComponents()) if (item instanceof JMenuItem) menu.add(((JMenuItem) item).getText());
|
||||||
|
lastMenu = popup;
|
||||||
|
}
|
||||||
|
|
||||||
|
static JPopupMenu lastMenu;
|
||||||
|
|
||||||
|
/** The menus Java shows (a shown menu lies in its frame's layer, not in the window that made it). */
|
||||||
|
static List<JPopupMenu> popups() {
|
||||||
|
List<JPopupMenu> found = new ArrayList<>();
|
||||||
|
for (Window window : Window.getWindows()) {
|
||||||
|
all(window, JPopupMenu.class, found);
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A right click and the item chosen in its menu. */
|
||||||
|
static void choose(String entry, String item, String... typed) throws Exception {
|
||||||
|
rightClick(entry);
|
||||||
|
synchronized (dialogs) {
|
||||||
|
answers.addAll(Arrays.asList(typed));
|
||||||
|
}
|
||||||
|
JMenuItem chosen = null;
|
||||||
|
for (Component each : lastMenu.getComponents()) if (each instanceof JMenuItem && item.equals(((JMenuItem) each).getText())) chosen = (JMenuItem) each;
|
||||||
|
if (chosen == null) throw new IllegalStateException("no item " + item + " in " + menu);
|
||||||
|
JMenuItem pressed = chosen;
|
||||||
|
onEdt(() -> {
|
||||||
|
lastMenu.setVisible(false);
|
||||||
|
pressed.doClick();
|
||||||
|
});
|
||||||
|
Thread.sleep(400);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Chooses the tree's row of the path (its folders' names from the root), as a click on it does. */
|
||||||
|
static void select(String... names) throws Exception {
|
||||||
|
JTree tree = tree();
|
||||||
|
onEdt(() -> {
|
||||||
|
DefaultMutableTreeNode node = (DefaultMutableTreeNode) tree.getModel().getRoot();
|
||||||
|
TreePath path = new TreePath(node);
|
||||||
|
for (String name : names) {
|
||||||
|
DefaultMutableTreeNode next = null;
|
||||||
|
for (Enumeration<?> each = node.children(); each.hasMoreElements();) {
|
||||||
|
DefaultMutableTreeNode child = (DefaultMutableTreeNode) each.nextElement();
|
||||||
|
if (name.equals(child.getUserObject() instanceof Datei ? ((Datei) child.getUserObject()).getName() : child.toString())) next = child;
|
||||||
|
}
|
||||||
|
if (next == null) throw new IllegalStateException("no tree node " + name);
|
||||||
|
node = next;
|
||||||
|
path = path.pathByAddingChild(node);
|
||||||
|
}
|
||||||
|
tree.setSelectionPath(path);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = String.valueOf(datei.getDateiTyp()).startsWith("text");
|
||||||
|
files.add(Arrays.asList(path + "/" + datei.getName(), String.valueOf(datei.getDateiTyp()), text ? content : "(" + (content == null ? 0 : content.length()) + " characters)"));
|
||||||
|
// A node below a file (Java can make one): written as it is.
|
||||||
|
if (node.getChildCount() > 0) walk(node, path + "/" + datei.getName(), files);
|
||||||
|
} else {
|
||||||
|
files.add(Arrays.asList(path + "/" + node + "/", "folder", ""));
|
||||||
|
walk(node, path + "/" + node, files);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static String title(GUIApplicationWindow window) throws Exception {
|
||||||
|
return ((JLabel) field(GUIApplicationWindow.class, window, "titleLabel")).getText();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void step(String name) throws Exception {
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
result.put("step", name);
|
||||||
|
JTree tree = tree();
|
||||||
|
List<String> rows = new ArrayList<>();
|
||||||
|
SwingUtilities.invokeAndWait(() -> {
|
||||||
|
for (int row = 0; row < tree.getRowCount(); row++) tree.expandRow(row);
|
||||||
|
for (int row = 0; row < tree.getRowCount(); row++) {
|
||||||
|
TreePath path = tree.getPathForRow(row);
|
||||||
|
rows.add(path.getPathCount() - 1 + ":" + path.getLastPathComponent());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
result.put("tree", rows);
|
||||||
|
List<String> entries = new ArrayList<>();
|
||||||
|
ListModel<String> model = list().getModel();
|
||||||
|
for (int index = 0; index < model.getSize(); index++) entries.add(model.getElementAt(index));
|
||||||
|
result.put("list", entries);
|
||||||
|
if (menu != null) result.put("menu", menu);
|
||||||
|
menu = null;
|
||||||
|
synchronized (dialogs) {
|
||||||
|
result.put("dialogs", new ArrayList<>(dialogs));
|
||||||
|
dialogs.clear();
|
||||||
|
}
|
||||||
|
if (!shown.isEmpty()) result.put("shown", shown);
|
||||||
|
shown = new ArrayList<>();
|
||||||
|
result.put("files", files());
|
||||||
|
steps.add(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the image viewer shows: its title and its pictures (their sizes; -1 when none could be read). */
|
||||||
|
static void viewerStep(String name) throws Exception {
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
result.put("step", name);
|
||||||
|
result.put("title", title(viewer));
|
||||||
|
List<String> pictures = new ArrayList<>();
|
||||||
|
for (JLabel label : all((JPanel) field(GUIApplicationImageViewerWindow.class, viewer, "backPanel"), JLabel.class, new ArrayList<>())) {
|
||||||
|
ImageIcon icon = (ImageIcon) label.getIcon();
|
||||||
|
pictures.add(icon == null ? "none" : icon.getIconWidth() + "x" + icon.getIconHeight() + (label.isShowing() ? "" : " hidden") + " at " + label.getX() + "," + label.getY());
|
||||||
|
}
|
||||||
|
result.put("pictures", pictures);
|
||||||
|
if (!shown.isEmpty()) result.put("shown", shown);
|
||||||
|
shown = new ArrayList<>();
|
||||||
|
steps.add(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
static JDialog fileDialog() 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");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The viewer's "Öffnen": the folders double clicked, the file's name typed, "Öffnen". */
|
||||||
|
static void viewerOpen(String folder, String name) throws Exception {
|
||||||
|
onEdt(button(viewer, "Öffnen")::doClick);
|
||||||
|
JDialog dialog = fileDialog();
|
||||||
|
if (folder != null) {
|
||||||
|
JList<?> list = find(dialog.getContentPane(), JList.class);
|
||||||
|
int index = -1;
|
||||||
|
for (int each = 0; each < list.getModel().getSize(); each++) if (("Ordner;" + folder).equals(String.valueOf(list.getModel().getElementAt(each)))) index = each;
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
JTextField field = find(dialog.getContentPane(), JTextField.class);
|
||||||
|
onEdt(() -> field.setText(name));
|
||||||
|
onEdt(button(dialog.getContentPane(), "Öffnen")::doClick);
|
||||||
|
Thread.sleep(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
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.FileExplorer", false);
|
||||||
|
system.installApp("filius.software.lokal.ImageViewer", 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");
|
||||||
|
watchDialogs();
|
||||||
|
|
||||||
|
JFrame frame = new JFrame();
|
||||||
|
desktop = new GUIDesktopPanel(system);
|
||||||
|
frame.getContentPane().add(desktop);
|
||||||
|
explorer = (GUIApplicationFileExplorerWindow) desktop.starteAnwendung("filius.software.lokal.FileExplorer");
|
||||||
|
SwingUtilities.invokeAndWait(() -> {
|
||||||
|
frame.pack();
|
||||||
|
frame.setVisible(true);
|
||||||
|
});
|
||||||
|
Thread.sleep(500);
|
||||||
|
Map<String, Object> open = new LinkedHashMap<>();
|
||||||
|
open.put("step", "explorer title");
|
||||||
|
open.put("title", title(explorer));
|
||||||
|
open.put("buttons", Arrays.asList(button(explorer, "Aktualisieren").getText(), button(explorer, "Importieren").getText()));
|
||||||
|
steps.add(open);
|
||||||
|
step("open");
|
||||||
|
|
||||||
|
// The menu below the entries: a new folder, named in Java's input dialog.
|
||||||
|
choose(null, "Neuer Ordner", "ordner");
|
||||||
|
step("new folder ordner");
|
||||||
|
// The command line changes the files: does the list follow by itself?
|
||||||
|
command("mkdir extern");
|
||||||
|
command("touch notiz.txt");
|
||||||
|
step("command line made extern and notiz.txt");
|
||||||
|
onEdt(button(explorer, "Aktualisieren")::doClick);
|
||||||
|
step("Aktualisieren");
|
||||||
|
|
||||||
|
// An entry's menu; copy, then the free space's menu has "Einfügen", once and again.
|
||||||
|
rightClick("notiz.txt");
|
||||||
|
step("menu of an entry");
|
||||||
|
choose("notiz.txt", "Kopieren");
|
||||||
|
rightClick(null);
|
||||||
|
step("menu after copy");
|
||||||
|
select("ordner");
|
||||||
|
choose(null, "Einfügen");
|
||||||
|
step("paste the copy into ordner");
|
||||||
|
select("extern");
|
||||||
|
rightClick(null);
|
||||||
|
step("menu after the paste of a copy");
|
||||||
|
choose(null, "Einfügen");
|
||||||
|
step("paste the copy again, into extern");
|
||||||
|
|
||||||
|
// Cut and paste: the free space's menu has no "Einfügen" afterwards.
|
||||||
|
select();
|
||||||
|
choose("notiz.txt", "Ausschneiden");
|
||||||
|
select("webserver");
|
||||||
|
choose(null, "Einfügen");
|
||||||
|
rightClick(null);
|
||||||
|
step("cut notiz.txt and paste it into webserver, then the menu");
|
||||||
|
|
||||||
|
// Rename: Java's question, a name there already, a blank name, a new one.
|
||||||
|
select();
|
||||||
|
choose("ordner", "Umbenennen", "extern");
|
||||||
|
step("rename ordner to extern");
|
||||||
|
choose("ordner", "Umbenennen", " ");
|
||||||
|
step("rename ordner to blanks");
|
||||||
|
choose("ordner", "Umbenennen", "Ordner Neu");
|
||||||
|
step("rename ordner to Ordner Neu");
|
||||||
|
|
||||||
|
// Delete: Java asks; no, then yes.
|
||||||
|
choose("extern", "Löschen", "no");
|
||||||
|
step("delete extern, no");
|
||||||
|
choose("extern", "Löschen", "yes");
|
||||||
|
step("delete extern, yes");
|
||||||
|
|
||||||
|
// A file chosen in the tree: the list is empty; a new folder there.
|
||||||
|
select("webserver", "index.html");
|
||||||
|
step("a file chosen in the tree");
|
||||||
|
choose(null, "Neuer Ordner", "unter");
|
||||||
|
step("new folder in a file chosen in the tree");
|
||||||
|
|
||||||
|
// The import window: its texts, an import without a file, one with the fields as "Datei wählen" fills them.
|
||||||
|
select();
|
||||||
|
onEdt(button(explorer, "Importieren")::doClick);
|
||||||
|
List<String> texts = new ArrayList<>();
|
||||||
|
JPanel layer = (JPanel) field(GUIDesktopPanel.class, desktop, "dialogLayer");
|
||||||
|
for (JLabel label : all(layer, JLabel.class, new ArrayList<>())) texts.add("label:" + label.getText());
|
||||||
|
for (AbstractButton each : all(layer, AbstractButton.class, new ArrayList<>())) texts.add("button:" + each.getText());
|
||||||
|
List<JTextField> fields = all(layer, JTextField.class, new ArrayList<>());
|
||||||
|
for (JTextField each : fields) texts.add("field:" + each.getText() + (each.isEditable() ? "" : " (read only)"));
|
||||||
|
shown.add(texts);
|
||||||
|
JTextArea output = find(layer, JTextArea.class);
|
||||||
|
press(button(layer, "Datei importieren"));
|
||||||
|
shown.add("output:" + output.getText());
|
||||||
|
java.io.File local = java.io.File.createTempFile("import", ".txt");
|
||||||
|
java.nio.file.Files.write(local.toPath(), "Zeile 1\nZeile 2".getBytes(StandardCharsets.UTF_8));
|
||||||
|
setField(GUIApplicationFileExplorerWindow.class, explorer, "datei", local.getName());
|
||||||
|
setField(GUIApplicationFileExplorerWindow.class, explorer, "pfad", local.getParent() + java.io.File.separator);
|
||||||
|
onEdt(() -> {
|
||||||
|
fields.get(0).setText(local.getPath());
|
||||||
|
fields.get(1).setText("importiert.txt");
|
||||||
|
});
|
||||||
|
press(button(layer, "Datei importieren"));
|
||||||
|
shown.add("output:" + output.getText());
|
||||||
|
press(button(layer, "Schließen"));
|
||||||
|
shown.add("layer visible:" + layer.isVisible());
|
||||||
|
step("import window");
|
||||||
|
|
||||||
|
// The image viewer.
|
||||||
|
viewer = (GUIApplicationImageViewerWindow) desktop.starteAnwendung("filius.software.lokal.ImageViewer");
|
||||||
|
Thread.sleep(500);
|
||||||
|
viewerStep("viewer open");
|
||||||
|
viewerOpen("webserver", "splashscreen-mini.png");
|
||||||
|
viewerStep("viewer: the web server's picture");
|
||||||
|
viewerOpen("webserver", "splashscreen-mini.png");
|
||||||
|
viewerStep("viewer: the picture again");
|
||||||
|
viewerOpen(null, "importiert.txt");
|
||||||
|
viewerStep("viewer: a text file");
|
||||||
|
viewerOpen(null, "fehlt.png");
|
||||||
|
viewerStep("viewer: no such file");
|
||||||
|
|
||||||
|
Map<String, Object> output2 = new LinkedHashMap<>();
|
||||||
|
output2.put("steps", steps);
|
||||||
|
try (FileOutputStream out = new FileOutputStream(args[1])) {
|
||||||
|
out.write((FlsOracle.Json.write(output2, "") + "\n").getBytes(StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
network.stop();
|
||||||
|
System.out.println("file explorer window: " + steps.size() + " steps");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A press with the mouse, as Java's import window listens (mousePressed). */
|
||||||
|
static void press(AbstractButton button) throws Exception {
|
||||||
|
onEdt(() -> {
|
||||||
|
MouseEvent event = new MouseEvent(button, MouseEvent.MOUSE_PRESSED, System.currentTimeMillis(), 0, 5, 5, 1, false);
|
||||||
|
for (MouseListener listener : button.getMouseListeners()) listener.mousePressed(event);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user