From 45d1a7c3f718eebd42415889416fa089eb3120b0 Mon Sep 17 00:00:00 2001 From: Maxime GRANDCOLAS Date: Tue, 21 Jan 2025 15:58:17 +0100 Subject: [PATCH] [MS] Adds download file feature in web mode --- client/package-lock.json | 73 +++++++++++++++++++ client/package.json | 1 + client/src/parsec/file.ts | 45 ++++++++++++ client/src/services/fileOperationManager.ts | 11 ++- client/src/views/files/FileContextMenu.vue | 3 +- client/src/views/files/FoldersPage.vue | 21 +++++- client/src/views/viewers/FileViewer.vue | 8 +- .../e2e/specs/document_context_menu.spec.ts | 10 ++- 8 files changed, 155 insertions(+), 17 deletions(-) diff --git a/client/package-lock.json b/client/package-lock.json index 22301a1fe6f..13ada1eddc1 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -30,6 +30,7 @@ "luxon": "^3.4.4", "megashark-lib": "git+https://github.com/Scille/megashark-lib.git#77e1cde26a5769ce9105687cbd64362ce0056c9c", "monaco-editor": "^0.52.0", + "native-file-system-adapter": "^3.0.1", "pdfjs-dist": "^4.8.69", "qrcode-vue3": "^1.6.8", "uuid": "^9.0.1", @@ -5592,6 +5593,29 @@ "pend": "~1.2.0" } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "optional": true, + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/figures": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", @@ -7667,6 +7691,27 @@ "integrity": "sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==", "optional": true }, + "node_modules/native-file-system-adapter": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/native-file-system-adapter/-/native-file-system-adapter-3.0.1.tgz", + "integrity": "sha512-ocuhsYk2SY0906LPc3QIMW+rCV3MdhqGiy7wV5Bf0e8/5TsMjDdyIwhNiVPiKxzTJLDrLT6h8BoV9ERfJscKhw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "engines": { + "node": ">=14.8.0" + }, + "optionalDependencies": { + "fetch-blob": "^3.2.0" + } + }, "node_modules/native-run": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/native-run/-/native-run-2.0.1.tgz", @@ -7746,6 +7791,25 @@ "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", "optional": true }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "optional": true, + "engines": { + "node": ">=10.5.0" + } + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -11180,6 +11244,15 @@ "defaults": "^1.0.3" } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "optional": true, + "engines": { + "node": ">= 8" + } + }, "node_modules/webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", diff --git a/client/package.json b/client/package.json index 8e3602db356..37f301b6c14 100644 --- a/client/package.json +++ b/client/package.json @@ -53,6 +53,7 @@ "luxon": "^3.4.4", "megashark-lib": "git+https://github.com/Scille/megashark-lib.git#77e1cde26a5769ce9105687cbd64362ce0056c9c", "monaco-editor": "^0.52.0", + "native-file-system-adapter": "^3.0.1", "pdfjs-dist": "^4.8.69", "qrcode-vue3": "^1.6.8", "uuid": "^9.0.1", diff --git a/client/src/parsec/file.ts b/client/src/parsec/file.ts index 3811e31a876..ec6a39f0f2a 100644 --- a/client/src/parsec/file.ts +++ b/client/src/parsec/file.ts @@ -45,6 +45,8 @@ import { DateTime } from 'luxon'; const MOCK_OPENED_FILES = new Map(); let MOCK_CURRENT_FD = 1; +export const DEFAULT_READ_SIZE = 256_000; + export async function createFile(workspaceHandle: WorkspaceHandle, path: FsPath): Promise> { if (!needsMocks()) { return await libparsec.workspaceCreateFile(workspaceHandle, path); @@ -238,6 +240,49 @@ export async function parseFileLink(link: string): Promise; } +export async function createReadStream(workspaceHandle: WorkspaceHandle, path: FsPath): Promise { + let fd: FileDescriptor | undefined; + let offset = 0; + + return new ReadableStream({ + async start(controller: ReadableStreamDefaultController): Promise { + const result = await openFile(workspaceHandle, path, { read: true }); + if (!result.ok) { + return controller.error(result.error); + } + fd = result.value; + }, + + async pull(controller: ReadableStreamDefaultController): Promise { + if (fd === undefined) { + controller.error('No file descriptor'); + return; + } + const result = await readFile(workspaceHandle, fd, offset, DEFAULT_READ_SIZE); + + if (!result.ok) { + return controller.error(result.error); + } + if (result.value.length === 0) { + controller.close(); + await closeFile(workspaceHandle, fd); + fd = undefined; + } else { + offset += result.value.length; + controller.enqueue(result.value); + } + }, + + async cancel(): Promise { + if (fd !== undefined) { + await closeFile(workspaceHandle, fd); + } + }, + + type: 'bytes', + }); +} + export async function openFile( workspaceHandle: WorkspaceHandle, path: FsPath, diff --git a/client/src/services/fileOperationManager.ts b/client/src/services/fileOperationManager.ts index 597de6f71f4..a71780127c2 100644 --- a/client/src/services/fileOperationManager.ts +++ b/client/src/services/fileOperationManager.ts @@ -1,6 +1,7 @@ // Parsec Cloud (https://parsec.cloud) Copyright (c) BUSL-1.1 2016-present Scille SAS import { + DEFAULT_READ_SIZE, EntryStatFile, EntryTree, FileDescriptor, @@ -409,7 +410,6 @@ class FileOperationManager { let loop = true; let offset = 0; - const READ_CHUNK_SIZE = 1_000_000; while (loop) { // Check if the copy has been cancelled let shouldCancel = false; @@ -426,7 +426,7 @@ class FileOperationManager { } // Read the source - const readResult = await readFile(data.workspaceHandle, fdR, offset, READ_CHUNK_SIZE); + const readResult = await readFile(data.workspaceHandle, fdR, offset, DEFAULT_READ_SIZE); // Failed to read, cancel the copy if (!readResult.ok) { @@ -440,7 +440,7 @@ class FileOperationManager { throw Error('Failed to write the destination'); } // Smaller that what we asked for, we're at the end of the file - if (chunk.byteLength < READ_CHUNK_SIZE) { + if (chunk.byteLength < DEFAULT_READ_SIZE) { loop = false; } else { // Otherwise, move the offset and keep going @@ -612,7 +612,6 @@ class FileOperationManager { let loop = true; let offset = 0; - const READ_CHUNK_SIZE = 1_000_000; while (loop) { // Check if the copy has been cancelled let shouldCancel = false; @@ -629,7 +628,7 @@ class FileOperationManager { } // Read the source - const readResult = await readHistoryFile(data.workspaceHandle, fdR, offset, READ_CHUNK_SIZE); + const readResult = await readHistoryFile(data.workspaceHandle, fdR, offset, DEFAULT_READ_SIZE); // Failed to read, cancel the copy if (!readResult.ok) { @@ -643,7 +642,7 @@ class FileOperationManager { throw Error('Failed to write the destination'); } // Smaller that what we asked for, we're at the end of the file - if (chunk.byteLength < READ_CHUNK_SIZE) { + if (chunk.byteLength < DEFAULT_READ_SIZE) { loop = false; } else { // Otherwise, move the offset and keep going diff --git a/client/src/views/files/FileContextMenu.vue b/client/src/views/files/FileContextMenu.vue index 141b097a3a7..4ef943e4062 100644 --- a/client/src/views/files/FileContextMenu.vue +++ b/client/src/views/files/FileContextMenu.vue @@ -94,10 +94,9 @@ diff --git a/client/src/views/files/FoldersPage.vue b/client/src/views/files/FoldersPage.vue index a5c43c8e967..c027c8e162d 100644 --- a/client/src/views/files/FoldersPage.vue +++ b/client/src/views/files/FoldersPage.vue @@ -279,6 +279,7 @@ import { Ref, computed, inject, onMounted, onUnmounted, ref } from 'vue'; import { EntrySyncedData, EventData, EventDistributor, EventDistributorKey, Events } from '@/services/eventDistributor'; import { openPath, showInExplorer } from '@/services/fileOpener'; import { WorkspaceTagRole } from '@/components/workspaces'; +import { showSaveFilePicker } from 'native-file-system-adapter'; interface FoldersPageSavedData { displayState?: DisplayState; @@ -1161,7 +1162,25 @@ async function copyEntries(entries: EntryModel[]): Promise { } async function downloadEntries(entries: EntryModel[]): Promise { - console.log('Download', entries); + if (entries.length !== 1) { + return; + } + if (!workspaceInfo.value) { + window.electronAPI.log('error', 'No workspace info when trying to download a file'); + return; + } + + try { + const saveHandle = await showSaveFilePicker({ + _preferPolyfill: false, + suggestedName: entries[0].name, + }); + + const stream = await parsec.createReadStream(workspaceInfo.value.handle, entries[0].path); + await stream.pipeTo(await saveHandle.createWritable()); + } catch (e: any) { + window.electronAPI.log('error', `Failed to download file: ${e.toString()}`); + } } async function showHistory(entries: EntryModel[]): Promise { diff --git a/client/src/views/viewers/FileViewer.vue b/client/src/views/viewers/FileViewer.vue index 3644a77090e..77ffc6a5961 100644 --- a/client/src/views/viewers/FileViewer.vue +++ b/client/src/views/viewers/FileViewer.vue @@ -65,6 +65,7 @@ import { openFileAt, readHistoryFile, closeHistoryFile, + DEFAULT_READ_SIZE, } from '@/parsec'; import { IonPage, IonContent, IonButton, IonText, IonIcon, IonButtons, modalController } from '@ionic/vue'; import { informationCircle, open } from 'ionicons/icons'; @@ -85,7 +86,6 @@ const viewerComponent: Ref = shallowRef(null); const contentInfo: Ref = ref(null); const detectedFileType = ref(null); const loaded = ref(false); -const READ_CHUNK_SIZE = 512_000; const atDateTime: Ref = ref(null); onMounted(async () => { @@ -160,16 +160,16 @@ onMounted(async () => { while (loop) { let readResult; if (!atDateTime.value) { - readResult = await readFile(workspaceHandle, openResult.value, offset, READ_CHUNK_SIZE); + readResult = await readFile(workspaceHandle, openResult.value, offset, DEFAULT_READ_SIZE); } else { - readResult = await readHistoryFile(workspaceHandle, openResult.value, offset, READ_CHUNK_SIZE); + readResult = await readHistoryFile(workspaceHandle, openResult.value, offset, DEFAULT_READ_SIZE); } if (!readResult.ok) { throw Error(JSON.stringify(readResult.error)); } const buffer = new Uint8Array(readResult.value); contentInfo.value?.data.set(buffer, offset); - if (readResult.value.byteLength < READ_CHUNK_SIZE) { + if (readResult.value.byteLength < DEFAULT_READ_SIZE) { loop = false; } offset += readResult.value.byteLength; diff --git a/client/tests/e2e/specs/document_context_menu.spec.ts b/client/tests/e2e/specs/document_context_menu.spec.ts index a1f9ab1fd7b..32043bd349f 100644 --- a/client/tests/e2e/specs/document_context_menu.spec.ts +++ b/client/tests/e2e/specs/document_context_menu.spec.ts @@ -48,7 +48,7 @@ for (const gridMode of [false, true]) { await expect(documents.locator('.file-context-menu')).toBeVisible(); const popover = documents.locator('.file-context-menu'); await expect(popover.getByRole('group')).toHaveCount(2); - await expect(popover.getByRole('listitem')).toHaveCount(9); + await expect(popover.getByRole('listitem')).toHaveCount(10); await expect(popover.getByRole('listitem')).toHaveText([ 'Manage file', 'Rename', @@ -56,6 +56,7 @@ for (const gridMode of [false, true]) { 'Make a copy', 'Delete', 'History', + 'Download', 'Details', 'Collaboration', 'Copy link', @@ -75,7 +76,7 @@ for (const gridMode of [false, true]) { await expect(documents.locator('.file-context-menu')).toBeVisible(); const popover = documents.locator('.file-context-menu'); await expect(popover.getByRole('group')).toHaveCount(2); - await expect(popover.getByRole('listitem')).toHaveCount(9); + await expect(popover.getByRole('listitem')).toHaveCount(10); await expect(popover.getByRole('listitem')).toHaveText([ 'Manage file', 'Rename', @@ -83,6 +84,7 @@ for (const gridMode of [false, true]) { 'Make a copy', 'Delete', 'History', + 'Download', 'Details', 'Collaboration', 'Copy link', @@ -203,8 +205,8 @@ for (const gridMode of [false, true]) { await expect(documentsReadOnly.locator('.file-context-menu')).toBeVisible(); const popover = documentsReadOnly.locator('.file-context-menu'); await expect(popover.getByRole('group')).toHaveCount(2); - await expect(popover.getByRole('listitem')).toHaveCount(4); - await expect(popover.getByRole('listitem')).toHaveText(['Manage file', 'Details', 'Collaboration', 'Copy link']); + await expect(popover.getByRole('listitem')).toHaveCount(5); + await expect(popover.getByRole('listitem')).toHaveText(['Manage file', 'Download', 'Details', 'Collaboration', 'Copy link']); }); msTest(`Move document in ${gridMode ? 'grid' : 'list'} mode`, async ({ documents }) => {