feat(app): add storage workspace

- List local and remote documents from an explicit storage workspace route

- Open remote .fig documents into deduplicated storage-bound editor tabs

- Create locally durable documents only after storage configuration is complete

- Cover unconfigured guidance and mocked S3 document opening end to end

Co-authored-by: Rob Coenen <753704+rcoenen@users.noreply.github.com>
This commit is contained in:
Danila Poyarkov 2026-07-26 15:06:01 +03:00
parent 7a6c4cf8cb
commit ca80b0e787
17 changed files with 427 additions and 5 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -70,6 +70,12 @@
"settingsAIAndAgents": "IA et agents",
"settingsMedia": "Médias",
"settingsStorage": "Stockage cloud",
"storageWorkspace": "Espace de stockage",
"openStorageWorkspace": "Ouvrir lespace",
"newStoredDocument": "Nouveau document",
"emptyStorageWorkspace": "Aucun document stocké pour le moment.",
"loadingDocuments": "Chargement des documents…",
"storageNotConfigured": "Configurez le stockage avant dutiliser cet espace.",
"storageEndpoint": "Point de terminaison",
"storageBucket": "Bucket",
"storageRegion": "Région",

View file

@ -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 larchiviazione prima di usare questarea.",
"storageEndpoint": "Endpoint",
"storageBucket": "Bucket",
"storageRegion": "Regione",

View file

@ -70,6 +70,12 @@
"settingsAIAndAgents": "AIとエージェント",
"settingsMedia": "メディア",
"settingsStorage": "クラウドストレージ",
"storageWorkspace": "ストレージワークスペース",
"openStorageWorkspace": "ワークスペースを開く",
"newStoredDocument": "新規ドキュメント",
"emptyStorageWorkspace": "保存されたドキュメントはまだありません。",
"loadingDocuments": "ドキュメントを読み込み中…",
"storageNotConfigured": "このワークスペースを使用する前にストレージを設定してください。",
"storageEndpoint": "エンドポイント",
"storageBucket": "バケット",
"storageRegion": "リージョン",

View file

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

View file

@ -70,6 +70,12 @@
"settingsAIAndAgents": "ИИ и агенты",
"settingsMedia": "Медиа",
"settingsStorage": "Облачное хранилище",
"storageWorkspace": "Рабочая область хранилища",
"openStorageWorkspace": "Открыть область",
"newStoredDocument": "Новый документ",
"emptyStorageWorkspace": "Сохранённых документов пока нет.",
"loadingDocuments": "Загрузка документов…",
"storageNotConfigured": "Настройте хранилище перед использованием этой области.",
"storageEndpoint": "Конечная точка",
"storageBucket": "Бакет",
"storageRegion": "Регион",

View file

@ -70,6 +70,12 @@
"settingsAIAndAgents": "AI 和代理",
"settingsMedia": "媒体",
"settingsStorage": "云存储",
"storageWorkspace": "存储工作区",
"openStorageWorkspace": "打开工作区",
"newStoredDocument": "新建文档",
"emptyStorageWorkspace": "暂无已存储的文档。",
"loadingDocuments": "正在加载文档…",
"storageNotConfigured": "请先配置存储,再使用此工作区。",
"storageEndpoint": "端点",
"storageBucket": "存储桶",
"storageRegion": "区域",

View file

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

View file

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

View file

@ -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<void> {
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
}

View file

@ -1,5 +1,6 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from '@open-pencil/vue'
import {
@ -7,21 +8,31 @@ import {
createActiveStorageAdapter,
readStoragePreferences,
storageCredentialStatuses,
storagePreferencesComplete,
storageProviderRegistry,
writeStoragePreference
} from '@/app/integrations/storage'
import { appCredentialServices } from '@/app/settings/credentials/app'
import { settingsDialogOpen } from '@/app/settings/dialog'
import { credentialRef } from '@/app/settings/credentials/reference'
import type { CredentialStatus } from '@/app/settings/credentials/types'
import AppInput from '@/components/ui/AppInput.vue'
const { dialogs } = useI18n()
const router = useRouter()
const provider = storageProviderRegistry.get(activeStorageProviderID.value)
const preferenceDrafts = ref<Record<string, string>>({ ...readStoragePreferences(provider.id) })
const credentialDrafts = ref<Record<string, string>>({})
const credentialStatuses = ref<Record<string, CredentialStatus>>({})
const busy = ref(false)
const result = ref<{ ok: boolean; message: string } | null>(null)
const configured = computed(
() =>
storagePreferencesComplete(provider.id) &&
provider.credentialFields.every(
(field) => !field.required || credentialStatuses.value[field.id] === 'configured'
)
)
function preferenceLabel(field: string): string {
if (field === 'endpoint') return dialogs.value.storageEndpoint
@ -60,6 +71,11 @@ async function clearCredential(field: string): Promise<void> {
await refreshStatuses()
}
async function openWorkspace(): Promise<void> {
settingsDialogOpen.value = false
await router.push('/storage')
}
async function testConnection(): Promise<void> {
busy.value = true
result.value = null
@ -158,6 +174,16 @@ onMounted(() => void refreshStatuses())
{{ dialogs.testConnection }}
</button>
<button
type="button"
class="rounded border border-border px-3 py-1.5 text-[11px] font-medium text-surface hover:bg-hover disabled:text-muted disabled:opacity-50"
:disabled="!configured"
data-test-id="settings-storage-open-workspace"
@click="openWorkspace"
>
{{ dialogs.openStorageWorkspace }}
</button>
<p
v-if="result"
class="rounded border border-border bg-panel px-2 py-1.5 text-[10px] text-muted data-[state=success]:text-success data-[state=error]:text-danger"

View file

@ -1,11 +1,13 @@
import { createRouter, createWebHistory } from 'vue-router'
import EditorView from './views/EditorView.vue'
import StorageView from './views/StorageView.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 }
]

189
src/views/StorageView.vue Normal file
View file

@ -0,0 +1,189 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from '@open-pencil/vue'
import {
activeStorageProviderID,
createActiveStorageAdapter,
storageCredentialStatuses,
storagePreferencesComplete,
storageProviderRegistry,
type StorageDocument
} from '@/app/integrations/storage'
import { fadeOutGlobalLoader } from '@/app/editor/canvas/loader-overlay'
import { openSettingsDialog } from '@/app/settings/dialog'
import type { CredentialStatus } from '@/app/settings/credentials/types'
import { createCanvasId } from '@/app/storage/id'
import { getLocalCanvasStore } from '@/app/storage/local-store'
import { activeTab, createTab, openStorageDocumentInNewTab } from '@/app/tabs'
const { dialogs } = useI18n()
const router = useRouter()
const provider = storageProviderRegistry.get(activeStorageProviderID.value)
const documents = ref<StorageDocument[]>([])
const credentialStatuses = ref<Record<string, CredentialStatus>>({})
const configured = computed(
() =>
storagePreferencesComplete(provider.id) &&
provider.credentialFields.every(
(field) => !field.required || credentialStatuses.value[field.id] === 'configured'
)
)
const loading = ref(false)
const error = ref<string | null>(null)
async function paintLocalDocuments(): Promise<void> {
const local = await getLocalCanvasStore().listMetas()
documents.value = local.map((metadata) => ({
id: metadata.id,
name: metadata.name,
updatedAt: metadata.updatedAt,
metadataAuthoritative: true
}))
}
async function refresh(): Promise<void> {
loading.value = true
error.value = null
await paintLocalDocuments()
try {
credentialStatuses.value = await storageCredentialStatuses(provider.id)
if (!configured.value) {
error.value = dialogs.value.storageNotConfigured
return
}
const remote = await createActiveStorageAdapter().listDocuments()
const local = await getLocalCanvasStore().listMetas()
const localById = new Map(local.map((metadata) => [metadata.id, metadata]))
const merged = new Map(remote.map((document) => [document.id, document]))
for (const metadata of local) {
if (metadata.syncStatus === 'synced' && merged.has(metadata.id)) continue
merged.set(metadata.id, {
id: metadata.id,
name: metadata.name,
updatedAt: metadata.updatedAt,
metadataAuthoritative: true
})
}
documents.value = [...merged.values()].sort((first, second) =>
second.updatedAt.localeCompare(first.updatedAt)
)
for (const document of remote) {
if (localById.has(document.id)) continue
await getLocalCanvasStore().upsertIndexMeta({
id: document.id,
providerId: activeStorageProviderID.value,
name: document.name,
updatedAt: document.updatedAt,
syncStatus: 'synced',
lastSyncedAt: document.updatedAt,
lastSyncError: null
})
}
} catch (reason) {
error.value = reason instanceof Error ? reason.message : String(reason)
} finally {
loading.value = false
}
}
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()
}
onMounted(() => {
fadeOutGlobalLoader()
void refresh()
})
</script>
<template>
<main class="min-h-screen 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 gap-2">
<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 max-w-6xl p-6">
<div class="mb-4 flex items-center justify-between">
<p v-if="loading" class="text-xs text-muted">{{ dialogs.loadingDocuments }}</p>
<p v-else-if="error" class="text-xs text-danger" role="alert">{{ error }}</p>
<span v-else />
<button
type="button"
class="rounded px-2 py-1 text-xs text-muted hover:bg-hover hover:text-surface"
@click="refresh"
>
{{ dialogs.refresh }}
</button>
</div>
<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 class="aspect-[4/3] bg-panel-field" />
<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>
<div v-else-if="!loading" class="py-24 text-center text-xs text-muted">
{{ dialogs.emptyStorageWorkspace }}
</div>
</section>
</main>
</template>

View file

@ -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: `<ListBucketResult>
<IsTruncated>false</IsTruncated>
<Contents>
<Key>open_pencil_storage/canvases/remote-1.fig</Key>
<LastModified>2026-01-02T03:04:05.000Z</LastModified>
<Size>${fixture.byteLength}</Size>
</Contents>
</ListBucketResult>`
})
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()
})