diff --git a/packages/core/src/io/formats/fig/read.ts b/packages/core/src/io/formats/fig/read.ts index 71e760c6d..75a883552 100644 --- a/packages/core/src/io/formats/fig/read.ts +++ b/packages/core/src/io/formats/fig/read.ts @@ -4,6 +4,7 @@ import type { SceneGraph } from '@open-pencil/scene-graph' import { IS_BROWSER } from '#core/constants' import { importNodeChanges } from '#core/kiwi/fig/import' +import { createFigParseWorker } from '#core/kiwi/fig/parse/client' import { deserializeSceneGraph } from '#core/kiwi/fig/parse/transfer' import type { SerializedSceneGraph } from '#core/kiwi/fig/parse/transfer' import { registerFigPopulationWorker } from '#core/kiwi/fig/population/client' @@ -42,9 +43,7 @@ type WorkerParseResult = WorkerGraphResult | WorkerPageManifestResult function parseViaWorker(buffer: ArrayBuffer, options: ParseFigFileOptions): Promise { return new Promise((resolve, reject) => { - const worker = new Worker(new URL('../../../kiwi/fig/parse/worker.ts', import.meta.url), { - type: 'module' - }) + const worker = createFigParseWorker() worker.onmessage = (e: MessageEvent) => { if (e.data.type === 'page-manifest') { diff --git a/packages/core/src/kiwi/fig/parse/client.ts b/packages/core/src/kiwi/fig/parse/client.ts new file mode 100644 index 000000000..899e85a1c --- /dev/null +++ b/packages/core/src/kiwi/fig/parse/client.ts @@ -0,0 +1,5 @@ +export function createFigParseWorker(): Worker { + return new Worker(new URL('./worker.ts', import.meta.url), { + type: 'module' + }) +} diff --git a/packages/vue/src/i18n/locales/de/dialogs.json b/packages/vue/src/i18n/locales/de/dialogs.json index be8607f03..0b46dd741 100644 --- a/packages/vue/src/i18n/locales/de/dialogs.json +++ b/packages/vue/src/i18n/locales/de/dialogs.json @@ -159,6 +159,15 @@ "recentFilesDescription": "Arbeite an einem Entwurf weiter oder beginne einen neuen.", "noRecentFiles": "Noch keine zuletzt verwendeten Dateien", "noRecentFilesDescription": "Dateien, die du in der Desktop-App öffnest, werden hier angezeigt.", + "searchFiles": "Dateien suchen…", + "searchRecentAndStorageFiles": "Zuletzt verwendete und gespeicherte Dateien suchen…", + "newDesign": "Neues Design", + "noMatchingFiles": "Keine Dateien entsprechen „{query}“.", + "storageProviderS3": "S3-Speicher", + "storageProviderR2": "Cloudflare R2", + "storageProviderAmazonS3": "Amazon S3", + "storageProviderBackblaze": "Backblaze B2", + "loadingStorageWorkspace": "Speicher-Arbeitsbereich wird geladen…", "copyStorageCors": "CORS-JSON kopieren", "storageEndpoint": "Endpunkt", "storageBucket": "Bucket", diff --git a/packages/vue/src/i18n/locales/es/dialogs.json b/packages/vue/src/i18n/locales/es/dialogs.json index aa95087fe..5592b3021 100644 --- a/packages/vue/src/i18n/locales/es/dialogs.json +++ b/packages/vue/src/i18n/locales/es/dialogs.json @@ -159,6 +159,15 @@ "recentFilesDescription": "Continúa trabajando en un diseño o crea uno nuevo.", "noRecentFiles": "Aún no hay archivos recientes", "noRecentFilesDescription": "Los archivos que abras en la aplicación de escritorio aparecerán aquí.", + "searchFiles": "Buscar archivos…", + "searchRecentAndStorageFiles": "Buscar en archivos recientes y almacenados…", + "newDesign": "Nuevo diseño", + "noMatchingFiles": "Ningún archivo coincide con «{query}».", + "storageProviderS3": "Almacenamiento S3", + "storageProviderR2": "Cloudflare R2", + "storageProviderAmazonS3": "Amazon S3", + "storageProviderBackblaze": "Backblaze B2", + "loadingStorageWorkspace": "Cargando espacio de almacenamiento…", "copyStorageCors": "Copiar JSON de CORS", "storageEndpoint": "Punto de conexión", "storageBucket": "Bucket", diff --git a/packages/vue/src/i18n/locales/fr/dialogs.json b/packages/vue/src/i18n/locales/fr/dialogs.json index a45ce3d8f..aca99333b 100644 --- a/packages/vue/src/i18n/locales/fr/dialogs.json +++ b/packages/vue/src/i18n/locales/fr/dialogs.json @@ -159,6 +159,15 @@ "recentFilesDescription": "Continuez un design ou commencez-en un nouveau.", "noRecentFiles": "Aucun fichier récent", "noRecentFilesDescription": "Les fichiers ouverts dans l’application de bureau apparaîtront ici.", + "searchFiles": "Rechercher des fichiers…", + "searchRecentAndStorageFiles": "Rechercher dans les fichiers récents et stockés…", + "newDesign": "Nouveau design", + "noMatchingFiles": "Aucun fichier ne correspond à « {query} ».", + "storageProviderS3": "Stockage S3", + "storageProviderR2": "Cloudflare R2", + "storageProviderAmazonS3": "Amazon S3", + "storageProviderBackblaze": "Backblaze B2", + "loadingStorageWorkspace": "Chargement de l’espace de stockage…", "copyStorageCors": "Copier le JSON CORS", "storageEndpoint": "Point de terminaison", "storageBucket": "Bucket", diff --git a/packages/vue/src/i18n/locales/it/dialogs.json b/packages/vue/src/i18n/locales/it/dialogs.json index af0c7b5d4..e2cef10bf 100644 --- a/packages/vue/src/i18n/locales/it/dialogs.json +++ b/packages/vue/src/i18n/locales/it/dialogs.json @@ -159,6 +159,15 @@ "recentFilesDescription": "Continua a lavorare su un progetto o iniziane uno nuovo.", "noRecentFiles": "Nessun file recente", "noRecentFilesDescription": "I file aperti nell’app desktop appariranno qui.", + "searchFiles": "Cerca file…", + "searchRecentAndStorageFiles": "Cerca nei file recenti e archiviati…", + "newDesign": "Nuovo design", + "noMatchingFiles": "Nessun file corrisponde a «{query}».", + "storageProviderS3": "Archiviazione S3", + "storageProviderR2": "Cloudflare R2", + "storageProviderAmazonS3": "Amazon S3", + "storageProviderBackblaze": "Backblaze B2", + "loadingStorageWorkspace": "Caricamento area di archiviazione…", "copyStorageCors": "Copia JSON CORS", "storageEndpoint": "Endpoint", "storageBucket": "Bucket", diff --git a/packages/vue/src/i18n/locales/ja/dialogs.json b/packages/vue/src/i18n/locales/ja/dialogs.json index f7d133483..ec50fcecc 100644 --- a/packages/vue/src/i18n/locales/ja/dialogs.json +++ b/packages/vue/src/i18n/locales/ja/dialogs.json @@ -159,6 +159,15 @@ "recentFilesDescription": "デザインの作業を続けるか、新しく始めます。", "noRecentFiles": "最近使用したファイルはありません", "noRecentFilesDescription": "デスクトップアプリで開いたファイルがここに表示されます。", + "searchFiles": "ファイルを検索…", + "searchRecentAndStorageFiles": "最近使用したファイルとストレージを検索…", + "newDesign": "新しいデザイン", + "noMatchingFiles": "「{query}」に一致するファイルはありません。", + "storageProviderS3": "S3 ストレージ", + "storageProviderR2": "Cloudflare R2", + "storageProviderAmazonS3": "Amazon S3", + "storageProviderBackblaze": "Backblaze B2", + "loadingStorageWorkspace": "ストレージワークスペースを読み込み中…", "copyStorageCors": "CORS JSONをコピー", "storageEndpoint": "エンドポイント", "storageBucket": "バケット", diff --git a/packages/vue/src/i18n/locales/pl/dialogs.json b/packages/vue/src/i18n/locales/pl/dialogs.json index ae56a015e..1ad9dd2b9 100644 --- a/packages/vue/src/i18n/locales/pl/dialogs.json +++ b/packages/vue/src/i18n/locales/pl/dialogs.json @@ -159,6 +159,15 @@ "recentFilesDescription": "Kontynuuj pracę nad projektem lub rozpocznij nowy.", "noRecentFiles": "Brak ostatnich plików", "noRecentFilesDescription": "Pliki otwarte w aplikacji komputerowej pojawią się tutaj.", + "searchFiles": "Szukaj plików…", + "searchRecentAndStorageFiles": "Szukaj w ostatnich i zapisanych plikach…", + "newDesign": "Nowy projekt", + "noMatchingFiles": "Brak plików pasujących do „{query}”.", + "storageProviderS3": "Przechowywanie S3", + "storageProviderR2": "Cloudflare R2", + "storageProviderAmazonS3": "Amazon S3", + "storageProviderBackblaze": "Backblaze B2", + "loadingStorageWorkspace": "Ładowanie obszaru przechowywania…", "copyStorageCors": "Kopiuj JSON CORS", "storageEndpoint": "Punkt końcowy", "storageBucket": "Zasobnik", diff --git a/packages/vue/src/i18n/locales/ru/dialogs.json b/packages/vue/src/i18n/locales/ru/dialogs.json index faf2aba46..1fa547edc 100644 --- a/packages/vue/src/i18n/locales/ru/dialogs.json +++ b/packages/vue/src/i18n/locales/ru/dialogs.json @@ -159,6 +159,15 @@ "recentFilesDescription": "Продолжите работу над дизайном или начните новый.", "noRecentFiles": "Недавних файлов пока нет", "noRecentFilesDescription": "Файлы, открытые в приложении для компьютера, появятся здесь.", + "searchFiles": "Поиск файлов…", + "searchRecentAndStorageFiles": "Поиск в недавних файлах и хранилище…", + "newDesign": "Новый дизайн", + "noMatchingFiles": "Нет файлов по запросу «{query}».", + "storageProviderS3": "Хранилище S3", + "storageProviderR2": "Cloudflare R2", + "storageProviderAmazonS3": "Amazon S3", + "storageProviderBackblaze": "Backblaze B2", + "loadingStorageWorkspace": "Загрузка рабочей области хранилища…", "copyStorageCors": "Скопировать JSON CORS", "storageEndpoint": "Конечная точка", "storageBucket": "Бакет", diff --git a/packages/vue/src/i18n/locales/zh-cn/dialogs.json b/packages/vue/src/i18n/locales/zh-cn/dialogs.json index eb18c63f7..965ca727e 100644 --- a/packages/vue/src/i18n/locales/zh-cn/dialogs.json +++ b/packages/vue/src/i18n/locales/zh-cn/dialogs.json @@ -159,6 +159,15 @@ "recentFilesDescription": "继续编辑设计或新建设计。", "noRecentFiles": "暂无最近使用的文件", "noRecentFilesDescription": "在桌面应用中打开的文件会显示在这里。", + "searchFiles": "搜索文件…", + "searchRecentAndStorageFiles": "搜索最近使用和存储的文件…", + "newDesign": "新建设计", + "noMatchingFiles": "没有与“{query}”匹配的文件。", + "storageProviderS3": "S3 存储", + "storageProviderR2": "Cloudflare R2", + "storageProviderAmazonS3": "Amazon S3", + "storageProviderBackblaze": "Backblaze B2", + "loadingStorageWorkspace": "正在加载存储工作区…", "copyStorageCors": "复制 CORS JSON", "storageEndpoint": "端点", "storageBucket": "存储桶", diff --git a/packages/vue/src/i18n/messages/dialogs.ts b/packages/vue/src/i18n/messages/dialogs.ts index 354b9454f..970b0cc7b 100644 --- a/packages/vue/src/i18n/messages/dialogs.ts +++ b/packages/vue/src/i18n/messages/dialogs.ts @@ -262,6 +262,15 @@ export const dialogMessageDefaults = { recentFilesDescription: 'Continue working on a design or start a new one.', noRecentFiles: 'No recent files yet', noRecentFilesDescription: 'Files you open in the desktop app will appear here.', + searchFiles: 'Search files…', + searchRecentAndStorageFiles: 'Search recent and storage files…', + newDesign: 'New design', + noMatchingFiles: params('No files match “{query}”.'), + storageProviderS3: 'S3 storage', + storageProviderR2: 'Cloudflare R2', + storageProviderAmazonS3: 'Amazon S3', + storageProviderBackblaze: 'Backblaze B2', + loadingStorageWorkspace: 'Loading storage workspace…', copyStorageCors: 'Copy CORS JSON', storageEndpoint: 'Endpoint', storageBucket: 'Bucket', diff --git a/src/app/cache/index.ts b/src/app/cache/index.ts index 47a9884c0..c01f9d5ca 100644 --- a/src/app/cache/index.ts +++ b/src/app/cache/index.ts @@ -116,25 +116,6 @@ export async function removeCachePrefix(prefix: string): Promise { removeStorageEntriesWithPrefix(prefix) } -export async function removeCacheEntriesWithPrefix(prefix: string): Promise { - if (isTauriRuntime()) { - try { - const { BaseDirectory, readDir, remove } = await import('@tauri-apps/plugin-fs') - const entries = await readDir(APP_CACHE_DIR, { baseDir: BaseDirectory.AppLocalData }) - await Promise.all( - entries - .filter((entry) => entry.isFile && entry.name.startsWith(prefix)) - .map((entry) => remove(cachePath(entry.name), { baseDir: BaseDirectory.AppLocalData })) - ) - } catch (error) { - console.warn(`Cache entry cleanup skipped for "${prefix}":`, error) - } - return - } - - removeStorageEntriesWithPrefix(prefix) -} - type JSONCacheEnvelope = { updatedAt: number value: T diff --git a/src/app/document/io/fig-page-manifest.ts b/src/app/document/io/fig.ts similarity index 83% rename from src/app/document/io/fig-page-manifest.ts rename to src/app/document/io/fig.ts index 03e0f793d..79e5a59a2 100644 --- a/src/app/document/io/fig-page-manifest.ts +++ b/src/app/document/io/fig.ts @@ -4,7 +4,7 @@ import type { FigPageManifestEntry } from '@open-pencil/kiwi/fig' import { SceneGraph } from '@open-pencil/scene-graph' /** Show lightweight page shells while the FIG worker continues decoding the full document. */ -export function showFigPageManifest(editor: Editor, pages: readonly FigPageManifestEntry[]): void { +function showFigPageManifest(editor: Editor, pages: readonly FigPageManifestEntry[]): void { if (pages.length === 0) return const graph = new SceneGraph() @@ -21,7 +21,7 @@ export function showFigPageManifest(editor: Editor, pages: readonly FigPageManif editor.state.loading = true } -export function readFigFileProgressively(file: File, editor: Editor) { +export function readFigDocument(file: File, editor: Editor) { return readFigFile(file, { populate: 'first-page', onPages: (pages) => showFigPageManifest(editor, pages) diff --git a/src/app/document/io/read.ts b/src/app/document/io/read.ts index db0cf3871..530a90d94 100644 --- a/src/app/document/io/read.ts +++ b/src/app/document/io/read.ts @@ -2,7 +2,7 @@ import type { Editor, EditorState } from '@open-pencil/core/editor' import { computeAllLayouts } from '@open-pencil/core/layout' import { yieldToUI } from '@/app/document/io/browser' -import { readFigFileProgressively } from '@/app/document/io/fig-page-manifest' +import { readFigDocument } from '@/app/document/io/fig' import { applyImportedDocument } from '@/app/document/io/imported-document' import { readReloadSource } from '@/app/document/io/reload-source' import { captureReloadState, restoreReloadState } from '@/app/document/io/reload-state' @@ -46,7 +46,7 @@ export function createOpenActions({ try { state.loading = true await yieldToUI() - const imported = await readFigFileProgressively(file, editor) + const imported = await readFigDocument(file, editor) await yieldToUI() await applyImportedDocument(editor, imported) state.documentName = file.name.replace(/\.fig$/i, '') diff --git a/src/app/integrations/storage/providers.ts b/src/app/integrations/storage/providers.ts index 75bee95e9..7c2562624 100644 --- a/src/app/integrations/storage/providers.ts +++ b/src/app/integrations/storage/providers.ts @@ -3,7 +3,7 @@ import { createS3StorageAdapter } from './s3/adapter' export const S3_STORAGE_PROVIDER = defineStorageProvider({ id: 's3-compatible', - label: 'S3 compatible', + label: 'S3 storage', description: 'AWS S3, Backblaze B2, Cloudflare R2, MinIO, and compatible storage', preferenceFields: [ { id: 'endpoint', label: 'Endpoint', kind: 'url', required: true }, diff --git a/src/app/recent-files/index.ts b/src/app/recent-files/index.ts new file mode 100644 index 000000000..620794def --- /dev/null +++ b/src/app/recent-files/index.ts @@ -0,0 +1,19 @@ +export { + clearRecentFiles, + forgetRecentDocument, + forgetRecentFile, + recentDocuments, + recentFiles, + recentLocalFileAt, + recentLocalFilePaths, + rememberRecentFile, + rememberRecentStorageDocument, + type RecentDocument, + type RecentLocalDocument, + type RecentStorageDocument +} from './store' +export { + cacheRecentFileThumbnail, + loadCachedRecentFileThumbnail, + loadRecentFileThumbnail +} from './thumbnails' diff --git a/src/app/recent-files/store.ts b/src/app/recent-files/store.ts new file mode 100644 index 000000000..23a599f37 --- /dev/null +++ b/src/app/recent-files/store.ts @@ -0,0 +1,103 @@ +import { useLocalStorage } from '@vueuse/core' +import { computed } from 'vue' + +import type { StorageProviderID } from '@/app/integrations/storage' + +import { clearRecentFileThumbnails } from './thumbnails' + +const MAX_RECENT_DOCUMENTS = 10 +const RECENT_DOCUMENTS_STORAGE_KEY = 'open-pencil:recent-documents' + +export interface RecentLocalDocument { + id: string + kind: 'local' + path: string + name: string + updatedAt: string +} + +export interface RecentStorageDocument { + id: string + kind: 'storage' + providerId: StorageProviderID + documentId: string + name: string + updatedAt: string +} + +export type RecentDocument = RecentLocalDocument | RecentStorageDocument + +export const recentDocuments = useLocalStorage(RECENT_DOCUMENTS_STORAGE_KEY, []) + +function fileName(path: string): string { + return path.split(/[\\/]/).pop() ?? path +} + +function localDocumentId(path: string): string { + return `local:${path}` +} + +function storageDocumentId(providerId: StorageProviderID, documentId: string): string { + return `storage:${providerId}:${documentId}` +} + +function normalizedRecentDocuments(): RecentDocument[] { + return recentDocuments.value.slice(0, MAX_RECENT_DOCUMENTS) +} + +export const recentFiles = computed(normalizedRecentDocuments) + +export const recentLocalFilePaths = computed(() => + normalizedRecentDocuments().flatMap((document) => + document.kind === 'local' ? [document.path] : [] + ) +) + +function remember(document: RecentDocument): void { + recentDocuments.value = [ + document, + ...normalizedRecentDocuments().filter((recent) => recent.id !== document.id) + ].slice(0, MAX_RECENT_DOCUMENTS) +} + +export function rememberRecentFile(path: string): void { + remember({ + id: localDocumentId(path), + kind: 'local', + path, + name: fileName(path), + updatedAt: new Date().toISOString() + }) +} + +export function rememberRecentStorageDocument( + providerId: StorageProviderID, + documentId: string, + name: string +): void { + remember({ + id: storageDocumentId(providerId, documentId), + kind: 'storage', + providerId, + documentId, + name, + updatedAt: new Date().toISOString() + }) +} + +export function forgetRecentDocument(id: string): void { + recentDocuments.value = normalizedRecentDocuments().filter((document) => document.id !== id) +} + +export function forgetRecentFile(path: string): void { + forgetRecentDocument(localDocumentId(path)) +} + +export async function clearRecentFiles(): Promise { + recentDocuments.value = [] + await clearRecentFileThumbnails() +} + +export function recentLocalFileAt(index: number): string | null { + return recentLocalFilePaths.value[index] ?? null +} diff --git a/src/app/recent-files/thumbnails.ts b/src/app/recent-files/thumbnails.ts new file mode 100644 index 000000000..899d7c95e --- /dev/null +++ b/src/app/recent-files/thumbnails.ts @@ -0,0 +1,65 @@ +import { extractFigThumbnailFromReader } from '@open-pencil/fig' + +import { readCacheBytes, removeCachePrefix, writeCacheBytes } from '@/app/cache' +import { isTauri } from '@/app/tauri/env' + +const RECENT_FILE_THUMBNAIL_CACHE_DIR = 'recent-file-thumbnails/v2' + +async function recentFileThumbnailCacheKey(path: string): Promise { + let fingerprint = path + try { + const { stat } = await import('@tauri-apps/plugin-fs') + const info = await stat(path) + fingerprint = `${path}\0${info.size}\0${info.mtime?.getTime() ?? 0}` + } catch (error) { + // A path-only key still permits a preview when metadata is unavailable. + console.warn('[Recent files] Could not stat the file for thumbnail caching', error) + } + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(fingerprint)) + const hash = [...new Uint8Array(digest)] + .map((byte) => byte.toString(16).padStart(2, '0')) + .join('') + return `${RECENT_FILE_THUMBNAIL_CACHE_DIR}/${hash}.png` +} + +export async function loadCachedRecentFileThumbnail(path: string): Promise { + if (!isTauri()) return null + const bytes = await readCacheBytes(await recentFileThumbnailCacheKey(path)) + return bytes ? new Uint8Array(bytes) : null +} + +export async function cacheRecentFileThumbnail(path: string, bytes: Uint8Array): Promise { + if (!isTauri()) return + await writeCacheBytes(await recentFileThumbnailCacheKey(path), Uint8Array.from(bytes).buffer) +} + +export function clearRecentFileThumbnails(): Promise { + return removeCachePrefix(RECENT_FILE_THUMBNAIL_CACHE_DIR) +} + +export async function loadRecentFileThumbnail(path: string): Promise { + if (!isTauri() || !path.toLowerCase().endsWith('.fig')) return null + const cached = await loadCachedRecentFileThumbnail(path) + if (cached) return cached + const { open, SeekMode } = await import('@tauri-apps/plugin-fs') + const file = await open(path, { read: true }) + try { + const info = await file.stat() + return await extractFigThumbnailFromReader({ + size: info.size, + async read(start, endExclusive) { + await file.seek(start, SeekMode.Start) + const bytes = new Uint8Array(endExclusive - start) + let offset = 0 + while (offset < bytes.byteLength) { + const count = await file.read(bytes.subarray(offset)) + if (count === null) break + offset += count + } + return offset === bytes.byteLength ? bytes : bytes.subarray(0, offset) + } + }) + } finally { + await file.close() + } +} diff --git a/src/app/shell/keyboard/use.ts b/src/app/shell/keyboard/use.ts index a223f8bfa..92a568d57 100644 --- a/src/app/shell/keyboard/use.ts +++ b/src/app/shell/keyboard/use.ts @@ -11,7 +11,7 @@ import { isInputElement } from '@/app/shell/keyboard/focus' import { bindNudgeKeys } from '@/app/shell/keyboard/nudging' import { registerKeyboardShortcuts } from '@/app/shell/keyboard/registry' import { openFileDialog } from '@/app/shell/menu/use' -import { closeTab, createTab, activeTab as activeTabRef } from '@/app/tabs' +import { closeTab, createHomeTab, activeTab as activeTabRef } from '@/app/tabs' export function useKeyboard() { const { activeTab } = useAIChat() @@ -43,6 +43,6 @@ export function useKeyboard() { closeActiveTab: () => { if (activeTabRef.value) void closeTab(activeTabRef.value.id) }, - createTab: () => createTab() + createTab: () => createHomeTab() }) } diff --git a/src/app/shell/menu/editor.ts b/src/app/shell/menu/editor.ts index 1197cb68c..84ed8bb62 100644 --- a/src/app/shell/menu/editor.ts +++ b/src/app/shell/menu/editor.ts @@ -2,16 +2,11 @@ import { useEditorCommands } from '@open-pencil/vue' import type { EditorCommandId } from '@open-pencil/vue' import { useEditorStore } from '@/app/editor/active-store' +import { clearRecentFiles, forgetRecentFile, recentLocalFileAt } from '@/app/recent-files' import { createSharedEditorMenuActions } from '@/app/shell/menu/editor-actions' import { openFileDialog, openFileFromPath } from '@/app/shell/menu/files' import { useNativeMenuEvents } from '@/app/shell/menu/native-events' -import { - clearRecentFiles, - forgetRecentFile, - OPEN_RECENT_EVENT_PREFIX, - recentFileAt, - syncRecentFilesMenu -} from '@/app/shell/menu/recent-files' +import { OPEN_RECENT_EVENT_PREFIX, watchRecentFilesMenu } from '@/app/shell/menu/recent-files' import { APP_MENU_SCHEMA, type AppMenuEntry } from '@/app/shell/menu/schema' import { createSelectionMenuActions } from '@/app/shell/menu/selection-actions' import { SHELL_MENU_IDS } from '@/app/shell/menu/shell' @@ -34,9 +29,7 @@ const COMMAND_MENU_IDS = new Set( export function useEditorMenu() { if (!isTauri()) return - void syncRecentFilesMenu().catch((error) => { - console.warn('[Recent files] Failed to initialize the native menu', error) - }) + watchRecentFilesMenu() const { setTheme } = useAppTheme() const { runCommand } = useEditorCommands() @@ -78,7 +71,7 @@ export function useEditorMenu() { } if (id.startsWith(OPEN_RECENT_EVENT_PREFIX)) { const index = Number(id.slice(OPEN_RECENT_EVENT_PREFIX.length)) - const path = Number.isInteger(index) ? recentFileAt(index) : null + const path = Number.isInteger(index) ? recentLocalFileAt(index) : null if (path) { void openFileFromPath(path).catch((error) => { forgetRecentFile(path) diff --git a/src/app/shell/menu/files.ts b/src/app/shell/menu/files.ts index 4e9aecb0c..4acb47283 100644 --- a/src/app/shell/menu/files.ts +++ b/src/app/shell/menu/files.ts @@ -1,16 +1,30 @@ import { useFileDialog } from '@vueuse/core' +import { BUILTIN_IO_FORMATS, IORegistry } from '@open-pencil/core/io' + import { setOpenPencilOpenFileHandler } from '@/app/browser-bridge' import { resolveBrowserFileURL } from '@/app/document/io/browser' import { notificationMessages } from '@/app/i18n/notifications' -import { rememberRecentFile } from '@/app/shell/menu/recent-files' +import { rememberRecentFile } from '@/app/recent-files' import { toast } from '@/app/shell/ui' import { openFileInNewTab } from '@/app/tabs' import { isTauri } from '@/app/tauri/env' import { IS_BROWSER } from '@/constants' +const io = new IORegistry(BUILTIN_IO_FORMATS) +const DOM_DOCUMENT_EXTENSIONS = ['html', 'htm', 'xhtml'] as const +const READABLE_DOCUMENT_EXTENSIONS = [ + ...new Set([ + ...io.listReadableFormats().flatMap((format) => format.extensions), + ...DOM_DOCUMENT_EXTENSIONS + ]) +] +const DESIGN_FILE_ACCEPT = READABLE_DOCUMENT_EXTENSIONS.map((extension) => `.${extension}`).join( + ',' +) + const fileDialog = useFileDialog({ - accept: '.fig,.pen,.html,.htm,.xhtml', + accept: DESIGN_FILE_ACCEPT, multiple: true, reset: true }) @@ -20,7 +34,10 @@ fileDialog.onChange((files) => { void openDesignFileBatch( files, (file) => file.name, - (file) => openFileInNewTab(file) + (file) => { + assertSupportedDesignFile(file.name) + return openFileInNewTab(file) + } ) }) @@ -30,11 +47,26 @@ if (IS_BROWSER && 'window' in globalThis) { const response = await fetch(resourceURL) const blob = await response.blob() const name = resourceURL.pathname.split('/').pop() ?? 'file.fig' + assertSupportedDesignFile(name) const file = new File([blob], name, { type: 'application/octet-stream' }) await openFileInNewTab(file, undefined, resourceURL.href) }) } +function isSupportedDesignFile(fileName: string): boolean { + const lowerName = fileName.toLowerCase() + return ( + io.findReader(fileName) !== null || + DOM_DOCUMENT_EXTENSIONS.some((extension) => lowerName.endsWith(`.${extension}`)) + ) +} + +function assertSupportedDesignFile(fileName: string): void { + if (!isSupportedDesignFile(fileName)) { + throw new Error(`Unsupported document format: ${fileName}`) + } +} + export async function openDesignFileBatch( items: Iterable, displayName: (item: T) => string, @@ -53,6 +85,7 @@ export async function openDesignFileBatch( } export async function readTauriDesignFile(path: string): Promise { + assertSupportedDesignFile(path) const { readFile } = await import('@tauri-apps/plugin-fs') const bytes = await readFile(path) return new File([bytes], path.split('/').pop() ?? 'file.fig') @@ -61,7 +94,7 @@ export async function readTauriDesignFile(path: string): Promise { export async function chooseTauriOpenPaths(): Promise { const { open } = await import('@tauri-apps/plugin-dialog') const paths = await open({ - filters: [{ name: 'Design file', extensions: ['fig', 'pen', 'html', 'htm', 'xhtml'] }], + filters: [{ name: 'Design file', extensions: READABLE_DOCUMENT_EXTENSIONS }], multiple: true }) if (!paths) return [] @@ -104,6 +137,7 @@ export async function openFileDialog() { (handle) => handle.name, async (handle) => { const file = await handle.getFile() + assertSupportedDesignFile(file.name) await openFileInNewTab(file, handle) } ) diff --git a/src/app/shell/menu/navigation.ts b/src/app/shell/menu/navigation.ts index b57d471ec..9523b427b 100644 --- a/src/app/shell/menu/navigation.ts +++ b/src/app/shell/menu/navigation.ts @@ -4,5 +4,5 @@ export function openStorageWorkspace(router: Router): void { void router .push('/') .then(() => import('@/app/tabs')) - .then(({ showRecentFiles }) => showRecentFiles()) + .then(({ showNewTab }) => showNewTab()) } diff --git a/src/app/shell/menu/recent-files.ts b/src/app/shell/menu/recent-files.ts index b6e102dd9..52d32b4ab 100644 --- a/src/app/shell/menu/recent-files.ts +++ b/src/app/shell/menu/recent-files.ts @@ -1,152 +1,24 @@ -import { useLocalStorage } from '@vueuse/core' -import { computed } from 'vue' +import { watch } from 'vue' -import { extractFigThumbnailFromReader } from '@open-pencil/fig' - -import { - readCacheBytes, - removeCacheEntriesWithPrefix, - removeCachePrefix, - writeCacheBytes -} from '@/app/cache' +import { recentLocalFilePaths } from '@/app/recent-files' import { isTauri } from '@/app/tauri/env' -const MAX_RECENT_FILES = 10 -const RECENT_FILES_STORAGE_KEY = 'open-pencil:recent-files' -const RECENT_FILE_OPENED_AT_STORAGE_KEY = 'open-pencil:recent-file-opened-at' -const RECENT_FILE_THUMBNAIL_CACHE_DIR = 'recent-file-thumbnails/v2' -const LEGACY_RECENT_FILE_THUMBNAIL_CACHE_PREFIX = 'recent-file-thumbnail-v2-' - -export type RecentFile = { - id: string - path: string - name: string - updatedAt: string -} - export const OPEN_RECENT_EVENT_PREFIX = 'open-recent:' -export const recentFilePaths = useLocalStorage(RECENT_FILES_STORAGE_KEY, []) -const recentFileOpenedAt = useLocalStorage>( - RECENT_FILE_OPENED_AT_STORAGE_KEY, - {} -) - -function fileName(path: string): string { - return path.split(/[\\/]/).pop() ?? path -} - -export const recentFiles = computed(() => - normalizedRecentFiles().map((path) => ({ - id: path, - path, - name: fileName(path), - updatedAt: recentFileOpenedAt.value[path] ?? new Date(0).toISOString() - })) -) - -function normalizedRecentFiles(): string[] { - return recentFilePaths.value - .filter((path): path is string => typeof path === 'string' && path.length > 0) - .slice(0, MAX_RECENT_FILES) -} export async function syncRecentFilesMenu(): Promise { if (!isTauri()) return const { invoke } = await import('@tauri-apps/api/core') - await invoke('set_recent_files', { paths: normalizedRecentFiles() }) + await invoke('set_recent_files', { paths: recentLocalFilePaths.value }) } -function updateRecentFiles(paths: string[]): void { - const nextPaths = paths.slice(0, MAX_RECENT_FILES) - const retainedPaths = new Set(nextPaths) - recentFilePaths.value = nextPaths - recentFileOpenedAt.value = Object.fromEntries( - Object.entries(recentFileOpenedAt.value).filter(([path]) => retainedPaths.has(path)) +export function watchRecentFilesMenu(): () => void { + return watch( + recentLocalFilePaths, + () => { + void syncRecentFilesMenu().catch((error) => { + console.warn('[Recent files] Failed to update the native menu', error) + }) + }, + { immediate: true } ) - void syncRecentFilesMenu().catch((error) => { - console.warn('[Recent files] Failed to update the native menu', error) - }) -} - -export function rememberRecentFile(path: string): void { - recentFileOpenedAt.value = { - ...recentFileOpenedAt.value, - [path]: new Date().toISOString() - } - updateRecentFiles([path, ...normalizedRecentFiles().filter((recent) => recent !== path)]) -} - -export function forgetRecentFile(path: string): void { - recentFileOpenedAt.value = Object.fromEntries( - Object.entries(recentFileOpenedAt.value).filter(([recent]) => recent !== path) - ) - updateRecentFiles(normalizedRecentFiles().filter((recent) => recent !== path)) -} - -export async function clearRecentFiles(): Promise { - recentFileOpenedAt.value = {} - updateRecentFiles([]) - await Promise.all([ - removeCachePrefix(RECENT_FILE_THUMBNAIL_CACHE_DIR), - removeCacheEntriesWithPrefix(LEGACY_RECENT_FILE_THUMBNAIL_CACHE_PREFIX) - ]) -} - -export function recentFileAt(index: number): string | null { - return normalizedRecentFiles()[index] ?? null -} - -async function recentFileThumbnailCacheKey(path: string): Promise { - let fingerprint = path - try { - const { stat } = await import('@tauri-apps/plugin-fs') - const info = await stat(path) - fingerprint = `${path}\0${info.size}\0${info.mtime?.getTime() ?? 0}` - } catch (error) { - // A path-only key still permits a preview when metadata is unavailable. - console.warn('[Recent files] Could not stat the file for thumbnail caching', error) - } - const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(fingerprint)) - const hash = [...new Uint8Array(digest)] - .map((byte) => byte.toString(16).padStart(2, '0')) - .join('') - return `${RECENT_FILE_THUMBNAIL_CACHE_DIR}/${hash}.png` -} - -export async function loadCachedRecentFileThumbnail(path: string): Promise { - if (!isTauri()) return null - const bytes = await readCacheBytes(await recentFileThumbnailCacheKey(path)) - return bytes ? new Uint8Array(bytes) : null -} - -export async function cacheRecentFileThumbnail(path: string, bytes: Uint8Array): Promise { - if (!isTauri()) return - await writeCacheBytes(await recentFileThumbnailCacheKey(path), Uint8Array.from(bytes).buffer) -} - -export async function loadRecentFileThumbnail(path: string): Promise { - if (!isTauri() || !path.toLowerCase().endsWith('.fig')) return null - const cached = await loadCachedRecentFileThumbnail(path) - if (cached) return cached - const { open, SeekMode } = await import('@tauri-apps/plugin-fs') - const file = await open(path, { read: true }) - try { - const info = await file.stat() - return await extractFigThumbnailFromReader({ - size: info.size, - async read(start, endExclusive) { - await file.seek(start, SeekMode.Start) - const bytes = new Uint8Array(endExclusive - start) - let offset = 0 - while (offset < bytes.byteLength) { - const count = await file.read(bytes.subarray(offset)) - if (count === null) break - offset += count - } - return offset === bytes.byteLength ? bytes : bytes.subarray(0, offset) - } - }) - } finally { - await file.close() - } } diff --git a/src/app/tabs/index.ts b/src/app/tabs/index.ts index 2c23803ca..b797ece12 100644 --- a/src/app/tabs/index.ts +++ b/src/app/tabs/index.ts @@ -9,7 +9,7 @@ import { computeAllLayouts } from '@open-pencil/core/layout' import type { SceneGraph } from '@open-pencil/scene-graph' import { setOpenPencilStore } from '@/app/browser-bridge' -import { readFigFileProgressively } from '@/app/document/io/fig-page-manifest' +import { readFigDocument } from '@/app/document/io/fig' import type { DocumentSourceIdentity } from '@/app/document/io/types' import { getRecoveryStore, type RecoverySnapshotMeta } from '@/app/document/recovery' import { setActiveEditorStore } from '@/app/editor/active-store' @@ -22,17 +22,20 @@ import { } from '@/app/integrations/storage' import { cacheRecentFileThumbnail, - loadCachedRecentFileThumbnail -} from '@/app/shell/menu/recent-files' + loadCachedRecentFileThumbnail, + rememberRecentStorageDocument +} from '@/app/recent-files' import { getLocalCanvasStore } from '@/app/storage/local-store' import { seedStorageCanvasFromRemote } from '@/app/storage/sync/persist' import { createFileOpenCoordinator } from '@/app/tabs/open/coordinator' import { findTabByFileIdentity } from '@/app/tabs/open/identity' +export type TabKind = 'home' | 'document' + export interface Tab { id: string store: EditorStore - showHome: boolean + kind: TabKind } const io = new IORegistry(BUILTIN_IO_FORMATS) @@ -55,7 +58,7 @@ export const allTabs = computed(() => tabsRef.value.map((t) => ({ id: t.id, name: t.store.state.documentName, - isHome: t.showHome, + isHome: t.kind === 'home', isActive: t.id === activeTabId.value })) ) @@ -82,9 +85,16 @@ export function getTabsSnapshot(): Tab[] { return [...tabsRef.value] } -export function createTab(store?: EditorStore, initialGraph?: SceneGraph, showHome = false): Tab { +export function createTab(store?: EditorStore, initialGraph?: SceneGraph): Tab { const s = store ?? createEditorStore(initialGraph) - const tab: Tab = { id: generateTabId(), store: s, showHome } + const tab: Tab = { id: generateTabId(), store: s, kind: 'document' } + tabsRef.value = [...tabsRef.value, tab] + activateTab(tab) + return tab +} + +export function createHomeTab(): Tab { + const tab: Tab = { id: generateTabId(), store: createEditorStore(), kind: 'home' } tabsRef.value = [...tabsRef.value, tab] activateTab(tab) return tab @@ -94,17 +104,24 @@ export function leaveHome(tabId: string): void { const tabIndex = tabsRef.value.findIndex((candidate) => candidate.id === tabId) if (tabIndex === -1) return const tab = tabsRef.value[tabIndex] - if (!tab.showHome) return - tabsRef.value = tabsRef.value.with(tabIndex, { ...tab, showHome: false }) + if (tab.kind !== 'home') return + tabsRef.value = tabsRef.value.with(tabIndex, { ...tab, kind: 'document' }) } -export function showRecentFiles(): void { - const homeTab = tabsRef.value.find((tab) => tab.showHome) +export function createDocumentInCurrentTab(): Tab { + const current = activeTab.value + if (current?.kind !== 'home') return createTab() + leaveHome(current.id) + return getTabById(current.id) ?? current +} + +export function showNewTab(): void { + const homeTab = tabsRef.value.find((tab) => tab.kind === 'home') if (homeTab) { switchTab(homeTab.id) return } - createTab(undefined, undefined, true) + createHomeTab() } function activateTab(tab: Tab) { @@ -129,7 +146,7 @@ export async function closeTab(tabId: string): Promise { if (idx === -1) return const closingTab = tabsRef.value[idx] - if (closingTab.showHome) return + if (closingTab.kind === 'home' && tabsRef.value.length === 1) return const wasActive = activeTabId.value === tabId coverThumbnailListeners.get(closingTab.store)?.() coverThumbnailListeners.delete(closingTab.store) @@ -138,7 +155,7 @@ export async function closeTab(tabId: string): Promise { tabsRef.value = tabsRef.value.filter((t) => t.id !== tabId) if (tabsRef.value.length === 0) { - createTab(undefined, undefined, true) + createHomeTab() return } @@ -160,7 +177,10 @@ function isDOMImportFile(file: File): boolean { function reusableTabStore(): { store: EditorStore; created: boolean } { const current = activeTab.value - if (current?.showHome) return { store: createTab().store, created: true } + if (current?.kind === 'home') { + leaveHome(current.id) + return { store: current.store, created: false } + } const isUntouched = current?.store.state.documentName === 'Untitled' && !current.store.undo.canUndo if (isUntouched) { @@ -171,7 +191,7 @@ function reusableTabStore(): { store: EditorStore; created: boolean } { } async function readFigForTab(file: File, store: EditorStore): Promise { - const imported = await readFigFileProgressively(file, store) + const imported = await readFigDocument(file, store) const firstPageId = imported.getPages()[0]?.id if (firstPageId) computeAllLayouts(imported, firstPageId) const coverPageId = findFigThumbnailPageId(imported.getPages()) @@ -250,6 +270,7 @@ export async function openStorageDocumentInNewTab(document: StorageDocument): Pr const existing = findStorageTab(providerId, document.id) if (existing) { switchTab(existing.id) + rememberRecentStorageDocument(providerId, document.id, document.name) return } @@ -286,6 +307,7 @@ export async function openStorageDocumentInNewTab(document: StorageDocument): Pr await showImportedGraph(store, imported, () => store.setStorageDocumentSource({ providerId, documentId: document.id }, document.name) ) + rememberRecentStorageDocument(providerId, document.id, document.name) } catch (error) { if (created) { const tab = getTabForStore(store) @@ -431,6 +453,8 @@ export function useTabsStore() { return { tabs: allTabs, activeTabId, + createHomeTab, + createDocumentInCurrentTab, createTab, leaveHome, switchTab, diff --git a/src/components/TabBar.vue b/src/components/TabBar.vue index 469cae256..0e42a9123 100644 --- a/src/components/TabBar.vue +++ b/src/components/TabBar.vue @@ -5,7 +5,7 @@ import { tv } from 'tailwind-variants' import Tip from '@/components/ui/Tip.vue' import tabBarTheme from '@/theme/tab-bar' -import { useTabsStore, showRecentFiles } from '@/app/tabs' +import { useTabsStore, createHomeTab } from '@/app/tabs' import { useI18n } from '@open-pencil/vue' const { dialogs } = useI18n() @@ -19,8 +19,14 @@ const modelValue = computed({ set: (id: string) => switchTab(id) }) +function createNewTab(event: MouseEvent): void { + event.preventDefault() + if (event.currentTarget instanceof HTMLElement) event.currentTarget.blur() + createHomeTab() +} + function onMiddleClick(e: MouseEvent, tabId: string, isHome: boolean) { - if (e.button === 1 && !isHome) { + if (e.button === 1 && (!isHome || tabs.value.length > 1)) { e.preventDefault() void closeTab(tabId) } @@ -51,13 +57,16 @@ function onClose(e: MouseEvent, tabId: string) { > - {{ tab.isHome ? dialogs.recentFiles : tab.name }} - + {{ tab.isHome ? dialogs.newTab : tab.name }} + diff --git a/src/components/editor/EditorWorkspace.vue b/src/components/editor/EditorWorkspace.vue new file mode 100644 index 000000000..2e7fad084 --- /dev/null +++ b/src/components/editor/EditorWorkspace.vue @@ -0,0 +1,126 @@ + + + diff --git a/src/components/home/HomeWorkspace.vue b/src/components/home/HomeWorkspace.vue new file mode 100644 index 000000000..ab7b5d753 --- /dev/null +++ b/src/components/home/HomeWorkspace.vue @@ -0,0 +1,440 @@ + + + diff --git a/src/components/home/search/HomeSearchActions.vue b/src/components/home/search/HomeSearchActions.vue new file mode 100644 index 000000000..67d823572 --- /dev/null +++ b/src/components/home/search/HomeSearchActions.vue @@ -0,0 +1,67 @@ + + + diff --git a/src/components/recent-files/RecentFilesHome.vue b/src/components/recent-files/RecentFilesHome.vue deleted file mode 100644 index 3db15ab15..000000000 --- a/src/components/recent-files/RecentFilesHome.vue +++ /dev/null @@ -1,313 +0,0 @@ - - - diff --git a/src/router.ts b/src/router.ts index 79540fd9c..eeed39002 100644 --- a/src/router.ts +++ b/src/router.ts @@ -1,15 +1,14 @@ import { createRouter, createWebHistory } from 'vue-router' -import EditorView from './views/EditorView.vue' -import StorageView from './views/StorageView.vue' +import WorkspaceView from './views/WorkspaceView.vue' const router = createRouter({ history: createWebHistory(), routes: [ - { path: '/', component: EditorView }, - { path: '/storage', component: StorageView }, - { path: '/demo', component: EditorView, meta: { demo: true } }, - { path: '/share/:roomId', component: EditorView } + { path: '/', component: WorkspaceView }, + { path: '/storage', redirect: '/' }, + { path: '/demo', component: WorkspaceView, meta: { demo: true } }, + { path: '/share/:roomId', component: WorkspaceView } ] }) diff --git a/src/theme/tab-bar.ts b/src/theme/tab-bar.ts index 536f04176..1e73508cf 100644 --- a/src/theme/tab-bar.ts +++ b/src/theme/tab-bar.ts @@ -3,14 +3,14 @@ const tabBarTheme = { root: 'scrollbar-none flex h-9 shrink-0 items-end overflow-x-auto border-b border-border bg-canvas', list: 'flex h-full items-end', trigger: - 'group/tab flex h-full max-w-48 min-w-0 cursor-pointer items-center gap-1.5 border-r border-border px-3 text-[11px] transition-colors outline-none select-none focus-visible:ring-1 focus-visible:ring-accent', + 'group/tab flex h-full max-w-48 min-w-0 cursor-pointer touch-manipulation items-center gap-1.5 border-r border-border px-3 text-[11px] transition-colors outline-none select-none focus-visible:ring-1 focus-visible:ring-accent', icon: 'size-3 shrink-0 opacity-50', label: 'min-w-0 flex-1 truncate', close: - 'flex size-4 shrink-0 cursor-pointer items-center justify-center rounded transition-opacity group-hover/tab:opacity-100 hover:bg-hover focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-panel-focus', + 'flex size-6 shrink-0 cursor-pointer touch-manipulation items-center justify-center rounded transition-opacity group-hover/tab:opacity-100 hover:bg-hover focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-panel-focus sm:size-4', closeIcon: 'size-3', newAction: - 'flex size-9 shrink-0 cursor-pointer items-center justify-center text-muted transition-colors hover:text-surface focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-panel-focus', + 'flex size-9 shrink-0 cursor-pointer touch-manipulation items-center justify-center text-muted transition-colors hover:text-surface focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-panel-focus', newIcon: 'size-3.5' }, variants: { diff --git a/src/views/EditorView.vue b/src/views/EditorView.vue deleted file mode 100644 index beaa4647c..000000000 --- a/src/views/EditorView.vue +++ /dev/null @@ -1,237 +0,0 @@ - - - diff --git a/src/views/StorageView.vue b/src/views/StorageView.vue deleted file mode 100644 index 0d45c0075..000000000 --- a/src/views/StorageView.vue +++ /dev/null @@ -1,174 +0,0 @@ - - - diff --git a/src/views/WorkspaceView.vue b/src/views/WorkspaceView.vue new file mode 100644 index 000000000..30517d758 --- /dev/null +++ b/src/views/WorkspaceView.vue @@ -0,0 +1,112 @@ + + + diff --git a/tests/e2e/app/recent-files.spec.ts b/tests/e2e/app/recent-files.spec.ts index 6d43f1d82..c3e5ae69b 100644 --- a/tests/e2e/app/recent-files.spec.ts +++ b/tests/e2e/app/recent-files.spec.ts @@ -1,5 +1,21 @@ import { expect, test } from '#tests/e2e/fixtures' +test('keeps the New tab layout usable on mobile', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }) + await page.goto('/?recent-files') + + const search = page.getByLabel('Search files…') + await expect(search).toBeVisible() + await expect(search).not.toBeFocused() + await expect(search).toHaveAttribute('placeholder', 'Search files…') + await expect(page.getByTestId('home-open-file')).toBeVisible() + await expect(page.getByTestId('home-new-document')).toBeVisible() + await expect(page.getByTestId('recent-files-home')).toHaveJSProperty( + 'scrollWidth', + await page.getByTestId('recent-files-home').evaluate((element) => element.clientWidth) + ) +}) + test('opens to the recent-files home and starts a new document', async ({ page }) => { await page.goto('/?recent-files') @@ -11,18 +27,21 @@ test('opens to the recent-files home and starts a new document', async ({ page } await expect(page.getByTestId('recent-files-home')).toBeHidden() const tab = page.getByTestId('tabbar-tab') - await expect(tab).toHaveCount(2) + await expect(tab).toHaveCount(1) + await expect(tab).toContainText('Untitled') + await expect(page.getByTestId('tabbar-close')).toHaveCount(1) await page.getByTestId('tabbar-new').click() await expect(page.getByTestId('recent-files-home')).toBeVisible() await expect(page.getByTestId('tabbar-tab')).toHaveCount(2) - await expect(page.getByTestId('tabbar-tab').first()).toContainText('Recent files') - await expect(page.getByTestId('tabbar-close')).toHaveCount(1) + await expect(page.getByTestId('tabbar-tab').last()).toContainText('New tab') + await expect(page.getByLabel('Search files…')).toBeFocused() + await expect(page.getByTestId('tabbar-close')).toHaveCount(2) - await page.getByTestId('tabbar-tab').last().click() - await page.getByTestId('tabbar-tab').last().hover() - await page.getByTestId('tabbar-tab').last().getByTestId('tabbar-close').click() + await page.getByTestId('tabbar-tab').first().click() + await page.getByTestId('tabbar-tab').first().hover() + await page.getByTestId('tabbar-tab').first().getByTestId('tabbar-close').click() await expect(page.getByTestId('recent-files-home')).toBeVisible() }) diff --git a/tests/engine/app/cache.test.ts b/tests/engine/app/cache.test.ts index 92e54fffd..53c49c853 100644 --- a/tests/engine/app/cache.test.ts +++ b/tests/engine/app/cache.test.ts @@ -56,20 +56,4 @@ describe('app cache', () => { await expect(readCacheText('openrouter/other')).resolves.toBeNull() await expect(readCacheText('fonts/manifest')).resolves.toBe('fonts') }) - - test('removes web cache entries sharing a file-name prefix', async () => { - installLocalStorage() - const { readCacheText, removeCacheEntriesWithPrefix, writeCacheText } = - await import('@/app/cache') - - await writeCacheText('recent-file-thumbnail-v2-one.png', 'one') - await writeCacheText('recent-file-thumbnail-v2-two.png', 'two') - await writeCacheText('font-cache/v1/manifest', 'fonts') - - await removeCacheEntriesWithPrefix('recent-file-thumbnail-v2-') - - await expect(readCacheText('recent-file-thumbnail-v2-one.png')).resolves.toBeNull() - await expect(readCacheText('recent-file-thumbnail-v2-two.png')).resolves.toBeNull() - await expect(readCacheText('font-cache/v1/manifest')).resolves.toBe('fonts') - }) }) diff --git a/tests/engine/app/recent-files/store.test.ts b/tests/engine/app/recent-files/store.test.ts new file mode 100644 index 000000000..3a13cea15 --- /dev/null +++ b/tests/engine/app/recent-files/store.test.ts @@ -0,0 +1,56 @@ +import { afterEach, describe, expect, test } from 'bun:test' + +import { + clearRecentFiles, + forgetRecentFile, + recentDocuments, + recentFiles, + recentLocalFileAt, + rememberRecentFile, + rememberRecentStorageDocument +} from '@/app/recent-files' + +afterEach(() => clearRecentFiles()) + +describe('recent documents', () => { + test('keeps the latest local file first without duplicates', () => { + recentDocuments.value = [] + + rememberRecentFile('/tmp/first.fig') + rememberRecentFile('/tmp/second.fig') + rememberRecentFile('/tmp/first.fig') + + expect(recentFiles.value.map(({ kind, name }) => ({ kind, name }))).toEqual([ + { kind: 'local', name: 'first.fig' }, + { kind: 'local', name: 'second.fig' } + ]) + expect(recentLocalFileAt(0)).toBe('/tmp/first.fig') + }) + + test('tracks storage documents alongside local files', () => { + rememberRecentFile('/tmp/local.fig') + rememberRecentStorageDocument('s3-compatible', 'remote-1', 'Remote design') + + expect(recentFiles.value.map(({ id, kind, name }) => ({ id, kind, name }))).toEqual([ + { + id: 'storage:s3-compatible:remote-1', + kind: 'storage', + name: 'Remote design' + }, + { id: 'local:/tmp/local.fig', kind: 'local', name: 'local.fig' } + ]) + expect(recentLocalFileAt(0)).toBe('/tmp/local.fig') + }) + + test('forgets missing local files and clears the list', () => { + rememberRecentFile('/tmp/first.fig') + rememberRecentFile('/tmp/second.fig') + + forgetRecentFile('/tmp/first.fig') + expect(recentFiles.value.map((document) => document.name)).toEqual(['second.fig']) + + clearRecentFiles() + expect(recentFiles.value).toEqual([]) + expect(recentLocalFileAt(0)).toBeNull() + }) +}) diff --git a/tests/engine/app/shell/menu/recent-files.test.ts b/tests/engine/app/shell/menu/recent-files.test.ts deleted file mode 100644 index 3fd184b28..000000000 --- a/tests/engine/app/shell/menu/recent-files.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { afterEach, describe, expect, test } from 'bun:test' - -import { - clearRecentFiles, - forgetRecentFile, - recentFileAt, - recentFilePaths, - recentFiles, - rememberRecentFile -} from '@/app/shell/menu/recent-files' - -afterEach(() => clearRecentFiles()) - -describe('recent files', () => { - test('keeps the latest file first without duplicates', () => { - recentFilePaths.value = [] - - rememberRecentFile('/tmp/first.fig') - rememberRecentFile('/tmp/second.fig') - rememberRecentFile('/tmp/first.fig') - - expect(recentFilePaths.value).toEqual(['/tmp/first.fig', '/tmp/second.fig']) - expect(recentFiles.value.map(({ id, name }) => ({ id, name }))).toEqual([ - { id: '/tmp/first.fig', name: 'first.fig' }, - { id: '/tmp/second.fig', name: 'second.fig' } - ]) - expect(recentFileAt(0)).toBe('/tmp/first.fig') - }) - - test('forgets missing files and clears the list', () => { - recentFilePaths.value = ['/tmp/first.fig', '/tmp/second.fig'] - - forgetRecentFile('/tmp/first.fig') - expect(recentFilePaths.value).toEqual(['/tmp/second.fig']) - - clearRecentFiles() - expect(recentFilePaths.value).toEqual([]) - expect(recentFileAt(0)).toBeNull() - }) -}) diff --git a/tests/engine/app/tabs/open-file-dedup.test.ts b/tests/engine/app/tabs/open-file-dedup.test.ts index ed26c93db..ee52963db 100644 --- a/tests/engine/app/tabs/open-file-dedup.test.ts +++ b/tests/engine/app/tabs/open-file-dedup.test.ts @@ -7,11 +7,13 @@ import { SceneGraph } from '@open-pencil/scene-graph' import { resolveBrowserFileURL } from '@/app/document/io/browser' import type { DocumentSourceIdentity } from '@/app/document/io/types' import { + createDocumentInCurrentTab, + createHomeTab, createTab, getActiveStore, getTabsSnapshot, openFileInNewTab, - showRecentFiles, + showNewTab, tabCount } from '@/app/tabs' import { fileIdentitiesMatch, findTabByFileIdentity } from '@/app/tabs/open/identity' @@ -122,15 +124,39 @@ describe('openFileInNewTab deduplication', () => { expect(resolveBrowserFileURL('/design.fig#selection').href).toBe('http://localhost/design.fig') }) - test('keeps one permanent Recent Files tab', () => { + test('reuses the existing New tab when navigating to the files workspace', () => { const initialCount = tabCount() - const initialHomeCount = getTabsSnapshot().filter((tab) => tab.showHome).length + const initialHomeCount = getTabsSnapshot().filter((tab) => tab.kind === 'home').length - showRecentFiles() - showRecentFiles() + showNewTab() + showNewTab() expect(tabCount()).toBe(initialCount + (initialHomeCount === 0 ? 1 : 0)) - expect(getTabsSnapshot().filter((tab) => tab.showHome)).toHaveLength(1) + expect(getTabsSnapshot().filter((tab) => tab.kind === 'home')).toHaveLength(1) + }) + + test('converts the current New tab into a blank document', () => { + createHomeTab() + const home = getTabsSnapshot().at(-1) + const count = tabCount() + + const document = createDocumentInCurrentTab() + + expect(tabCount()).toBe(count) + expect(document.id).toBe(home?.id) + expect(document.kind).toBe('document') + }) + + test('opens a file in the current New tab', async () => { + createHomeTab() + const home = getTabsSnapshot().at(-1) + const count = tabCount() + + await openFileInNewTab(new File([], 'design.fig'), undefined, '/tmp/from-home.fig') + + expect(tabCount()).toBe(count) + expect(getTabsSnapshot().at(-1)?.id).toBe(home?.id) + expect(getTabsSnapshot().at(-1)?.kind).toBe('document') }) test('activates the existing tab when the same path is opened again', async () => { diff --git a/tests/engine/tauri/file-actions.test.ts b/tests/engine/tauri/file-actions.test.ts index 0cc5c3ce3..954c4af9c 100644 --- a/tests/engine/tauri/file-actions.test.ts +++ b/tests/engine/tauri/file-actions.test.ts @@ -27,6 +27,12 @@ describe('Tauri file actions', () => { await expect(chooseTauriOpenPaths()).resolves.toEqual(['/tmp/design.fig', '/tmp/design.pen']) }) + test('rejects unsupported Tauri design files before reading them', async () => { + await expect(readTauriDesignFile('/tmp/design.sketch')).rejects.toThrow( + 'Unsupported document format: /tmp/design.sketch' + ) + }) + test('reads a Tauri design file into a File object', async () => { await mockTauriIPC((cmd, args) => { expect(cmd).toBe('plugin:fs|read_file')