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:
parent
0ed6697946
commit
d4fedcb6ed
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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.'
|
||||
)
|
||||
}
|
||||
|
|
|
|||
71
src/app/shell/menu/editor.ts
Normal file
71
src/app/shell/menu/editor.ts
Normal 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]?.()
|
||||
})
|
||||
}
|
||||
22
src/app/shell/menu/native-events.ts
Normal file
22
src/app/shell/menu/native-events.ts
Normal 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
|
||||
})
|
||||
}
|
||||
|
|
@ -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' },
|
||||
{
|
||||
|
|
|
|||
41
src/app/shell/menu/shell.ts
Normal file
41
src/app/shell/menu/shell.ts
Normal 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]?.())
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
9
src/components/Shell/AppShell.vue
Normal file
9
src/components/Shell/AppShell.vue
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<script setup lang="ts">
|
||||
import { useShellMenu } from '@/app/shell/menu/use'
|
||||
|
||||
useShellMenu()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<slot />
|
||||
</template>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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) : []
|
||||
|
|
|
|||
Loading…
Reference in a new issue