diff --git a/CHANGELOG.md b/CHANGELOG.md index adff3b329..ce77d95a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Open multiple selected design files in separate tabs. - Add deterministic two-browser collaboration coverage for bidirectional edits, awareness, departure cleanup, partitioned-peer convergence, and reconnect synchronization without public network dependencies. (#530) - Import, render, edit, resize, select, and export Figma text-on-path layers while preserving their curved glyph layout. diff --git a/src/app/shell/menu/files.ts b/src/app/shell/menu/files.ts index c3c2e1a2a..5fa99bbce 100644 --- a/src/app/shell/menu/files.ts +++ b/src/app/shell/menu/files.ts @@ -2,19 +2,24 @@ import { useFileDialog } from '@vueuse/core' import { setOpenPencilOpenFileHandler } from '@/app/browser-bridge' import { resolveBrowserFileURL } from '@/app/document/io/browser' +import { toast } from '@/app/shell/ui' import { openFileInNewTab } from '@/app/tabs' import { isTauri } from '@/app/tauri/env' import { IS_BROWSER } from '@/constants' const fileDialog = useFileDialog({ accept: '.fig,.pen,.html,.htm,.xhtml', - multiple: false, + multiple: true, reset: true }) fileDialog.onChange((files) => { - const file = files?.[0] - if (file) void openFileInNewTab(file) + if (!files) return + void openDesignFileBatch( + files, + (file) => file.name, + (file) => openFileInNewTab(file) + ) }) if (IS_BROWSER && 'window' in globalThis) { @@ -28,19 +33,37 @@ if (IS_BROWSER && 'window' in globalThis) { }) } +export async function openDesignFileBatch( + items: Iterable, + displayName: (item: T) => string, + openItem: (item: T) => Promise +): Promise { + for (const item of items) { + try { + await openItem(item) + } catch (error) { + const name = displayName(item) + const detail = error instanceof Error ? error.message : String(error) + console.error(`Failed to open ${name}:`, error) + toast.error(`Failed to open ${name}: ${detail}`) + } + } +} + export async function readTauriDesignFile(path: string): Promise { const { readFile } = await import('@tauri-apps/plugin-fs') const bytes = await readFile(path) return new File([bytes], path.split('/').pop() ?? 'file.fig') } -export async function chooseTauriOpenPath(): Promise { +export async function chooseTauriOpenPaths(): Promise { const { open } = await import('@tauri-apps/plugin-dialog') - const path = await open({ + const paths = await open({ filters: [{ name: 'Design file', extensions: ['fig', 'pen', 'html', 'htm', 'xhtml'] }], - multiple: false + multiple: true }) - return typeof path === 'string' ? path : null + if (!paths) return [] + return typeof paths === 'string' ? [paths] : paths } export async function openFileFromPath(path: string) { @@ -51,15 +74,15 @@ export async function openFileFromPath(path: string) { export async function openFileDialog() { if (isTauri()) { - const path = await chooseTauriOpenPath() - if (!path) return - await openFileFromPath(path) + const paths = await chooseTauriOpenPaths() + await openDesignFileBatch(paths, (path) => path.split(/[/\\]/).pop() ?? path, openFileFromPath) return } if (window.showOpenFilePicker) { try { - const [handle] = await window.showOpenFilePicker({ + const handles = await window.showOpenFilePicker({ + multiple: true, types: [ { description: 'Design file', @@ -73,8 +96,14 @@ export async function openFileDialog() { } ] }) - const file = await handle.getFile() - await openFileInNewTab(file, handle) + await openDesignFileBatch( + handles, + (handle) => handle.name, + async (handle) => { + const file = await handle.getFile() + await openFileInNewTab(file, handle) + } + ) return } catch (e) { if ((e as Error).name === 'AbortError') return diff --git a/src/global.d.ts b/src/global.d.ts index 4e32d6be7..bd7d8b684 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -14,6 +14,7 @@ declare global { } interface FilePickerOptions { + multiple?: boolean types?: FilePickerAcceptType[] suggestedName?: string } diff --git a/tests/engine/app/shell/menu/file-batch.test.ts b/tests/engine/app/shell/menu/file-batch.test.ts new file mode 100644 index 000000000..ac1d71a99 --- /dev/null +++ b/tests/engine/app/shell/menu/file-batch.test.ts @@ -0,0 +1,41 @@ +import { afterEach, describe, expect, mock, test } from 'bun:test' + +import { openDesignFileBatch } from '@/app/shell/menu/files' +import { toast } from '@/app/shell/ui' + +afterEach(() => { + toast.toasts.value = [] +}) + +describe('openDesignFileBatch', () => { + test('opens selected files sequentially in selection order', async () => { + const opened: string[] = [] + const openItem = mock(async (name: string) => { + opened.push(name) + }) + + await openDesignFileBatch(['first.fig', 'second.pen'], (name) => name, openItem) + + expect(opened).toEqual(['first.fig', 'second.pen']) + expect(openItem).toHaveBeenCalledTimes(2) + }) + + test('reports one failed file and continues opening later selections', async () => { + const opened: string[] = [] + const openItem = mock(async (name: string) => { + opened.push(name) + if (name === 'broken.fig') throw new Error('Invalid FIG container') + }) + + await expect( + openDesignFileBatch(['first.fig', 'broken.fig', 'last.pen'], (name) => name, openItem) + ).resolves.toBeUndefined() + + expect(opened).toEqual(['first.fig', 'broken.fig', 'last.pen']) + expect(toast.toasts.value).toHaveLength(1) + expect(toast.toasts.value[0]).toMatchObject({ + message: 'Failed to open broken.fig: Invalid FIG container', + variant: 'error' + }) + }) +}) diff --git a/tests/engine/tauri/file-actions.test.ts b/tests/engine/tauri/file-actions.test.ts index a211b04ff..0cc5c3ce3 100644 --- a/tests/engine/tauri/file-actions.test.ts +++ b/tests/engine/tauri/file-actions.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, test } from 'bun:test' import { saveExportedFile } from '@/app/document/export/files' import { watchTauriFile } from '@/app/document/io/watch-targets' -import { chooseTauriOpenPath, readTauriDesignFile } from '@/app/shell/menu/files' +import { chooseTauriOpenPaths, readTauriDesignFile } from '@/app/shell/menu/files' import { clearTauriMocks, mockTauriIPC } from '#tests/helpers/tauri/mocks' @@ -12,19 +12,19 @@ afterEach(async () => { }) describe('Tauri file actions', () => { - test('chooses a design file through plugin-dialog', async () => { + test('chooses multiple design files through plugin-dialog', async () => { await mockTauriIPC((cmd, args) => { expect(cmd).toBe('plugin:dialog|open') expect(args).toEqual({ options: { filters: [{ name: 'Design file', extensions: ['fig', 'pen', 'html', 'htm', 'xhtml'] }], - multiple: false + multiple: true } }) - return '/tmp/design.fig' + return ['/tmp/design.fig', '/tmp/design.pen'] }) - await expect(chooseTauriOpenPath()).resolves.toBe('/tmp/design.fig') + await expect(chooseTauriOpenPaths()).resolves.toEqual(['/tmp/design.fig', '/tmp/design.pen']) }) test('reads a Tauri design file into a File object', async () => {