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:
@@ -74,6 +74,11 @@ export function isDirectoryContent(content: VirtualFileContent): boolean {
|
|||||||
|
|
||||||
const encoder = new TextEncoder();
|
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 {
|
export function contentByteCount(content: VirtualFileContent): number {
|
||||||
switch (content.kind) {
|
switch (content.kind) {
|
||||||
case 'directory':
|
case 'directory':
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
import * as Y from 'yjs';
|
import * as Y from 'yjs';
|
||||||
import type { Id, VirtualFileContent, VirtualFileSystem } from '@mdg-filius/core';
|
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 { blobSha, blobSideOf } from './blobs.js';
|
||||||
import { fileEntry, fileSystemOf, inOrder, ROOTS } from './projectDoc.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;
|
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):
|
* 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
|
* what differs only, an entry the document no longer has removed, a new one
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import * as Y from 'yjs';
|
import * as Y from 'yjs';
|
||||||
import { saveTextAs, VirtualFileSystem } from '@mdg-filius/core';
|
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() {
|
function setUp() {
|
||||||
const doc = new Y.Doc();
|
const doc = new Y.Doc();
|
||||||
@@ -56,6 +56,35 @@ describe("a device's files in the document", () => {
|
|||||||
expect(fileText(empty, 'pc', '/Notiz.txt')).toBeUndefined();
|
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", () => {
|
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();
|
const { doc } = setUp();
|
||||||
changeFiles(doc, 'pc', 'me', (fileSystem) => {
|
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();
|
||||||
|
});
|
||||||
@@ -13,6 +13,10 @@ export const COMMANDS = {
|
|||||||
SET_LOCALE: 'setLocale',
|
SET_LOCALE: 'setLocale',
|
||||||
/** `{ theme: 'light' | 'dark', tokens?: { [name]: value } }`: the dashboard's resolved colours. */
|
/** `{ theme: 'light' | 'dark', tokens?: { [name]: value } }`: the dashboard's resolved colours. */
|
||||||
SET_THEME: 'setTheme',
|
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;
|
} as const;
|
||||||
|
|
||||||
/** Editor → dashboard. */
|
/** Editor → dashboard. */
|
||||||
@@ -21,6 +25,10 @@ export const EVENTS = {
|
|||||||
READY: 'ready',
|
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: [{ id, name, color }] }`: the others in the project, each time they change (plan §7.4: the dashboard's header shows them). */
|
||||||
PEOPLE: 'people',
|
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;
|
} as const;
|
||||||
|
|
||||||
export interface BridgeHandlers {
|
export interface BridgeHandlers {
|
||||||
@@ -29,7 +37,7 @@ export interface BridgeHandlers {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface Window {
|
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;
|
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;
|
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 {
|
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);
|
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 { useRef, useState, useSyncExternalStore, type FormEvent, type MouseEvent as ReactMouseEvent } from 'react';
|
||||||
import type * as Y from 'yjs';
|
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 fileIcon from '../assets/desktop/datei.png';
|
||||||
import folderIcon from '../assets/desktop/ordner.png';
|
import folderIcon from '../assets/desktop/ordner.png';
|
||||||
|
import { useDashboardFiles } from '../bridge/dashboardFilesContext.js';
|
||||||
import { ContextMenu } from '../design/ContextMenu.js';
|
import { ContextMenu } from '../design/ContextMenu.js';
|
||||||
import { useTranslate } from '../i18n/i18n.js';
|
import { useTranslate } from '../i18n/i18n.js';
|
||||||
import { fileExplorerWindow, type ExplorerItem, type FileExplorerWindow } from './fileExplorer.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 [notice, setNotice] = useState<string | null>(null);
|
||||||
const folder = explorer.folder;
|
const folder = explorer.folder;
|
||||||
const selected = explorer.selected;
|
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) => {
|
const run = (item: ExplorerItem) => {
|
||||||
setNotice(null);
|
setNotice(null);
|
||||||
@@ -102,6 +121,11 @@ export function FileExplorerPanel({ doc, node }: { readonly doc: Y.Doc; readonly
|
|||||||
<button type="button" onClick={() => setAsking({ kind: 'import' })}>
|
<button type="button" onClick={() => setAsking({ kind: 'import' })}>
|
||||||
{t('explorer.import')}
|
{t('explorer.import')}
|
||||||
</button>
|
</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) => (
|
{(['newFolder', 'delete', 'cut', 'copy', 'paste', 'rename'] as const).map((item) => (
|
||||||
<button key={item} type="button" data-item={item} disabled={!enabled(item)} onClick={() => run(item)}>
|
<button key={item} type="button" data-item={item} disabled={!enabled(item)} onClick={() => run(item)}>
|
||||||
{t(ITEM_KEYS[item])}
|
{t(ITEM_KEYS[item])}
|
||||||
@@ -157,7 +181,11 @@ export function FileExplorerPanel({ doc, node }: { readonly doc: Y.Doc; readonly
|
|||||||
{menu === null ? null : (
|
{menu === null ? null : (
|
||||||
<ContextMenu
|
<ContextMenu
|
||||||
at={{ x: menu.x, y: menu.y }}
|
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)}
|
onClose={() => setMenu(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -198,7 +226,7 @@ export function FileExplorerPanel({ doc, node }: { readonly doc: Y.Doc; readonly
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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>
|
</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",
|
* wählen", the new name (the local file's at first), "Datei importieren",
|
||||||
* the result, "Schließen".
|
* 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 t = useTranslate();
|
||||||
const picker = useRef<HTMLInputElement>(null);
|
const picker = useRef<HTMLInputElement>(null);
|
||||||
const [local, setLocal] = useState<{ readonly name: string; readonly bytes: Uint8Array } | null>(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()}>
|
<button type="button" onClick={() => picker.current?.click()}>
|
||||||
{t('explorer.chooseFile')}
|
{t('explorer.chooseFile')}
|
||||||
</button>
|
</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>
|
</label>
|
||||||
<input
|
<input
|
||||||
ref={picker}
|
ref={picker}
|
||||||
|
|||||||
@@ -208,6 +208,7 @@
|
|||||||
"explorer.entries": "Inhalt des Ordners",
|
"explorer.entries": "Inhalt des Ordners",
|
||||||
"explorer.file": "Datei",
|
"explorer.file": "Datei",
|
||||||
"explorer.folder": "Ordner",
|
"explorer.folder": "Ordner",
|
||||||
|
"explorer.fromFiles": "Aus meinen Dateien …",
|
||||||
"explorer.import": "Importieren",
|
"explorer.import": "Importieren",
|
||||||
"explorer.importButton": "Datei importieren",
|
"explorer.importButton": "Datei importieren",
|
||||||
"explorer.importMissing": "Eingabe- und Ausgabedatei müssen angegeben werden!",
|
"explorer.importMissing": "Eingabe- und Ausgabedatei müssen angegeben werden!",
|
||||||
@@ -222,12 +223,16 @@
|
|||||||
"explorer.newName": "Neuer Name:",
|
"explorer.newName": "Neuer Name:",
|
||||||
"explorer.no": "Nein",
|
"explorer.no": "Nein",
|
||||||
"explorer.noName": "Geben Sie einen Namen ein.",
|
"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.notImported": "Datei konnte nicht importiert werden",
|
||||||
"explorer.ok": "OK",
|
"explorer.ok": "OK",
|
||||||
"explorer.paste": "Einfügen",
|
"explorer.paste": "Einfügen",
|
||||||
"explorer.pasteMissing": "Was eingefügt werden sollte, gibt es nicht mehr.",
|
"explorer.pasteMissing": "Was eingefügt werden sollte, gibt es nicht mehr.",
|
||||||
"explorer.pasteTaken": "In diesem Ordner gibt es „{name}“ schon.",
|
"explorer.pasteTaken": "In diesem Ordner gibt es „{name}“ schon.",
|
||||||
"explorer.rename": "Umbenennen",
|
"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.sure": "Sicher?",
|
||||||
"explorer.tooLarge": "Datei existiert nicht oder ist zu groß (> 150KB)",
|
"explorer.tooLarge": "Datei existiert nicht oder ist zu groß (> 150KB)",
|
||||||
"explorer.tree": "Ordner und Dateien",
|
"explorer.tree": "Ordner und Dateien",
|
||||||
|
|||||||
@@ -208,6 +208,7 @@
|
|||||||
"explorer.entries": "Contents of the folder",
|
"explorer.entries": "Contents of the folder",
|
||||||
"explorer.file": "File",
|
"explorer.file": "File",
|
||||||
"explorer.folder": "Folder",
|
"explorer.folder": "Folder",
|
||||||
|
"explorer.fromFiles": "From my files …",
|
||||||
"explorer.import": "Import",
|
"explorer.import": "Import",
|
||||||
"explorer.importButton": "Import file",
|
"explorer.importButton": "Import file",
|
||||||
"explorer.importMissing": "Select input and output file!",
|
"explorer.importMissing": "Select input and output file!",
|
||||||
@@ -222,12 +223,16 @@
|
|||||||
"explorer.newName": "New name:",
|
"explorer.newName": "New name:",
|
||||||
"explorer.no": "No",
|
"explorer.no": "No",
|
||||||
"explorer.noName": "Enter a name.",
|
"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.notImported": "File could not be imported.",
|
||||||
"explorer.ok": "OK",
|
"explorer.ok": "OK",
|
||||||
"explorer.paste": "Paste",
|
"explorer.paste": "Paste",
|
||||||
"explorer.pasteMissing": "What was to be pasted is no longer there.",
|
"explorer.pasteMissing": "What was to be pasted is no longer there.",
|
||||||
"explorer.pasteTaken": "This folder already has an entry “{name}”.",
|
"explorer.pasteTaken": "This folder already has an entry “{name}”.",
|
||||||
"explorer.rename": "Rename",
|
"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.sure": "Sure?",
|
||||||
"explorer.tooLarge": "File does not exist or is too large (> 150KB)",
|
"explorer.tooLarge": "File does not exist or is too large (> 150KB)",
|
||||||
"explorer.tree": "Folders and files",
|
"explorer.tree": "Folders and files",
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
// The editor's page. On a project of server F (`?project=<uuid>`, and
|
// 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
|
// `&server=<address>` when F is not where the page comes from) it opens the
|
||||||
// shared document and the room there (SharedProject); embedded in 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'
|
// — for development and the browser tests — it opens one of FILIUS'
|
||||||
// examples, an empty project or a file (`?example=<name>` opens that example
|
// 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
|
// 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 * as Y from 'yjs';
|
||||||
import { changeFiles, desktopView, deviceSheet, emptyProject, fileText, topologyView, writeProject } from '@mdg-filius/doc';
|
import { changeFiles, desktopView, deviceSheet, emptyProject, fileText, topologyView, writeProject } from '@mdg-filius/doc';
|
||||||
import { openProjectArchive } from '@mdg-filius/fls';
|
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 { Editor } from './Editor.js';
|
||||||
import { SharedProject } from './remote/SharedProject.js';
|
import { SharedProject } from './remote/SharedProject.js';
|
||||||
import type { PresenceUser } from './remote/presence.js';
|
import type { PresenceUser } from './remote/presence.js';
|
||||||
@@ -39,6 +41,9 @@ function App() {
|
|||||||
const [example, setExample] = useState(params.get('example') ?? '');
|
const [example, setExample] = useState(params.get('example') ?? '');
|
||||||
const project = params.get('project');
|
const project = params.get('project');
|
||||||
const parent = params.get('parent');
|
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(() => {
|
useEffect(() => {
|
||||||
if (params.get('theme') === 'dark') document.documentElement.dataset.theme = 'dark';
|
if (params.get('theme') === 'dark') document.documentElement.dataset.theme = 'dark';
|
||||||
@@ -96,11 +101,13 @@ function App() {
|
|||||||
return (
|
return (
|
||||||
<LanguageProvider language={language}>
|
<LanguageProvider language={language}>
|
||||||
<main style={{ height: '100%' }}>
|
<main style={{ height: '100%' }}>
|
||||||
<SharedProject
|
<DashboardFilesContext.Provider value={files}>
|
||||||
server={params.get('server') ?? window.location.origin}
|
<SharedProject
|
||||||
project={project}
|
server={params.get('server') ?? window.location.origin}
|
||||||
{...(parent === null ? {} : { onPeople: (people: readonly PresenceUser[]) => tellDashboard(window, parent, EVENTS.PEOPLE, { people }) })}
|
project={project}
|
||||||
/>
|
{...(parent === null ? {} : { onPeople: (people: readonly PresenceUser[]) => tellDashboard(window, parent, EVENTS.PEOPLE, { people }) })}
|
||||||
|
/>
|
||||||
|
</DashboardFilesContext.Provider>
|
||||||
</main>
|
</main>
|
||||||
</LanguageProvider>
|
</LanguageProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,10 +7,12 @@
|
|||||||
// embeds it (`onPeople`: the dashboard's header, over the bridge). The
|
// embeds it (`onPeople`: the dashboard's header, over the bridge). The
|
||||||
// document's large binary files come from F and go to it (remoteBlobs):
|
// 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
|
// 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 { useEffect, useMemo, useState } from 'react';
|
||||||
import { attachBlobs } from '@mdg-filius/doc';
|
import { attachBlobs } from '@mdg-filius/doc';
|
||||||
|
import { DashboardFilesContext, useDashboardFiles } from '../bridge/dashboardFilesContext.js';
|
||||||
import { remoteBlobs, type RemoteBlobs } from './remoteBlobs.js';
|
import { remoteBlobs, type RemoteBlobs } from './remoteBlobs.js';
|
||||||
import { peopleOf, type PresenceUser } from './presence.js';
|
import { peopleOf, type PresenceUser } from './presence.js';
|
||||||
import { usePresence } from './usePresence.js';
|
import { usePresence } from './usePresence.js';
|
||||||
@@ -45,7 +47,14 @@ function ReadyEditor({
|
|||||||
const presence = usePresence(state.awareness, state.you);
|
const presence = usePresence(state.awareness, state.you);
|
||||||
const key = JSON.stringify(peopleOf(presence.peers, state.you.id));
|
const key = JSON.stringify(peopleOf(presence.peers, state.you.id));
|
||||||
useEffect(() => onPeople?.(JSON.parse(key) as PresenceUser[]), [key, onPeople]);
|
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 {
|
export interface SharedProjectProps {
|
||||||
|
|||||||
@@ -2,15 +2,15 @@
|
|||||||
// and the editor says when it listens.
|
// and the editor says when it listens.
|
||||||
|
|
||||||
import { describe, expect, it } from 'vitest';
|
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() {
|
function fakeWindow() {
|
||||||
const posted: [unknown, string][] = [];
|
const posted: [unknown, string, Transferable[]?][] = [];
|
||||||
const listeners = new Set<(event: { origin: string; data: unknown }) => void>();
|
const listeners = new Set<(event: { origin: string; data: unknown }) => void>();
|
||||||
return {
|
return {
|
||||||
posted,
|
posted,
|
||||||
window: {
|
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),
|
addEventListener: (_: 'message', listener: (event: { origin: string; data: unknown }) => void) => listeners.add(listener),
|
||||||
removeEventListener: (_: 'message', listener: (event: { origin: string; data: unknown }) => void) => listeners.delete(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);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user