feat(core): the file explorer, text editor and image viewer's file operations after Java, measured against Java

FileCases does what the windows of FILIUS' file explorer and text editor
do to a host's files: new folder, delete, copy, cut and paste, rename,
import, save and save as. The core's filePrograms.ts does the same on the
node's file system; the file system renames an entry in its place, as
Java's tree does.

Departures from Java: a folder name that comes to no name makes nothing,
a paste onto a name there already is refused, a rename to a name with a
slash or blanks at its ends is refused and one of the case only is done,
save as and import onto a folder's name are refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 11:59:11 +00:00
co-authored by Claude Opus 5.5
parent f622959ecc
commit 9d42b90157
13 changed files with 7653 additions and 3 deletions
+1
View File
@@ -26,6 +26,7 @@ export * from './runtime/apps/routerPages.js';
export * from './runtime/apps/firewallSettings.js';
export * from './runtime/apps/emailServer.js';
export * from './runtime/apps/emailClient.js';
export * from './runtime/apps/filePrograms.js';
export * from './runtime/apps/terminal.js';
export * from './runtime/apps/terminalTexts.js';
export * from './runtime/email.js';
@@ -343,6 +343,32 @@ export class VirtualFileSystem {
return this.existingPath(destination);
}
/**
* Java's rename (Datei.setName, DefaultMutableTreeNode.setUserObject): the
* entry keeps its place among its folder's entries, also when only its
* case changes; another entry of the folder may not have the name yet.
* Returns the new path.
*/
renameInPlace(rawPath: string, newName: string): string {
if (newName.includes('/') || newName.includes('\\')) fail('invalidPathComponent', newName);
const sourcePath = this.existingPath(rawPath);
if (sourcePath === '/') fail('cannotMutateRoot');
const destination = normalizedAbsolutePath(join(parentPath(sourcePath), newName));
if (entryName(destination) !== newName) fail('invalidPathComponent', newName);
const taken = this.existingPathIfPresent(destination);
if (taken !== undefined && !swiftEqual(taken, sourcePath)) fail('itemAlreadyExists', destination);
this.transaction((candidate) => {
const store: Store = new Map();
for (const [key, entry] of candidate.store) {
const inside = entry.path === sourcePath || entry.path.startsWith(`${sourcePath}/`);
const path = inside ? destination + entry.path.slice(sourcePath.length) : entry.path;
store.set(inside ? keyOf(path) : key, { path, content: entry.content });
}
candidate.store = store;
});
return destination;
}
deleteItem(rawPath: string, recursive = false): void {
const path = this.existingPath(rawPath);
if (path === '/') fail('cannotMutateRoot');
@@ -0,0 +1,239 @@
// What FILIUS' file explorer, text editor and image viewer do to a host's
// files (filius.software.lokal.FileExplorer, TextEditor, ImageViewer; most of
// it in their windows, GUIApplicationFileExplorerWindow,
// GUIApplicationTextEditorWindow, GUIApplicationImageViewerWindow): the
// operations behind their buttons, on the node's file system, for the
// editor's windows to call. The files are the project's own, so what they
// change is in a project saved afterwards.
//
// Departures from Java (filePrograms.test.ts names each):
// - a new folder's name that comes to no name (".." at the root) makes no
// folder (Java makes one without a name);
// - a paste where the folder has the name already is refused (Java adds a
// second entry of the same name);
// - a rename to a name with "/" is refused (Java makes a file whose name
// holds the separator), as one to a name with blanks at its ends (the
// file system's rule), and one that changes only the case is done (Java
// finds the entry itself and refuses);
// - "save as" onto a folder's name, and an import onto it, is refused (Java
// throws);
// - a text file's size is its length in characters (as the command line's).
import type { VirtualFileContent, VirtualFileSystem } from '../../model/virtualFileSystem.js';
import { entryName, parentPath } from '../../model/virtualFileSystem.js';
import type { PageLanguage } from './routerPages.js';
import { evaluatedPath } from './terminal.js';
/** FILIUS' config/filetypes.txt: a file's type by its extension. */
const FILE_TYPES: Readonly<Record<string, 'text' | 'sound' | 'image'>> = {
txt: 'text',
cfg: 'text',
bat: 'text',
sh: 'text',
html: 'text',
htm: 'text',
ini: 'text',
php: 'text',
xml: 'text',
mp3: 'sound',
mp2: 'sound',
wav: 'sound',
jpeg: 'image',
jpg: 'image',
bmp: 'image',
tiff: 'image',
png: 'image',
};
/** An image's media type by its extension. */
const IMAGE_MEDIA_TYPES: Readonly<Record<string, string>> = { jpeg: 'image/jpeg', jpg: 'image/jpeg', bmp: 'image/bmp', tiff: 'image/tiff', png: 'image/png' };
/** FileExplorer.getFileType: the type of the last extension; a name without one is text, an unknown one has none. */
export function javaFileType(name: string): 'text' | 'sound' | 'image' | undefined {
const parts = name
.toLowerCase()
.split('.')
.filter((part) => part !== '');
if (parts.length < 2) return 'text';
return Object.hasOwn(FILE_TYPES, parts.at(-1)!) ? FILE_TYPES[parts.at(-1)!] : undefined;
}
/** FileExplorer.addFile: the largest file it imports, in bytes. */
export const IMPORT_MAXIMUM_BYTES = 150_000;
/** The explorer's messages (the import's, the paste's refusal), as Java's message bundle gives them. */
export const FILE_EXPLORER_TEXTS: Readonly<Record<PageLanguage, Readonly<Record<'imported' | 'notImported' | 'tooLarge' | 'invalidTarget', string>>>> = {
de: {
imported: 'Datei erfolgreich importiert!',
notImported: 'Datei konnte nicht importiert werden',
tooLarge: 'Datei existiert nicht oder ist zu groß (> 150KB)',
invalidTarget: 'Unzulässiger Zielordner!',
},
en: {
imported: 'File successfully imported!',
notImported: 'File could not be imported.',
tooLarge: 'File does not exist or is too large (> 150KB)',
invalidTarget: 'Invalid target folder!',
},
};
/** An entry of a folder as the explorer lists it (ordnerInhaltAnzeigen), in the order it was made. */
export interface FolderEntry {
readonly kind: 'file' | 'folder';
readonly name: string;
readonly path: string;
}
/** What the explorer copied or cut, to paste. */
export interface Clipboard {
readonly path: string;
readonly cut: boolean;
}
function found(files: VirtualFileSystem, path: string): { readonly path: string; readonly content: VirtualFileContent } | undefined {
try {
return files.entry(path);
} catch {
return undefined;
}
}
const childPath = (folder: string, name: string) => `${folder === '/' ? '' : folder}/${name}`;
/** The folder's entries, files and folders, in the order they were made; none when it is no folder. */
export function folderEntries(files: VirtualFileSystem, folder: string): FolderEntry[] {
if (found(files, folder)?.content.kind !== 'directory') return [];
return files.entriesInCreationOrder(folder).map((entry) => ({ kind: entry.content.kind === 'directory' ? 'folder' : 'file', name: entryName(entry.path), path: entry.path }));
}
/**
* "New folder" (Dateisystem.erstelleVerzeichnis): the name taken as a path
* from the folder ("a/b" makes b in a); nothing when the name is empty or
* there already. False when its folder is not there, or the name comes to
* no name.
*/
export function makeFolder(files: VirtualFileSystem, folder: string, name: string): boolean {
if (name === '') return false;
const path = evaluatedPath(name.startsWith('/') ? name : `${folder}/${name}`);
if (path === '/') return false;
if (found(files, parentPath(path))?.content.kind !== 'directory') return false;
if (found(files, path) === undefined) files.createDirectory(path);
return true;
}
/** "Delete": the entry of the folder, a folder with all in it; false when it is not there. */
export function deleteEntry(files: VirtualFileSystem, folder: string, name: string): boolean {
const entry = found(files, childPath(folder, name));
if (entry === undefined || entry.path === '/') return false;
files.deleteItem(entry.path, true);
return true;
}
/**
* "Paste": a copy of the copied or cut entry, with all in it, added to the
* folder (at the end of its entries); a cut one removed from where it was.
* Not into the entry itself or below it (invalidTarget), not where the
* folder has an entry of that name (nameTaken; a cut entry pasted into its
* own folder moves to the end).
*/
export function pasteEntry(files: VirtualFileSystem, clipboard: Clipboard, folder: string): 'pasted' | 'invalidTarget' | 'nameTaken' | 'missing' {
const source = found(files, clipboard.path);
const target = found(files, folder);
if (source === undefined || target?.content.kind !== 'directory') return 'missing';
const lower = (path: string) => path.toLowerCase();
if (lower(target.path) === lower(source.path) || lower(target.path).startsWith(`${lower(source.path)}/`)) return 'invalidTarget';
const destination = childPath(target.path, entryName(source.path));
const taken = found(files, destination);
if (taken !== undefined && !(clipboard.cut && taken.path === source.path)) return 'nameTaken';
// The entry and all below it in preorder, each folder's entries in their order (Java's deep copy keeps it).
const below = (path: string): { path: string; content: VirtualFileContent }[] =>
files.entriesInCreationOrder(path).flatMap((entry) => [entry, ...(entry.content.kind === 'directory' ? below(entry.path) : [])]);
const subtree = [source, ...(source.content.kind === 'directory' ? below(source.path) : [])].map((entry) => ({
path: destination + entry.path.slice(source.path.length),
content: entry.content,
}));
if (clipboard.cut) files.deleteItem(source.path, true);
for (const entry of subtree) write(files, entry.path, entry.content);
return 'pasted';
}
/**
* "Rename": a name that is not blank and that no other entry of the folder
* has yet; the entry keeps its place. False otherwise, and for a name with
* "/".
*/
export function renameEntry(files: VirtualFileSystem, folder: string, name: string, newName: string): boolean {
const entry = found(files, childPath(folder, name));
if (entry === undefined || entry.path === '/') return false;
// The file system refuses an empty name, one with "/", one around blanks and one another entry has.
try {
files.renameInPlace(entry.path, newName);
} catch {
return false;
}
return true;
}
/** Writes the content at the path, a file over a file of that name. */
function write(files: VirtualFileSystem, path: string, content: VirtualFileContent): void {
if (content.kind === 'directory') files.createDirectory(path);
else if (content.kind === 'text') files.writeTextFile(path, content.text);
else if (content.kind === 'image') files.writeImageFile(path, content.data, content.mediaType);
else if (content.mediaType === undefined) files.writeBinaryFile(path, content.data);
else files.writeBinaryFile(path, content.data, content.mediaType);
}
/** A file's path in the folder for a name: the entry of that name in any case, kept as it is, else a new one. */
function fileTarget(files: VirtualFileSystem, folder: string, name: string): { readonly path: string; readonly isFolder: boolean } | undefined {
if (found(files, folder)?.content.kind !== 'directory') return undefined;
const existing = found(files, childPath(folder, name));
return existing === undefined ? { path: childPath(found(files, folder)!.path, name), isFolder: false } : { path: existing.path, isFolder: existing.content.kind === 'directory' };
}
/**
* "Import" (FileExplorer.addFile): a local file into the folder, under the
* new name ("noName" when empty), its type by the local file's name. A text
* file line by line, each line ended with CR LF; an image as an image; any
* other as its bytes. Over one of that name its content (and kind), the
* name kept as it is.
*/
export function importFile(files: VirtualFileSystem, folder: string, newName: string, localName: string, bytes: Uint8Array): 'imported' | 'tooLarge' | 'notImported' {
if (bytes.length > IMPORT_MAXIMUM_BYTES) return 'tooLarge';
const target = fileTarget(files, folder, newName === '' ? 'noName' : newName);
if (target === undefined || target.isFolder) return 'notImported';
const type = javaFileType(localName);
if (type === 'text') {
// BufferedReader.readLine: a line ends at LF, CR or CR LF; the last one may end without.
const lines = new TextDecoder().decode(bytes).split(/\r\n|\r|\n/);
if (lines.at(-1) === '') lines.pop();
files.writeTextFile(target.path, lines.map((line) => `${line}\r\n`).join(''));
} else if (type === 'image') {
files.writeImageFile(target.path, bytes, IMAGE_MEDIA_TYPES[localName.toLowerCase().split('.').at(-1)!]!);
} else {
files.writeBinaryFile(target.path, bytes);
}
return 'imported';
}
/** The editor's "save": the open file's text. */
export function saveText(files: VirtualFileSystem, path: string, text: string): void {
files.writeTextFile(path, text);
}
/**
* The editor's "save as" (Dateisystem.speicherDatei): the text as a file of
* the folder, over one of that name in any case (its name kept as it is).
*/
export function saveTextAs(files: VirtualFileSystem, folder: string, name: string, text: string): 'saved' | 'folderMissing' | 'isFolder' {
const target = fileTarget(files, folder, name);
if (target === undefined) return 'folderMissing';
if (target.isFolder) return 'isFolder';
files.writeTextFile(target.path, text);
return 'saved';
}
/** 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);
return entry?.content.kind === 'image' ? { data: entry.content.data, mediaType: entry.content.mediaType } : undefined;
}
+1 -1
View File
@@ -94,7 +94,7 @@ const USAGE: Record<string, string> = {
* Dateisystem.evaluatePathString for an absolute path: "." and empty parts
* left out, ".." one up (at the root it stays there).
*/
function evaluatedPath(path: string): string {
export function evaluatedPath(path: string): string {
const parts: string[] = [];
for (const part of path.split('/')) {
if (part === '..') parts.pop();