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
+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',