From 7907435cff2e74d09d1ffc87c203c30884531e78 Mon Sep 17 00:00:00 2001 From: digua Date: Wed, 12 Aug 2026 18:21:18 +0800 Subject: [PATCH] feat(navigation): apply plugin layout --- apps/web-wasm/src/main.ts | 10 +- .../navigation-layout-service.test.ts | 14 +- .../src/services/navigation-layout-service.ts | 11 +- packages/shared-types/index.ts | 4 +- src/App.vue | 20 +++ src/bootstrap/mount-app.ts | 3 + src/components/common/Sidebar.vue | 44 ++++-- src/navigation/layout.test.ts | 118 ++++++++++++++ src/navigation/layout.ts | 145 ++++++++++++++++++ src/navigation/vue.ts | 22 +++ src/pages/insight/index.vue | 24 ++- src/plugins/desktop-cli-web.ts | 2 + src/plugins/insight-catalog.ts | 35 ++++- src/plugins/web-wasm.ts | 2 + 14 files changed, 427 insertions(+), 27 deletions(-) create mode 100644 src/navigation/layout.test.ts create mode 100644 src/navigation/layout.ts create mode 100644 src/navigation/vue.ts diff --git a/apps/web-wasm/src/main.ts b/apps/web-wasm/src/main.ts index ef44dae0..90ee6888 100644 --- a/apps/web-wasm/src/main.ts +++ b/apps/web-wasm/src/main.ts @@ -18,8 +18,15 @@ import { WebWasmSessionSync } from './session-sync' import { useSessionStore } from '@/stores/session' import { initializeWebWasmLocale } from './locale-bootstrap' import { installInsightPluginRuntime } from '@/plugins/insight-vue' -import { webWasmInsightBuiltins, webWasmInsightRuntime, webWasmUiHost, webWasmUiServices } from '@/plugins/web-wasm' +import { + webWasmInsightBuiltins, + webWasmInsightRuntime, + webWasmNavigationLayout, + webWasmUiHost, + webWasmUiServices, +} from '@/plugins/web-wasm' import { installStaticInsightPluginUiServices } from '@/plugins/static-insight' +import { installNavigationLayout } from '@/navigation/vue' import '@/assets/styles/main.css' initializeWebWasmLocale() @@ -53,6 +60,7 @@ async function start(): Promise { app.use(router) app.use(ui) app.use(i18n) + installNavigationLayout(app, webWasmNavigationLayout) await installStaticInsightPluginUiServices(webWasmInsightBuiltins, webWasmInsightRuntime, webWasmUiServices) installInsightPluginRuntime(app, webWasmInsightRuntime) diff --git a/packages/node-runtime/src/services/navigation-layout-service.test.ts b/packages/node-runtime/src/services/navigation-layout-service.test.ts index c71c5f8e..81fcf47b 100644 --- a/packages/node-runtime/src/services/navigation-layout-service.test.ts +++ b/packages/node-runtime/src/services/navigation-layout-service.test.ts @@ -87,8 +87,20 @@ test('navigation layout validation enforces two levels, stable unique entries, a () => normalizeNavigationLayout({ ...layout, - primary: [{ kind: 'group', id: 'group', title: 'Group', children: [{ kind: 'entry', entryId: 'nested' }] }], + primary: [{ kind: 'group', id: 'group', children: [{ kind: 'entry', entryId: 'nested' }] }], }), /must be a non-empty string/ ) + assert.deepEqual( + normalizeNavigationLayout({ + schemaVersion: 1, + primary: [{ kind: 'group', id: 'host.insight', children: [] }], + hiddenEntryIds: [], + }), + { + schemaVersion: 1, + primary: [{ kind: 'group', id: 'host.insight', children: [] }], + hiddenEntryIds: [], + } + ) }) diff --git a/packages/node-runtime/src/services/navigation-layout-service.ts b/packages/node-runtime/src/services/navigation-layout-service.ts index 3741f96e..10a13a72 100644 --- a/packages/node-runtime/src/services/navigation-layout-service.ts +++ b/packages/node-runtime/src/services/navigation-layout-service.ts @@ -101,15 +101,20 @@ function normalizePrimaryItem( if (item.kind === 'group') { const id = requireId(item.id, `primary[${index}].id`) addUnique(groupIds, id, 'navigation group') - if (typeof item.title !== 'string' || item.title.trim().length === 0) { - throw new NavigationLayoutValidationError(`primary[${index}].title must be a non-empty string`) + if (item.title !== undefined && (typeof item.title !== 'string' || item.title.trim().length === 0)) { + throw new NavigationLayoutValidationError(`primary[${index}].title must be a non-empty string when provided`) } if (!Array.isArray(item.children)) { throw new NavigationLayoutValidationError(`primary[${index}].children must be an array`) } const children = normalizeUniqueIds(item.children, `primary[${index}].children`) for (const entryId of children) addUnique(visibleEntryIds, entryId, 'navigation entry') - return { kind: 'group', id, title: item.title, children } + return { + kind: 'group', + id, + ...(typeof item.title === 'string' && { title: item.title }), + children, + } } throw new NavigationLayoutValidationError(`primary[${index}].kind must be "entry" or "group"`) } diff --git a/packages/shared-types/index.ts b/packages/shared-types/index.ts index 5332789a..5c763abb 100644 --- a/packages/shared-types/index.ts +++ b/packages/shared-types/index.ts @@ -21,8 +21,8 @@ export interface NavigationLayoutEntryItem { export interface NavigationLayoutGroupItem { kind: 'group' id: string - /** User-authored content. Keep it as-is instead of translating it. */ - title: string + /** User-authored override. Missing values use the host-localized group title. */ + title?: string /** Stable navigation entry IDs. Unknown IDs are intentionally preserved. */ children: string[] } diff --git a/src/App.vue b/src/App.vue index 2bd53015..d85bc65d 100644 --- a/src/App.vue +++ b/src/App.vue @@ -23,6 +23,9 @@ import { configureHttpClient } from '@/services/utils/http' 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 type { PresentationPreferences } from '@/services/preferences/types' import { resolvePageTransitionKey } from '@/routes/page-transition-key' import { useLockScreenBootstrap } from '@/components/lock-screen/bootstrap' @@ -55,6 +58,7 @@ const authStore = useAuthStore() const apiServerStore = useApiServerStore() const route = useRoute() const router = useRouter() +const { controller: navigationLayoutController } = useNavigationLayout() const { isBootstrapMaskVisible, isApplicationInteractive, markLockScreenReady, syncBootstrapMask, updateLockState } = useLockScreenBootstrap(IS_ELECTRON) @@ -130,6 +134,18 @@ function scheduleNonCriticalUiPrefetch() { cancelNonCriticalUiPrefetch = () => window.clearTimeout(timer) } +async function hydrateNavigationLayout(): Promise { + const result = await useNavigationLayoutService().load() + navigationLayoutController.applyLoadResult(result) + + 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 }) +} + async function initializeApp() { if (initInProgress || isRuntimeReady.value) return initInProgress = true @@ -161,6 +177,10 @@ async function initializeApp() { deferAfterPresentationError: () => PLATFORM_CAPABILITIES.requiresAuth && authStore.requiresAuth && !authStore.isAuthenticated, initializeBackground: [ + { + name: 'navigation-layout', + run: hydrateNavigationLayout, + }, { name: 'preferences', run: async () => { diff --git a/src/bootstrap/mount-app.ts b/src/bootstrap/mount-app.ts index 2856ce70..3d5c2a42 100644 --- a/src/bootstrap/mount-app.ts +++ b/src/bootstrap/mount-app.ts @@ -11,11 +11,13 @@ import { markStartupPhase } from '@/bootstrap/startup-performance' import { desktopCliWebInsightBuiltins, desktopCliWebInsightRuntime, + desktopCliWebNavigationLayout, desktopCliWebUiHost, desktopCliWebUiServices, } from '@/plugins/desktop-cli-web' import { installInsightPluginRuntime } from '@/plugins/insight-vue' import { installStaticInsightPluginUiServices } from '@/plugins/static-insight' +import { installNavigationLayout } from '@/navigation/vue' import '@/assets/styles/main.css' export interface MountChatLabAppOptions { @@ -41,6 +43,7 @@ export async function mountChatLabApp(options: MountChatLabAppOptions = {}): Pro app.use(router) app.use(ui) app.use(i18n) + installNavigationLayout(app, desktopCliWebNavigationLayout) await installStaticInsightPluginUiServices( desktopCliWebInsightBuiltins, desktopCliWebInsightRuntime, diff --git a/src/components/common/Sidebar.vue b/src/components/common/Sidebar.vue index 79b26ff1..4919f722 100644 --- a/src/components/common/Sidebar.vue +++ b/src/components/common/Sidebar.vue @@ -22,6 +22,9 @@ import { CompactTabs } from '@/components/navigation' import { useSessionStore } from '@/stores/session' import { useLayoutStore } from '@/stores/layout' import { usePlatformService } from '@/services' +import { useHostLocale } from '@/plugins/insight-vue' +import { DEFAULT_INSIGHT_NAVIGATION_GROUP_ID } from '@/navigation/layout' +import { useNavigationLayout } from '@/navigation/vue' import { IS_ELECTRON } from '@/utils/platform' import logoSvg from '@/assets/images/logo.svg' @@ -42,6 +45,7 @@ const props = withDefaults( ) const { t } = useI18n() +const { translate } = useHostLocale() const LATEST_VERSION_URL = 'https://chatlab.fun/latest-version' const UPDATE_CHECK_CACHE_KEY = 'chatlab:latest-version-check:v2' const hasMacTitlebarInset = @@ -54,6 +58,7 @@ const { isSidebarCollapsed: isCollapsed } = storeToRefs(layoutStore) const { toggleSidebar } = layoutStore const router = useRouter() const route = useRoute() +const { controller: navigationLayoutController, snapshot: navigationLayoutSnapshot } = useNavigationLayout() const SESSION_ROW_SIZE = 44 const SESSION_LIST_BOTTOM_PADDING = 32 @@ -68,10 +73,21 @@ const showContactsEntry = computed(() => groupSessionCount: groupSessionCount.value, }) ) -const showInsightEntry = computed(() => props.backendFeatures || props.insightEnabled) +const showInsightNavigation = computed(() => props.backendFeatures || props.insightEnabled) +const resolvedPrimaryNavigation = computed(() => { + void navigationLayoutSnapshot.value.revision + return navigationLayoutController.getResolvedLayout().primary +}) +const activeInsightPageId = computed(() => String(route.meta.insightPageId ?? '')) -// 是否在足迹页 -const isInsightPage = computed(() => String(route.name ?? '').startsWith('insight-')) +function navigationGroupTitle(groupId: string, customTitle?: string): string { + if (customTitle) return customTitle + return groupId === DEFAULT_INSIGHT_NAVIGATION_GROUP_ID ? t('layout.insight') : groupId +} + +function openInsightRoute(routeName: string): void { + void router.push({ name: routeName }) +} // 重命名相关状态 const showRenameModal = ref(false) @@ -510,14 +526,20 @@ function getAvatarColorClass(session: AnalysisSession, isActive: boolean) { @click="handleImport" /> - - + 'en-US', + subscribe: () => () => {}, + translate: (key) => key, + }) + const ui: UiHostContext = { locale, insightScope: new InsightScopeController(), services: new UiServiceRegistry() } + const plugin = (id: string, pageId: string, order: number): InsightPlugin => ({ + id, + platforms: ['cli-web'], + activate(context) { + context.locale.register(`plugins.${id}`, { + 'en-US': { title: pageId }, + 'zh-CN': { title: pageId }, + 'zh-TW': { title: pageId }, + 'ja-JP': { title: pageId }, + }) + context.pages.register({ + id: pageId, + path: pageId, + routeName: `insight-${pageId}`, + title: { namespace: `plugins.${id}`, key: 'title' }, + icon: `icon-${pageId}`, + view: { load: async () => ({}) }, + }) + context.navigation.register({ id: `entry.${pageId}`, pageId, order }) + }, + }) + return createInsightPluginRuntime('cli-web', ui, locale, [ + plugin('first', 'first', 10), + plugin('second', 'second', 20), + ]) +} + +test('default layout keeps the current single Insight primary group and contribution order', () => { + const layout = createDefaultNavigationLayout(createRuntime()) + + assert.deepEqual(layout, { + schemaVersion: NAVIGATION_LAYOUT_SCHEMA_VERSION, + primary: [ + { + kind: 'group', + id: 'host.insight', + children: ['entry.first', 'entry.second', 'insight.relationship-changes'], + }, + ], + hiddenEntryIds: [], + }) +}) + +test('saved layout promotes entries, preserves custom groups, hides entries, and omits unavailable IDs', () => { + const runtime = createRuntime() + const layout: NavigationLayout = { + schemaVersion: NAVIGATION_LAYOUT_SCHEMA_VERSION, + primary: [ + { kind: 'entry', entryId: 'entry.second' }, + { kind: 'group', id: 'custom', title: 'My group', children: ['plugin.unavailable', 'entry.first'] }, + ], + hiddenEntryIds: ['insight.relationship-changes'], + } + + assert.deepEqual( + resolveNavigationLayout(runtime, layout).primary.map((item) => + item.kind === 'entry' + ? { kind: item.kind, entryId: item.entryId } + : { kind: item.kind, id: item.id, title: item.title, entries: item.entries.map(({ entryId }) => entryId) } + ), + [ + { kind: 'entry', entryId: 'entry.second' }, + { kind: 'group', id: 'custom', title: 'My group', entries: ['entry.first'] }, + ] + ) + assert.deepEqual(layout.primary[1], { + kind: 'group', + id: 'custom', + title: 'My group', + children: ['plugin.unavailable', 'entry.first'], + }) +}) + +test('new plugin entries are appended to the localized default group without mutating saved layout', () => { + const runtime = createRuntime() + const layout: NavigationLayout = { + schemaVersion: 1, + primary: [{ kind: 'entry', entryId: 'entry.first' }], + hiddenEntryIds: [], + } + const resolved = resolveNavigationLayout(runtime, layout) + + assert.deepEqual( + resolved.primary.map((item) => + item.kind === 'entry' ? item.entryId : { id: item.id, entries: item.entries.map(({ entryId }) => entryId) } + ), + ['entry.first', { id: 'host.insight', entries: ['entry.second', 'insight.relationship-changes'] }] + ) + assert.deepEqual(layout.primary, [{ kind: 'entry', entryId: 'entry.first' }]) +}) + +test('controller falls back to defaults for missing or invalid saved layouts', () => { + const controller = new NavigationLayoutController(createRuntime()) + let notifications = 0 + controller.subscribe(() => notifications++) + + controller.applyLoadResult({ status: 'invalid', layout: null }) + + assert.equal(controller.getSnapshot().source, 'invalid') + assert.equal(controller.getResolvedLayout().primary.length, 1) + assert.equal(notifications, 1) +}) diff --git a/src/navigation/layout.ts b/src/navigation/layout.ts new file mode 100644 index 00000000..971de4e6 --- /dev/null +++ b/src/navigation/layout.ts @@ -0,0 +1,145 @@ +import { + NAVIGATION_LAYOUT_SCHEMA_VERSION, + type NavigationLayout, + type NavigationLayoutLoadResult, +} from '@openchatlab/shared-types' +import type { InsightPageDefinition, InsightPluginRuntime } from '@/plugins/insight' +import { listInsightShellNavigation } from '@/plugins/insight-catalog' +import type { Disposer } from '@/plugins/core' + +export const DEFAULT_INSIGHT_NAVIGATION_GROUP_ID = 'host.insight' + +export interface ResolvedNavigationEntry { + kind: 'entry' + entryId: string + page: InsightPageDefinition +} + +export interface ResolvedNavigationGroup { + kind: 'group' + id: string + title?: string + entries: ResolvedNavigationEntry[] +} + +export type ResolvedPrimaryNavigationItem = ResolvedNavigationEntry | ResolvedNavigationGroup + +export interface ResolvedNavigationLayout { + primary: ResolvedPrimaryNavigationItem[] + hiddenEntryIds: string[] +} + +export function listResolvedNavigationEntries(layout: ResolvedNavigationLayout): ResolvedNavigationEntry[] { + return layout.primary.flatMap((item) => (item.kind === 'entry' ? [item] : item.entries)) +} + +export interface NavigationLayoutSnapshot { + layout: NavigationLayout + source: NavigationLayoutLoadResult['status'] | 'default' + revision: number +} + +export function createDefaultNavigationLayout(runtime: InsightPluginRuntime): NavigationLayout { + return { + schemaVersion: NAVIGATION_LAYOUT_SCHEMA_VERSION, + primary: [ + { + kind: 'group', + id: DEFAULT_INSIGHT_NAVIGATION_GROUP_ID, + children: listInsightShellNavigation(runtime).map(({ entryId }) => entryId), + }, + ], + hiddenEntryIds: [], + } +} + +export function resolveNavigationLayout( + runtime: InsightPluginRuntime, + layout: NavigationLayout +): ResolvedNavigationLayout { + const catalog = listInsightShellNavigation(runtime) + const availableEntries = new Map( + catalog.map(({ entryId, page }) => [entryId, { kind: 'entry' as const, entryId, page }]) + ) + const hiddenEntryIds = new Set(layout.hiddenEntryIds) + const positionedEntryIds = new Set() + const primary: ResolvedPrimaryNavigationItem[] = [] + + for (const item of layout.primary) { + if (item.kind === 'entry') { + positionedEntryIds.add(item.entryId) + const entry = availableEntries.get(item.entryId) + if (entry && !hiddenEntryIds.has(item.entryId)) primary.push(entry) + continue + } + + const entries: ResolvedNavigationEntry[] = [] + for (const entryId of item.children) { + positionedEntryIds.add(entryId) + const entry = availableEntries.get(entryId) + if (entry && !hiddenEntryIds.has(entryId)) entries.push(entry) + } + if (entries.length > 0) primary.push({ kind: 'group', id: item.id, title: item.title, entries }) + } + + const newEntries = catalog + .filter(({ entryId }) => !positionedEntryIds.has(entryId) && !hiddenEntryIds.has(entryId)) + .map(({ entryId }) => availableEntries.get(entryId)!) + if (newEntries.length > 0) { + const defaultGroup = primary.find( + (item): item is ResolvedNavigationGroup => + item.kind === 'group' && item.id === DEFAULT_INSIGHT_NAVIGATION_GROUP_ID + ) + if (defaultGroup) defaultGroup.entries.push(...newEntries) + else primary.push({ kind: 'group', id: DEFAULT_INSIGHT_NAVIGATION_GROUP_ID, entries: newEntries }) + } + + return { primary, hiddenEntryIds: [...layout.hiddenEntryIds] } +} + +export class NavigationLayoutController { + private layout: NavigationLayout + private source: NavigationLayoutSnapshot['source'] = 'default' + private revision = 0 + private readonly listeners = new Set<() => void>() + + constructor(readonly runtime: InsightPluginRuntime) { + this.layout = createDefaultNavigationLayout(runtime) + } + + getSnapshot(): NavigationLayoutSnapshot { + return { layout: this.layout, source: this.source, revision: this.revision } + } + + getResolvedLayout(): ResolvedNavigationLayout { + return resolveNavigationLayout(this.runtime, this.layout) + } + + applyLoadResult(result: NavigationLayoutLoadResult): void { + this.layout = result.layout ?? createDefaultNavigationLayout(this.runtime) + this.source = result.status + this.emit() + } + + applySavedLayout(layout: NavigationLayout): void { + this.layout = layout + this.source = 'saved' + this.emit() + } + + applyDefaultLayout(): void { + this.layout = createDefaultNavigationLayout(this.runtime) + this.source = 'default' + this.emit() + } + + subscribe(listener: () => void): Disposer { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } + + private emit(): void { + this.revision++ + for (const listener of this.listeners) listener() + } +} diff --git a/src/navigation/vue.ts b/src/navigation/vue.ts new file mode 100644 index 00000000..79b3690d --- /dev/null +++ b/src/navigation/vue.ts @@ -0,0 +1,22 @@ +import { inject, onBeforeUnmount, shallowRef, type App, type InjectionKey, type ShallowRef } from 'vue' +import type { NavigationLayoutController, NavigationLayoutSnapshot } from './layout' + +const NAVIGATION_LAYOUT_KEY: InjectionKey = Symbol('NavigationLayoutController') + +export function installNavigationLayout(app: App, controller: NavigationLayoutController): void { + app.provide(NAVIGATION_LAYOUT_KEY, controller) +} + +export function useNavigationLayout(): { + controller: NavigationLayoutController + snapshot: Readonly> +} { + const controller = inject(NAVIGATION_LAYOUT_KEY) + if (!controller) throw new Error('Navigation layout controller is unavailable') + const snapshot = shallowRef(controller.getSnapshot()) + const unsubscribe = controller.subscribe(() => { + snapshot.value = controller.getSnapshot() + }) + onBeforeUnmount(unsubscribe) + return { controller, snapshot } +} diff --git a/src/pages/insight/index.vue b/src/pages/insight/index.vue index 6ea53166..f4f36506 100644 --- a/src/pages/insight/index.vue +++ b/src/pages/insight/index.vue @@ -8,12 +8,14 @@ import { PageTabs } from '@/components/navigation' import { listInsightShellPages } from '@/plugins/insight-catalog' import { InsightScopeController } from '@/plugins/insight-scope' import { useHostLocale, useInsightPluginRuntime } from '@/plugins/insight-vue' +import { useNavigationLayout } from '@/navigation/vue' import { createInsightTimeRange, getInsightTimeFilterSignature } from './insight-time-range' const { t } = useI18n() const { translate } = useHostLocale() const route = useRoute() const pluginRuntime = useInsightPluginRuntime() +const { controller: navigationLayoutController, snapshot: navigationLayoutSnapshot } = useNavigationLayout() const pages = listInsightShellPages(pluginRuntime) const activeSubpage = computed(() => String(route.meta.insightPageId ?? pages[0]?.id ?? '')) const activePage = computed(() => pages.find((page) => page.id === activeSubpage.value)) @@ -54,8 +56,17 @@ const stopSyncingScope = watch( }, { immediate: true } ) +const resolvedNavigation = computed(() => { + void navigationLayoutSnapshot.value.revision + return navigationLayoutController.getResolvedLayout() +}) +const activeNavigationGroup = computed(() => + resolvedNavigation.value.primary.find( + (item) => item.kind === 'group' && item.entries.some(({ page }) => page.id === activeSubpage.value) + ) +) const navigationItems = computed(() => - pages.map((page) => ({ + (activeNavigationGroup.value?.kind === 'group' ? activeNavigationGroup.value.entries : []).map(({ page }) => ({ id: page.id, label: translate(page.title), icon: page.icon, @@ -89,6 +100,7 @@ onBeforeUnmount(() => { size="compact" > { /> +
+ +
diff --git a/src/plugins/desktop-cli-web.ts b/src/plugins/desktop-cli-web.ts index 754e120d..4026bd78 100644 --- a/src/plugins/desktop-cli-web.ts +++ b/src/plugins/desktop-cli-web.ts @@ -5,6 +5,7 @@ import { getLegacyInsightPages } from './insight-catalog' import { createStaticInsightPluginRuntime } from './static-insight' import { UiServiceRegistry } from './ui-host' import { createVueUiHostContext } from './vue-ui-host' +import { NavigationLayoutController } from '@/navigation/layout' const platform = PLATFORM_CAPABILITIES.platform export const desktopCliWebInsightBuiltins = [annualSummaryBuiltin, timeInvestmentBuiltin] as const @@ -17,3 +18,4 @@ export const desktopCliWebInsightRuntime = createStaticInsightPluginRuntime( desktopCliWebInsightBuiltins, getLegacyInsightPages(platform) ) +export const desktopCliWebNavigationLayout = new NavigationLayoutController(desktopCliWebInsightRuntime) diff --git a/src/plugins/insight-catalog.ts b/src/plugins/insight-catalog.ts index 22acd1c9..1687168f 100644 --- a/src/plugins/insight-catalog.ts +++ b/src/plugins/insight-catalog.ts @@ -2,6 +2,7 @@ import type { RuntimePlatform } from '@/utils/platform-capabilities' import type { InsightPageDefinition, InsightPluginRuntime } from './insight' export interface LegacyInsightPage extends InsightPageDefinition { + navigationId: string platforms: readonly RuntimePlatform[] order: number } @@ -9,6 +10,7 @@ export interface LegacyInsightPage extends InsightPageDefinition { export const LEGACY_INSIGHT_PAGES: readonly LegacyInsightPage[] = [ { id: 'relationship-changes', + navigationId: 'insight.relationship-changes', path: 'relationship-changes', routeName: 'insight-relationship-changes', title: { key: 'insight.tabs.relationshipChanges' }, @@ -22,14 +24,7 @@ export function listInsightShellPages( runtime: InsightPluginRuntime, legacyPages: readonly LegacyInsightPage[] = LEGACY_INSIGHT_PAGES ): InsightPageDefinition[] { - const pluginPages = runtime.listNavigation().map(({ entry, page }) => ({ page, order: entry.order })) - const availableLegacyPages = legacyPages - .filter((page) => page.platforms.includes(runtime.platform)) - .map((page) => ({ page, order: page.order })) - - const pages = [...pluginPages, ...availableLegacyPages] - .sort((left, right) => left.order - right.order || left.page.id.localeCompare(right.page.id)) - .map(({ page }) => page) + const pages = listInsightShellNavigation(runtime, legacyPages).map(({ page }) => page) const pageIds = new Set() for (const page of pages) { @@ -39,6 +34,30 @@ export function listInsightShellPages( return pages } +export interface InsightShellNavigationItem { + entryId: string + order: number + page: InsightPageDefinition +} + +export function listInsightShellNavigation( + runtime: InsightPluginRuntime, + legacyPages: readonly LegacyInsightPage[] = LEGACY_INSIGHT_PAGES +): InsightShellNavigationItem[] { + const pluginItems = runtime.listNavigation().map(({ entry, page }) => ({ + entryId: entry.id, + order: entry.order, + page, + })) + const legacyItems = legacyPages + .filter((page) => page.platforms.includes(runtime.platform)) + .map((page) => ({ entryId: page.navigationId, order: page.order, page })) + + return [...pluginItems, ...legacyItems].sort( + (left, right) => left.order - right.order || left.entryId.localeCompare(right.entryId) + ) +} + export function getLegacyInsightPages(platform: RuntimePlatform): InsightPageDefinition[] { return LEGACY_INSIGHT_PAGES.filter((page) => page.platforms.includes(platform)) } diff --git a/src/plugins/web-wasm.ts b/src/plugins/web-wasm.ts index bf206bc5..3a65e997 100644 --- a/src/plugins/web-wasm.ts +++ b/src/plugins/web-wasm.ts @@ -3,6 +3,7 @@ import { getLegacyInsightPages } from './insight-catalog' import { createStaticInsightPluginRuntime } from './static-insight' import { UiServiceRegistry } from './ui-host' import { createVueUiHostContext } from './vue-ui-host' +import { NavigationLayoutController } from '@/navigation/layout' export const webWasmInsightBuiltins = [timeInvestmentBuiltin] as const export const webWasmUiServices = new UiServiceRegistry() @@ -14,3 +15,4 @@ export const webWasmInsightRuntime = createStaticInsightPluginRuntime( webWasmInsightBuiltins, getLegacyInsightPages('web-wasm') ) +export const webWasmNavigationLayout = new NavigationLayoutController(webWasmInsightRuntime)