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:
2026-10-03 18:13:22 +00:00
co-authored by Claude Opus 5.5
parent 2c7733b575
commit 3dc2713f81
21 changed files with 2951 additions and 39 deletions
+147
View File
@@ -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 -4
View File
@@ -13,8 +13,10 @@
// icon opens its window over the desktop (ProgramFrame), the home button
// leaves it; so far the command line (F4.6b), the web browser and the web
// server (F4.6d), the e-mail program and server (F4.6e), the text editor
// (F4.6f2) have one, the others follow. The browser's window takes its title
// from its page, the text editor's from its open file, as Java's do.
// (F4.6f2), the file explorer and the image viewer (F4.6f3) have one, the
// 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 type * as Y from 'yjs';
@@ -35,6 +37,8 @@ import { pageTitle } from './browserPage.js';
import { InstallationPanel } from './InstallationPanel.js';
import { TerminalPanel } from './TerminalPanel.js';
import { TextEditorPanel, useTextEditorWindow } from './TextEditorPanel.js';
import { FileExplorerPanel } from './FileExplorerPanel.js';
import { ImageViewerPanel, useImageViewer } from './ImageViewerPanel.js';
import { WebServerPanel } from './WebServerPanel.js';
import './desktop.css';
@@ -64,7 +68,7 @@ export interface DesktopWindowProps {
export type DesktopWindowName = DesktopProgram | 'installation';
/** The programs whose windows are built so far. */
const WITH_WINDOW: ReadonlySet<DesktopProgram> = new Set(['commandPrompt', 'webBrowser', 'webServer', 'emailClient', 'emailServer', '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). */
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 view = useDesktopView(doc, id);
const textEditor = useTextEditorWindow(doc, id);
const imageViewer = useImageViewer(doc, id);
const { watch } = simulation;
useEffect(() => watch([`node:${id}`]), [watch, 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))
: shown === 'textEditor'
? textEditor.title(t('program.textEditor'))
: t(`program.${shown}`)
: shown === 'imageViewer'
? imageViewer.title(t('program.imageViewer'))
: t(`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 === 'emailServer' ? <EmailServerPanel node={id} simulation={simulation} /> : 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>
)}
{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>
);
}
+121
View File
@@ -905,3 +905,124 @@
.file-dialog-entries button[aria-pressed='true'] {
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%;
}
+252
View File
@@ -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));
}
+4 -25
View File
@@ -26,20 +26,19 @@
import * as Y from 'yjs';
import { entryName, openText, saveTextAs, type Id } from '@mdg-filius/core';
import { changeFiles, fileText, nodeFiles, replaceText } from '@mdg-filius/doc';
import { WindowState, windowOf } from './windowState.js';
export type OpenResult = 'opened' | 'notFound' | 'notText';
export type SaveResult = 'saved' | 'askName';
export type SaveAsResult = 'saved' | 'folderMissing' | 'isFolder' | 'invalidName';
export class TextEditorWindow {
export class TextEditorWindow extends WindowState {
private pathValue: string | null = null;
private draft = '';
private goneValue: string | null = null;
private bound: Y.Text | undefined;
private lastText = '';
private undoManager: Y.UndoManager | undefined;
private readonly listeners = new Set<() => void>();
private versionValue = 0;
/** The window's origin: what it writes into the document, its own undo takes back. */
readonly origin: object = {};
@@ -47,6 +46,7 @@ export class TextEditorWindow {
private readonly doc: Y.Doc,
private readonly node: Id,
) {
super();
doc.on('update', () => this.refresh());
}
@@ -69,21 +69,6 @@ export class TextEditorWindow {
return this.bound === undefined ? this.draft : this.bound.toString();
}
/** Changes with each change of what the window shows (for React). */
get version(): number {
return this.versionValue;
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private changed(): void {
this.versionValue += 1;
for (const listener of this.listeners) listener();
}
/** GUIApplicationWindow.setTitle: the program's name, " - " and the open file's name. */
title(programName: string): string {
return this.pathValue === null ? programName : `${programName} - ${entryName(this.pathValue)}`;
@@ -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). */
export function textEditorWindow(doc: Y.Doc, node: Id): TextEditorWindow {
let ofDoc = windows.get(doc);
if (ofDoc === undefined) windows.set(doc, (ofDoc = new Map()));
let window = ofDoc.get(node);
if (window === undefined) ofDoc.set(node, (window = new TextEditorWindow(doc, node)));
return window;
return windowOf(doc, node, 'textEditor', () => new TextEditorWindow(doc, node));
}
@@ -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;
}
+37
View File
@@ -122,6 +122,39 @@
"emailServer.username": "Benutzername:",
"emailServer.whiteSpace": "Der Benutzername darf keine Leerzeichen enthalten!",
"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.entries": "Inhalt des Ordners",
"fileDialog.folder": "Ordner:",
@@ -279,6 +312,10 @@
"textEditor.saveAs": "Speichern unter",
"textEditor.savedLive": "Was Sie tippen, steht sofort in der Datei.",
"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.hostName": "Hostname",
"webServer.useVirtualHosts": "Verwende virtuelle Hosts",
+37
View File
@@ -122,6 +122,39 @@
"emailServer.username": "User:",
"emailServer.whiteSpace": "User names may not contain whitespace!",
"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.entries": "Contents of the folder",
"fileDialog.folder": "Folder:",
@@ -279,6 +312,10 @@
"textEditor.saveAs": "Save as",
"textEditor.savedLive": "What you type is in the file at once.",
"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.hostName": "Hostname",
"webServer.useVirtualHosts": "Enable virtual hosts",
+276
View File
@@ -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'));
});
});
+22
View File
@@ -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. */
const FROM_JAVA: Readonly<Record<string, string>> = {
'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.open': 'texteditor_msg4',
'textEditor.save': 'texteditor_msg5',