refactor(app): polish files workspace and New tabs

- Keep New tabs provisional so opening or creating a design reuses the active tab
- Separate recent document, storage, menu, worker, and workspace responsibilities
- Add source-aware recents, responsive files UI, loading states, and localized copy
- Preserve native local Open Recent behavior while supporting remote storage history
This commit is contained in:
Danila Poyarkov 2026-08-20 16:24:00 +03:00
parent 376447a5dd
commit bb5960cd62
40 changed files with 1264 additions and 1008 deletions

View file

@ -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<SceneGraph> {
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<WorkerParseResult>) => {
if (e.data.type === 'page-manifest') {

View file

@ -0,0 +1,5 @@
export function createFigParseWorker(): Worker {
return new Worker(new URL('./worker.ts', import.meta.url), {
type: 'module'
})
}

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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": "バケット",

View file

@ -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",

View file

@ -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": "Бакет",

View file

@ -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": "存储桶",

View file

@ -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',

View file

@ -116,25 +116,6 @@ export async function removeCachePrefix(prefix: string): Promise<void> {
removeStorageEntriesWithPrefix(prefix)
}
export async function removeCacheEntriesWithPrefix(prefix: string): Promise<void> {
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<T> = {
updatedAt: number
value: T

View file

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

View file

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

View file

@ -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 },

View file

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

View file

@ -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<RecentDocument[]>(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<RecentDocument[]>(normalizedRecentDocuments)
export const recentLocalFilePaths = computed<string[]>(() =>
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<void> {
recentDocuments.value = []
await clearRecentFileThumbnails()
}
export function recentLocalFileAt(index: number): string | null {
return recentLocalFilePaths.value[index] ?? null
}

View file

@ -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<string> {
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<Uint8Array | null> {
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<void> {
if (!isTauri()) return
await writeCacheBytes(await recentFileThumbnailCacheKey(path), Uint8Array.from(bytes).buffer)
}
export function clearRecentFileThumbnails(): Promise<void> {
return removeCachePrefix(RECENT_FILE_THUMBNAIL_CACHE_DIR)
}
export async function loadRecentFileThumbnail(path: string): Promise<Uint8Array | null> {
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()
}
}

View file

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

View file

@ -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<EditorCommandId>(
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)

View file

@ -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<T>(
items: Iterable<T>,
displayName: (item: T) => string,
@ -53,6 +85,7 @@ export async function openDesignFileBatch<T>(
}
export async function readTauriDesignFile(path: string): Promise<File> {
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<File> {
export async function chooseTauriOpenPaths(): Promise<string[]> {
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)
}
)

View file

@ -4,5 +4,5 @@ export function openStorageWorkspace(router: Router): void {
void router
.push('/')
.then(() => import('@/app/tabs'))
.then(({ showRecentFiles }) => showRecentFiles())
.then(({ showNewTab }) => showNewTab())
}

View file

@ -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<string[]>(RECENT_FILES_STORAGE_KEY, [])
const recentFileOpenedAt = useLocalStorage<Record<string, string>>(
RECENT_FILE_OPENED_AT_STORAGE_KEY,
{}
)
function fileName(path: string): string {
return path.split(/[\\/]/).pop() ?? path
}
export const recentFiles = computed<RecentFile[]>(() =>
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<void> {
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<void> {
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<string> {
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<Uint8Array | null> {
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<void> {
if (!isTauri()) return
await writeCacheBytes(await recentFileThumbnailCacheKey(path), Uint8Array.from(bytes).buffer)
}
export async function loadRecentFileThumbnail(path: string): Promise<Uint8Array | null> {
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()
}
}

View file

@ -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<void> {
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<void> {
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<SceneGraph> {
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,

View file

@ -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) {
>
<icon-lucide-house v-if="tab.isHome" :class="baseStyles.icon()" />
<icon-lucide-file v-else :class="baseStyles.icon()" />
<span :class="baseStyles.label()">{{ tab.isHome ? dialogs.recentFiles : tab.name }}</span>
<Tip v-if="!tab.isHome" :label="dialogs.closeTab({ name: tab.name })">
<span :class="baseStyles.label()">{{ tab.isHome ? dialogs.newTab : tab.name }}</span>
<Tip
v-if="!tab.isHome || tabs.length > 1"
:label="dialogs.closeTab({ name: tab.isHome ? dialogs.newTab : tab.name })"
>
<button
data-test-id="tabbar-close"
:class="tabBarStyles({ active: tab.isActive }).close()"
:data-active="tab.isActive || undefined"
:aria-label="dialogs.closeTab({ name: tab.name })"
:aria-label="dialogs.closeTab({ name: tab.isHome ? dialogs.newTab : tab.name })"
tabindex="-1"
@click="onClose($event, tab.id)"
>
@ -71,7 +80,7 @@ function onClose(e: MouseEvent, tabId: string) {
data-test-id="tabbar-new"
:class="baseStyles.newAction()"
:aria-label="dialogs.newTab"
@click="showRecentFiles"
@click="createNewTab"
>
<icon-lucide-plus :class="baseStyles.newIcon()" />
</button>

View file

@ -0,0 +1,126 @@
<script setup lang="ts">
import { tv } from 'tailwind-variants'
import { useUrlSearchParams } from '@vueuse/core'
import { SplitterGroup, SplitterPanel, SplitterResizeHandle } from 'reka-ui'
import { formatShortcut, useI18n, useViewportKind } from '@open-pencil/vue'
import { useEditorStore } from '@/app/editor/active-store'
import { appMenuShortcut } from '@/app/shell/menu/shortcut'
import { activeTab } from '@/app/tabs'
import CanvasSplitRoot from '@/components/canvas/CanvasSplitRoot.vue'
import CollabPanel from '@/components/CollabPanel/CollabPanel.vue'
import EditorCanvas from '@/components/EditorCanvas.vue'
import LayersPanel from '@/components/LayersPanel.vue'
import MobileDrawer from '@/components/MobileDrawer.vue'
import MobileHud from '@/components/MobileHud/MobileHud.vue'
import PropertiesPanel from '@/components/PropertiesPanel.vue'
import Tip from '@/components/ui/Tip.vue'
import Toolbar from '@/components/Toolbar/Toolbar.vue'
import { loadEditorLayout, saveEditorLayout } from '@/app/shell/layout-storage'
import splitterTheme from '@/theme/splitter'
const params = useUrlSearchParams('history')
const showChrome = !('no-chrome' in params)
const store = useEditorStore()
const { dialogs } = useI18n()
const { isMobile } = useViewportKind()
const initialEditorLayout = loadEditorLayout()
const horizontalSplitterStyles = tv(splitterTheme)({ direction: 'horizontal' })
</script>
<template>
<SplitterGroup
v-if="!isMobile && showChrome && store.state.showUI"
:key="activeTab?.id"
direction="horizontal"
class="flex-1 overflow-hidden"
@layout="saveEditorLayout"
>
<SplitterPanel
id="layers"
:default-size="initialEditorLayout[0]"
:min-size="10"
:max-size="30"
class="flex"
>
<LayersPanel />
</SplitterPanel>
<SplitterResizeHandle
data-test-id="left-splitter-handle"
:class="horizontalSplitterStyles.handle()"
>
<div :class="horizontalSplitterStyles.divider()" />
</SplitterResizeHandle>
<SplitterPanel id="canvas" :default-size="initialEditorLayout[1]" :min-size="30" class="flex">
<div class="relative flex min-w-0 flex-1">
<CanvasSplitRoot />
<Toolbar />
</div>
</SplitterPanel>
<SplitterResizeHandle :class="horizontalSplitterStyles.handle()">
<div :class="horizontalSplitterStyles.divider()" />
</SplitterResizeHandle>
<SplitterPanel
id="properties"
:default-size="initialEditorLayout[2]"
:min-size="10"
:max-size="30"
class="flex flex-col"
>
<div class="flex shrink-0 items-center justify-between border-b border-border px-1.5 py-1.5">
<CollabPanel />
</div>
<PropertiesPanel />
</SplitterPanel>
</SplitterGroup>
<div
v-else-if="isMobile && showChrome && store.state.showUI"
:key="'mobile-' + activeTab?.id"
class="flex flex-1 overflow-hidden"
>
<div class="relative flex min-w-0 flex-1">
<EditorCanvas />
<MobileHud />
<Toolbar />
</div>
<MobileDrawer />
</div>
<div
v-else-if="showChrome"
:key="'collapsed-' + activeTab?.id"
class="flex flex-1 overflow-hidden"
>
<div class="relative flex min-w-0 flex-1">
<EditorCanvas />
<div
v-if="!isMobile"
class="absolute top-7 left-7 z-10 flex items-center gap-2 rounded-lg border border-border bg-panel px-2 py-1 shadow-sm"
>
<img src="/favicon-32.png" class="size-4" alt="OpenPencil" />
<span data-test-id="editor-document-name" class="text-xs text-surface">{{
store.state.documentName
}}</span>
<Tip
:label="dialogs.showUI({ shortcut: formatShortcut(appMenuShortcut('toggle-ui')) ?? '' })"
>
<button
data-test-id="editor-show-ui"
class="ml-1 flex size-6 cursor-pointer items-center justify-center rounded text-muted transition-colors hover:bg-hover hover:text-surface"
@click="store.state.showUI = true"
>
<icon-lucide-sidebar class="size-3.5" />
</button>
</Tip>
</div>
</div>
</div>
<div v-else :key="'bare-' + activeTab?.id" class="flex flex-1 overflow-hidden">
<div class="relative flex min-w-0 flex-1">
<EditorCanvas />
</div>
</div>
</template>

View file

@ -0,0 +1,440 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useLocalStorage } from '@vueuse/core'
import { useDocumentWorkspace, useI18n } from '@open-pencil/vue'
import {
activeStorageProviderID,
readStoragePreferences,
storagePreferencesComplete,
storageProviderRegistry,
type StorageDocument
} from '@/app/integrations/storage'
import {
clearRecentFiles,
forgetRecentDocument,
loadRecentFileThumbnail,
recentFiles,
type RecentDocument
} from '@/app/recent-files'
import { openSettingsDialog } from '@/app/settings/dialog'
import { openFileFromPath } from '@/app/shell/menu/use'
import { createStorageWorkspaceSource } from '@/app/storage/workspace/source'
import { openStorageDocumentInNewTab } from '@/app/tabs'
import HomeSearchActions from '@/components/home/search/HomeSearchActions.vue'
import Tip from '@/components/ui/Tip.vue'
const emit = defineEmits<{ 'new-document': [] }>()
const { dialogs, panels, locale } = useI18n()
const view = useLocalStorage<'grid' | 'list'>('open-pencil:home-files-view', 'grid')
const query = ref('')
const openError = ref<string | null>(null)
const storageConfigured = ref(storagePreferencesComplete(activeStorageProviderID.value))
const workspace = useDocumentWorkspace<RecentDocument>({
source: {
async refresh() {
return recentFiles.value
},
loadPreview(documentId) {
const document = recentFiles.value.find((candidate) => candidate.id === documentId)
if (!document) return Promise.resolve(null)
if (document.kind === 'local') return loadRecentFileThumbnail(document.path)
return createStorageWorkspaceSource(() => undefined).loadPreview(document.documentId)
}
},
refreshOnFocus: false,
refreshOnReconnect: false,
previewConcurrency: 2
})
const documents = workspace.documents
const previewURL = workspace.previewURL
const vWorkspacePreview = workspace.previewDirective
const storageWorkspace = useDocumentWorkspace<StorageDocument>({
source: createStorageWorkspaceSource((snapshot) => {
storageConfigured.value = snapshot.configured
}),
refreshInterval: 60_000,
previewConcurrency: 6
})
const storageDocuments = storageWorkspace.documents
const storageLoading = storageWorkspace.loading
const storageError = computed(() => {
const error = storageWorkspace.error.value
if (error == null) return null
return error instanceof Error ? error.message : String(error)
})
const storageDescription = computed(() => {
const provider = storageProviderRegistry.get(activeStorageProviderID.value)
const preferences = readStoragePreferences(activeStorageProviderID.value)
const bucket = preferences.bucket?.trim()
const endpoint = preferences.endpoint?.trim()
let label = provider.label
if (endpoint) {
try {
const hostname = new URL(endpoint).hostname
if (hostname.endsWith('.r2.cloudflarestorage.com')) label = dialogs.value.storageProviderR2
else if (hostname.includes('amazonaws.com')) label = dialogs.value.storageProviderAmazonS3
else if (hostname.includes('backblazeb2.com')) label = dialogs.value.storageProviderBackblaze
else if (hostname) label = dialogs.value.storageProviderS3
} catch {
label = provider.label
}
}
return bucket ? `${label} · ${bucket}` : label
})
const storagePreviewURL = storageWorkspace.previewURL
const vStoragePreview = storageWorkspace.previewDirective
const normalizedQuery = computed(() => query.value.trim().toLocaleLowerCase(locale.value))
const filteredRecentFiles = computed(() => {
if (!normalizedQuery.value) return documents.value
return documents.value.filter((document) =>
`${document.name}\n${document.kind === 'local' ? document.path : document.documentId}`
.toLocaleLowerCase(locale.value)
.includes(normalizedQuery.value)
)
})
const filteredStorageDocuments = computed(() => {
if (!normalizedQuery.value) return storageDocuments.value
return storageDocuments.value.filter((document) =>
document.name.toLocaleLowerCase(locale.value).includes(normalizedQuery.value)
)
})
const hasRecentFiles = computed(() => documents.value.length > 0)
const noSearchMatches = computed(
() =>
Boolean(normalizedQuery.value) &&
filteredRecentFiles.value.length === 0 &&
filteredStorageDocuments.value.length === 0
)
watch(recentFiles, () => void workspace.invalidate())
async function openRecent(document: RecentDocument): Promise<void> {
openError.value = null
try {
if (document.kind === 'local') {
await openFileFromPath(document.path)
return
}
const storageDocument = storageDocuments.value.find(
(candidate) => candidate.id === document.documentId
)
await openStorageDocumentInNewTab(
storageDocument ?? {
id: document.documentId,
name: document.name,
updatedAt: document.updatedAt
}
)
} catch (error) {
forgetRecentDocument(document.id)
openError.value = error instanceof Error ? error.message : String(error)
}
}
async function openStorageDocument(document: StorageDocument): Promise<void> {
openError.value = null
try {
await openStorageDocumentInNewTab(document)
} catch (error) {
openError.value = error instanceof Error ? error.message : String(error)
}
}
function formattedDate(updatedAt: string): string {
const date = new Date(updatedAt)
if (date.getTime() === 0) return ''
return date.toLocaleString(locale.value)
}
</script>
<template>
<main
class="flex min-h-0 flex-1 flex-col overflow-x-hidden overflow-y-auto bg-app text-surface"
data-test-id="recent-files-home"
>
<section
class="mx-auto flex w-full max-w-7xl flex-col pt-4 pr-[max(1rem,env(safe-area-inset-right))] pb-4 pl-[max(1rem,env(safe-area-inset-left))] sm:px-6 sm:py-5"
>
<HomeSearchActions v-model="query" @new-document="emit('new-document')" />
<p v-if="openError" class="mb-4 text-xs text-danger" role="alert">{{ openError }}</p>
<p
v-if="noSearchMatches"
class="rounded-lg border border-dashed border-border px-4 py-8 text-center text-xs text-muted"
>
{{ dialogs.noMatchingFiles({ query: query.trim() }) }}
</p>
<section v-if="!noSearchMatches">
<div class="mb-3">
<div class="flex items-start gap-3">
<div class="min-w-0 flex-1">
<h1 class="text-base font-semibold">{{ dialogs.recentFiles }}</h1>
<p class="mt-0.5 text-pretty text-xs text-muted">
{{ dialogs.recentFilesDescription }}
</p>
</div>
<div class="ml-auto hidden shrink-0 items-center gap-1 sm:flex">
<Tip v-if="hasRecentFiles" :label="dialogs.clear">
<button
type="button"
class="flex size-10 items-center justify-center rounded text-muted hover:bg-hover hover:text-surface sm:size-7"
:aria-label="dialogs.clear"
data-test-id="recent-files-clear"
@click="clearRecentFiles"
>
<icon-lucide-trash-2 class="size-3.5" />
</button>
</Tip>
<div class="flex rounded border border-border p-0.5">
<Tip :label="panels.gridView">
<button
type="button"
class="flex size-10 items-center justify-center rounded-sm text-muted hover:text-surface sm:size-7"
:class="{ 'bg-hover text-surface': view === 'grid' }"
:aria-label="panels.gridView"
@click="view = 'grid'"
>
<icon-lucide-layout-grid class="size-3.5" />
</button>
</Tip>
<Tip :label="panels.listView">
<button
type="button"
class="flex size-10 items-center justify-center rounded-sm text-muted hover:text-surface sm:size-7"
:class="{ 'bg-hover text-surface': view === 'list' }"
:aria-label="panels.listView"
@click="view = 'list'"
>
<icon-lucide-list class="size-3.5" />
</button>
</Tip>
</div>
</div>
</div>
<div class="mt-2 flex items-center justify-end gap-1 sm:hidden">
<Tip v-if="hasRecentFiles" :label="dialogs.clear">
<button
type="button"
class="flex size-8 items-center justify-center rounded text-muted hover:bg-hover hover:text-surface"
:aria-label="dialogs.clear"
data-test-id="recent-files-clear"
@click="clearRecentFiles"
>
<icon-lucide-trash-2 class="size-3.5" />
</button>
</Tip>
<div class="flex rounded border border-border p-0.5">
<button
type="button"
class="flex size-8 items-center justify-center rounded-sm text-muted"
:class="{ 'bg-hover text-surface': view === 'grid' }"
:aria-label="panels.gridView"
@click="view = 'grid'"
>
<icon-lucide-layout-grid class="size-3.5" />
</button>
<button
type="button"
class="flex size-8 items-center justify-center rounded-sm text-muted"
:class="{ 'bg-hover text-surface': view === 'list' }"
:aria-label="panels.listView"
@click="view = 'list'"
>
<icon-lucide-list class="size-3.5" />
</button>
</div>
</div>
</div>
<div
v-if="filteredRecentFiles.length && view === 'grid'"
class="grid grid-cols-1 gap-x-5 gap-y-6 sm:grid-cols-[repeat(auto-fill,minmax(200px,1fr))]"
>
<button
v-for="document in filteredRecentFiles"
:key="document.id"
type="button"
class="group min-w-0 text-left"
@click="openRecent(document)"
>
<div
v-workspace-preview="document.id"
class="flex aspect-video items-center justify-center overflow-hidden rounded-lg border border-border bg-panel-field transition-colors group-hover:border-panel-focus"
>
<img
v-if="previewURL(document.id)"
:src="previewURL(document.id) ?? undefined"
alt=""
class="size-full object-cover transition-transform duration-200 group-hover:scale-[1.015]"
/>
<icon-lucide-file-image v-else class="size-8 text-muted/40" />
</div>
<p class="mt-2 truncate text-xs font-medium">{{ document.name }}</p>
<p class="mt-0.5 truncate text-[10px] text-muted">
{{ formattedDate(document.updatedAt) }}
</p>
</button>
</div>
<div
v-else-if="filteredRecentFiles.length"
class="overflow-hidden rounded-lg border border-border"
>
<button
v-for="document in filteredRecentFiles"
:key="document.id"
type="button"
class="flex min-h-14 w-full items-center gap-3 border-b border-border px-3 py-2 text-left last:border-b-0 hover:bg-hover sm:min-h-0 sm:px-4 sm:py-3"
@click="openRecent(document)"
>
<icon-lucide-file-image class="size-4 shrink-0 text-accent" />
<span class="min-w-0 flex-1">
<span class="block truncate text-xs font-medium">{{ document.name }}</span>
<span class="mt-0.5 block truncate text-[10px] text-muted sm:hidden">{{
formattedDate(document.updatedAt)
}}</span>
</span>
<span class="hidden shrink-0 text-[10px] text-muted sm:inline">{{
formattedDate(document.updatedAt)
}}</span>
</button>
</div>
<div
v-else-if="!normalizedQuery"
class="rounded-lg border border-dashed border-border px-4 py-4 text-center sm:py-6"
>
<p class="text-xs font-medium">{{ dialogs.noRecentFiles }}</p>
<p class="mt-1 text-xs text-muted">{{ dialogs.noRecentFilesDescription }}</p>
</div>
</section>
<section v-if="storageConfigured || storageLoading || storageError" class="mt-7">
<div class="mb-3 flex items-start gap-3">
<div class="min-w-0">
<h2 class="text-base font-semibold">{{ dialogs.storageWorkspace }}</h2>
<p class="mt-0.5 truncate text-xs text-muted sm:whitespace-normal">
{{ storageDescription }}
</p>
</div>
<div class="ml-auto flex shrink-0 items-center gap-1">
<Tip :label="dialogs.refresh">
<button
type="button"
class="flex size-10 items-center justify-center rounded text-muted hover:bg-hover hover:text-surface sm:size-7"
:aria-label="dialogs.refresh"
@click="storageWorkspace.refresh"
>
<icon-lucide-refresh-cw class="size-3.5" />
</button>
</Tip>
<Tip :label="dialogs.settings">
<button
type="button"
class="flex size-10 items-center justify-center rounded text-muted hover:bg-hover hover:text-surface sm:size-7"
:aria-label="dialogs.settings"
@click="openSettingsDialog('storage')"
>
<icon-lucide-settings-2 class="size-3.5" />
</button>
</Tip>
</div>
</div>
<div
v-if="storageLoading && storageDocuments.length === 0"
class="grid grid-cols-1 gap-x-5 gap-y-6 sm:grid-cols-[repeat(auto-fill,minmax(200px,1fr))]"
:aria-label="dialogs.loadingStorageWorkspace"
>
<div v-for="index in 3" :key="index" class="min-w-0 animate-pulse">
<div class="aspect-video rounded-lg border border-border bg-panel-field" />
<div class="mt-2 h-3 w-2/3 rounded bg-panel-field" />
<div class="mt-1.5 h-2.5 w-1/3 rounded bg-panel-field" />
</div>
</div>
<div
v-else-if="storageError && storageDocuments.length === 0"
class="rounded-lg border border-danger/40 px-4 py-6 text-center"
role="alert"
>
<p class="text-xs text-danger">{{ storageError }}</p>
<button
type="button"
class="mt-3 rounded border border-border px-3 py-1.5 text-xs hover:bg-hover"
@click="storageWorkspace.refresh"
>
{{ dialogs.refresh }}
</button>
</div>
<div
v-else-if="filteredStorageDocuments.length && view === 'grid'"
class="grid grid-cols-1 gap-x-5 gap-y-6 sm:grid-cols-[repeat(auto-fill,minmax(200px,1fr))]"
>
<button
v-for="document in filteredStorageDocuments"
:key="document.id"
type="button"
class="group min-w-0 text-left"
@click="openStorageDocument(document)"
>
<div
v-storage-preview="document.id"
class="flex aspect-video items-center justify-center overflow-hidden rounded-lg border border-border bg-panel-field transition-colors group-hover:border-panel-focus"
>
<img
v-if="storagePreviewURL(document.id)"
:src="storagePreviewURL(document.id) ?? undefined"
alt=""
class="size-full object-cover transition-transform duration-200 group-hover:scale-[1.015]"
/>
<icon-lucide-file-image v-else class="size-8 text-muted/40" />
</div>
<p class="mt-2 truncate text-xs font-medium">{{ document.name }}</p>
<p class="mt-0.5 truncate text-[10px] text-muted">
{{ formattedDate(document.updatedAt) }}
</p>
</button>
</div>
<div
v-else-if="filteredStorageDocuments.length"
class="overflow-hidden rounded-lg border border-border"
>
<button
v-for="document in filteredStorageDocuments"
:key="document.id"
type="button"
class="flex min-h-14 w-full items-center gap-3 border-b border-border px-3 py-2 text-left last:border-b-0 hover:bg-hover sm:min-h-0 sm:px-4 sm:py-3"
@click="openStorageDocument(document)"
>
<icon-lucide-file-image class="size-4 shrink-0 text-accent" />
<span class="min-w-0 flex-1">
<span class="block truncate text-xs font-medium">{{ document.name }}</span>
<span class="mt-0.5 block truncate text-[10px] text-muted sm:hidden">{{
formattedDate(document.updatedAt)
}}</span>
</span>
<span class="hidden shrink-0 text-[10px] text-muted sm:inline">{{
formattedDate(document.updatedAt)
}}</span>
</button>
</div>
<div
v-else-if="!normalizedQuery"
class="rounded-lg border border-dashed border-border px-4 py-4 text-center text-xs text-muted sm:py-6"
>
{{ dialogs.emptyStorageWorkspace }}
</div>
</section>
</section>
</main>
</template>

View file

@ -0,0 +1,67 @@
<script setup lang="ts">
import { nextTick, watch } from 'vue'
import { templateRef } from '@vueuse/core'
import { useI18n, useViewportKind } from '@open-pencil/vue'
import { openFileDialog } from '@/app/shell/menu/use'
import { activeTab } from '@/app/tabs'
const emit = defineEmits<{ 'new-document': [] }>()
const query = defineModel<string>({ required: true })
const { dialogs, menu } = useI18n()
const { isMobile } = useViewportKind()
const searchInput = templateRef<HTMLInputElement>('searchInput')
async function focusSearch(): Promise<void> {
if (isMobile.value || activeTab.value?.kind !== 'home') return
await nextTick()
setTimeout(() => searchInput.value?.focus(), 300)
}
watch(
() => activeTab.value?.id,
() => void focusSearch(),
{ immediate: true }
)
</script>
<template>
<div class="mb-4 flex flex-col gap-3 sm:mb-6 sm:flex-row sm:items-center">
<label
class="flex h-12 min-w-0 flex-1 items-center gap-2.5 rounded-lg border border-border bg-panel px-3.5 focus-within:border-panel-focus focus-within:ring-1 focus-within:ring-panel-focus sm:h-9 sm:gap-2 sm:bg-panel-field sm:px-3 sm:focus-within:ring-0"
>
<icon-lucide-search class="size-4.5 shrink-0 text-muted sm:size-4" />
<input
ref="searchInput"
v-model="query"
type="search"
name="file-search"
autocomplete="off"
class="min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted"
:placeholder="isMobile ? dialogs.searchFiles : dialogs.searchRecentAndStorageFiles"
:aria-label="dialogs.searchFiles"
/>
</label>
<div class="grid grid-cols-2 gap-2 sm:contents">
<button
type="button"
class="flex h-9 min-w-0 items-center justify-center rounded border border-border px-3 text-xs text-muted hover:bg-hover hover:text-surface sm:h-auto sm:flex-none sm:border-0 sm:py-2"
data-test-id="home-open-file"
@click="openFileDialog"
>
<icon-lucide-folder-open class="mr-1.5 size-3.5" />
{{ menu.open }}
</button>
<button
type="button"
class="flex h-9 min-w-0 items-center justify-center rounded bg-accent px-3 text-xs font-medium text-white hover:bg-accent/90 sm:h-auto sm:flex-none sm:py-2"
data-test-id="home-new-document"
@click="emit('new-document')"
>
<icon-lucide-plus class="mr-1 size-3.5" />
{{ dialogs.newDesign }}
</button>
</div>
</div>
</template>

View file

@ -1,313 +0,0 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useLocalStorage } from '@vueuse/core'
import { useDocumentWorkspace, useI18n } from '@open-pencil/vue'
import {
clearRecentFiles,
forgetRecentFile,
loadRecentFileThumbnail,
recentFiles,
type RecentFile
} from '@/app/shell/menu/recent-files'
import { openFileDialog, openFileFromPath } from '@/app/shell/menu/use'
import { activeStorageProviderID, type StorageDocument } from '@/app/integrations/storage'
import { openSettingsDialog } from '@/app/settings/dialog'
import { createStorageWorkspaceSource } from '@/app/storage/workspace/source'
import { openStorageDocumentInNewTab } from '@/app/tabs'
import Tip from '@/components/ui/Tip.vue'
const emit = defineEmits<{ 'new-document': [] }>()
const { dialogs, menu, panels, locale } = useI18n()
const view = useLocalStorage<'grid' | 'list'>('open-pencil:home-files-view', 'grid')
const openError = ref<string | null>(null)
const storageConfigured = ref(false)
const workspace = useDocumentWorkspace<RecentFile>({
source: {
async refresh() {
return recentFiles.value
},
loadPreview: loadRecentFileThumbnail
},
refreshOnFocus: false,
refreshOnReconnect: false,
previewConcurrency: 2
})
const documents = workspace.documents
const previewURL = workspace.previewURL
const vWorkspacePreview = workspace.previewDirective
const hasRecentFiles = computed(() => documents.value.length > 0)
const storageWorkspace = useDocumentWorkspace<StorageDocument>({
source: createStorageWorkspaceSource((snapshot) => {
storageConfigured.value = snapshot.configured
}),
refreshInterval: 60_000,
previewConcurrency: 6
})
const storageDocuments = storageWorkspace.documents
const storagePreviewURL = storageWorkspace.previewURL
const vStoragePreview = storageWorkspace.previewDirective
watch(recentFiles, () => void workspace.invalidate())
async function refreshHome(): Promise<void> {
await Promise.all([workspace.refresh(), storageWorkspace.refresh()])
}
async function openRecent(document: RecentFile): Promise<void> {
openError.value = null
try {
await openFileFromPath(document.path)
} catch (error) {
forgetRecentFile(document.path)
openError.value = error instanceof Error ? error.message : String(error)
}
}
async function openStorageDocument(document: StorageDocument): Promise<void> {
openError.value = null
try {
await openStorageDocumentInNewTab(document)
} catch (error) {
openError.value = error instanceof Error ? error.message : String(error)
}
}
function formattedDate(updatedAt: string): string {
const date = new Date(updatedAt)
if (date.getTime() === 0) return ''
return date.toLocaleString(locale.value)
}
</script>
<template>
<main class="flex min-h-0 flex-1 flex-col bg-app text-surface" data-test-id="recent-files-home">
<header class="flex h-14 shrink-0 items-center border-b border-border px-6">
<div class="flex items-center gap-2.5">
<img src="/favicon-32.png" class="size-5" alt="" />
<span class="text-sm font-semibold">OpenPencil</span>
</div>
<div class="ml-auto flex items-center gap-2">
<Tip :label="dialogs.refresh">
<button
type="button"
class="flex size-7 items-center justify-center rounded text-muted hover:bg-hover hover:text-surface"
:aria-label="dialogs.refresh"
@click="refreshHome"
>
<icon-lucide-refresh-cw class="size-3.5" />
</button>
</Tip>
<button
type="button"
class="rounded px-3 py-1.5 text-xs text-muted hover:bg-hover hover:text-surface"
@click="openSettingsDialog('storage')"
>
<icon-lucide-settings-2 class="mr-1.5 inline size-3.5" />
{{ dialogs.settings }}
</button>
<button
type="button"
class="rounded px-3 py-1.5 text-xs text-muted hover:bg-hover hover:text-surface"
data-test-id="home-open-file"
@click="openFileDialog"
>
<icon-lucide-folder-open class="mr-1.5 inline size-3.5" />
{{ menu.open }}
</button>
<button
type="button"
class="rounded bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent/90"
data-test-id="home-new-document"
@click="emit('new-document')"
>
<icon-lucide-plus class="mr-1 inline size-3.5" />
{{ menu.new }}
</button>
</div>
</header>
<section class="mx-auto flex min-h-0 w-full max-w-7xl flex-1 flex-col px-8 py-10">
<div class="mb-6 flex items-center">
<div>
<h1 class="text-xl font-semibold">{{ dialogs.recentFiles }}</h1>
<p class="mt-1 text-xs text-muted">{{ dialogs.recentFilesDescription }}</p>
</div>
<div class="ml-auto flex rounded border border-border p-0.5">
<Tip v-if="hasRecentFiles" :label="dialogs.clear">
<button
type="button"
class="flex size-7 items-center justify-center rounded-sm text-muted hover:text-surface"
:aria-label="dialogs.clear"
data-test-id="recent-files-clear"
@click="clearRecentFiles"
>
<icon-lucide-trash-2 class="size-3.5" />
</button>
</Tip>
<Tip :label="panels.gridView">
<button
type="button"
class="flex size-7 items-center justify-center rounded-sm text-muted hover:text-surface"
:class="{ 'bg-hover text-surface': view === 'grid' }"
:aria-label="panels.gridView"
@click="view = 'grid'"
>
<icon-lucide-layout-grid class="size-3.5" />
</button>
</Tip>
<Tip :label="panels.listView">
<button
type="button"
class="flex size-7 items-center justify-center rounded-sm text-muted hover:text-surface"
:class="{ 'bg-hover text-surface': view === 'list' }"
:aria-label="panels.listView"
@click="view = 'list'"
>
<icon-lucide-list class="size-3.5" />
</button>
</Tip>
</div>
</div>
<p v-if="openError" class="mb-4 text-xs text-danger" role="alert">{{ openError }}</p>
<div
v-if="hasRecentFiles && view === 'grid'"
class="grid grid-cols-[repeat(auto-fill,minmax(220px,1fr))] gap-5"
>
<button
v-for="document in documents"
:key="document.path"
type="button"
class="group overflow-hidden rounded-lg border border-border bg-panel text-left transition-colors hover:border-panel-focus hover:bg-hover"
@click="openRecent(document)"
>
<div
v-workspace-preview="document.path"
class="flex aspect-video items-center justify-center overflow-hidden bg-panel-field"
>
<img
v-if="previewURL(document.path)"
:src="previewURL(document.path) ?? undefined"
alt=""
class="size-full object-cover transition-transform duration-200 group-hover:scale-[1.015]"
/>
<icon-lucide-file-image v-else class="size-8 text-muted/40" />
</div>
<div class="border-t border-border p-3">
<p class="truncate text-xs font-medium">{{ document.name }}</p>
<p class="mt-1 truncate text-[10px] text-muted">
{{ formattedDate(document.updatedAt) }}
</p>
</div>
</button>
</div>
<div v-else-if="hasRecentFiles" class="overflow-hidden rounded-lg border border-border">
<button
v-for="document in documents"
:key="document.path"
type="button"
class="flex w-full items-center gap-3 border-b border-border px-4 py-3 text-left last:border-b-0 hover:bg-hover"
@click="openRecent(document)"
>
<icon-lucide-file-image class="size-4 shrink-0 text-accent" />
<span class="min-w-0 flex-1 truncate text-xs font-medium">{{ document.name }}</span>
<span class="shrink-0 text-[10px] text-muted">{{
formattedDate(document.updatedAt)
}}</span>
</button>
</div>
<div
v-else-if="!storageConfigured"
class="flex flex-1 flex-col items-center justify-center pb-20 text-center"
>
<div class="mb-4 flex size-12 items-center justify-center rounded-xl bg-panel-field">
<icon-lucide-files class="size-5 text-muted" />
</div>
<p class="text-sm font-medium">{{ dialogs.noRecentFiles }}</p>
<p class="mt-1 max-w-sm text-xs text-muted">{{ dialogs.noRecentFilesDescription }}</p>
<button
type="button"
class="mt-5 rounded border border-border px-3 py-1.5 text-xs hover:bg-hover"
@click="openFileDialog"
>
{{ menu.open }}
</button>
</div>
<section v-if="storageConfigured" class="mt-10">
<div class="mb-6 flex items-center">
<div>
<h2 class="text-xl font-semibold">{{ dialogs.storageWorkspace }}</h2>
<p class="mt-1 text-xs text-muted">{{ activeStorageProviderID }}</p>
</div>
</div>
<div
v-if="storageDocuments.length && view === 'grid'"
class="grid grid-cols-[repeat(auto-fill,minmax(220px,1fr))] gap-5"
>
<button
v-for="document in storageDocuments"
:key="document.id"
type="button"
class="group overflow-hidden rounded-lg border border-border bg-panel text-left transition-colors hover:border-panel-focus hover:bg-hover"
@click="openStorageDocument(document)"
>
<div
v-storage-preview="document.id"
class="flex aspect-video items-center justify-center overflow-hidden bg-panel-field"
>
<img
v-if="storagePreviewURL(document.id)"
:src="storagePreviewURL(document.id) ?? undefined"
alt=""
class="size-full object-cover transition-transform duration-200 group-hover:scale-[1.015]"
/>
<icon-lucide-file-image v-else class="size-8 text-muted/40" />
</div>
<div class="border-t border-border p-3">
<p class="truncate text-xs font-medium">{{ document.name }}</p>
<p class="mt-1 truncate text-[10px] text-muted">
{{ formattedDate(document.updatedAt) }}
</p>
</div>
</button>
</div>
<div
v-else-if="storageDocuments.length"
class="overflow-hidden rounded-lg border border-border"
>
<button
v-for="document in storageDocuments"
:key="document.id"
type="button"
class="flex w-full items-center gap-3 border-b border-border px-4 py-3 text-left last:border-b-0 hover:bg-hover"
@click="openStorageDocument(document)"
>
<icon-lucide-file-image class="size-4 shrink-0 text-accent" />
<span class="min-w-0 flex-1 truncate text-xs font-medium">{{ document.name }}</span>
<span class="shrink-0 text-[10px] text-muted">{{
formattedDate(document.updatedAt)
}}</span>
</button>
</div>
<div
v-else
class="rounded-lg border border-border border-dashed px-4 py-8 text-center text-xs text-muted"
>
{{ dialogs.emptyStorageWorkspace }}
</div>
</section>
</section>
</main>
</template>

View file

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

View file

@ -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: {

View file

@ -1,237 +0,0 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, provide, ref } from 'vue'
import { tv } from 'tailwind-variants'
import { useEventListener, useUrlSearchParams } from '@vueuse/core'
import { useRoute } from 'vue-router'
import { useHead } from '@unhead/vue'
import { SplitterGroup, SplitterPanel, SplitterResizeHandle } from 'reka-ui'
import { useViewportKind, formatShortcut, useI18n } from '@open-pencil/vue'
import { useKeyboard } from '@/app/shell/keyboard/use'
import { loadEditorLayout, saveEditorLayout } from '@/app/shell/layout-storage'
import { openFileFromPath, useEditorMenu } from '@/app/shell/menu/use'
import { useCollab, COLLAB_KEY } from '@/app/collab/use'
import { connectAutomation } from '@/app/automation/bridge/server'
import { exposeCollaborationActions } from '@/app/browser-bridge'
import { spawnMCPIfNeeded } from '@/app/automation/mcp/spawn'
import { isTauri } from '@/app/tauri/env'
import { appMenuShortcut } from '@/app/shell/menu/shortcut'
import { createDemoShapes } from '@/app/demo/document'
import { useEditorStore } from '@/app/editor/active-store'
import { createTab, activeTab, getActiveStore, tabCount } from '@/app/tabs'
import CollabPanel from '@/components/CollabPanel/CollabPanel.vue'
import EditorCanvas from '@/components/EditorCanvas.vue'
import CanvasSplitRoot from '@/components/canvas/CanvasSplitRoot.vue'
import FontStatusBanner from '@/components/font-status/FontStatusBanner.vue'
import LayersPanel from '@/components/LayersPanel.vue'
import MobileDrawer from '@/components/MobileDrawer.vue'
import MobileHud from '@/components/MobileHud/MobileHud.vue'
import PropertiesPanel from '@/components/PropertiesPanel.vue'
import RecentFilesHome from '@/components/recent-files/RecentFilesHome.vue'
import RenameSelectionDialog from '@/components/selection/RenameSelectionDialog.vue'
import SafariBanner from '@/components/SafariBanner.vue'
import TabBar from '@/components/TabBar.vue'
import Tip from '@/components/ui/Tip.vue'
import Toolbar from '@/components/Toolbar/Toolbar.vue'
import splitterTheme from '@/theme/splitter'
const route = useRoute()
const params = useUrlSearchParams('history')
const showChrome = !('no-chrome' in params)
const createdInitialTab = tabCount() === 0
const showInitialHome = route.path === '/' && !('test' in params) && !route.meta.demo
const firstTab = createdInitialTab
? createTab(undefined, undefined, showInitialHome)
: (activeTab.value ?? createTab())
const store = useEditorStore()
const { dialogs } = useI18n()
const { isMobile } = useViewportKind()
if (createdInitialTab && route.meta.demo && !('test' in params)) {
void createDemoShapes(firstTab.store)
}
useHead({ title: route.meta.demo ? 'Demo' : undefined })
useKeyboard()
useEditorMenu()
const collab = useCollab(getActiveStore)
provide(COLLAB_KEY, collab)
exposeCollaborationActions(collab)
useEventListener(
document,
'wheel',
(e: WheelEvent) => {
if (e.ctrlKey || e.metaKey) e.preventDefault()
},
{ passive: false }
)
const automationCleanup = ref<(() => void) | null>(null)
const mcpCleanup = ref<(() => void) | null>(null)
const fileAssociationCleanup = ref<(() => void) | null>(null)
const initialEditorLayout = loadEditorLayout()
const horizontalSplitterStyles = tv(splitterTheme)({ direction: 'horizontal' })
const showingHome = computed(() => activeTab.value?.showHome === true)
type PendingOpenFile = {
path: string
}
async function openPendingAssociatedFiles() {
const { invoke } = await import('@tauri-apps/api/core')
const files = await invoke<PendingOpenFile[]>('take_pending_open')
for (const file of files) {
await openFileFromPath(file.path)
}
}
async function bindAssociatedFileOpen() {
if (!isTauri()) return
const { listen } = await import('@tauri-apps/api/event')
fileAssociationCleanup.value = await listen('open-associated-files', () => {
void openPendingAssociatedFiles().catch((e) => console.error('[Open With]', e))
})
await openPendingAssociatedFiles()
}
onMounted(async () => {
const mcp = await spawnMCPIfNeeded()
mcpCleanup.value = mcp?.disconnect ?? null
const tauri = isTauri()
if (import.meta.env.DEV || (tauri && mcp)) {
automationCleanup.value = connectAutomation(getActiveStore, mcp?.authToken ?? null).disconnect
}
try {
await bindAssociatedFileOpen()
} catch (e) {
console.error('[Open With]', e)
}
})
onUnmounted(() => {
mcpCleanup.value?.()
automationCleanup.value?.()
fileAssociationCleanup.value?.()
})
</script>
<template>
<div data-test-id="editor-root" class="flex h-screen w-screen flex-col">
<SafariBanner />
<FontStatusBanner />
<RenameSelectionDialog />
<TabBar />
<RecentFilesHome v-if="showingHome" @new-document="createTab" />
<!-- Desktop layout -->
<SplitterGroup
v-if="!showingHome && !isMobile && showChrome && store.state.showUI"
:key="activeTab?.id"
direction="horizontal"
class="flex-1 overflow-hidden"
@layout="saveEditorLayout"
>
<SplitterPanel
id="layers"
:default-size="initialEditorLayout[0]"
:min-size="10"
:max-size="30"
class="flex"
>
<LayersPanel />
</SplitterPanel>
<SplitterResizeHandle
data-test-id="left-splitter-handle"
:class="horizontalSplitterStyles.handle()"
>
<div :class="horizontalSplitterStyles.divider()" />
</SplitterResizeHandle>
<SplitterPanel id="canvas" :default-size="initialEditorLayout[1]" :min-size="30" class="flex">
<div class="relative flex min-w-0 flex-1">
<CanvasSplitRoot />
<Toolbar />
</div>
</SplitterPanel>
<SplitterResizeHandle :class="horizontalSplitterStyles.handle()">
<div :class="horizontalSplitterStyles.divider()" />
</SplitterResizeHandle>
<SplitterPanel
id="properties"
:default-size="initialEditorLayout[2]"
:min-size="10"
:max-size="30"
class="flex flex-col"
>
<div
class="flex shrink-0 items-center justify-between border-b border-border px-1.5 py-1.5"
>
<CollabPanel />
</div>
<PropertiesPanel />
</SplitterPanel>
</SplitterGroup>
<!-- Mobile layout -->
<div
v-else-if="!showingHome && isMobile && showChrome && store.state.showUI"
:key="'mobile-' + activeTab?.id"
class="flex flex-1 overflow-hidden"
>
<div class="relative flex min-w-0 flex-1">
<EditorCanvas />
<MobileHud />
<Toolbar />
</div>
<MobileDrawer />
</div>
<!-- Collapsed UI (showUI=false) -->
<div
v-else-if="!showingHome && showChrome"
:key="'collapsed-' + activeTab?.id"
class="flex flex-1 overflow-hidden"
>
<div class="relative flex min-w-0 flex-1">
<EditorCanvas />
<div
v-if="!isMobile"
class="absolute top-7 left-7 z-10 flex items-center gap-2 rounded-lg border border-border bg-panel px-2 py-1 shadow-sm"
>
<img src="/favicon-32.png" class="size-4" alt="OpenPencil" />
<span data-test-id="editor-document-name" class="text-xs text-surface">{{
store.state.documentName
}}</span>
<Tip
:label="
dialogs.showUI({ shortcut: formatShortcut(appMenuShortcut('toggle-ui')) ?? '' })
"
>
<button
data-test-id="editor-show-ui"
class="ml-1 flex size-6 cursor-pointer items-center justify-center rounded text-muted transition-colors hover:bg-hover hover:text-surface"
@click="store.state.showUI = true"
>
<icon-lucide-sidebar class="size-3.5" />
</button>
</Tip>
</div>
</div>
</div>
<!-- Bare canvas (no chrome, e.g. ?no-chrome) -->
<div
v-else-if="!showingHome"
:key="'bare-' + activeTab?.id"
class="flex flex-1 overflow-hidden"
>
<div class="relative flex min-w-0 flex-1">
<EditorCanvas />
</div>
</div>
</div>
</template>

View file

@ -1,174 +0,0 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useDocumentWorkspace, useI18n } from '@open-pencil/vue'
import { activeStorageProviderID, type StorageDocument } from '@/app/integrations/storage'
import { openSettingsDialog, settingsDialogOpen } from '@/app/settings/dialog'
import { createCanvasId } from '@/app/storage/id'
import { createStorageWorkspaceSource } from '@/app/storage/workspace/source'
import AppPlaceholder from '@/components/ui/AppPlaceholder.vue'
import Tip from '@/components/ui/Tip.vue'
import { activeTab, createTab, openStorageDocumentInNewTab } from '@/app/tabs'
const { dialogs } = useI18n()
const router = useRouter()
const configured = ref(false)
const workspace = useDocumentWorkspace({
source: createStorageWorkspaceSource((snapshot) => {
configured.value = snapshot.configured
}),
refreshInterval: 60_000,
previewConcurrency: 6
})
const documents = workspace.documents
const loading = workspace.loading
const errorMessage = computed(() => {
const reason = workspace.error.value
if (reason == null) return null
return reason instanceof Error ? reason.message : String(reason)
})
const refresh = workspace.refresh
const invalidate = workspace.invalidate
const clearPreviews = workspace.clearPreviews
const previewURL = workspace.previewURL
const vWorkspacePreview = workspace.previewDirective
async function openDocument(document: StorageDocument): Promise<void> {
await router.push('/')
await nextTick()
await openStorageDocumentInNewTab(document)
}
async function createDocument(): Promise<void> {
if (!configured.value) return
await router.push('/')
await nextTick()
const current = activeTab.value
const store =
current?.store.state.documentName === 'Untitled' && !current.store.undo.canUndo
? current.store
: createTab().store
const documentId = createCanvasId()
store.setStorageDocumentSource(
{ providerId: activeStorageProviderID.value, documentId },
'Untitled'
)
await store.saveFigFile()
}
watch(activeStorageProviderID, () => {
clearPreviews()
void invalidate()
})
watch(settingsDialogOpen, (open, wasOpen) => {
if (wasOpen && !open) void invalidate()
})
</script>
<template>
<main class="flex min-h-screen flex-col bg-app text-surface" data-test-id="storage-workspace">
<header class="flex h-14 items-center border-b border-border px-6">
<div>
<h1 class="text-sm font-semibold">{{ dialogs.storageWorkspace }}</h1>
<p class="text-[10px] text-muted">{{ activeStorageProviderID }}</p>
</div>
<div class="ml-auto flex items-center gap-2">
<Tip v-if="configured" :label="dialogs.refresh">
<button
type="button"
class="flex size-7 items-center justify-center rounded text-muted hover:bg-hover hover:text-surface disabled:opacity-50"
:aria-label="dialogs.refresh"
:disabled="loading"
@click="refresh"
>
<icon-lucide-refresh-cw class="size-3.5" :class="{ 'animate-spin': loading }" />
</button>
</Tip>
<button
type="button"
class="rounded px-3 py-1.5 text-xs text-muted hover:bg-hover hover:text-surface"
@click="openSettingsDialog('storage')"
>
{{ dialogs.settings }}
</button>
<button
type="button"
class="rounded bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent/90 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!configured"
data-test-id="storage-new-document"
@click="createDocument"
>
{{ dialogs.newStoredDocument }}
</button>
</div>
</header>
<section class="mx-auto flex min-h-0 w-full max-w-6xl flex-1 flex-col p-6">
<p v-if="errorMessage && configured" class="mb-4 shrink-0 text-xs text-danger" role="alert">
{{ errorMessage }}
</p>
<div
v-if="documents.length"
class="grid grid-cols-[repeat(auto-fill,minmax(190px,1fr))] gap-4"
>
<button
v-for="document in documents"
:key="document.id"
type="button"
class="group overflow-hidden rounded-lg border border-border bg-panel text-left hover:border-panel-focus hover:bg-hover"
:data-document-id="document.id"
@click="openDocument(document)"
>
<div
v-workspace-preview="document.id"
class="flex aspect-[4/3] items-center justify-center bg-panel-field"
>
<img
v-if="previewURL(document.id)"
:src="previewURL(document.id) ?? undefined"
alt=""
class="size-full object-cover"
/>
<icon-lucide-file-image v-else class="size-6 text-muted/50" />
</div>
<div class="border-t border-border p-3">
<p class="truncate text-xs font-medium">{{ document.name }}</p>
<p class="mt-1 text-[10px] text-muted">
{{ new Date(document.updatedAt).toLocaleString() }}
</p>
</div>
</button>
</div>
<AppPlaceholder v-else-if="loading" :label="dialogs.loadingDocuments" size="page">
<template #icon>
<icon-lucide-loader-circle class="size-5 animate-spin" />
</template>
</AppPlaceholder>
<AppPlaceholder v-else-if="configured" :label="dialogs.emptyStorageWorkspace" size="page">
<template #icon>
<icon-lucide-files class="size-5" />
</template>
</AppPlaceholder>
<AppPlaceholder v-else :label="dialogs.storageNotConfigured" size="page">
<template #icon>
<icon-lucide-cloud class="size-5" />
</template>
<template #action>
<button
type="button"
class="rounded bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent/90"
@click="openSettingsDialog('storage')"
>
{{ dialogs.settings }}
</button>
</template>
</AppPlaceholder>
</section>
</main>
</template>

112
src/views/WorkspaceView.vue Normal file
View file

@ -0,0 +1,112 @@
<script setup lang="ts">
import { onMounted, onUnmounted, provide, ref } from 'vue'
import { useEventListener, useUrlSearchParams } from '@vueuse/core'
import { useHead } from '@unhead/vue'
import { useRoute } from 'vue-router'
import { connectAutomation } from '@/app/automation/bridge/server'
import { spawnMCPIfNeeded } from '@/app/automation/mcp/spawn'
import { exposeCollaborationActions } from '@/app/browser-bridge'
import { COLLAB_KEY, useCollab } from '@/app/collab/use'
import { createDemoShapes } from '@/app/demo/document'
import { useKeyboard } from '@/app/shell/keyboard/use'
import { openFileFromPath, useEditorMenu } from '@/app/shell/menu/use'
import {
activeTab,
createDocumentInCurrentTab,
createHomeTab,
createTab,
getActiveStore,
tabCount
} from '@/app/tabs'
import { isTauri } from '@/app/tauri/env'
import FontStatusBanner from '@/components/font-status/FontStatusBanner.vue'
import RenameSelectionDialog from '@/components/selection/RenameSelectionDialog.vue'
import SafariBanner from '@/components/SafariBanner.vue'
import TabBar from '@/components/TabBar.vue'
import EditorWorkspace from '@/components/editor/EditorWorkspace.vue'
import HomeWorkspace from '@/components/home/HomeWorkspace.vue'
const route = useRoute()
const params = useUrlSearchParams('history')
const createdInitialTab = tabCount() === 0
const shouldCreateHome = route.path === '/' && !('test' in params) && !route.meta.demo
let firstTab = activeTab.value
if (!firstTab) firstTab = shouldCreateHome ? createHomeTab() : createTab()
if (createdInitialTab && route.meta.demo && !('test' in params)) {
void createDemoShapes(firstTab.store)
}
useHead({ title: route.meta.demo ? 'Demo' : undefined })
useKeyboard()
useEditorMenu()
const collab = useCollab(getActiveStore)
provide(COLLAB_KEY, collab)
exposeCollaborationActions(collab)
useEventListener(
document,
'wheel',
(event: WheelEvent) => {
if (event.ctrlKey || event.metaKey) event.preventDefault()
},
{ passive: false }
)
const automationCleanup = ref<(() => void) | null>(null)
const mcpCleanup = ref<(() => void) | null>(null)
const fileAssociationCleanup = ref<(() => void) | null>(null)
interface PendingOpenFile {
path: string
}
async function openPendingAssociatedFiles(): Promise<void> {
const { invoke } = await import('@tauri-apps/api/core')
const files = await invoke<PendingOpenFile[]>('take_pending_open')
for (const file of files) await openFileFromPath(file.path)
}
async function bindAssociatedFileOpen(): Promise<void> {
if (!isTauri()) return
const { listen } = await import('@tauri-apps/api/event')
fileAssociationCleanup.value = await listen('open-associated-files', () => {
void openPendingAssociatedFiles().catch((error) => console.error('[Open With]', error))
})
await openPendingAssociatedFiles()
}
onMounted(async () => {
const mcp = await spawnMCPIfNeeded()
mcpCleanup.value = mcp?.disconnect ?? null
const tauri = isTauri()
if (import.meta.env.DEV || (tauri && mcp)) {
automationCleanup.value = connectAutomation(getActiveStore, mcp?.authToken ?? null).disconnect
}
try {
await bindAssociatedFileOpen()
} catch (error) {
console.error('[Open With]', error)
}
})
onUnmounted(() => {
mcpCleanup.value?.()
automationCleanup.value?.()
fileAssociationCleanup.value?.()
})
</script>
<template>
<div data-test-id="editor-root" class="flex h-screen w-screen flex-col">
<SafariBanner />
<FontStatusBanner />
<RenameSelectionDialog />
<TabBar />
<HomeWorkspace v-show="activeTab?.kind === 'home'" @new-document="createDocumentInCurrentTab" />
<EditorWorkspace v-if="activeTab?.kind !== 'home'" />
</div>
</template>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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