diff --git a/packages/core/src/model/virtualFileSystem.ts b/packages/core/src/model/virtualFileSystem.ts index 936f524..ca8b046 100644 --- a/packages/core/src/model/virtualFileSystem.ts +++ b/packages/core/src/model/virtualFileSystem.ts @@ -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': diff --git a/packages/doc/src/files.ts b/packages/doc/src/files.ts index f794492..619e058 100644 --- a/packages/doc/src/files.ts +++ b/packages/doc/src/files.ts @@ -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> | 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 diff --git a/packages/doc/test/files.test.ts b/packages/doc/test/files.test.ts index 3a8483f..383ab8a 100644 --- a/packages/doc/test/files.test.ts +++ b/packages/doc/test/files.test.ts @@ -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>(ROOTS.nodes).get('pc')!.get('files') as Y.Map>).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) => { diff --git a/packages/editor/browser/dashboardFiles.spec.ts b/packages/editor/browser/dashboardFiles.spec.ts new file mode 100644 index 0000000..d91df34 --- /dev/null +++ b/packages/editor/browser/dashboardFiles.spec.ts @@ -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 }; +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 { + 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(); +}); diff --git a/packages/editor/src/bridge/dashboardBridge.ts b/packages/editor/src/bridge/dashboardBridge.ts index a372355..f9d30ef 100644 --- a/packages/editor/src/bridge/dashboardBridge.ts +++ b/packages/editor/src/bridge/dashboardBridge.ts @@ -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, parentOrigin: string, type: (typeof EVENTS)[keyof typeof EVENTS], fields: Readonly> = {}): 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; + /** 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) => void }>(); + const listener = (event: { readonly origin: string; readonly data: unknown }) => { + if (event.origin !== parentOrigin) return; + const data = event.data as Record | 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, transfer: Transferable[] = []) => + new Promise>((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); + }, + }; +} diff --git a/packages/editor/src/bridge/dashboardFilesContext.ts b/packages/editor/src/bridge/dashboardFilesContext.ts new file mode 100644 index 0000000..f23ab5c --- /dev/null +++ b/packages/editor/src/bridge/dashboardFilesContext.ts @@ -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(null); + +export const useDashboardFiles = (): FilesLink | null => useContext(DashboardFilesContext); diff --git a/packages/editor/src/desktop/FileExplorerPanel.tsx b/packages/editor/src/desktop/FileExplorerPanel.tsx index 311c7d4..8cd3cc3 100644 --- a/packages/editor/src/desktop/FileExplorerPanel.tsx +++ b/packages/editor/src/desktop/FileExplorerPanel.tsx @@ -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(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 + {files === null ? null : ( + + )} {(['newFolder', 'delete', 'cut', 'copy', 'paste', 'rename'] as const).map((item) => ( ) : null} - {asking?.kind === 'import' ? setAsking(null)} /> : null} + {asking?.kind === 'import' ? setAsking(null)} /> : null} ); } @@ -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(null); const [local, setLocal] = useState<{ readonly name: string; readonly bytes: Uint8Array } | null>(null); @@ -232,6 +268,21 @@ function ImportDialog({ explorer, onClose }: { readonly explorer: FileExplorerWi + {/* 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 : ( + + )} 150KB)", "explorer.tree": "Ordner und Dateien", diff --git a/packages/editor/src/i18n/en.json b/packages/editor/src/i18n/en.json index d4df2c7..cdde8cb 100644 --- a/packages/editor/src/i18n/en.json +++ b/packages/editor/src/i18n/en.json @@ -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", diff --git a/packages/editor/src/main.tsx b/packages/editor/src/main.tsx index 949b82b..27913fd 100644 --- a/packages/editor/src/main.tsx +++ b/packages/editor/src/main.tsx @@ -1,7 +1,8 @@ // The editor's page. On a project of server F (`?project=`, and // `&server=
` 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=` 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 (
- tellDashboard(window, parent, EVENTS.PEOPLE, { people }) })} - /> + + tellDashboard(window, parent, EVENTS.PEOPLE, { people }) })} + /> +
); diff --git a/packages/editor/src/remote/SharedProject.tsx b/packages/editor/src/remote/SharedProject.tsx index b7c4dc8..cb5e0a6 100644 --- a/packages/editor/src/remote/SharedProject.tsx +++ b/packages/editor/src/remote/SharedProject.tsx @@ -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 ; + const files = useDashboardFiles(); + const writing = !state.readOnly && !state.frozen; + const reach = useMemo(() => (files === null || writing ? files : { saveFile: files.saveFile }), [files, writing]); + return ( + + + + ); } export interface SharedProjectProps { diff --git a/packages/editor/test/bridge.test.ts b/packages/editor/test/bridge.test.ts index 772e734..a7b178b 100644 --- a/packages/editor/test/bridge.test.ts +++ b/packages/editor/test/bridge.test.ts @@ -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); + }); +});