diff --git a/AGENTS.md b/AGENTS.md index b00c804c8..cdf33f19f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,6 +56,8 @@ Credential persistence lives under `src/app/settings/credentials/`. Settings com Tauri stores secrets in the native system credential store through `desktop/src/credentials.rs`; browser sessions default to memory and may explicitly opt into WebCrypto-encrypted IndexedDB storage. Native failures must never silently fall back to browser or plaintext storage. New integration credentials use stable `CredentialRef` values and join the unified Settings surface rather than adding feature-local key forms. +Storage-provider schemas and runtime adapters live under `src/app/integrations/storage/`; non-secret preferences and credential references stay separate, and adapters resolve secrets at operation time. Local-first document caching and outbox synchronization live under `src/app/storage/`. A remote storage binding augments document source state and must not replace local file identity. + App dialogs compose the Reka-backed components under `src/components/ui/dialog/` and the typed theme in `src/theme/dialog.ts`. Do not repeat portal, overlay, content, header, or footer infrastructure in feature dialogs. ## Commands diff --git a/CHANGELOG.md b/CHANGELOG.md index a6d14d139..123ce7881 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ - Target a specific open document and page from live CLI and MCP automation, including sessions with multiple documents. - Test OpenAI-compatible provider connections from AI settings with clearer setup errors. - Manage AI, agent, and media credentials from unified Settings, using the system credential store on desktop and optional encrypted storage in the browser. +- Connect an S3-compatible storage workspace with local-first saves, background synchronization, and centrally managed credentials. - Build custom property panels with new Vue SDK number fields, bindable values, property sections, responsive property grids, segmented controls, property lists, color models, fill controls, and gradient primitives. - Connect local MCP clients through automatically discovered private Unix sockets on macOS and Linux, with localhost TCP fallback. (#338) - Create centered frames from current Figma-style device and asset presets, or resize selected frames from the Design panel while preserving their names. diff --git a/packages/vue/src/i18n/locales/de/dialogs.json b/packages/vue/src/i18n/locales/de/dialogs.json index 22e10b15e..7f6e848ae 100644 --- a/packages/vue/src/i18n/locales/de/dialogs.json +++ b/packages/vue/src/i18n/locales/de/dialogs.json @@ -70,6 +70,12 @@ "settingsAIAndAgents": "KI und Agenten", "settingsMedia": "Medien", "settingsStorage": "Cloud-Speicher", + "storageWorkspace": "Speicher-Arbeitsbereich", + "openStorageWorkspace": "Arbeitsbereich öffnen", + "newStoredDocument": "Neues Dokument", + "emptyStorageWorkspace": "Noch keine gespeicherten Dokumente.", + "loadingDocuments": "Dokumente werden geladen…", + "storageNotConfigured": "Konfiguriere den Speicher, bevor du diesen Arbeitsbereich verwendest.", "storageEndpoint": "Endpunkt", "storageBucket": "Bucket", "storageRegion": "Region", diff --git a/packages/vue/src/i18n/locales/es/dialogs.json b/packages/vue/src/i18n/locales/es/dialogs.json index aff7acd1e..503828225 100644 --- a/packages/vue/src/i18n/locales/es/dialogs.json +++ b/packages/vue/src/i18n/locales/es/dialogs.json @@ -70,6 +70,12 @@ "settingsAIAndAgents": "IA y agentes", "settingsMedia": "Multimedia", "settingsStorage": "Almacenamiento en la nube", + "storageWorkspace": "Espacio de almacenamiento", + "openStorageWorkspace": "Abrir espacio", + "newStoredDocument": "Nuevo documento", + "emptyStorageWorkspace": "Aún no hay documentos guardados.", + "loadingDocuments": "Cargando documentos…", + "storageNotConfigured": "Configura el almacenamiento antes de usar este espacio.", "storageEndpoint": "Punto de conexión", "storageBucket": "Bucket", "storageRegion": "Región", diff --git a/packages/vue/src/i18n/locales/fr/dialogs.json b/packages/vue/src/i18n/locales/fr/dialogs.json index c0801fc01..377bdc408 100644 --- a/packages/vue/src/i18n/locales/fr/dialogs.json +++ b/packages/vue/src/i18n/locales/fr/dialogs.json @@ -70,6 +70,12 @@ "settingsAIAndAgents": "IA et agents", "settingsMedia": "Médias", "settingsStorage": "Stockage cloud", + "storageWorkspace": "Espace de stockage", + "openStorageWorkspace": "Ouvrir l’espace", + "newStoredDocument": "Nouveau document", + "emptyStorageWorkspace": "Aucun document stocké pour le moment.", + "loadingDocuments": "Chargement des documents…", + "storageNotConfigured": "Configurez le stockage avant d’utiliser cet espace.", "storageEndpoint": "Point de terminaison", "storageBucket": "Bucket", "storageRegion": "Région", diff --git a/packages/vue/src/i18n/locales/it/dialogs.json b/packages/vue/src/i18n/locales/it/dialogs.json index 4ac8f048f..8a9e4f3f5 100644 --- a/packages/vue/src/i18n/locales/it/dialogs.json +++ b/packages/vue/src/i18n/locales/it/dialogs.json @@ -70,6 +70,12 @@ "settingsAIAndAgents": "IA e agenti", "settingsMedia": "Media", "settingsStorage": "Archiviazione cloud", + "storageWorkspace": "Area di archiviazione", + "openStorageWorkspace": "Apri area", + "newStoredDocument": "Nuovo documento", + "emptyStorageWorkspace": "Nessun documento archiviato.", + "loadingDocuments": "Caricamento documenti…", + "storageNotConfigured": "Configura l’archiviazione prima di usare quest’area.", "storageEndpoint": "Endpoint", "storageBucket": "Bucket", "storageRegion": "Regione", diff --git a/packages/vue/src/i18n/locales/ja/dialogs.json b/packages/vue/src/i18n/locales/ja/dialogs.json index e32126ebc..323fe7635 100644 --- a/packages/vue/src/i18n/locales/ja/dialogs.json +++ b/packages/vue/src/i18n/locales/ja/dialogs.json @@ -70,6 +70,12 @@ "settingsAIAndAgents": "AIとエージェント", "settingsMedia": "メディア", "settingsStorage": "クラウドストレージ", + "storageWorkspace": "ストレージワークスペース", + "openStorageWorkspace": "ワークスペースを開く", + "newStoredDocument": "新規ドキュメント", + "emptyStorageWorkspace": "保存されたドキュメントはまだありません。", + "loadingDocuments": "ドキュメントを読み込み中…", + "storageNotConfigured": "このワークスペースを使用する前にストレージを設定してください。", "storageEndpoint": "エンドポイント", "storageBucket": "バケット", "storageRegion": "リージョン", diff --git a/packages/vue/src/i18n/locales/pl/dialogs.json b/packages/vue/src/i18n/locales/pl/dialogs.json index cf0605428..76fd66ed0 100644 --- a/packages/vue/src/i18n/locales/pl/dialogs.json +++ b/packages/vue/src/i18n/locales/pl/dialogs.json @@ -70,6 +70,12 @@ "settingsAIAndAgents": "AI i agenci", "settingsMedia": "Media", "settingsStorage": "Przechowywanie w chmurze", + "storageWorkspace": "Obszar przechowywania", + "openStorageWorkspace": "Otwórz obszar", + "newStoredDocument": "Nowy dokument", + "emptyStorageWorkspace": "Brak zapisanych dokumentów.", + "loadingDocuments": "Ładowanie dokumentów…", + "storageNotConfigured": "Skonfiguruj przechowywanie przed użyciem tego obszaru.", "storageEndpoint": "Punkt końcowy", "storageBucket": "Zasobnik", "storageRegion": "Region", diff --git a/packages/vue/src/i18n/locales/ru/dialogs.json b/packages/vue/src/i18n/locales/ru/dialogs.json index 66116c231..57bc0c03f 100644 --- a/packages/vue/src/i18n/locales/ru/dialogs.json +++ b/packages/vue/src/i18n/locales/ru/dialogs.json @@ -70,6 +70,12 @@ "settingsAIAndAgents": "ИИ и агенты", "settingsMedia": "Медиа", "settingsStorage": "Облачное хранилище", + "storageWorkspace": "Рабочая область хранилища", + "openStorageWorkspace": "Открыть область", + "newStoredDocument": "Новый документ", + "emptyStorageWorkspace": "Сохранённых документов пока нет.", + "loadingDocuments": "Загрузка документов…", + "storageNotConfigured": "Настройте хранилище перед использованием этой области.", "storageEndpoint": "Конечная точка", "storageBucket": "Бакет", "storageRegion": "Регион", diff --git a/packages/vue/src/i18n/locales/zh-cn/dialogs.json b/packages/vue/src/i18n/locales/zh-cn/dialogs.json index 367e72614..61050fd47 100644 --- a/packages/vue/src/i18n/locales/zh-cn/dialogs.json +++ b/packages/vue/src/i18n/locales/zh-cn/dialogs.json @@ -70,6 +70,12 @@ "settingsAIAndAgents": "AI 和代理", "settingsMedia": "媒体", "settingsStorage": "云存储", + "storageWorkspace": "存储工作区", + "openStorageWorkspace": "打开工作区", + "newStoredDocument": "新建文档", + "emptyStorageWorkspace": "暂无已存储的文档。", + "loadingDocuments": "正在加载文档…", + "storageNotConfigured": "请先配置存储,再使用此工作区。", "storageEndpoint": "端点", "storageBucket": "存储桶", "storageRegion": "区域", diff --git a/packages/vue/src/i18n/messages/dialogs.ts b/packages/vue/src/i18n/messages/dialogs.ts index b9ede2628..a3173d3a5 100644 --- a/packages/vue/src/i18n/messages/dialogs.ts +++ b/packages/vue/src/i18n/messages/dialogs.ts @@ -119,6 +119,12 @@ export const dialogMessageDefaults = { settingsAIAndAgents: 'AI & agents', settingsMedia: 'Media', settingsStorage: 'Cloud storage', + storageWorkspace: 'Storage workspace', + openStorageWorkspace: 'Open workspace', + newStoredDocument: 'New document', + emptyStorageWorkspace: 'No stored documents yet.', + loadingDocuments: 'Loading documents…', + storageNotConfigured: 'Configure storage before using this workspace.', storageEndpoint: 'Endpoint', storageBucket: 'Bucket', storageRegion: 'Region', diff --git a/src/app/editor/session/modules.ts b/src/app/editor/session/modules.ts index c8d4087a7..7f67b1d33 100644 --- a/src/app/editor/session/modules.ts +++ b/src/app/editor/session/modules.ts @@ -77,7 +77,9 @@ export function createEditorStoreModules( saveFigFileAs: documentIO.saveFigFileAs, getDocumentFilePath: documentIO.getDocumentFilePath, getSourceIdentity: documentIO.getSourceIdentity, + getStorageBinding: documentIO.getStorageBinding, setDocumentSource: documentIO.setDocumentSource, + setStorageDocumentSource: documentIO.setStorageDocumentSource, setPlannedFilePath: documentIO.setPlannedFilePath, startWatchingCurrentFile: documentIO.startWatchingCurrentFile, dispose: documentIO.disposeDocumentIO, diff --git a/src/app/tabs/index.ts b/src/app/tabs/index.ts index a4e13bd50..0f729c0c1 100644 --- a/src/app/tabs/index.ts +++ b/src/app/tabs/index.ts @@ -10,6 +10,13 @@ import type { DocumentSourceIdentity } from '@/app/document/io/types' import { setActiveEditorStore } from '@/app/editor/active-store' import { createEditorStore } from '@/app/editor/session' import type { EditorStore } from '@/app/editor/session' +import { + activeStorageProviderID, + createActiveStorageAdapter, + type StorageDocument +} from '@/app/integrations/storage' +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' @@ -115,6 +122,72 @@ function isDOMImportFile(file: File): boolean { return /\.(html?|xhtml)$/i.test(file.name) } +function reusableTabStore(): EditorStore { + const current = activeTab.value + const isUntouched = + current?.store.state.documentName === 'Untitled' && !current.store.undo.canUndo + return isUntouched ? current.store : createTab().store +} + +function findStorageTab(providerId: string, documentId: string): Tab | undefined { + return tabsRef.value.find((tab) => { + const binding = tab.store.getStorageBinding() + return binding?.providerId === providerId && binding.documentId === documentId + }) +} + +export async function openStorageDocumentInNewTab(document: StorageDocument): Promise { + const providerId = activeStorageProviderID.value + const existing = findStorageTab(providerId, document.id) + if (existing) { + switchTab(existing.id) + return + } + + const store = reusableTabStore() + store.state.documentName = document.name + store.state.loading = true + try { + const local = getLocalCanvasStore() + const localMetadata = await local.getMeta(document.id) + const localBytes = localMetadata?.hasFig ? await local.readFig(document.id) : null + const localIsAuthoritative = + localMetadata?.syncStatus !== 'synced' || + !document.metadataAuthoritative || + localMetadata.updatedAt >= document.updatedAt + let bytes = localBytes && localIsAuthoritative ? localBytes : null + + if (!bytes) { + bytes = await createActiveStorageAdapter(providerId).getDocument(document.id) + await seedStorageCanvasFromRemote({ + providerId, + canvasId: document.id, + name: document.name, + updatedAt: document.updatedAt, + figBytes: bytes + }) + } + + const fileBytes = new Uint8Array(bytes.byteLength) + fileBytes.set(bytes) + const file = new File([fileBytes.buffer], `${document.name}.fig`, { + type: 'application/octet-stream' + }) + const imported = await readFigFile(file, { populate: 'first-page' }) + const firstPageId = imported.getPages()[0]?.id + if (firstPageId) computeAllLayouts(imported, firstPageId) + store.replaceGraph(imported) + store.undo.clear() + store.setStorageDocumentSource({ providerId, documentId: document.id }, document.name) + store.clearSelection() + const pageId = store.graph.getPages()[0]?.id ?? store.graph.rootId + await store.switchPage(pageId) + await store.fitCurrentPageToViewport() + } finally { + store.state.loading = false + } +} + export async function openFileInNewTab( file: File, handle?: FileSystemFileHandle, @@ -138,10 +211,7 @@ export async function openFileInNewTab( return { kind: 'existing' as const } } - const current = activeTab.value - const isUntouched = - current?.store.state.documentName === 'Untitled' && !current.store.undo.canUndo - const store = isUntouched ? current.store : createTab().store + const store = reusableTabStore() store.state.documentName = file.name.replace(/\.[^.]+$/i, '') store.state.loading = true @@ -211,6 +281,7 @@ export function useTabsStore() { getTabForStore, getTabsSnapshot, openFileInNewTab, + openStorageDocumentInNewTab, getActiveStore, tabCount } diff --git a/src/components/settings/storage/StorageSettingsPanel.vue b/src/components/settings/storage/StorageSettingsPanel.vue index 43a3fb9f2..a3655dac0 100644 --- a/src/components/settings/storage/StorageSettingsPanel.vue +++ b/src/components/settings/storage/StorageSettingsPanel.vue @@ -1,5 +1,6 @@ + + diff --git a/tests/e2e/storage/workspace.spec.ts b/tests/e2e/storage/workspace.spec.ts new file mode 100644 index 000000000..d9bf9c6f6 --- /dev/null +++ b/tests/e2e/storage/workspace.spec.ts @@ -0,0 +1,75 @@ +import { readFileSync } from 'node:fs' + +import { expect, test } from '@playwright/test' + +import { CanvasHelper } from '#tests/helpers/canvas' + +test('configured storage lists and opens a remote document', async ({ page }) => { + const fixture = readFileSync('tests/fixtures/gold-preview.fig') + await page.route('https://s3.example.com/**', async (route) => { + const url = new URL(route.request().url()) + if (url.searchParams.get('list-type') === '2') { + await route.fulfill({ + contentType: 'application/xml', + body: ` + false + + open_pencil_storage/canvases/remote-1.fig + 2026-01-02T03:04:05.000Z + ${fixture.byteLength} + + ` + }) + return + } + if (url.pathname.endsWith('/remote-1.meta.json')) { + await route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ name: 'Remote design', updatedAt: '2026-01-02T03:04:05.000Z' }) + }) + return + } + if (url.pathname.endsWith('/remote-1.fig')) { + await route.fulfill({ contentType: 'application/octet-stream', body: fixture }) + return + } + await route.fulfill({ status: 404 }) + }) + + await page.goto('/?test') + const canvas = new CanvasHelper(page) + await canvas.waitForInit() + await page.getByTestId('app-settings-trigger').click() + await page.getByTestId('settings-section-storage').click() + await page.getByLabel('Endpoint').fill('https://s3.example.com') + await page.getByLabel('Bucket').fill('designs') + + for (const [field, value] of [ + ['access-key-id', 'access-key'], + ['secret-access-key', 'secret-key'] + ] as const) { + const container = page.locator(`[data-credential="${field}"]`) + await container.locator('input').fill(value) + await container.getByRole('button', { name: 'Save' }).click() + } + + await page.getByTestId('settings-storage-open-workspace').click() + await expect(page.getByTestId('storage-workspace')).toBeVisible() + await expect(page.getByText('Remote design')).toBeVisible() + + await page.locator('[data-document-id="remote-1"]').click() + await expect(page).toHaveURL(/\/$/) + await canvas.waitForInit() + await expect(page.getByText('Remote design').first()).toBeVisible() +}) + +test('storage workspace directs unconfigured users to Settings', async ({ page }) => { + await page.goto('/storage?test') + + await expect(page.getByTestId('storage-workspace')).toBeVisible() + await expect(page.getByRole('alert')).toContainText('Configure storage') + await expect(page.getByTestId('storage-new-document')).toBeDisabled() + + await page.getByRole('button', { name: 'Settings' }).click() + await expect(page.getByTestId('settings-storage-panel')).toBeVisible() +})