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:
parent
7a6c4cf8cb
commit
ca80b0e787
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -70,6 +70,12 @@
|
|||
"settingsAIAndAgents": "AIとエージェント",
|
||||
"settingsMedia": "メディア",
|
||||
"settingsStorage": "クラウドストレージ",
|
||||
"storageWorkspace": "ストレージワークスペース",
|
||||
"openStorageWorkspace": "ワークスペースを開く",
|
||||
"newStoredDocument": "新規ドキュメント",
|
||||
"emptyStorageWorkspace": "保存されたドキュメントはまだありません。",
|
||||
"loadingDocuments": "ドキュメントを読み込み中…",
|
||||
"storageNotConfigured": "このワークスペースを使用する前にストレージを設定してください。",
|
||||
"storageEndpoint": "エンドポイント",
|
||||
"storageBucket": "バケット",
|
||||
"storageRegion": "リージョン",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -70,6 +70,12 @@
|
|||
"settingsAIAndAgents": "ИИ и агенты",
|
||||
"settingsMedia": "Медиа",
|
||||
"settingsStorage": "Облачное хранилище",
|
||||
"storageWorkspace": "Рабочая область хранилища",
|
||||
"openStorageWorkspace": "Открыть область",
|
||||
"newStoredDocument": "Новый документ",
|
||||
"emptyStorageWorkspace": "Сохранённых документов пока нет.",
|
||||
"loadingDocuments": "Загрузка документов…",
|
||||
"storageNotConfigured": "Настройте хранилище перед использованием этой области.",
|
||||
"storageEndpoint": "Конечная точка",
|
||||
"storageBucket": "Бакет",
|
||||
"storageRegion": "Регион",
|
||||
|
|
|
|||
|
|
@ -70,6 +70,12 @@
|
|||
"settingsAIAndAgents": "AI 和代理",
|
||||
"settingsMedia": "媒体",
|
||||
"settingsStorage": "云存储",
|
||||
"storageWorkspace": "存储工作区",
|
||||
"openStorageWorkspace": "打开工作区",
|
||||
"newStoredDocument": "新建文档",
|
||||
"emptyStorageWorkspace": "暂无已存储的文档。",
|
||||
"loadingDocuments": "正在加载文档…",
|
||||
"storageNotConfigured": "请先配置存储,再使用此工作区。",
|
||||
"storageEndpoint": "端点",
|
||||
"storageBucket": "存储桶",
|
||||
"storageRegion": "区域",
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
189
src/views/StorageView.vue
Normal 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>
|
||||
75
tests/e2e/storage/workspace.spec.ts
Normal file
75
tests/e2e/storage/workspace.spec.ts
Normal 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()
|
||||
})
|
||||
Loading…
Reference in a new issue