feat(app): open multiple files from the Open dialog (#552)

- Open multiple selected design files in separate tabs.\n- Support desktop, File System Access, and fallback pickers.\n- Continue opening later selections when one file fails.
This commit is contained in:
Victor Wads 2026-08-18 05:30:36 -03:00 committed by GitHub
parent 15bd0ba19f
commit 4e48420ac1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 90 additions and 18 deletions

View file

@ -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.

View file

@ -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<T>(
items: Iterable<T>,
displayName: (item: T) => string,
openItem: (item: T) => Promise<void>
): Promise<void> {
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<File> {
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<string | null> {
export async function chooseTauriOpenPaths(): Promise<string[]> {
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

1
src/global.d.ts vendored
View file

@ -14,6 +14,7 @@ declare global {
}
interface FilePickerOptions {
multiple?: boolean
types?: FilePickerAcceptType[]
suggestedName?: string
}

View file

@ -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'
})
})
})

View file

@ -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 () => {