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
@@ -74,6 +74,11 @@ export function isDirectoryContent(content: VirtualFileContent): boolean {
const encoder = new TextEncoder();
/** A text's bytes as UTF-8 — how a text file goes out of a computer (F8) and how the import reads one. */
export function textBytes(text: string): Uint8Array {
return encoder.encode(text);
}
export function contentByteCount(content: VirtualFileContent): number {
switch (content.kind) {
case 'directory':
+25 -1
View File
@@ -10,7 +10,7 @@
import * as Y from 'yjs';
import type { Id, VirtualFileContent, VirtualFileSystem } from '@mdg-filius/core';
import { VirtualFileSystem as FileSystem } from '@mdg-filius/core';
import { textBytes, VirtualFileSystem as FileSystem } from '@mdg-filius/core';
import { blobSha, blobSideOf } from './blobs.js';
import { fileEntry, fileSystemOf, inOrder, ROOTS } from './projectDoc.js';
@@ -125,6 +125,30 @@ export function fileText(doc: Y.Doc, id: Id, path: string): Y.Text | undefined {
return entry?.get('text') as Y.Text | undefined;
}
/**
* A file's bytes to take out of the project (F8, D5: into the person's own
* files): a text as UTF-8 — as the import reads it —, a picture or other
* file as it is. `missing` while a blob's bytes have not come yet (they read
* as none: saved so, the file would be empty); `notFile` for a folder or
* nothing at the path.
*/
export function fileBytes(doc: Y.Doc, id: Id, path: string): { readonly name: string; readonly bytes: Uint8Array } | 'missing' | 'notFile' {
const files = nodeMap(doc, id)?.get('files') as Y.Map<Y.Map<unknown>> | undefined;
if (files === undefined) return 'notFile';
const lower = path.toLowerCase();
const found = files.has(path) ? ([path, files.get(path)!] as const) : [...files.entries()].find(([key]) => key.toLowerCase() === lower);
if (found === undefined) return 'notFile';
const [key, entry] = found;
const name = key.slice(key.lastIndexOf('/') + 1);
const kind = entry.get('kind');
if (kind === 'text') return { name, bytes: textBytes((entry.get('text') as Y.Text).toString()) };
if (kind !== 'binary' && kind !== 'image') return 'notFile';
const blob = entry.get('blob');
if (typeof blob !== 'string') return { name, bytes: entry.get('data') as Uint8Array };
const held = blobSideOf(doc)?.bytes(blob);
return held === undefined ? 'missing' : { name, bytes: held };
}
/**
* The document's files of a device into a file system (a running engine's):
* what differs only, an entry the document no longer has removed, a new one
+30 -1
View File
@@ -5,7 +5,7 @@
import { describe, expect, it } from 'vitest';
import * as Y from 'yjs';
import { saveTextAs, VirtualFileSystem } from '@mdg-filius/core';
import { addDevice, changeFiles, emptyProject, fileText, nodeFiles, readFilesInto, ROOTS, writeProject } from '../src/index.js';
import { addDevice, attachBlobs, BLOB_THRESHOLD_BYTES, changeFiles, emptyProject, fileBytes, fileText, memoryBlobSide, nodeFiles, readFilesInto, ROOTS, writeProject } from '../src/index.js';
function setUp() {
const doc = new Y.Doc();
@@ -56,6 +56,35 @@ describe("a device's files in the document", () => {
expect(fileText(empty, 'pc', '/Notiz.txt')).toBeUndefined();
});
it('give a file its bytes to take out: a text as UTF-8, a picture as it is, in any case; nothing for a folder or what is not there', () => {
const { doc } = setUp();
const picture = new Uint8Array([137, 80, 78, 71]);
changeFiles(doc, 'pc', 'me', (fileSystem) => {
fileSystem.writeTextFile('/Grüße.txt', 'Hallo Jörg\r\n');
fileSystem.createDirectory('/ordner');
fileSystem.writeImageFile('/ordner/Bild.png', picture, 'image/png');
});
expect(fileBytes(doc, 'pc', '/Grüße.txt')).toEqual({ name: 'Grüße.txt', bytes: new TextEncoder().encode('Hallo Jörg\r\n') });
expect(fileBytes(doc, 'pc', '/ORDNER/bild.PNG')).toEqual({ name: 'Bild.png', bytes: picture });
expect([fileBytes(doc, 'pc', '/ordner'), fileBytes(doc, 'pc', '/fehlt.txt'), fileBytes(doc, 'none', '/Grüße.txt')]).toEqual(['notFile', 'notFile', 'notFile']);
});
it("say a blob is missing until its bytes have come — read so, the file would be empty", () => {
const big = new Uint8Array(BLOB_THRESHOLD_BYTES + 5).map((_, index) => index % 251);
const { doc } = setUp();
attachBlobs(doc, memoryBlobSide());
changeFiles(doc, 'pc', 'me', (fileSystem) => fileSystem.writeBinaryFile('/gross.bin', big));
expect(fileBytes(doc, 'pc', '/gross.bin')).toEqual({ name: 'gross.bin', bytes: big });
// Another browser: the document, but not the bytes yet.
const there = new Y.Doc();
const side = memoryBlobSide();
attachBlobs(there, side);
Y.applyUpdate(there, Y.encodeStateAsUpdate(doc));
expect(fileBytes(there, 'pc', '/gross.bin')).toBe('missing');
side.arrived(new Map([[(doc.getMap<Y.Map<unknown>>(ROOTS.nodes).get('pc')!.get('files') as Y.Map<Y.Map<unknown>>).get('/gross.bin')!.get('blob') as string, big]]));
expect(fileBytes(there, 'pc', '/gross.bin')).toEqual({ name: 'gross.bin', bytes: big });
});
it("go into a running engine's file system, what differs only: new ones after the others, changed ones in their place, gone ones removed", () => {
const { doc } = setUp();
changeFiles(doc, 'pc', 'me', (fileSystem) => {
@@ -0,0 +1,134 @@
// The person's own files over the bridge (F8): the page is its own parent
// here, and a little script stands in for the dashboard — it answers
// "chooseFile" with a picture and "saveFile" with a new name, and keeps what
// it was asked. Mia, who writes, brings the picture into a computer's file
// explorer and takes it out again; Rita, who only reads, may take a file out,
// but bring nothing in.
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { expect, test, type Browser, type Page } from '@playwright/test';
import { serveEditor, showDesktop } from './editorPage.js';
import { startServerF, type ServerF } from './serverF.js';
let editor: { readonly base: string; readonly close: () => Promise<void> };
let f: ServerF;
test.beforeAll(async () => {
editor = await serveEditor();
f = await startServerF(editor.base);
});
test.afterAll(async () => {
await f?.close();
await editor?.close();
});
const PICTURE = [...readFileSync(fileURLToPath(new URL('../src/assets/desktop/datei.png', import.meta.url)))];
/** A person with the editor embedded as the dashboard embeds it: `parent` is the page itself, and it answers. */
async function embedded(browser: Browser, project: string, name: string, level: 'read' | 'write'): Promise<Page> {
const context = await browser.newContext();
await f.signIn(context, project, { name, level });
const page = await context.newPage();
await page.addInitScript((picture: number[]) => {
const asked: unknown[] = [];
(window as unknown as { asked: unknown[] }).asked = asked;
window.addEventListener('message', (event) => {
const data = event.data as { type?: string; request?: number; name?: string; data?: ArrayBuffer; maxBytes?: number };
if (data?.type === 'chooseFile') {
asked.push({ type: data.type, maxBytes: data.maxBytes });
const bytes = new Uint8Array(picture).buffer;
window.postMessage({ v: 1, type: 'chosenFile', request: data.request, name: 'Logo Schule.png', data: bytes }, window.location.origin, [bytes]);
}
if (data?.type === 'saveFile') {
asked.push({ type: data.type, name: data.name, bytes: [...new Uint8Array(data.data!)] });
window.postMessage({ v: 1, type: 'savedFile', request: data.request, result: 'saved', name: 'Logo Schule (2).png' }, window.location.origin);
}
});
}, PICTURE);
const origin = new URL(editor.base).origin;
await page.goto(`${editor.base}?project=${project}&server=${encodeURIComponent(f.base)}&parent=${encodeURIComponent(origin)}`);
await page.waitForSelector('svg.filius-canvas');
return page;
}
const asked = (page: Page) => page.evaluate(() => (window as unknown as { asked: unknown[] }).asked);
test("brings a picture of the person's own files into a computer and takes it out again; who only reads takes out only", async ({ browser }) => {
test.setTimeout(120_000);
const project = await f.project('java-examples/internetwork_komplett.fls');
const notebook = project.node('Notebook 1.10');
const mia = await embedded(browser, project.id, 'Mia', 'write');
await mia.locator('button[name="simulationMode"]').click();
await expect(mia.locator('svg.filius-canvas')).toHaveAttribute('data-mode', 'simulation');
const desktop = await showDesktop(mia, notebook);
await desktop.locator('[data-program="installation"]').click();
const installation = desktop.locator('[data-program-window="installation"]');
await installation.locator('[data-list="available"] [role="option"]').filter({ hasText: 'Datei-Explorer' }).click();
await installation.getByRole('button', { name: 'Installieren' }).click();
await installation.getByRole('button', { name: 'Änderungen annehmen' }).click();
await desktop.locator('[data-program="fileExplorer"]').click();
const explorer = desktop.locator('[data-program-window="fileExplorer"]');
const toolbar = explorer.getByRole('toolbar', { name: 'Aktionen' });
// D4: Java's import window, the file from her own files — with Java's limit.
await toolbar.getByRole('button', { name: 'Importieren' }).click();
const importing = explorer.getByRole('dialog', { name: 'Datei importieren' });
await importing.getByRole('button', { name: 'Aus meinen Dateien …' }).click();
await expect(importing.locator('input[readonly]')).toHaveValue('Logo Schule.png');
await importing.getByRole('button', { name: 'Datei importieren' }).click();
await expect(importing.getByRole('status')).toHaveText('Datei erfolgreich importiert!');
expect(await asked(mia)).toEqual([{ type: 'chooseFile', maxBytes: 150_000 }]);
await importing.getByRole('button', { name: 'Schließen' }).click();
// D5: nothing chosen, nothing to save; the file chosen, its bytes go out as they came in.
const save = toolbar.getByRole('button', { name: 'In meine Dateien speichern …' });
await expect(save).toBeDisabled();
await explorer.locator('.file-explorer-entries button', { hasText: 'Logo Schule.png' }).click();
await save.click();
await expect(explorer.getByRole('alert')).toHaveText('„Logo Schule (2).png“ steht jetzt in deinen Dateien.');
expect((await asked(mia))[1]).toEqual({ type: 'saveFile', name: 'Logo Schule.png', bytes: PICTURE });
// The entry's menu has it too — after Java's own items.
await explorer.locator('.file-explorer-entries button', { hasText: 'Logo Schule.png' }).click({ button: 'right' });
await expect(explorer.getByRole('menuitem').last()).toHaveText('In meine Dateien speichern …');
await mia.keyboard.press('Escape');
// Rita reads: the explorer of the running simulation, saving yes, bringing in no.
const rita = await embedded(browser, project.id, 'Rita', 'read');
await expect(rita.locator('svg.filius-canvas')).toHaveAttribute('data-mode', 'simulation');
const ritaDesktop = await showDesktop(rita, notebook);
await ritaDesktop.locator('[data-program="fileExplorer"]').click();
const ritaExplorer = ritaDesktop.locator('[data-program-window="fileExplorer"]');
const ritaBar = ritaExplorer.getByRole('toolbar', { name: 'Aktionen' });
await ritaExplorer.locator('.file-explorer-entries button', { hasText: 'Logo Schule.png' }).click();
await expect(ritaBar.getByRole('button', { name: 'In meine Dateien speichern …' })).toBeEnabled();
await ritaBar.getByRole('button', { name: 'Importieren' }).click();
await expect(ritaExplorer.getByRole('dialog', { name: 'Datei importieren' }).getByRole('button', { name: 'Datei wählen' })).toBeVisible();
await expect(ritaExplorer.getByRole('button', { name: 'Aus meinen Dateien …' })).toHaveCount(0);
await mia.context().close();
await rita.context().close();
});
test('offers neither way on its own, without a dashboard', async ({ browser }) => {
const project = await f.project('java-examples/internetwork_komplett.fls');
const context = await browser.newContext();
await f.signIn(context, project.id, { name: 'Mia' });
const page = await context.newPage();
await page.goto(`${editor.base}?project=${project.id}&server=${encodeURIComponent(f.base)}`);
await page.waitForSelector('svg.filius-canvas');
await page.locator('button[name="simulationMode"]').click();
await expect(page.locator('svg.filius-canvas')).toHaveAttribute('data-mode', 'simulation');
const desktop = await showDesktop(page, project.node('Notebook 1.10'));
await desktop.locator('[data-program="installation"]').click();
const installation = desktop.locator('[data-program-window="installation"]');
await installation.locator('[data-list="available"] [role="option"]').filter({ hasText: 'Datei-Explorer' }).click();
await installation.getByRole('button', { name: 'Installieren' }).click();
await installation.getByRole('button', { name: 'Änderungen annehmen' }).click();
await desktop.locator('[data-program="fileExplorer"]').click();
const explorer = desktop.locator('[data-program-window="fileExplorer"]');
await expect(explorer.getByRole('button', { name: 'In meine Dateien speichern …' })).toHaveCount(0);
await explorer.getByRole('toolbar', { name: 'Aktionen' }).getByRole('button', { name: 'Importieren' }).click();
await expect(explorer.getByRole('button', { name: 'Aus meinen Dateien …' })).toHaveCount(0);
await context.close();
});
+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",
+9 -2
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%' }}>
<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 {
+59 -3
View File
@@ -2,15 +2,15 @@
// and the editor says when it listens.
import { describe, expect, it } from 'vitest';
import { BRIDGE_VERSION, startBridge } from '../src/bridge/dashboardBridge.js';
import { BRIDGE_VERSION, dashboardFiles, startBridge } from '../src/bridge/dashboardBridge.js';
function fakeWindow() {
const posted: [unknown, string][] = [];
const posted: [unknown, string, Transferable[]?][] = [];
const listeners = new Set<(event: { origin: string; data: unknown }) => void>();
return {
posted,
window: {
parent: { postMessage: (message: unknown, origin: string) => posted.push([message, origin]) },
parent: { postMessage: (message: unknown, origin: string, transfer?: Transferable[]) => posted.push(transfer === undefined ? [message, origin] : [message, origin, transfer]) },
addEventListener: (_: 'message', listener: (event: { origin: string; data: unknown }) => void) => listeners.add(listener),
removeEventListener: (_: 'message', listener: (event: { origin: string; data: unknown }) => void) => listeners.delete(listener),
},
@@ -51,3 +51,59 @@ describe('the bridge to the dashboard', () => {
expect(fake.listening()).toBe(0);
});
});
describe("the person's own files over the bridge (F8)", () => {
const DASHBOARD = 'https://dashboard.example';
it('asks for a file with its limit and takes the answer to that question only', async () => {
const fake = fakeWindow();
const files = dashboardFiles(fake.window, DASHBOARD);
const chosen = files.chooseFile(150_000);
expect(fake.posted).toEqual([[{ v: BRIDGE_VERSION, type: 'chooseFile', request: 1, maxBytes: 150_000 }, DASHBOARD, []]]);
const data = new Uint8Array([1, 2, 3]).buffer;
// From elsewhere, of another version, for another question, of the other kind: no answer — the question still waits.
fake.send('https://evil.example', { v: 1, type: 'chosenFile', request: 1, name: 'x.png', data });
fake.send(DASHBOARD, { v: 2, type: 'chosenFile', request: 1, name: 'x.png', data });
fake.send(DASHBOARD, { v: 1, type: 'chosenFile', request: 9, name: 'x.png', data });
fake.send(DASHBOARD, { v: 1, type: 'savedFile', request: 1, result: 'saved', name: 'x.png' });
fake.send(DASHBOARD, { v: 1, type: 'chosenFile', request: 1, name: 'Bild.png', data });
expect(await chosen).toEqual({ name: 'Bild.png', bytes: new Uint8Array([1, 2, 3]) });
});
it('hears a cancelled choice as none', async () => {
const fake = fakeWindow();
const files = dashboardFiles(fake.window, DASHBOARD);
const chosen = files.chooseFile(10);
fake.send(DASHBOARD, { v: 1, type: 'chosenFile', request: 1, cancelled: true });
expect(await chosen).toBeNull();
});
it('hands a copy of the bytes over to save, and keeps its own', async () => {
const fake = fakeWindow();
const files = dashboardFiles(fake.window, DASHBOARD);
const bytes = new Uint8Array([9, 8, 7, 6]).subarray(1, 3);
const saved = files.saveFile('Grüße.txt', bytes);
const [message, origin, transfer] = fake.posted[0]!;
expect(origin).toBe(DASHBOARD);
expect(message).toMatchObject({ v: BRIDGE_VERSION, type: 'saveFile', request: 1, name: 'Grüße.txt' });
const data = (message as { data: ArrayBuffer }).data;
expect([...new Uint8Array(data)]).toEqual([8, 7]);
expect(transfer).toEqual([data]);
expect(data).not.toBe(bytes.buffer);
fake.send(DASHBOARD, { v: 1, type: 'savedFile', request: 1, result: 'saved', name: 'Grüße (2).txt' });
expect(await saved).toEqual({ result: 'saved', name: 'Grüße (2).txt' });
});
it('counts its questions, and an unknown result is a failure', async () => {
const fake = fakeWindow();
const files = dashboardFiles(fake.window, DASHBOARD);
const first = files.saveFile('a.txt', new Uint8Array());
const second = files.saveFile('b.txt', new Uint8Array());
fake.send(DASHBOARD, { v: 1, type: 'savedFile', request: 2, result: 'cancelled' });
fake.send(DASHBOARD, { v: 1, type: 'savedFile', request: 1, result: 'weird' });
expect(await second).toEqual({ result: 'cancelled', name: 'b.txt' });
expect(await first).toEqual({ result: 'failed', name: 'a.txt' });
files.stop();
expect(fake.listening()).toBe(0);
});
});