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:
@@ -20,7 +20,8 @@
|
||||
// is refused (Java makes such a file), a name with "/" leads into a
|
||||
// folder (Java makes a file whose name holds the separator);
|
||||
// - "open" opens text files only (Java shows an image as its Base64 text)
|
||||
// and keeps the open file when it finds none (Java forgets it);
|
||||
// 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).
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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. */
|
||||
export function imageFile(files: VirtualFileSystem, path: string): { readonly data: Uint8Array; readonly mediaType: string } | undefined {
|
||||
const entry = found(files, path);
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
saveText,
|
||||
saveTextAs,
|
||||
openText,
|
||||
openImage,
|
||||
VirtualFileSystem,
|
||||
type Clipboard,
|
||||
type PageLanguage,
|
||||
@@ -265,4 +266,18 @@ describe('the file programs beyond the cases', () => {
|
||||
expect(saveTextAs(files, '/', 'ORDNER', 'e')).toBe('isFolder');
|
||||
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 type { Id, VirtualFileContent, VirtualFileSystem } 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 {
|
||||
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;
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
let files = node.get('files') as Y.Map<Y.Map<unknown>> | undefined;
|
||||
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);
|
||||
}
|
||||
}
|
||||
// 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 {
|
||||
|
||||
@@ -116,19 +116,17 @@ describe("a device's files in the document", () => {
|
||||
const order = (fileSystem: VirtualFileSystem) => fileSystem.entriesInCreationOrder('/').map((entry) => entry.path);
|
||||
expect(order(engine)).toEqual(order(nodeFiles(doc, 'pc')!));
|
||||
expect(order(engine).indexOf('/Gross.txt')).toBeLessThan(order(engine).indexOf('/NEU.txt'));
|
||||
// So also in a folder.
|
||||
// So also in a folder: a file renamed there keeps its place, in the document and in the engine.
|
||||
changeFiles(doc, 'pc', 'me', (fileSystem) => {
|
||||
fileSystem.createDirectory('/tief');
|
||||
fileSystem.writeTextFile('/tief/x', 'x');
|
||||
fileSystem.writeTextFile('/tief/y.txt', 'y');
|
||||
});
|
||||
readFilesInto(doc, 'pc', engine);
|
||||
changeFiles(doc, 'pc', 'me', (fileSystem) => {
|
||||
fileSystem.deleteItem('/tief/x');
|
||||
fileSystem.createDirectory('/tief/x');
|
||||
});
|
||||
changeFiles(doc, 'pc', 'me', (fileSystem) => fileSystem.renameInPlace('/tief/x', 'x.txt'));
|
||||
expect(nodeFiles(doc, 'pc')!.entriesInCreationOrder('/tief').map((entry) => entry.path)).toEqual(['/tief/x.txt', '/tief/y.txt']);
|
||||
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', () => {
|
||||
|
||||
@@ -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
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
@@ -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 { 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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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. */
|
||||
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',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user