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