diff --git a/web/__tests__/goto-anything/search-error-handling.test.ts b/web/__tests__/goto-anything/search-error-handling.test.ts index 3a495834cd..0e4062edcb 100644 --- a/web/__tests__/goto-anything/search-error-handling.test.ts +++ b/web/__tests__/goto-anything/search-error-handling.test.ts @@ -9,7 +9,7 @@ import type { MockedFunction } from 'vitest' * 4. Ensure errors don't propagate to UI layer causing "search failed" */ -import { Actions, searchAnything } from '@/app/components/goto-anything/actions' +import { appScope, knowledgeScope, pluginScope, searchAnything } from '@/app/components/goto-anything/actions' import { fetchAppList } from '@/service/apps' import { postMarketplace } from '@/service/base' import { fetchDatasets } from '@/service/datasets' @@ -30,6 +30,7 @@ vi.mock('@/service/datasets', () => ({ const mockPostMarketplace = postMarketplace as MockedFunction const mockFetchAppList = fetchAppList as MockedFunction const mockFetchDatasets = fetchDatasets as MockedFunction +const searchScopes = [appScope, knowledgeScope, pluginScope] describe('GotoAnything Search Error Handling', () => { beforeEach(() => { @@ -49,10 +50,7 @@ describe('GotoAnything Search Error Handling', () => { // Mock marketplace API failure (403 permission denied) mockPostMarketplace.mockRejectedValue(new Error('HTTP 403: Forbidden')) - const pluginAction = Actions.plugin - - // Directly call plugin action's search method - const result = await pluginAction.search('@plugin', 'test', 'en') + const result = await pluginScope.search('@plugin', 'test', 'en') // Should return empty array instead of throwing error expect(result).toEqual([]) @@ -72,8 +70,7 @@ describe('GotoAnything Search Error Handling', () => { data: { plugins: [] }, }) - const pluginAction = Actions.plugin - const result = await pluginAction.search('@plugin', '', 'en') + const result = await pluginScope.search('@plugin', '', 'en') expect(result).toEqual([]) }) @@ -84,8 +81,7 @@ describe('GotoAnything Search Error Handling', () => { data: null, }) - const pluginAction = Actions.plugin - const result = await pluginAction.search('@plugin', 'test', 'en') + const result = await pluginScope.search('@plugin', 'test', 'en') expect(result).toEqual([]) }) @@ -96,8 +92,7 @@ describe('GotoAnything Search Error Handling', () => { // Mock app API failure mockFetchAppList.mockRejectedValue(new Error('API Error')) - const appAction = Actions.app - const result = await appAction.search('@app', 'test', 'en') + const result = await appScope.search('@app', 'test', 'en') expect(result).toEqual([]) }) @@ -106,8 +101,7 @@ describe('GotoAnything Search Error Handling', () => { // Mock knowledge API failure mockFetchDatasets.mockRejectedValue(new Error('API Error')) - const knowledgeAction = Actions.knowledge - const result = await knowledgeAction.search('@knowledge', 'test', 'en') + const result = await knowledgeScope.search('@knowledge', 'test', 'en') expect(result).toEqual([]) }) @@ -120,7 +114,7 @@ describe('GotoAnything Search Error Handling', () => { mockFetchDatasets.mockResolvedValue({ data: [], has_more: false, limit: 10, page: 1, total: 0 }) mockPostMarketplace.mockRejectedValue(new Error('Plugin API failed')) - const result = await searchAnything('en', 'test') + const result = await searchAnything('en', 'test', undefined, searchScopes) // Should return successful results even if plugin search fails expect(result).toEqual([]) @@ -131,8 +125,7 @@ describe('GotoAnything Search Error Handling', () => { // Mock plugin API failure mockPostMarketplace.mockRejectedValue(new Error('Plugin service unavailable')) - const pluginAction = Actions.plugin - const result = await searchAnything('en', '@plugin test', pluginAction) + const result = await searchAnything('en', '@plugin test', pluginScope, searchScopes) // Should return empty array instead of throwing error expect(result).toEqual([]) @@ -142,8 +135,7 @@ describe('GotoAnything Search Error Handling', () => { // Mock app API failure mockFetchAppList.mockRejectedValue(new Error('App service unavailable')) - const appAction = Actions.app - const result = await searchAnything('en', '@app test', appAction) + const result = await searchAnything('en', '@app test', appScope, searchScopes) expect(result).toEqual([]) }) @@ -157,9 +149,9 @@ describe('GotoAnything Search Error Handling', () => { mockFetchDatasets.mockRejectedValue(new Error('Dataset API failed')) const actions = [ - { name: '@plugin', action: Actions.plugin }, - { name: '@app', action: Actions.app }, - { name: '@knowledge', action: Actions.knowledge }, + { name: '@plugin', action: pluginScope }, + { name: '@app', action: appScope }, + { name: '@knowledge', action: knowledgeScope }, ] for (const { name, action } of actions) { @@ -173,7 +165,7 @@ describe('GotoAnything Search Error Handling', () => { it('empty search term should be handled properly', async () => { mockPostMarketplace.mockResolvedValue({ data: { plugins: [] } }) - const result = await searchAnything('en', '@plugin ', Actions.plugin) + const result = await searchAnything('en', '@plugin ', pluginScope, searchScopes) expect(result).toEqual([]) }) @@ -183,7 +175,7 @@ describe('GotoAnything Search Error Handling', () => { mockPostMarketplace.mockRejectedValue(timeoutError) - const result = await searchAnything('en', '@plugin test', Actions.plugin) + const result = await searchAnything('en', '@plugin test', pluginScope, searchScopes) expect(result).toEqual([]) }) @@ -191,7 +183,7 @@ describe('GotoAnything Search Error Handling', () => { const parseError = new SyntaxError('Unexpected token in JSON') mockPostMarketplace.mockRejectedValue(parseError) - const result = await searchAnything('en', '@plugin test', Actions.plugin) + const result = await searchAnything('en', '@plugin test', pluginScope, searchScopes) expect(result).toEqual([]) }) }) diff --git a/web/app/components/goto-anything/actions/app.tsx b/web/app/components/goto-anything/actions/app.tsx index d391556604..bf7d6be220 100644 --- a/web/app/components/goto-anything/actions/app.tsx +++ b/web/app/components/goto-anything/actions/app.tsx @@ -1,4 +1,4 @@ -import type { ActionItem, AppSearchResult } from './types' +import type { AppSearchResult, ScopeDescriptor } from './types' import type { App } from '@/types/app' import { fetchAppList } from '@/service/apps' import { getRedirectionPath } from '@/utils/app-redirection' @@ -36,8 +36,8 @@ const parser = (apps: App[]): AppSearchResult[] => { })) } -export const appAction: ActionItem = { - key: ACTION_KEYS.APP, +export const appScope: ScopeDescriptor = { + id: 'app', shortcut: ACTION_KEYS.APP, title: 'Search Applications', description: 'Search and navigate to your applications', diff --git a/web/app/components/goto-anything/actions/commands/index.ts b/web/app/components/goto-anything/actions/commands/index.ts index 72388f6565..7258840d7e 100644 --- a/web/app/components/goto-anything/actions/commands/index.ts +++ b/web/app/components/goto-anything/actions/commands/index.ts @@ -9,7 +9,7 @@ export { export { slashCommandRegistry, SlashCommandRegistry } from './registry' // Command system exports -export { slashAction } from './slash' +export { slashScope } from './slash' export { registerSlashCommands, SlashCommandProvider, unregisterSlashCommands } from './slash' export type { SlashCommandHandler } from './types' diff --git a/web/app/components/goto-anything/actions/commands/slash.tsx b/web/app/components/goto-anything/actions/commands/slash.tsx index 2eaca0beae..cfc38bf068 100644 --- a/web/app/components/goto-anything/actions/commands/slash.tsx +++ b/web/app/components/goto-anything/actions/commands/slash.tsx @@ -1,5 +1,5 @@ 'use client' -import type { ActionItem } from '../types' +import type { ScopeDescriptor } from '../types' import type { SlashCommandDependencies } from './types' import { useTheme } from 'next-themes' import { useEffect } from 'react' @@ -8,7 +8,6 @@ import i18n from '@/i18n-config/i18next-config' import { ACTION_KEYS } from '../../constants' import { accountCommand } from './account' import { bananaCommand } from './banana' -import { executeCommand } from './command-bus' import { communityCommand } from './community' import { docsCommand } from './docs' import { forumCommand } from './forum' @@ -17,17 +16,11 @@ import { slashCommandRegistry } from './registry' import { themeCommand } from './theme' import { zenCommand } from './zen' -export const slashAction: ActionItem = { - key: ACTION_KEYS.SLASH, +export const slashScope: ScopeDescriptor = { + id: 'slash', shortcut: ACTION_KEYS.SLASH, title: i18n.t('app.gotoAnything.actions.slashTitle'), description: i18n.t('app.gotoAnything.actions.slashDesc'), - action: (result) => { - if (result.type !== 'command') - return - const { command, args } = result.data - executeCommand(command, args) - }, search: async (query, _searchTerm = '') => { // Delegate all search logic to the command registry system return slashCommandRegistry.search(query, i18n.language) diff --git a/web/app/components/goto-anything/actions/index.ts b/web/app/components/goto-anything/actions/index.ts index f425a6f623..b8866475be 100644 --- a/web/app/components/goto-anything/actions/index.ts +++ b/web/app/components/goto-anything/actions/index.ts @@ -5,96 +5,64 @@ * Actions handle different types of searches: apps, knowledge bases, plugins, workflow nodes, and commands. */ -import type { ActionItem, ScopeDescriptor, SearchResult } from './types' +import type { ScopeContext, ScopeDescriptor, SearchResult } from './types' import { ACTION_KEYS } from '../constants' -import { appAction } from './app' -import { slashAction } from './commands' +import { appScope } from './app' +import { slashScope } from './commands' import { slashCommandRegistry } from './commands/registry' -import { knowledgeAction } from './knowledge' -import { pluginAction } from './plugin' -import { scopeRegistry } from './scope-registry' +import { knowledgeScope } from './knowledge' +import { pluginScope } from './plugin' +import { registerRagPipelineNodeScope } from './rag-pipeline-nodes' +import { scopeRegistry, useScopeRegistry } from './scope-registry' +import { registerWorkflowNodeScope } from './workflow-nodes' -let defaultScopesRegistered = false +let scopesInitialized = false -export const registerDefaultScopes = () => { - if (defaultScopesRegistered) +export const initGotoAnythingScopes = () => { + if (scopesInitialized) return - defaultScopesRegistered = true + scopesInitialized = true - scopeRegistry.register({ - id: 'slash', - shortcut: ACTION_KEYS.SLASH, - title: 'Commands', - description: 'Execute commands', - search: slashAction.search, - isAvailable: () => true, - }) - - scopeRegistry.register({ - id: 'app', - shortcut: ACTION_KEYS.APP, - title: 'Search Applications', - description: 'Search and navigate to your applications', - search: appAction.search, - isAvailable: () => true, - }) - - scopeRegistry.register({ - id: 'knowledge', - shortcut: ACTION_KEYS.KNOWLEDGE, - title: 'Search Knowledge Bases', - description: 'Search and navigate to your knowledge bases', - search: knowledgeAction.search, - isAvailable: () => true, - }) - - scopeRegistry.register({ - id: 'plugin', - shortcut: ACTION_KEYS.PLUGIN, - title: 'Search Plugins', - description: 'Search and navigate to your plugins', - search: pluginAction.search, - isAvailable: () => true, - }) + scopeRegistry.register(slashScope) + scopeRegistry.register(appScope) + scopeRegistry.register(knowledgeScope) + scopeRegistry.register(pluginScope) + registerWorkflowNodeScope() + registerRagPipelineNodeScope() } -// Legacy export for backward compatibility -export const Actions = { - slash: slashAction, - app: appAction, - knowledge: knowledgeAction, - plugin: pluginAction, +export const useGotoAnythingScopes = (context: ScopeContext) => { + initGotoAnythingScopes() + return useScopeRegistry(context) } -const getScopeId = (scope: ScopeDescriptor | ActionItem) => ('id' in scope ? scope.id : scope.key) +const isSlashScope = (scope: ScopeDescriptor) => { + if (scope.shortcut === ACTION_KEYS.SLASH) + return true + return scope.aliases?.includes(ACTION_KEYS.SLASH) ?? false +} -const isSlashScope = (scope: ScopeDescriptor | ActionItem) => scope.shortcut === ACTION_KEYS.SLASH +const getScopeShortcuts = (scope: ScopeDescriptor) => [scope.shortcut, ...(scope.aliases ?? [])] export const searchAnything = async ( locale: string, query: string, - scope?: ScopeDescriptor | ActionItem, - scopes?: (ScopeDescriptor | ActionItem)[], + scope: ScopeDescriptor | undefined, + scopes: ScopeDescriptor[], ): Promise => { - registerDefaultScopes() const trimmedQuery = query.trim() - // Backwards compatibility: if scopes is not provided or empty, use non-page-specific scopes - const effectiveScopes = (scopes && scopes.length > 0) - ? scopes - : scopeRegistry.getScopes({ isWorkflowPage: false, isRagPipelinePage: false }) - if (scope) { const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - const scopeId = getScopeId(scope) - const prefixPattern = new RegExp(`^(${escapeRegExp(scope.shortcut)})\\s*`) + const shortcuts = getScopeShortcuts(scope).map(escapeRegExp) + const prefixPattern = new RegExp(`^(${shortcuts.join('|')})\\s*`) const searchTerm = trimmedQuery.replace(prefixPattern, '').trim() try { return await scope.search(query, searchTerm, locale) } catch (error) { - console.warn(`Search failed for ${scopeId}:`, error) + console.warn(`Search failed for ${scope.id}:`, error) return [] } } @@ -103,11 +71,11 @@ export const searchAnything = async ( return [] // Filter out slash commands from general search - const searchScopes = effectiveScopes.filter(scope => !isSlashScope(scope)) + const searchScopes = scopes.filter(scope => !isSlashScope(scope)) // Use Promise.allSettled to handle partial failures gracefully const searchPromises = searchScopes.map(async (action) => { - const actionId = getScopeId(action) + const actionId = action.id try { const results = await action.search(query, query, locale) return { success: true, data: results, actionType: actionId } @@ -128,7 +96,7 @@ export const searchAnything = async ( allResults.push(...result.value.data) } else { - const actionKey = getScopeId(searchScopes[index]) || 'unknown' + const actionKey = searchScopes[index]?.id || 'unknown' failedActions.push(actionKey) } }) @@ -142,11 +110,10 @@ export const searchAnything = async ( // ... export const matchAction = (query: string, scopes: ScopeDescriptor[]) => { - registerDefaultScopes() const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') return scopes.find((scope) => { // Special handling for slash commands - if (scope.shortcut === ACTION_KEYS.SLASH) { + if (isSlashScope(scope)) { const allCommands = slashCommandRegistry.getAllCommands() return allCommands.some((cmd) => { const cmdPattern = `/${cmd.name}` @@ -158,7 +125,8 @@ export const matchAction = (query: string, scopes: ScopeDescriptor[]) => { // Check if query matches shortcut (exact or prefix) // Only match if it's the full shortcut followed by space - const reg = new RegExp(`^(${escapeRegExp(scope.shortcut)})(?:\\s|$)`) + const shortcuts = getScopeShortcuts(scope).map(escapeRegExp) + const reg = new RegExp(`^(${shortcuts.join('|')})(?:\\s|$)`) return reg.test(query) }) } @@ -166,4 +134,4 @@ export const matchAction = (query: string, scopes: ScopeDescriptor[]) => { export * from './commands' export * from './scope-registry' export * from './types' -export { appAction, knowledgeAction, pluginAction } +export { appScope, knowledgeScope, pluginScope } diff --git a/web/app/components/goto-anything/actions/knowledge.tsx b/web/app/components/goto-anything/actions/knowledge.tsx index c338386446..11188ab468 100644 --- a/web/app/components/goto-anything/actions/knowledge.tsx +++ b/web/app/components/goto-anything/actions/knowledge.tsx @@ -1,4 +1,4 @@ -import type { ActionItem, KnowledgeSearchResult } from './types' +import type { KnowledgeSearchResult, ScopeDescriptor } from './types' import type { DataSet } from '@/models/datasets' import { fetchDatasets } from '@/service/datasets' import { cn } from '@/utils/classnames' @@ -31,9 +31,10 @@ const parser = (datasets: DataSet[]): KnowledgeSearchResult[] => { }) } -export const knowledgeAction: ActionItem = { - key: ACTION_KEYS.KNOWLEDGE, - shortcut: '@kb', +export const knowledgeScope: ScopeDescriptor = { + id: 'knowledge', + shortcut: ACTION_KEYS.KNOWLEDGE, + aliases: ['@kb'], title: 'Search Knowledge Bases', description: 'Search and navigate to your knowledge bases', // action, diff --git a/web/app/components/goto-anything/actions/plugin.tsx b/web/app/components/goto-anything/actions/plugin.tsx index 7c3baa2381..f9602775dd 100644 --- a/web/app/components/goto-anything/actions/plugin.tsx +++ b/web/app/components/goto-anything/actions/plugin.tsx @@ -1,5 +1,5 @@ import type { Plugin, PluginsFromMarketplaceResponse } from '../../plugins/types' -import type { ActionItem, PluginSearchResult } from './types' +import type { PluginSearchResult, ScopeDescriptor } from './types' import { renderI18nObject } from '@/i18n-config' import { postMarketplace } from '@/service/base' import Icon from '../../plugins/card/base/card-icon' @@ -19,8 +19,8 @@ const parser = (plugins: Plugin[], locale: string): PluginSearchResult[] => { }) } -export const pluginAction: ActionItem = { - key: ACTION_KEYS.PLUGIN, +export const pluginScope: ScopeDescriptor = { + id: 'plugin', shortcut: ACTION_KEYS.PLUGIN, title: 'Search Plugins', description: 'Search and navigate to your plugins', diff --git a/web/app/components/goto-anything/actions/rag-pipeline-nodes.tsx b/web/app/components/goto-anything/actions/rag-pipeline-nodes.tsx index 7afc784ffc..14a4f8c3f3 100644 --- a/web/app/components/goto-anything/actions/rag-pipeline-nodes.tsx +++ b/web/app/components/goto-anything/actions/rag-pipeline-nodes.tsx @@ -4,6 +4,7 @@ import { ACTION_KEYS } from '../constants' import { scopeRegistry } from './scope-registry' const scopeId = 'rag-pipeline-node' +let scopeRegistered = false const buildSearchHandler = (searchFn?: (searchTerm: string) => SearchResult[]): ScopeSearchHandler => { return async (_, searchTerm = '', _locale) => { @@ -19,22 +20,22 @@ const buildSearchHandler = (searchFn?: (searchTerm: string) => SearchResult[]): } } +export const registerRagPipelineNodeScope = () => { + if (scopeRegistered) + return + + scopeRegistered = true + scopeRegistry.register({ + id: scopeId, + shortcut: ACTION_KEYS.NODE, + title: 'Search RAG Pipeline Nodes', + description: 'Find and jump to nodes in the current RAG pipeline by name or type', + isAvailable: context => context.isRagPipelinePage, + search: buildSearchHandler(), + }) +} + export const setRagPipelineNodesSearchFn = (fn: (searchTerm: string) => SearchResult[]) => { + registerRagPipelineNodeScope() scopeRegistry.updateSearchHandler(scopeId, buildSearchHandler(fn)) } - -// Register the RAG pipeline nodes action -scopeRegistry.register({ - id: scopeId, - shortcut: ACTION_KEYS.NODE, - title: 'Search RAG Pipeline Nodes', - description: 'Find and jump to nodes in the current RAG pipeline by name or type', - isAvailable: context => context.isRagPipelinePage, - search: buildSearchHandler(), -}) - -// Legacy export -export const ragPipelineNodesAction = { - key: ACTION_KEYS.NODE, - search: async () => [], -} diff --git a/web/app/components/goto-anything/actions/scope-registry.ts b/web/app/components/goto-anything/actions/scope-registry.ts index f007fadb3b..fc27c3b9fb 100644 --- a/web/app/components/goto-anything/actions/scope-registry.ts +++ b/web/app/components/goto-anything/actions/scope-registry.ts @@ -23,6 +23,10 @@ export type ScopeDescriptor = { * Shortcut to trigger this scope (e.g. '@app') */ shortcut: string + /** + * Additional shortcuts that map to this scope (e.g. ['@kb']) + */ + aliases?: string[] /** * I18n key or string for the scope title */ @@ -95,9 +99,7 @@ class ScopeRegistry { export const scopeRegistry = new ScopeRegistry() -export const useScopeRegistry = (context: ScopeContext, initialize?: () => void) => { - initialize?.() - +export const useScopeRegistry = (context: ScopeContext) => { const subscribe = useCallback( (listener: Listener) => scopeRegistry.subscribe(listener), [], diff --git a/web/app/components/goto-anything/actions/types.ts b/web/app/components/goto-anything/actions/types.ts index 56c745ade9..9e04832cd4 100644 --- a/web/app/components/goto-anything/actions/types.ts +++ b/web/app/components/goto-anything/actions/types.ts @@ -1,8 +1,6 @@ import type { ReactNode } from 'react' -import type { TypeWithI18N } from '../../base/form/types' import type { Plugin } from '../../plugins/types' import type { CommonNodeType } from '../../workflow/types' -import type { ActionKey } from '../constants' import type { DataSet } from '@/models/datasets' import type { App } from '@/types/app' @@ -44,25 +42,4 @@ export type CommandSearchResult = { export type SearchResult = AppSearchResult | PluginSearchResult | KnowledgeSearchResult | WorkflowNodeSearchResult | CommandSearchResult -// Legacy ActionItem for backward compatibility if needed, but we should move to ScopeDescriptor -export type ActionItem = { - key: ActionKey - shortcut: string - title: string | TypeWithI18N - description: string - /** - * @deprecated use search() instead - */ - action?: (data: SearchResult) => void - /** - * @deprecated use search() instead - */ - searchFn?: (searchTerm: string) => SearchResult[] - search: ( - query: string, - searchTerm: string, - locale?: string, - ) => (Promise | SearchResult[]) -} - export type { ScopeContext, ScopeDescriptor } from './scope-registry' diff --git a/web/app/components/goto-anything/actions/workflow-nodes.tsx b/web/app/components/goto-anything/actions/workflow-nodes.tsx index 107830c2ea..d4de980011 100644 --- a/web/app/components/goto-anything/actions/workflow-nodes.tsx +++ b/web/app/components/goto-anything/actions/workflow-nodes.tsx @@ -4,6 +4,7 @@ import { ACTION_KEYS } from '../constants' import { scopeRegistry } from './scope-registry' const scopeId = 'workflow-node' +let scopeRegistered = false const buildSearchHandler = (searchFn?: (searchTerm: string) => SearchResult[]): ScopeSearchHandler => { return async (_, searchTerm = '', _locale) => { @@ -19,22 +20,22 @@ const buildSearchHandler = (searchFn?: (searchTerm: string) => SearchResult[]): } } +export const registerWorkflowNodeScope = () => { + if (scopeRegistered) + return + + scopeRegistered = true + scopeRegistry.register({ + id: scopeId, + shortcut: ACTION_KEYS.NODE, + title: 'Search Workflow Nodes', + description: 'Find and jump to nodes in the current workflow by name or type', + isAvailable: context => context.isWorkflowPage, + search: buildSearchHandler(), + }) +} + export const setWorkflowNodesSearchFn = (fn: (searchTerm: string) => SearchResult[]) => { + registerWorkflowNodeScope() scopeRegistry.updateSearchHandler(scopeId, buildSearchHandler(fn)) } - -// Register the workflow nodes action -scopeRegistry.register({ - id: scopeId, - shortcut: ACTION_KEYS.NODE, - title: 'Search Workflow Nodes', - description: 'Find and jump to nodes in the current workflow by name or type', - isAvailable: context => context.isWorkflowPage, - search: buildSearchHandler(), -}) - -// Legacy export if needed (though we should migrate away from it) -export const workflowNodesAction = { - key: ACTION_KEYS.NODE, - search: async () => [], // Dummy implementation -} diff --git a/web/app/components/goto-anything/command-selector.tsx b/web/app/components/goto-anything/command-selector.tsx index 411ae73528..86a8b1690c 100644 --- a/web/app/components/goto-anything/command-selector.tsx +++ b/web/app/components/goto-anything/command-selector.tsx @@ -55,9 +55,11 @@ const CommandSelector: FC = ({ scopes, onCommandSelect, searchFilter, com if (!searchFilter) return true - // Match against shortcut or title - return scope.shortcut.toLowerCase().includes(searchFilter.toLowerCase()) - || scope.title.toLowerCase().includes(searchFilter.toLowerCase()) + // Match against shortcut/aliases or title + const filterLower = searchFilter.toLowerCase() + const shortcuts = [scope.shortcut, ...(scope.aliases || [])] + return shortcuts.some(shortcut => shortcut.toLowerCase().includes(filterLower)) + || scope.title.toLowerCase().includes(filterLower) }).map(scope => ({ key: scope.shortcut, // Map to shortcut for UI display consistency shortcut: scope.shortcut, diff --git a/web/app/components/goto-anything/index.spec.tsx b/web/app/components/goto-anything/index.spec.tsx index 059b290593..adcadfa1fa 100644 --- a/web/app/components/goto-anything/index.spec.tsx +++ b/web/app/components/goto-anything/index.spec.tsx @@ -48,33 +48,25 @@ vi.mock('./context', () => ({ GotoAnythingProvider: ({ children }: { children: React.ReactNode }) => <>{children}, })) -const createScope = (id: ScopeDescriptor['id'], shortcut: string): ScopeDescriptor => ({ - id, - shortcut, - title: `${id} title`, - description: `${id} desc`, - search: vi.fn(), -}) - -const scopesMock = [ - createScope('slash', '/'), - createScope('app', '@app'), - createScope('plugin', '@plugin'), -] - type MatchAction = typeof import('./actions').matchAction type SearchAnything = typeof import('./actions').searchAnything -const useScopeRegistryMock = vi.fn(() => scopesMock) -const matchActionMock = vi.fn(() => undefined) -const searchAnythingMock = vi.fn(async () => mockQueryResult.data) -const registerDefaultScopesMock = vi.fn() +const mockState = vi.hoisted(() => { + const state = { + scopes: [] as ScopeDescriptor[], + useGotoAnythingScopesMock: vi.fn(() => state.scopes), + matchActionMock: vi.fn(() => undefined), + searchAnythingMock: vi.fn(async () => []), + } + + return state +}) vi.mock('./actions', () => ({ __esModule: true, - matchAction: (...args: Parameters) => matchActionMock(...args), - searchAnything: (...args: Parameters) => searchAnythingMock(...args), - registerDefaultScopes: () => registerDefaultScopesMock(), + matchAction: (...args: Parameters) => mockState.matchActionMock(...args), + searchAnything: (...args: Parameters) => mockState.searchAnythingMock(...args), + useGotoAnythingScopes: () => mockState.useGotoAnythingScopesMock(), })) vi.mock('./actions/commands', () => ({ @@ -90,9 +82,19 @@ vi.mock('./actions/commands/registry', () => ({ }, })) -vi.mock('./actions/scope-registry', () => ({ - useScopeRegistry: () => useScopeRegistryMock(), -})) +const createScope = (id: ScopeDescriptor['id'], shortcut: string): ScopeDescriptor => ({ + id, + shortcut, + title: `${id} title`, + description: `${id} desc`, + search: vi.fn(), +}) + +const scopesMock = [ + createScope('slash', '/'), + createScope('app', '@app'), + createScope('plugin', '@plugin'), +] vi.mock('@/app/components/workflow/utils/common', () => ({ getKeyboardKeyCodeBySystem: () => 'ctrl', @@ -118,8 +120,10 @@ describe('GotoAnything', () => { routerPush.mockClear() Object.keys(keyPressHandlers).forEach(key => delete keyPressHandlers[key]) mockQueryResult = { data: [], isLoading: false, isError: false, error: null } - matchActionMock.mockReset() - searchAnythingMock.mockClear() + mockState.scopes = scopesMock + mockState.matchActionMock.mockReset() + mockState.searchAnythingMock.mockClear() + mockState.searchAnythingMock.mockImplementation(async () => mockQueryResult.data) }) it('should open modal via shortcut and navigate to selected result', async () => { diff --git a/web/app/components/goto-anything/index.tsx b/web/app/components/goto-anything/index.tsx index 13dab864fc..b4c71c62f2 100644 --- a/web/app/components/goto-anything/index.tsx +++ b/web/app/components/goto-anything/index.tsx @@ -17,10 +17,9 @@ import { getKeyboardKeyCodeBySystem, isEventTargetInputArea, isMac } from '@/app import { selectWorkflowNode } from '@/app/components/workflow/utils/node-navigation' import { useGetLanguage } from '@/context/i18n' import InstallFromMarketplace from '../plugins/install-plugin/install-from-marketplace' -import { matchAction, registerDefaultScopes, searchAnything } from './actions' +import { matchAction, searchAnything, useGotoAnythingScopes } from './actions' import { executeCommand, SlashCommandProvider } from './actions/commands' import { slashCommandRegistry } from './actions/commands/registry' -import { useScopeRegistry } from './actions/scope-registry' import CommandSelector from './command-selector' import { ACTION_KEYS, EMPTY_STATE_I18N_MAP, GROUP_HEADING_I18N_MAP } from './constants' import { GotoAnythingProvider, useGotoAnythingContext } from './context' @@ -41,7 +40,7 @@ const GotoAnything: FC = ({ const inputRef = useRef(null) // Fetch scopes from registry based on context - const scopes = useScopeRegistry({ isWorkflowPage, isRagPipelinePage }, registerDefaultScopes) + const scopes = useGotoAnythingScopes({ isWorkflowPage, isRagPipelinePage }) const [activePlugin, setActivePlugin] = useState()