fix(storage): polish workspace and connection feedback (#492)

* fix(storage): polish workspace and connection feedback

- Move Refresh into the Storage Workspace header and keep shell theme actions available outside the editor route
- Split native shell and editor menu listeners behind the app shell
- Replace permanent CORS controls and inline connection results with standard toasts and clearer browser guidance

* fix(app): harden native menu routing

- Dispose menu listeners that finish registering after their Vue scope closes
- Mark shell-owned actions in the shared menu schema instead of duplicating IDs
- Cover route-neutral shell action classification
This commit is contained in:
Danila Poyarkov 2026-08-12 16:06:10 +03:00 committed by GitHub
parent 0ed6697946
commit d4fedcb6ed
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 198 additions and 154 deletions

View file

@ -6,6 +6,7 @@
- Keep imported Figma instances linked to their remapped source components so later component edits update existing instances. (#385)
- Restore native copy, cut, and paste shortcuts in desktop text inputs while preserving design clipboard handling on the canvas.
- Remove the permanent CORS configuration action from cloud-storage settings and report connection results through standard toasts with clear browser-specific guidance.
- Complete translated app, accessibility, font, color, collaboration, import, connection-test, and browser fallback text across all supported locales, and keep the document language synchronized with the selected locale.
- Preserve circles, ellipses, rectangles, lines, polylines, and polygons supplied as JSX children of inline SVG elements. (#452)

View file

@ -4,6 +4,7 @@ import { useHead } from '@unhead/vue'
import { TooltipProvider } from 'reka-ui'
import { provideEditor, useI18n } from '@open-pencil/vue'
import AppShell from '@/components/Shell/AppShell.vue'
import AppToast from '@/components/Shell/AppToast.vue'
import SettingsDialog from '@/components/settings/SettingsDialog.vue'
import { useEditorStore } from '@/app/editor/active-store'
@ -32,7 +33,9 @@ onMounted(() => {
<template>
<TooltipProvider :delay-duration="400">
<RouterView />
<AppShell>
<RouterView />
</AppShell>
<SettingsDialog />
<AppToast />
</TooltipProvider>

View file

@ -103,8 +103,10 @@ export function isLikelyCORSOrNetworkError(error: unknown): boolean {
}
export function formatBrowserCORSHelpMessage(): string {
const origin = IS_BROWSER ? window.location.origin : WEB_APP_ORIGIN
return (
'CORS issue: the browser blocked access to your bucket. ' +
'Click “Copy CORS JSON”, paste it into your bucket CORS settings, wait about a minute, then try again.'
`The browser could not reach this bucket from ${origin}. ` +
'Your bucket may not allow requests from this site, or the endpoint may be unavailable. ' +
'Add this site to the bucket’s allowed CORS origins, then try again.'
)
}

View file

@ -0,0 +1,71 @@
import { useEditorCommands } from '@open-pencil/vue'
import type { EditorCommandId } from '@open-pencil/vue'
import { useEditorStore } from '@/app/editor/active-store'
import { createSharedEditorMenuActions } from '@/app/shell/menu/editor-actions'
import { openFileDialog } from '@/app/shell/menu/files'
import { useNativeMenuEvents } from '@/app/shell/menu/native-events'
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'
import { useAppTheme } from '@/app/shell/theme'
import { createTab, closeTab, activeTab } from '@/app/tabs'
import { isTauri } from '@/app/tauri/env'
function commandMenuIds(entries: readonly AppMenuEntry[]): EditorCommandId[] {
return entries.flatMap((entry) => {
if (entry.type === 'separator') return []
return [...(entry.command ? [entry.command] : []), ...commandMenuIds(entry.sub ?? [])]
})
}
const store = useEditorStore()
const COMMAND_MENU_IDS = new Set<EditorCommandId>(
APP_MENU_SCHEMA.flatMap((group) => commandMenuIds(group.items))
)
export function useEditorMenu() {
if (!isTauri()) return
const { setTheme } = useAppTheme()
const { runCommand } = useEditorCommands()
const actions: Partial<Record<string, () => void>> = {
new: () => createTab(),
open: () => void openFileDialog(),
close: () => {
if (activeTab.value) closeTab(activeTab.value.id)
},
save: () => void store.saveFigFile(),
'save-as': () => void store.saveFigFileAs(),
'export-selection': () => {
if (store.state.selectedIds.size > 0) void store.exportSelection(1, 'png')
},
'export-png': () => {
if (store.state.selectedIds.size > 0) void store.exportSelection(1, 'png')
},
'export-svg': () => {
if (store.state.selectedIds.size > 0) void store.exportSelection(1, 'svg')
},
'export-pptx': () => {
if (store.state.selectedIds.size > 0) void store.exportSelection(1, 'pptx')
},
'export-fig': () => {
if (store.state.selectedIds.size > 0) void store.exportSelection(1, 'fig')
},
autosave: () => {
store.state.autosaveEnabled = !store.state.autosaveEnabled
},
...createSelectionMenuActions(store),
...createSharedEditorMenuActions(setTheme)
}
useNativeMenuEvents((id) => {
if (SHELL_MENU_IDS.has(id)) return
if (COMMAND_MENU_IDS.has(id as EditorCommandId)) {
runCommand(id as EditorCommandId)
return
}
actions[id]?.()
})
}

View file

@ -0,0 +1,22 @@
import { tryOnScopeDispose } from '@vueuse/core'
export function useNativeMenuEvents(handler: (id: string) => void): void {
let disposed = false
let unlisten: (() => void) | undefined
void import('@tauri-apps/api/event').then(({ listen }) => {
return listen<string>('menu-event', (event) => {
handler(event.payload)
}).then((fn) => {
if (disposed) fn()
else unlisten = fn
return undefined
})
})
tryOnScopeDispose(() => {
disposed = true
unlisten?.()
unlisten = undefined
})
}

View file

@ -1,6 +1,7 @@
import type { EditorCommandId } from '@open-pencil/vue'
export type AppMenuTarget = 'all' | 'browser' | 'native'
export type AppMenuHandler = 'editor' | 'shell'
export interface AppMenuActionItem {
type?: 'item'
@ -11,6 +12,7 @@ export interface AppMenuActionItem {
command?: EditorCommandId
checkbox?: boolean
target?: AppMenuTarget
handler?: AppMenuHandler
sub?: AppMenuEntry[]
}
@ -33,7 +35,7 @@ export const APP_MENU_SCHEMA = [
items: [
{ id: 'new', label: 'New', shortcut: 'MOD+N' },
{ id: 'open', label: 'Open…', shortcut: 'MOD+O' },
{ id: 'open-storage-workspace', label: 'Open Storage Workspace…' },
{ id: 'open-storage-workspace', label: 'Open Storage Workspace…', handler: 'shell' },
{ type: 'separator' },
{ id: 'save', label: 'Save', shortcut: 'MOD+S' },
{ id: 'save-as', label: 'Save As…', shortcut: 'MOD+SHIFT+S' },
@ -124,15 +126,15 @@ export const APP_MENU_SCHEMA = [
id: 'theme',
label: 'Theme',
sub: [
{ id: 'theme-light', label: 'Light', checkbox: true },
{ id: 'theme-dark', label: 'Dark', checkbox: true },
{ id: 'theme-auto', label: 'Auto', checkbox: true }
{ id: 'theme-light', label: 'Light', checkbox: true, handler: 'shell' },
{ id: 'theme-dark', label: 'Dark', checkbox: true, handler: 'shell' },
{ id: 'theme-auto', label: 'Auto', checkbox: true, handler: 'shell' }
]
},
{ id: 'language', label: 'Language', target: 'browser' },
{ type: 'separator' },
{ id: 'toggle-ui', label: 'Toggle UI', shortcut: 'MOD+\\' },
{ id: 'settings', label: 'Settings…' },
{ id: 'settings', label: 'Settings…', handler: 'shell' },
{ type: 'separator' },
{ id: 'profiler', label: 'Profiler', checkbox: true, target: 'browser' },
{

View file

@ -0,0 +1,41 @@
import { useI18n } from '@open-pencil/vue'
import { openSettingsDialog } from '@/app/settings/dialog'
import { useNativeMenuEvents } from '@/app/shell/menu/native-events'
import { openStorageWorkspace } from '@/app/shell/menu/navigation'
import { APP_MENU_SCHEMA, type AppMenuEntry } from '@/app/shell/menu/schema'
import { useAppTheme } from '@/app/shell/theme'
import { checkForAppUpdate } from '@/app/shell/updater'
import { isTauri } from '@/app/tauri/env'
function shellMenuIds(entries: readonly AppMenuEntry[]): string[] {
return entries.flatMap((entry) => {
if (entry.type === 'separator') return []
return [...(entry.handler === 'shell' ? [entry.id] : []), ...shellMenuIds(entry.sub ?? [])]
})
}
export const SHELL_MENU_IDS = new Set([
...APP_MENU_SCHEMA.flatMap((group) => shellMenuIds(group.items)),
// The macOS application menu is native-only and is not part of the shared schema.
'check-updates'
])
export function useShellMenu() {
if (!isTauri()) return
const { setTheme } = useAppTheme()
const { dialogs } = useI18n()
const actions: Partial<Record<string, () => void>> = {
'open-storage-workspace': () => {
void import('@/router').then(({ default: router }) => openStorageWorkspace(router))
},
settings: openSettingsDialog,
'theme-light': () => setTheme('light'),
'theme-dark': () => setTheme('dark'),
'theme-auto': () => setTheme('auto'),
'check-updates': () => void checkForAppUpdate({ messages: dialogs })
}
useNativeMenuEvents((id) => actions[id]?.())
}

View file

@ -1,90 +1,3 @@
import { tryOnScopeDispose } from '@vueuse/core'
import { useEditorCommands, useI18n } from '@open-pencil/vue'
import type { EditorCommandId } from '@open-pencil/vue'
import { useEditorStore } from '@/app/editor/active-store'
import { openSettingsDialog } from '@/app/settings/dialog'
import { createSharedEditorMenuActions } from '@/app/shell/menu/editor-actions'
import { importFileDialog, openFileDialog } from '@/app/shell/menu/files'
import { openStorageWorkspace } from '@/app/shell/menu/navigation'
import { APP_MENU_SCHEMA, type AppMenuEntry } from '@/app/shell/menu/schema'
import { createSelectionMenuActions } from '@/app/shell/menu/selection-actions'
import { useAppTheme } from '@/app/shell/theme'
import { checkForAppUpdate } from '@/app/shell/updater'
import { createTab, closeTab, activeTab } from '@/app/tabs'
import { isTauri } from '@/app/tauri/env'
function commandMenuIds(entries: readonly AppMenuEntry[]): EditorCommandId[] {
return entries.flatMap((entry) => {
if (entry.type === 'separator') return []
return [...(entry.command ? [entry.command] : []), ...commandMenuIds(entry.sub ?? [])]
})
}
const store = useEditorStore()
const COMMAND_MENU_IDS = new Set<EditorCommandId>(
APP_MENU_SCHEMA.flatMap((group) => commandMenuIds(group.items))
)
export { importFileDialog, openFileDialog }
export { openFileFromPath } from '@/app/shell/menu/files'
export function useMenu() {
if (!isTauri()) return
let unlisten: (() => void) | undefined
const { setTheme } = useAppTheme()
const { dialogs } = useI18n()
const { runCommand } = useEditorCommands()
const actions: Partial<Record<string, () => void>> = {
new: () => createTab(),
open: () => void openFileDialog(),
'open-storage-workspace': () => {
void import('@/router').then(({ default: router }) => openStorageWorkspace(router))
},
close: () => {
if (activeTab.value) closeTab(activeTab.value.id)
},
save: () => void store.saveFigFile(),
'save-as': () => void store.saveFigFileAs(),
'export-selection': () => {
if (store.state.selectedIds.size > 0) void store.exportSelection(1, 'png')
},
'export-png': () => {
if (store.state.selectedIds.size > 0) void store.exportSelection(1, 'png')
},
'export-svg': () => {
if (store.state.selectedIds.size > 0) void store.exportSelection(1, 'svg')
},
'export-pptx': () => {
if (store.state.selectedIds.size > 0) void store.exportSelection(1, 'pptx')
},
'export-fig': () => {
if (store.state.selectedIds.size > 0) void store.exportSelection(1, 'fig')
},
autosave: () => {
store.state.autosaveEnabled = !store.state.autosaveEnabled
},
...createSelectionMenuActions(store),
'check-updates': () => void checkForAppUpdate({ messages: dialogs }),
settings: openSettingsDialog,
...createSharedEditorMenuActions(setTheme)
}
void import('@tauri-apps/api/event').then(({ listen }) => {
return listen<string>('menu-event', (event) => {
if (COMMAND_MENU_IDS.has(event.payload as EditorCommandId)) {
runCommand(event.payload as EditorCommandId)
return
}
actions[event.payload]?.()
}).then((fn) => {
unlisten = fn
return undefined
})
})
tryOnScopeDispose(() => unlisten?.())
}
export { importFileDialog, openFileDialog, openFileFromPath } from '@/app/shell/menu/files'
export { useEditorMenu } from '@/app/shell/menu/editor'
export { useShellMenu } from '@/app/shell/menu/shell'

View file

@ -0,0 +1,9 @@
<script setup lang="ts">
import { useShellMenu } from '@/app/shell/menu/use'
useShellMenu()
</script>
<template>
<slot />
</template>

View file

@ -1,6 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useClipboard } from '@vueuse/core'
import { useRouter } from 'vue-router'
import { useI18n } from '@open-pencil/vue'
@ -13,20 +12,16 @@ import {
storageProviderRegistry,
writeStoragePreference
} from '@/app/integrations/storage'
import {
buildCORSConfigurationJSON,
collectCloudCORSOrigins
} from '@/app/integrations/storage/s3/cors'
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 { toast } from '@/app/shell/ui'
import { resumeStorageSync } from '@/app/storage/sync'
import AppInput from '@/components/ui/AppInput.vue'
const { dialogs } = useI18n()
const router = useRouter()
const { copy, copied } = useClipboard()
const provider = computed(() => storageProviderRegistry.get(activeStorageProviderID.value))
const preferenceDrafts = ref<Record<string, string>>({
...readStoragePreferences(provider.value.id)
@ -34,7 +29,6 @@ const preferenceDrafts = ref<Record<string, string>>({
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.value.id) &&
@ -87,25 +81,19 @@ async function openWorkspace(): Promise<void> {
await router.push('/storage')
}
function copyCORSConfiguration(): void {
void copy(buildCORSConfigurationJSON(collectCloudCORSOrigins()))
}
async function testConnection(): Promise<void> {
busy.value = true
result.value = null
try {
savePreferences()
for (const field of provider.value.credentialFields) {
await saveCredential(field.id)
}
await resumeStorageSync()
result.value = await createActiveStorageAdapter(provider.value.id).testConnection()
const connection = await createActiveStorageAdapter(provider.value.id).testConnection()
if (connection.ok) toast.info(connection.message)
else toast.error(connection.message)
} catch (error) {
result.value = {
ok: false,
message: error instanceof Error ? error.message : String(error)
}
toast.error(error instanceof Error ? error.message : String(error))
} finally {
busy.value = false
}
@ -114,7 +102,6 @@ async function testConnection(): Promise<void> {
watch(activeStorageProviderID, (providerID) => {
preferenceDrafts.value = { ...readStoragePreferences(providerID) }
credentialDrafts.value = {}
result.value = null
void refreshStatuses()
})
@ -197,15 +184,6 @@ onMounted(() => void refreshStatuses())
{{ dialogs.testConnection }}
</button>
<button
v-if="provider.id === 's3-compatible'"
type="button"
class="rounded px-3 py-1.5 text-[11px] text-muted hover:bg-hover hover:text-surface"
@click="copyCORSConfiguration"
>
{{ copied ? dialogs.copied : dialogs.copyStorageCors }}
</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"
@ -215,14 +193,5 @@ onMounted(() => void refreshStatuses())
>
{{ 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"
:data-state="result.ok ? 'success' : 'error'"
role="status"
>
{{ result.message }}
</p>
</section>
</template>

View file

@ -8,7 +8,7 @@ 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, useMenu } from '@/app/shell/menu/use'
import { openFileFromPath, useEditorMenu } from '@/app/shell/menu/use'
import { useCollab, COLLAB_KEY } from '@/app/collab/use'
import { connectAutomation } from '@/app/automation/bridge/server'
import { spawnMCPIfNeeded } from '@/app/automation/mcp/spawn'
@ -46,7 +46,7 @@ if (createdInitialTab && route.meta.demo && !('test' in params)) {
useHead({ title: route.meta.demo ? 'Demo' : undefined })
useKeyboard()
useMenu()
useEditorMenu()
const collab = useCollab(getActiveStore)
provide(COLLAB_KEY, collab)

View file

@ -74,7 +74,18 @@ watch(settingsDialogOpen, (open, wasOpen) => {
<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">
<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"
@ -95,23 +106,9 @@ watch(settingsDialogOpen, (open, wasOpen) => {
</header>
<section class="mx-auto flex min-h-0 w-full max-w-6xl flex-1 flex-col p-6">
<div class="mb-4 flex shrink-0 items-center justify-between">
<p v-if="errorMessage && configured" class="text-xs text-danger" role="alert">
{{ errorMessage }}
</p>
<span v-else />
<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>
</div>
<p v-if="errorMessage && configured" class="mb-4 shrink-0 text-xs text-danger" role="alert">
{{ errorMessage }}
</p>
<div
v-if="documents.length"

View file

@ -43,6 +43,20 @@ describe('APP_MENU_SCHEMA', () => {
expect(commandIds).toContain('selection.moveToPage')
})
test('marks route-neutral native actions for shell dispatch', () => {
const shellEntries = actionItems(APP_MENU_SCHEMA.flatMap((group) => group.items)).filter(
(entry) => !('type' in entry) && entry.handler === 'shell'
)
expect(shellEntries.map((entry) => ('type' in entry ? '' : entry.id))).toEqual([
'open-storage-workspace',
'theme-light',
'theme-dark',
'theme-auto',
'settings'
])
})
test('includes storage workspace navigation in the shared File menu', () => {
const fileMenu = APP_MENU_SCHEMA.find((group) => group.label === 'File')
const entries = fileMenu ? actionItems(fileMenu.items) : []