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:
parent
15bd0ba19f
commit
4e48420ac1
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
}
|
||||
]
|
||||
})
|
||||
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
1
src/global.d.ts
vendored
|
|
@ -14,6 +14,7 @@ declare global {
|
|||
}
|
||||
|
||||
interface FilePickerOptions {
|
||||
multiple?: boolean
|
||||
types?: FilePickerAcceptType[]
|
||||
suggestedName?: string
|
||||
}
|
||||
|
|
|
|||
41
tests/engine/app/shell/menu/file-batch.test.ts
Normal file
41
tests/engine/app/shell/menu/file-batch.test.ts
Normal 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'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue