diff --git a/src/App.vue b/src/App.vue index d85bc65d..77c12641 100644 --- a/src/App.vue +++ b/src/App.vue @@ -20,12 +20,13 @@ import { } from '@/composables/usePreferencesSync' import { useWindowsTitleBarOverlay } from '@/composables/useWindowsTitleBarOverlay' import { configureHttpClient } from '@/services/utils/http' +import { reportError } from '@/services/log-report' import { IS_ELECTRON } from '@/utils/platform' import { PLATFORM_CAPABILITIES } from '@/utils/platform-capabilities' import { usePlatformService } from '@/services' import { useNavigationLayoutService } from '@/services' -import { listResolvedNavigationEntries } from '@/navigation/layout' import { useNavigationLayout } from '@/navigation/vue' +import { redirectFromHiddenInsightPage } from '@/navigation/router' import type { PresentationPreferences } from '@/services/preferences/types' import { resolvePageTransitionKey } from '@/routes/page-transition-key' import { useLockScreenBootstrap } from '@/components/lock-screen/bootstrap' @@ -135,15 +136,25 @@ function scheduleNonCriticalUiPrefetch() { } async function hydrateNavigationLayout(): Promise { - const result = await useNavigationLayoutService().load() - navigationLayoutController.applyLoadResult(result) + try { + const result = await useNavigationLayoutService().load() + navigationLayoutController.applyLoadResult(result) + } catch (error) { + navigationLayoutController.applyLoadFailure() + const normalized = error instanceof Error ? error : new Error(String(error)) + reportError(`Navigation layout load failed: ${normalized.message}`, normalized.stack) + } - const currentPageId = String(router.currentRoute.value.meta.insightPageId ?? '') - if (!currentPageId) return - const visibleEntries = listResolvedNavigationEntries(navigationLayoutController.getResolvedLayout()) - if (visibleEntries.some(({ page }) => page.id === currentPageId)) return - const fallback = visibleEntries[0] - if (fallback) await router.replace({ name: fallback.page.routeName }) + await redirectFromHiddenInsightPageSafely() +} + +async function redirectFromHiddenInsightPageSafely(): Promise { + try { + await redirectFromHiddenInsightPage(router, navigationLayoutController) + } catch (error) { + const normalized = error instanceof Error ? error : new Error(String(error)) + reportError(`Navigation layout route fallback failed: ${normalized.message}`, normalized.stack) + } } async function initializeApp() { @@ -176,11 +187,8 @@ async function initializeApp() { }, deferAfterPresentationError: () => PLATFORM_CAPABILITIES.requiresAuth && authStore.requiresAuth && !authStore.isAuthenticated, + initializeShell: hydrateNavigationLayout, initializeBackground: [ - { - name: 'navigation-layout', - run: hydrateNavigationLayout, - }, { name: 'preferences', run: async () => { @@ -338,6 +346,11 @@ watch( watch(isBootstrapMaskVisible, () => syncBootstrapMask(bootstrapDialogRef.value), { flush: 'post' }) +watch( + () => route.meta.insightPageId, + () => void redirectFromHiddenInsightPageSafely() +) + useWindowsTitleBarOverlay([ colorMode, () => route.fullPath, diff --git a/src/bootstrap/app-initialization.test.ts b/src/bootstrap/app-initialization.test.ts index 123dcbcb..3ce33876 100644 --- a/src/bootstrap/app-initialization.test.ts +++ b/src/bootstrap/app-initialization.test.ts @@ -126,6 +126,7 @@ test('reveals the shell after presentation state and runs independent startup wo }, applyPresentation: (presentation) => void calls.push(`apply-${presentation.locale}`), applyPresentationFallback: () => void calls.push('fallback'), + initializeShell: async () => void calls.push('shell'), initializeBackground: ['preferences', 'llm', 'sessions'].map((name) => ({ name, run: async () => { @@ -144,6 +145,7 @@ test('reveals the shell after presentation state and runs independent startup wo 'services', 'load-presentation', 'apply-zh-CN', + 'shell', 'pull-listener', 'start-preferences', 'start-llm', diff --git a/src/bootstrap/app-initialization.ts b/src/bootstrap/app-initialization.ts index cee4bd2f..66af8acf 100644 --- a/src/bootstrap/app-initialization.ts +++ b/src/bootstrap/app-initialization.ts @@ -30,6 +30,7 @@ export interface ProgressiveInitializationPorts { applyPresentation(presentation: TPresentation): Promise | void applyPresentationFallback(): Promise | void deferAfterPresentationError?: (error: unknown) => boolean + initializeShell?: () => Promise initializeBackground: Array<{ name: string run(): Promise @@ -99,6 +100,7 @@ export async function initializeProgressiveAppRuntime( await ports.applyPresentationFallback() } + await ports.initializeShell?.() const stopListeningForPullResults = ports.listenForPullResults?.() ?? null const background = Promise.allSettled(ports.initializeBackground.map((task) => task.run())).then((results) => results.flatMap((result, index) => diff --git a/src/components/common/Settings/NavigationSettingsTab.vue b/src/components/common/Settings/NavigationSettingsTab.vue new file mode 100644 index 00000000..06a56cfe --- /dev/null +++ b/src/components/common/Settings/NavigationSettingsTab.vue @@ -0,0 +1,335 @@ + + + diff --git a/src/components/common/SettingsModal.vue b/src/components/common/SettingsModal.vue index f3d346ff..91c5d2f9 100644 --- a/src/components/common/SettingsModal.vue +++ b/src/components/common/SettingsModal.vue @@ -10,6 +10,7 @@ import SettingsDialogShell from './Settings/SettingsDialogShell.vue' import { watchLazyOverlayVisibility } from './lazy-overlay-visibility' const AISettingsTab = defineAsyncComponent(() => import('./Settings/AISettingsTab.vue')) +const NavigationSettingsTab = defineAsyncComponent(() => import('./Settings/NavigationSettingsTab.vue')) const BatchManageTab = defineAsyncComponent(() => import('./Settings/BatchManageTab.vue')) const StorageTab = defineAsyncComponent(() => import('./Settings/StorageTab.vue')) const AboutTab = defineAsyncComponent(() => import('./Settings/AboutTab.vue')) @@ -30,6 +31,7 @@ interface ScrollableTab { const tabs = computed(() => [ { id: 'settings', label: t('settings.tabs.basic'), icon: 'i-heroicons-cog-6-tooth' }, + { id: 'navigation', label: t('settings.tabs.navigation'), icon: 'i-heroicons-bars-3-bottom-left' }, { id: 'ai', label: t('settings.tabs.ai'), icon: 'i-heroicons-sparkles' }, { id: 'api', label: t('settings.tabs.api'), icon: 'i-heroicons-server-stack' }, { id: 'data', label: t('settings.tabs.dataManage'), icon: 'i-heroicons-rectangle-stack' }, @@ -91,6 +93,7 @@ watchLazyOverlayVisibility(showSettings, async (visible) => { :ref="(el: unknown) => setTabRef('ai', el)" @config-changed="handleAIConfigChanged" /> + { + const controller = new NavigationLayoutController(createRuntime()) + + controller.applyLoadFailure() + + assert.equal(controller.getSnapshot().source, 'failed') + assert.equal(controller.getResolvedLayout().primary.length, 1) + assert.equal(canWriteNavigationLayout(controller.getSnapshot().source), false) + assert.equal(canWriteNavigationLayout('invalid'), true) + assert.equal(canWriteNavigationLayout('missing'), true) + assert.equal(canWriteNavigationLayout('saved'), true) + assert.equal(canWriteNavigationLayout('default'), true) +}) + +test('editable layout materializes newly available entries while preserving unknown IDs', () => { + const layout: NavigationLayout = { + schemaVersion: 1, + primary: [ + { kind: 'entry', entryId: 'entry.first' }, + { kind: 'group', id: 'custom', title: 'Custom', children: ['plugin.unavailable'] }, + ], + hiddenEntryIds: [], + } + + assert.deepEqual(createEditableNavigationLayout(createRuntime(), layout), { + schemaVersion: 1, + primary: [ + { kind: 'entry', entryId: 'entry.first' }, + { kind: 'group', id: 'custom', title: 'Custom', children: ['plugin.unavailable'] }, + { + kind: 'group', + id: 'host.insight', + children: ['entry.second', 'insight.relationship-changes'], + }, + ], + hiddenEntryIds: [], + }) + assert.equal(layout.primary.length, 2) +}) + +test('navigation editing moves entries between primary, groups, and hidden without losing other IDs', () => { + const initial: NavigationLayout = { + schemaVersion: 1, + primary: [ + { kind: 'entry', entryId: 'entry.first' }, + { + kind: 'group', + id: 'host.insight', + children: ['entry.second', 'plugin.unavailable', 'insight.relationship-changes'], + }, + ], + hiddenEntryIds: [], + } + + const promoted = placeNavigationEntry(initial, 'entry.second', { kind: 'primary' }) + const hidden = placeNavigationEntry(promoted, 'entry.first', { kind: 'hidden' }) + const restored = placeNavigationEntry(hidden, 'entry.first', { kind: 'group', groupId: 'host.insight' }) + + assert.deepEqual(restored, { + schemaVersion: 1, + primary: [ + { + kind: 'group', + id: 'host.insight', + children: ['plugin.unavailable', 'insight.relationship-changes', 'entry.first'], + }, + { kind: 'entry', entryId: 'entry.second' }, + ], + hiddenEntryIds: [], + }) + assert.deepEqual(initial.primary[1], { + kind: 'group', + id: 'host.insight', + children: ['entry.second', 'plugin.unavailable', 'insight.relationship-changes'], + }) +}) + +test('navigation editing reorders primary items and grouped entries within two levels', () => { + const layout: NavigationLayout = { + schemaVersion: 1, + primary: [ + { kind: 'entry', entryId: 'entry.first' }, + { kind: 'group', id: 'host.insight', children: ['entry.second', 'insight.relationship-changes'] }, + ], + hiddenEntryIds: [], + } + + const primaryMoved = movePrimaryNavigationItem(layout, 1, -1) + const childMoved = moveGroupedNavigationEntry(primaryMoved, 'host.insight', 1, -1) + + assert.deepEqual( + childMoved.primary.map((item) => (item.kind === 'entry' ? item.entryId : item.children)), + [['insight.relationship-changes', 'entry.second'], 'entry.first'] + ) +}) + +test('restoring a hidden entry recreates the default group when the saved layout has no groups', () => { + const layout: NavigationLayout = { + schemaVersion: 1, + primary: [], + hiddenEntryIds: ['entry.first'], + } + + assert.deepEqual(placeNavigationEntry(layout, 'entry.first', { kind: 'group', groupId: 'host.insight' }), { + schemaVersion: 1, + primary: [{ kind: 'group', id: 'host.insight', children: ['entry.first'] }], + hiddenEntryIds: [], + }) +}) diff --git a/src/navigation/layout.ts b/src/navigation/layout.ts index 971de4e6..0f6f2544 100644 --- a/src/navigation/layout.ts +++ b/src/navigation/layout.ts @@ -2,6 +2,7 @@ import { NAVIGATION_LAYOUT_SCHEMA_VERSION, type NavigationLayout, type NavigationLayoutLoadResult, + type NavigationLayoutPrimaryItem, } from '@openchatlab/shared-types' import type { InsightPageDefinition, InsightPluginRuntime } from '@/plugins/insight' import { listInsightShellNavigation } from '@/plugins/insight-catalog' @@ -33,12 +34,18 @@ export function listResolvedNavigationEntries(layout: ResolvedNavigationLayout): return layout.primary.flatMap((item) => (item.kind === 'entry' ? [item] : item.entries)) } +export type NavigationLayoutSource = NavigationLayoutLoadResult['status'] | 'default' | 'failed' + export interface NavigationLayoutSnapshot { layout: NavigationLayout - source: NavigationLayoutLoadResult['status'] | 'default' + source: NavigationLayoutSource revision: number } +export function canWriteNavigationLayout(source: NavigationLayoutSource): boolean { + return source !== 'failed' +} + export function createDefaultNavigationLayout(runtime: InsightPluginRuntime): NavigationLayout { return { schemaVersion: NAVIGATION_LAYOUT_SCHEMA_VERSION, @@ -53,6 +60,133 @@ export function createDefaultNavigationLayout(runtime: InsightPluginRuntime): Na } } +export function createEditableNavigationLayout( + runtime: InsightPluginRuntime, + layout: NavigationLayout +): NavigationLayout { + const editable = cloneNavigationLayout(layout) + const positionedEntryIds = new Set(editable.hiddenEntryIds) + for (const item of editable.primary) { + if (item.kind === 'entry') positionedEntryIds.add(item.entryId) + else for (const entryId of item.children) positionedEntryIds.add(entryId) + } + + const newEntryIds = listInsightShellNavigation(runtime) + .map(({ entryId }) => entryId) + .filter((entryId) => !positionedEntryIds.has(entryId)) + if (newEntryIds.length === 0) return editable + + let defaultGroup = editable.primary.find( + (item): item is Extract => + item.kind === 'group' && item.id === DEFAULT_INSIGHT_NAVIGATION_GROUP_ID + ) + if (!defaultGroup) { + defaultGroup = { kind: 'group', id: DEFAULT_INSIGHT_NAVIGATION_GROUP_ID, children: [] } + editable.primary.push(defaultGroup) + } + defaultGroup.children.push(...newEntryIds) + return editable +} + +export type NavigationEntryDestination = { kind: 'primary' } | { kind: 'group'; groupId: string } | { kind: 'hidden' } + +export function placeNavigationEntry( + layout: NavigationLayout, + entryId: string, + destination: NavigationEntryDestination +): NavigationLayout { + const currentDestination = findNavigationEntryDestination(layout, entryId) + if ( + currentDestination?.kind === destination.kind && + (currentDestination.kind !== 'group' || + (destination.kind === 'group' && currentDestination.groupId === destination.groupId)) + ) { + return cloneNavigationLayout(layout) + } + + const next = cloneNavigationLayout(layout) + const primary: NavigationLayoutPrimaryItem[] = [] + for (const item of next.primary) { + if (item.kind === 'entry') { + if (item.entryId !== entryId) primary.push(item) + } else { + primary.push({ ...item, children: item.children.filter((childId) => childId !== entryId) }) + } + } + next.primary = primary + next.hiddenEntryIds = next.hiddenEntryIds.filter((hiddenId) => hiddenId !== entryId) + + if (destination.kind === 'primary') next.primary.push({ kind: 'entry', entryId }) + else if (destination.kind === 'hidden') next.hiddenEntryIds.push(entryId) + else { + let group = next.primary.find( + (item): item is Extract => + item.kind === 'group' && item.id === destination.groupId + ) + if (!group && destination.groupId === DEFAULT_INSIGHT_NAVIGATION_GROUP_ID) { + group = { kind: 'group', id: DEFAULT_INSIGHT_NAVIGATION_GROUP_ID, children: [] } + next.primary.push(group) + } + if (!group) throw new Error(`Navigation group "${destination.groupId}" does not exist`) + group.children.push(entryId) + } + return next +} + +export function movePrimaryNavigationItem(layout: NavigationLayout, index: number, offset: -1 | 1): NavigationLayout { + const next = cloneNavigationLayout(layout) + moveArrayItem(next.primary, index, offset) + return next +} + +export function moveGroupedNavigationEntry( + layout: NavigationLayout, + groupId: string, + index: number, + offset: -1 | 1 +): NavigationLayout { + const next = cloneNavigationLayout(layout) + const group = next.primary.find( + (item): item is Extract => + item.kind === 'group' && item.id === groupId + ) + if (!group) throw new Error(`Navigation group "${groupId}" does not exist`) + moveArrayItem(group.children, index, offset) + return next +} + +export function moveHiddenNavigationEntry(layout: NavigationLayout, index: number, offset: -1 | 1): NavigationLayout { + const next = cloneNavigationLayout(layout) + moveArrayItem(next.hiddenEntryIds, index, offset) + return next +} + +export function cloneNavigationLayout(layout: NavigationLayout): NavigationLayout { + return { + schemaVersion: layout.schemaVersion, + primary: layout.primary.map((item) => + item.kind === 'entry' ? { ...item } : { ...item, children: [...item.children] } + ), + hiddenEntryIds: [...layout.hiddenEntryIds], + } +} + +function findNavigationEntryDestination(layout: NavigationLayout, entryId: string): NavigationEntryDestination | null { + if (layout.hiddenEntryIds.includes(entryId)) return { kind: 'hidden' } + for (const item of layout.primary) { + if (item.kind === 'entry' && item.entryId === entryId) return { kind: 'primary' } + if (item.kind === 'group' && item.children.includes(entryId)) return { kind: 'group', groupId: item.id } + } + return null +} + +function moveArrayItem(items: T[], index: number, offset: -1 | 1): void { + const targetIndex = index + offset + if (index < 0 || index >= items.length || targetIndex < 0 || targetIndex >= items.length) return + const [item] = items.splice(index, 1) + items.splice(targetIndex, 0, item!) +} + export function resolveNavigationLayout( runtime: InsightPluginRuntime, layout: NavigationLayout @@ -133,6 +267,12 @@ export class NavigationLayoutController { this.emit() } + applyLoadFailure(): void { + this.layout = createDefaultNavigationLayout(this.runtime) + this.source = 'failed' + this.emit() + } + subscribe(listener: () => void): Disposer { this.listeners.add(listener) return () => this.listeners.delete(listener) diff --git a/src/navigation/router.test.ts b/src/navigation/router.test.ts new file mode 100644 index 00000000..8a79f93f --- /dev/null +++ b/src/navigation/router.test.ts @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { createMemoryHistory, createRouter } from 'vue-router' +import type { NavigationLayout } from '@openchatlab/shared-types' +import { desktopCliWebNavigationLayout } from '@/plugins/desktop-cli-web' +import { redirectFromHiddenInsightPage } from './router' + +const entryIds = { + annualSummary: 'insight.annual-summary', + relationshipChanges: 'insight.relationship-changes', + timeInvestment: 'insight.time-investment', +} as const + +test('redirects a hidden current Insight page to the first visible entry and falls back home when all are hidden', async (t) => { + t.after(() => desktopCliWebNavigationLayout.applyDefaultLayout()) + const view = { template: '
' } + const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/', name: 'home', component: view }, + { + path: '/annual-summary', + name: 'insight-annual-summary', + component: view, + meta: { insightPageId: 'annual-summary' }, + }, + { + path: '/time-investment', + name: 'insight-time-investment', + component: view, + meta: { insightPageId: 'time-investment' }, + }, + ], + }) + await router.push({ name: 'insight-annual-summary' }) + + const timeInvestmentOnly: NavigationLayout = { + schemaVersion: 1, + primary: [{ kind: 'entry', entryId: entryIds.timeInvestment }], + hiddenEntryIds: [entryIds.annualSummary, entryIds.relationshipChanges], + } + desktopCliWebNavigationLayout.applySavedLayout(timeInvestmentOnly) + await redirectFromHiddenInsightPage(router, desktopCliWebNavigationLayout) + assert.equal(router.currentRoute.value.name, 'insight-time-investment') + + const allHidden: NavigationLayout = { + schemaVersion: 1, + primary: [], + hiddenEntryIds: Object.values(entryIds), + } + desktopCliWebNavigationLayout.applySavedLayout(allHidden) + await redirectFromHiddenInsightPage(router, desktopCliWebNavigationLayout) + assert.equal(router.currentRoute.value.name, 'home') +}) diff --git a/src/navigation/router.ts b/src/navigation/router.ts new file mode 100644 index 00000000..e0439fd9 --- /dev/null +++ b/src/navigation/router.ts @@ -0,0 +1,15 @@ +import type { Router } from 'vue-router' +import { listResolvedNavigationEntries, type NavigationLayoutController } from './layout' + +export async function redirectFromHiddenInsightPage( + router: Router, + controller: NavigationLayoutController +): Promise { + const currentPageId = String(router.currentRoute.value.meta.insightPageId ?? '') + if (!currentPageId) return + const visibleEntries = listResolvedNavigationEntries(controller.getResolvedLayout()) + if (visibleEntries.some(({ page }) => page.id === currentPageId)) return + + const fallback = visibleEntries[0] + await router.replace(fallback ? { name: fallback.page.routeName } : { name: 'home' }) +}