feat(editor): a computer's files to and from the person's own files (F8.1)

Beside the dashboard only: Java's import window gets "Aus meinen Dateien"
(D4) — the dashboard is asked over the bridge for a file of at most Java's
150 000 bytes and hands its bytes over — and a file's menu gets "In meine
Dateien speichern" (D5), its bytes handed to the dashboard to save.

- Bridge: chooseFile/chosenFile and saveFile/savedFile, each question with
  its number; an answer from elsewhere, of another version, for no question
  or of the other kind is no answer.
- Whoever reads or finds the project frozen may save, not bring in.
- doc fileBytes: a text as UTF-8, a picture as it is, and "missing" while
  a blob's bytes have not come (read so, the file would be empty).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 16:02:43 +00:00
co-authored by Claude Opus 5.5
parent cc5e07f1a6
commit 8efbc442f1
12 changed files with 430 additions and 19 deletions
+70 -1
View File
@@ -13,6 +13,10 @@ export const COMMANDS = {
SET_LOCALE: 'setLocale',
/** `{ theme: 'light' | 'dark', tokens?: { [name]: value } }`: the dashboard's resolved colours. */
SET_THEME: 'setTheme',
/** `{ request, name, data: ArrayBuffer }` or `{ request, cancelled: true }`: the answer to `chooseFile` (F8, D4). */
CHOSEN_FILE: 'chosenFile',
/** `{ request, result: 'saved' | 'cancelled' | 'failed', name? }`: the answer to `saveFile` (F8, D5). */
SAVED_FILE: 'savedFile',
} as const;
/** Editor → dashboard. */
@@ -21,6 +25,10 @@ export const EVENTS = {
READY: 'ready',
/** `{ people: [{ id, name, color }] }`: the others in the project, each time they change (plan §7.4: the dashboard's header shows them). */
PEOPLE: 'people',
/** `{ request, maxBytes }`: let the person choose a file of her own files, at most so large (F8, D4). */
CHOOSE_FILE: 'chooseFile',
/** `{ request, name, data: ArrayBuffer }`: let the person save this file into her own files (F8, D5). */
SAVE_FILE: 'saveFile',
} as const;
export interface BridgeHandlers {
@@ -29,7 +37,7 @@ export interface BridgeHandlers {
}
interface Window {
readonly parent: { postMessage(message: unknown, targetOrigin: string): void };
readonly parent: { postMessage(message: unknown, targetOrigin: string, transfer?: Transferable[]): void };
addEventListener(type: 'message', listener: (event: { readonly origin: string; readonly data: unknown }) => void): void;
removeEventListener(type: 'message', listener: (event: { readonly origin: string; readonly data: unknown }) => void): void;
}
@@ -55,3 +63,64 @@ export function startBridge(window: Window, parentOrigin: string, handlers: Brid
export function tellDashboard(window: Pick<Window, 'parent'>, parentOrigin: string, type: (typeof EVENTS)[keyof typeof EVENTS], fields: Readonly<Record<string, unknown>> = {}): void {
window.parent.postMessage({ v: BRIDGE_VERSION, type, ...fields }, parentOrigin);
}
/** A file of the person's own files, as the dashboard hands it over. */
export interface ChosenFile {
readonly name: string;
readonly bytes: Uint8Array;
}
export type SaveResult = 'saved' | 'cancelled' | 'failed';
/** The two ways between a computer's files and the person's own files in the dashboard (F8: D4, D5). */
export interface DashboardFiles {
/** A file chosen there, at most `maxBytes` large; none when she cancels. */
chooseFile(maxBytes: number): Promise<ChosenFile | null>;
/** The bytes saved there under the name (she chooses the folder, and may rename). */
saveFile(name: string, bytes: Uint8Array): Promise<{ readonly result: SaveResult; readonly name: string }>;
}
/**
* Asks the dashboard at `parentOrigin` for files and to save them; each
* question has its number and waits for the answer with it. The bytes go as
* a copy (`slice`): the editor keeps its own, the copy is handed over
* (`transfer`) rather than copied a second time. An answer from elsewhere, of
* another version or for no question is no answer.
*/
export function dashboardFiles(window: Window, parentOrigin: string): DashboardFiles & { stop(): void } {
let next = 0;
const waiting = new Map<number, { readonly kind: string; readonly answer: (data: Record<string, unknown>) => void }>();
const listener = (event: { readonly origin: string; readonly data: unknown }) => {
if (event.origin !== parentOrigin) return;
const data = event.data as Record<string, unknown> | null;
if (data === null || typeof data !== 'object' || data.v !== BRIDGE_VERSION) return;
const question = typeof data.request === 'number' ? waiting.get(data.request) : undefined;
// Only the answer of its own kind: "saved" is no answer to "choose".
if (question === undefined || question.kind !== data.type) return;
waiting.delete(data.request as number);
question.answer(data);
};
window.addEventListener('message', listener);
const ask = (type: (typeof EVENTS)[keyof typeof EVENTS], kind: string, fields: Record<string, unknown>, transfer: Transferable[] = []) =>
new Promise<Record<string, unknown>>((resolve) => {
const request = ++next;
waiting.set(request, { kind, answer: resolve });
window.parent.postMessage({ v: BRIDGE_VERSION, type, request, ...fields }, parentOrigin, transfer);
});
return {
async chooseFile(maxBytes) {
const data = await ask(EVENTS.CHOOSE_FILE, COMMANDS.CHOSEN_FILE, { maxBytes });
if (typeof data.name !== 'string' || !(data.data instanceof ArrayBuffer)) return null;
return { name: data.name, bytes: new Uint8Array(data.data) };
},
async saveFile(name, bytes) {
const copy = bytes.slice().buffer;
const data = await ask(EVENTS.SAVE_FILE, COMMANDS.SAVED_FILE, { name, data: copy }, [copy]);
const result = data.result === 'saved' || data.result === 'cancelled' ? data.result : 'failed';
return { result, name: typeof data.name === 'string' ? data.name : name };
},
stop() {
window.removeEventListener('message', listener);
},
};
}
@@ -0,0 +1,17 @@
// The person's own files, as far as this editor may reach them (F8): none on
// the development page (no dashboard), no choosing for whoever may not write
// (read, frozen) — saving a file one sees is open to everybody who sees it.
import { createContext, useContext } from 'react';
import type { DashboardFiles } from './dashboardBridge.js';
export interface FilesLink {
/** Choose a file there (D4); missing where the editor may not write. */
readonly chooseFile?: DashboardFiles['chooseFile'];
/** Save bytes there (D5). */
readonly saveFile: DashboardFiles['saveFile'];
}
export const DashboardFilesContext = createContext<FilesLink | null>(null);
export const useDashboardFiles = (): FilesLink | null => useContext(DashboardFilesContext);
@@ -13,9 +13,11 @@
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 { entryName, IMPORT_MAXIMUM_BYTES } from '@mdg-filius/core';
import { fileBytes } from '@mdg-filius/doc';
import fileIcon from '../assets/desktop/datei.png';
import folderIcon from '../assets/desktop/ordner.png';
import { useDashboardFiles } from '../bridge/dashboardFilesContext.js';
import { ContextMenu } from '../design/ContextMenu.js';
import { useTranslate } from '../i18n/i18n.js';
import { fileExplorerWindow, type ExplorerItem, type FileExplorerWindow } from './fileExplorer.js';
@@ -49,6 +51,23 @@ export function FileExplorerPanel({ doc, node }: { readonly doc: Y.Doc; readonly
const [notice, setNotice] = useState<string | null>(null);
const folder = explorer.folder;
const selected = explorer.selected;
const files = useDashboardFiles();
const selectedFile = selected !== null && explorer.entries().some((entry) => entry.path === selected && entry.kind === 'file');
/* D5 (F8): the chosen file into the person's own files — not Java's, the dashboard's; she chooses the folder there. */
const saveToFiles = async () => {
setNotice(null);
if (files === null || selected === null) return;
const found = fileBytes(doc, node, selected);
if (found === 'notFile') return;
if (found === 'missing') {
setNotice(t('explorer.notHereYet'));
return;
}
const { result, name } = await files.saveFile(found.name, found.bytes);
if (result === 'saved') setNotice(t('explorer.savedToFiles', { name }));
else if (result === 'failed') setNotice(t('explorer.saveFailed'));
};
const run = (item: ExplorerItem) => {
setNotice(null);
@@ -102,6 +121,11 @@ export function FileExplorerPanel({ doc, node }: { readonly doc: Y.Doc; readonly
<button type="button" onClick={() => setAsking({ kind: 'import' })}>
{t('explorer.import')}
</button>
{files === null ? null : (
<button type="button" data-item="saveToFiles" disabled={!selectedFile} onClick={() => void saveToFiles()}>
{t('explorer.saveToFiles')}
</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])}
@@ -157,7 +181,11 @@ export function FileExplorerPanel({ doc, node }: { readonly doc: Y.Doc; readonly
{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) }))}
items={[
...explorer.menu(menu.onEntry).map((item) => ({ key: item, label: t(ITEM_KEYS[item]), onChoose: () => run(item) })),
// Not Java's: only beside the dashboard, only on a file.
...(files !== null && menu.onEntry && selectedFile ? [{ key: 'saveToFiles', label: t('explorer.saveToFiles'), onChoose: () => void saveToFiles() }] : []),
]}
onClose={() => setMenu(null)}
/>
)}
@@ -198,7 +226,7 @@ export function FileExplorerPanel({ doc, node }: { readonly doc: Y.Doc; readonly
</button>
</div>
) : null}
{asking?.kind === 'import' ? <ImportDialog explorer={explorer} onClose={() => setAsking(null)} /> : null}
{asking?.kind === 'import' ? <ImportDialog explorer={explorer} chooseFile={files?.chooseFile} onClose={() => setAsking(null)} /> : null}
</div>
);
}
@@ -208,7 +236,15 @@ export function FileExplorerPanel({ doc, node }: { readonly doc: Y.Doc; readonly
* 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 }) {
function ImportDialog({
explorer,
chooseFile,
onClose,
}: {
readonly explorer: FileExplorerWindow;
readonly chooseFile: ((maxBytes: number) => Promise<{ readonly name: string; readonly bytes: Uint8Array } | null>) | undefined;
readonly onClose: () => void;
}) {
const t = useTranslate();
const picker = useRef<HTMLInputElement>(null);
const [local, setLocal] = useState<{ readonly name: string; readonly bytes: Uint8Array } | null>(null);
@@ -232,6 +268,21 @@ function ImportDialog({ explorer, onClose }: { readonly explorer: FileExplorerWi
<button type="button" onClick={() => picker.current?.click()}>
{t('explorer.chooseFile')}
</button>
{/* D4 (F8): the same window, the file from the person's own files — Java's limit holds there too, the dashboard says it before fetching. */}
{chooseFile === undefined ? null : (
<button
type="button"
onClick={async () => {
const chosen = await chooseFile(IMPORT_MAXIMUM_BYTES);
if (chosen === null) return;
setLocal(chosen);
setName(chosen.name);
setOutput('');
}}
>
{t('explorer.fromFiles')}
</button>
)}
</label>
<input
ref={picker}
+5
View File
@@ -208,6 +208,7 @@
"explorer.entries": "Inhalt des Ordners",
"explorer.file": "Datei",
"explorer.folder": "Ordner",
"explorer.fromFiles": "Aus meinen Dateien …",
"explorer.import": "Importieren",
"explorer.importButton": "Datei importieren",
"explorer.importMissing": "Eingabe- und Ausgabedatei müssen angegeben werden!",
@@ -222,12 +223,16 @@
"explorer.newName": "Neuer Name:",
"explorer.no": "Nein",
"explorer.noName": "Geben Sie einen Namen ein.",
"explorer.notHereYet": "Die Datei ist in diesem Browser noch nicht ganz angekommen. Versuch es gleich noch einmal.",
"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.saveFailed": "Die Datei ließ sich nicht in deine Dateien speichern.",
"explorer.saveToFiles": "In meine Dateien speichern …",
"explorer.savedToFiles": "„{name}“ steht jetzt in deinen Dateien.",
"explorer.sure": "Sicher?",
"explorer.tooLarge": "Datei existiert nicht oder ist zu groß (> 150KB)",
"explorer.tree": "Ordner und Dateien",
+5
View File
@@ -208,6 +208,7 @@
"explorer.entries": "Contents of the folder",
"explorer.file": "File",
"explorer.folder": "Folder",
"explorer.fromFiles": "From my files …",
"explorer.import": "Import",
"explorer.importButton": "Import file",
"explorer.importMissing": "Select input and output file!",
@@ -222,12 +223,16 @@
"explorer.newName": "New name:",
"explorer.no": "No",
"explorer.noName": "Enter a name.",
"explorer.notHereYet": "The file has not fully arrived in this browser yet. Try again in a moment.",
"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.saveFailed": "The file could not be saved to your files.",
"explorer.saveToFiles": "Save to my files …",
"explorer.savedToFiles": "“{name}” is in your files now.",
"explorer.sure": "Sure?",
"explorer.tooLarge": "File does not exist or is too large (> 150KB)",
"explorer.tree": "Folders and files",
+14 -7
View File
@@ -1,7 +1,8 @@
// The editor's page. On a project of server F (`?project=<uuid>`, and
// `&server=<address>` when F is not where the page comes from) it opens the
// shared document and the room there (SharedProject); embedded in the
// dashboard (F6) it gets its language and theme over the bridge. On its own
// dashboard (F6) it gets its language and theme over the bridge, and reaches
// the person's own files through it (F8). On its own
// — for development and the browser tests — it opens one of FILIUS'
// examples, an empty project or a file (`?example=<name>` opens that example
// at once, `?example=new` an empty project, `?lang=en` and `?theme=dark` set
@@ -12,7 +13,8 @@ import { createRoot } from 'react-dom/client';
import * as Y from 'yjs';
import { changeFiles, desktopView, deviceSheet, emptyProject, fileText, topologyView, writeProject } from '@mdg-filius/doc';
import { openProjectArchive } from '@mdg-filius/fls';
import { EVENTS, startBridge, tellDashboard } from './bridge/dashboardBridge.js';
import { dashboardFiles, EVENTS, startBridge, tellDashboard } from './bridge/dashboardBridge.js';
import { DashboardFilesContext, type FilesLink } from './bridge/dashboardFilesContext.js';
import { Editor } from './Editor.js';
import { SharedProject } from './remote/SharedProject.js';
import type { PresenceUser } from './remote/presence.js';
@@ -39,6 +41,9 @@ function App() {
const [example, setExample] = useState(params.get('example') ?? '');
const project = params.get('project');
const parent = params.get('parent');
/* The person's own files (F8), only beside the dashboard. */
const [files] = useState<(FilesLink & { stop(): void }) | null>(() => (parent === null ? null : dashboardFiles(window, parent)));
useEffect(() => () => files?.stop(), [files]);
useEffect(() => {
if (params.get('theme') === 'dark') document.documentElement.dataset.theme = 'dark';
@@ -96,11 +101,13 @@ function App() {
return (
<LanguageProvider language={language}>
<main style={{ height: '100%' }}>
<SharedProject
server={params.get('server') ?? window.location.origin}
project={project}
{...(parent === null ? {} : { onPeople: (people: readonly PresenceUser[]) => tellDashboard(window, parent, EVENTS.PEOPLE, { people }) })}
/>
<DashboardFilesContext.Provider value={files}>
<SharedProject
server={params.get('server') ?? window.location.origin}
project={project}
{...(parent === null ? {} : { onPeople: (people: readonly PresenceUser[]) => tellDashboard(window, parent, EVENTS.PEOPLE, { people }) })}
/>
</DashboardFilesContext.Provider>
</main>
</LanguageProvider>
);
+11 -2
View File
@@ -7,10 +7,12 @@
// embeds it (`onPeople`: the dashboard's header, over the bridge). The
// document's large binary files come from F and go to it (remoteBlobs):
// fetched as the document names them; one that could not be taken to F is
// said.
// said. The person's own files (F8) reach a computer only where she may
// write: whoever reads or finds the project frozen may save, not choose.
import { useEffect, useMemo, useState } from 'react';
import { attachBlobs } from '@mdg-filius/doc';
import { DashboardFilesContext, useDashboardFiles } from '../bridge/dashboardFilesContext.js';
import { remoteBlobs, type RemoteBlobs } from './remoteBlobs.js';
import { peopleOf, type PresenceUser } from './presence.js';
import { usePresence } from './usePresence.js';
@@ -45,7 +47,14 @@ function ReadyEditor({
const presence = usePresence(state.awareness, state.you);
const key = JSON.stringify(peopleOf(presence.peers, state.you.id));
useEffect(() => onPeople?.(JSON.parse(key) as PresenceUser[]), [key, onPeople]);
return <Editor doc={state.doc} createSimHost={createSimHost} readOnly={state.readOnly} frozen={state.frozen} presence={presence} />;
const files = useDashboardFiles();
const writing = !state.readOnly && !state.frozen;
const reach = useMemo(() => (files === null || writing ? files : { saveFile: files.saveFile }), [files, writing]);
return (
<DashboardFilesContext.Provider value={reach}>
<Editor doc={state.doc} createSimHost={createSimHost} readOnly={state.readOnly} frozen={state.frozen} presence={presence} />
</DashboardFilesContext.Provider>
);
}
export interface SharedProjectProps {