refactor: Replace goto-anything actions with a new scope registry system, simplifying command management and registration.

This commit is contained in:
yyh 2025-12-27 22:27:10 +08:00
parent 0fd8d5c4e4
commit 283df5df88
No known key found for this signature in database
13 changed files with 599 additions and 371 deletions

View File

@ -3,201 +3,60 @@
*
* This file defines the action registry for the goto-anything search system.
* Actions handle different types of searches: apps, knowledge bases, plugins, workflow nodes, and commands.
*
* ## How to Add a New Slash Command
*
* 1. **Create Command Handler File** (in `./commands/` directory):
* ```typescript
* // commands/my-command.ts
* import type { SlashCommandHandler } from './types'
* import type { CommandSearchResult } from '../types'
* import { registerCommands, unregisterCommands } from './command-bus'
*
* interface MyCommandDeps {
* myService?: (data: any) => Promise<void>
* }
*
* export const myCommand: SlashCommandHandler<MyCommandDeps> = {
* name: 'mycommand',
* aliases: ['mc'], // Optional aliases
* description: 'My custom command description',
*
* async search(args: string, locale: string = 'en') {
* // Return search results based on args
* return [{
* id: 'my-result',
* title: 'My Command Result',
* description: 'Description of the result',
* type: 'command' as const,
* data: { command: 'my.action', args: { value: args } }
* }]
* },
*
* register(deps: MyCommandDeps) {
* registerCommands({
* 'my.action': async (args) => {
* await deps.myService?.(args?.value)
* }
* })
* },
*
* unregister() {
* unregisterCommands(['my.action'])
* }
* }
* ```
*
* **Example for Self-Contained Command (no external dependencies):**
* ```typescript
* // commands/calculator-command.ts
* export const calculatorCommand: SlashCommandHandler = {
* name: 'calc',
* aliases: ['calculator'],
* description: 'Simple calculator',
*
* async search(args: string) {
* if (!args.trim()) return []
* try {
* // Safe math evaluation (implement proper parser in real use)
* const result = Function('"use strict"; return (' + args + ')')()
* return [{
* id: 'calc-result',
* title: `${args} = ${result}`,
* description: 'Calculator result',
* type: 'command' as const,
* data: { command: 'calc.copy', args: { result: result.toString() } }
* }]
* } catch {
* return [{
* id: 'calc-error',
* title: 'Invalid expression',
* description: 'Please enter a valid math expression',
* type: 'command' as const,
* data: { command: 'calc.noop', args: {} }
* }]
* }
* },
*
* register() {
* registerCommands({
* 'calc.copy': (args) => navigator.clipboard.writeText(args.result),
* 'calc.noop': () => {} // No operation
* })
* },
*
* unregister() {
* unregisterCommands(['calc.copy', 'calc.noop'])
* }
* }
* ```
*
* 2. **Register Command** (in `./commands/slash.tsx`):
* ```typescript
* import { myCommand } from './my-command'
* import { calculatorCommand } from './calculator-command' // For self-contained commands
*
* export const registerSlashCommands = (deps: Record<string, any>) => {
* slashCommandRegistry.register(themeCommand, { setTheme: deps.setTheme })
* slashCommandRegistry.register(languageCommand, { setLocale: deps.setLocale })
* slashCommandRegistry.register(myCommand, { myService: deps.myService }) // With dependencies
* slashCommandRegistry.register(calculatorCommand) // Self-contained, no dependencies
* }
*
* export const unregisterSlashCommands = () => {
* slashCommandRegistry.unregister('theme')
* slashCommandRegistry.unregister('language')
* slashCommandRegistry.unregister('mycommand')
* slashCommandRegistry.unregister('calc') // Add this line
* }
* ```
*
*
* 3. **Update SlashCommandProvider** (in `./commands/slash.tsx`):
* ```typescript
* export const SlashCommandProvider = () => {
* const theme = useTheme()
* const myService = useMyService() // Add external dependency if needed
*
* useEffect(() => {
* registerSlashCommands({
* setTheme: theme.setTheme, // Required for theme command
* setLocale: setLocaleOnClient, // Required for language command
* myService: myService, // Required for your custom command
* // Note: calculatorCommand doesn't need dependencies, so not listed here
* })
* return () => unregisterSlashCommands()
* }, [theme.setTheme, myService]) // Update dependency array for all dynamic deps
*
* return null
* }
* ```
*
* **Note:** Self-contained commands (like calculator) don't require dependencies but are
* still registered through the same system for consistent lifecycle management.
*
* 4. **Usage**: Users can now type `/mycommand` or `/mc` to use your command
*
* ## Command System Architecture
* - Commands are registered via `SlashCommandRegistry`
* - Each command is self-contained with its own dependencies
* - Commands support aliases for easier access
* - Command execution is handled by the command bus system
* - All commands should be registered through `SlashCommandProvider` for consistent lifecycle management
*
* ## Command Types
* **Commands with External Dependencies:**
* - Require external services, APIs, or React hooks
* - Must provide dependencies in `SlashCommandProvider`
* - Example: theme commands (needs useTheme), API commands (needs service)
*
* **Self-Contained Commands:**
* - Pure logic operations, no external dependencies
* - Still recommended to register through `SlashCommandProvider` for consistency
* - Example: calculator, text manipulation commands
*
* ## Available Actions
* - `@app` - Search applications
* - `@knowledge` / `@kb` - Search knowledge bases
* - `@plugin` - Search plugins
* - `@node` - Search workflow nodes (workflow pages only)
* - `/` - Execute slash commands (theme, language, banana, etc.)
*/
import type { ActionItem, SearchResult } from './types'
import type { ActionItem, ScopeDescriptor, SearchResult } from './types'
import { ACTION_KEYS } from '../constants'
import { appAction } from './app'
import { slashAction } from './commands'
import { slashCommandRegistry } from './commands/registry'
import { knowledgeAction } from './knowledge'
import { pluginAction } from './plugin'
import { ragPipelineNodesAction } from './rag-pipeline-nodes'
import { workflowNodesAction } from './workflow-nodes'
import { scopeRegistry } from './scope-registry'
// Create dynamic Actions based on context
export const createActions = (isWorkflowPage: boolean, isRagPipelinePage: boolean) => {
const baseActions = {
slash: slashAction,
app: appAction,
knowledge: knowledgeAction,
plugin: pluginAction,
}
let defaultScopesRegistered = false
// Add appropriate node search based on context
if (isRagPipelinePage) {
return {
...baseActions,
node: ragPipelineNodesAction,
}
}
else if (isWorkflowPage) {
return {
...baseActions,
node: workflowNodesAction,
}
}
export const registerDefaultScopes = () => {
if (defaultScopesRegistered)
return
// Default actions without node search
return baseActions
defaultScopesRegistered = 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,
})
}
// Legacy export for backward compatibility
@ -206,26 +65,36 @@ export const Actions = {
app: appAction,
knowledge: knowledgeAction,
plugin: pluginAction,
node: workflowNodesAction,
}
const getScopeId = (scope: ScopeDescriptor | ActionItem) => ('id' in scope ? scope.id : scope.key)
const isSlashScope = (scope: ScopeDescriptor | ActionItem) => scope.shortcut === ACTION_KEYS.SLASH
export const searchAnything = async (
locale: string,
query: string,
actionItem?: ActionItem,
dynamicActions?: Record<string, ActionItem>,
scope?: ScopeDescriptor | ActionItem,
scopes?: (ScopeDescriptor | ActionItem)[],
): Promise<SearchResult[]> => {
registerDefaultScopes()
const trimmedQuery = query.trim()
if (actionItem) {
// 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 prefixPattern = new RegExp(`^(${escapeRegExp(actionItem.key)}|${escapeRegExp(actionItem.shortcut)})\\s*`)
const scopeId = getScopeId(scope)
const prefixPattern = new RegExp(`^(${escapeRegExp(scope.shortcut)})\\s*`)
const searchTerm = trimmedQuery.replace(prefixPattern, '').trim()
try {
return await actionItem.search(query, searchTerm, locale)
return await scope.search(query, searchTerm, locale)
}
catch (error) {
console.warn(`Search failed for ${actionItem.key}:`, error)
console.warn(`Search failed for ${scopeId}:`, error)
return []
}
}
@ -233,19 +102,19 @@ export const searchAnything = async (
if (trimmedQuery.startsWith('@') || trimmedQuery.startsWith('/'))
return []
const globalSearchActions = Object.values(dynamicActions || Actions)
// Exclude slash commands from general search results
.filter(action => action.key !== ACTION_KEYS.SLASH)
// Filter out slash commands from general search
const searchScopes = effectiveScopes.filter(scope => !isSlashScope(scope))
// Use Promise.allSettled to handle partial failures gracefully
const searchPromises = globalSearchActions.map(async (action) => {
const searchPromises = searchScopes.map(async (action) => {
const actionId = getScopeId(action)
try {
const results = await action.search(query, query, locale)
return { success: true, data: results, actionType: action.key }
return { success: true, data: results, actionType: actionId }
}
catch (error) {
console.warn(`Search failed for ${action.key}:`, error)
return { success: false, data: [], actionType: action.key, error }
console.warn(`Search failed for ${actionId}:`, error)
return { success: false, data: [], actionType: actionId, error }
}
})
@ -259,7 +128,7 @@ export const searchAnything = async (
allResults.push(...result.value.data)
}
else {
const actionKey = globalSearchActions[index]?.key || 'unknown'
const actionKey = getScopeId(searchScopes[index]) || 'unknown'
failedActions.push(actionKey)
}
})
@ -270,31 +139,31 @@ export const searchAnything = async (
return allResults
}
export const matchAction = (query: string, actions: Record<string, ActionItem>) => {
return Object.values(actions).find((action) => {
// Special handling for slash commands
if (action.key === ACTION_KEYS.SLASH) {
// Get all registered commands from the registry
const allCommands = slashCommandRegistry.getAllCommands()
// ...
// Check if query matches any registered command
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) {
const allCommands = slashCommandRegistry.getAllCommands()
return allCommands.some((cmd) => {
const cmdPattern = `/${cmd.name}`
// For direct mode commands, don't match (keep in command selector)
if (cmd.mode === 'direct')
return false
// For submenu mode commands, match when complete command is entered
return query === cmdPattern || query.startsWith(`${cmdPattern} `)
})
}
const reg = new RegExp(`^(${action.key}|${action.shortcut})(?:\\s|$)`)
// 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|$)`)
return reg.test(query)
})
}
export * from './commands'
export * from './scope-registry'
export * from './types'
export { appAction, knowledgeAction, pluginAction, workflowNodesAction }
export { appAction, knowledgeAction, pluginAction }

View File

@ -1,25 +1,40 @@
import type { ActionItem } from './types'
import type { ScopeSearchHandler } from './scope-registry'
import type { SearchResult } from './types'
import { ACTION_KEYS } from '../constants'
import { scopeRegistry } from './scope-registry'
// Create the RAG pipeline nodes action
export const ragPipelineNodesAction: ActionItem = {
key: ACTION_KEYS.NODE,
shortcut: ACTION_KEYS.NODE,
title: 'Search RAG Pipeline Nodes',
description: 'Find and jump to nodes in the current RAG pipeline by name or type',
searchFn: undefined, // Will be set by useRagPipelineSearch hook
search: async (_, searchTerm = '', _locale) => {
const scopeId = 'rag-pipeline-node'
const buildSearchHandler = (searchFn?: (searchTerm: string) => SearchResult[]): ScopeSearchHandler => {
return async (_, searchTerm = '', _locale) => {
try {
// Use the searchFn if available (set by useRagPipelineSearch hook)
if (ragPipelineNodesAction.searchFn)
return ragPipelineNodesAction.searchFn(searchTerm)
// If not in RAG pipeline context, return empty array
if (searchFn)
return searchFn(searchTerm)
return []
}
catch (error) {
console.warn('RAG pipeline nodes search failed:', error)
return []
}
},
}
}
export const setRagPipelineNodesSearchFn = (fn: (searchTerm: string) => SearchResult[]) => {
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 () => [],
}

View File

@ -0,0 +1,121 @@
import type { SearchResult } from './types'
import { useCallback, useMemo, useSyncExternalStore } from 'react'
export type ScopeContext = {
isWorkflowPage: boolean
isRagPipelinePage: boolean
isAdmin?: boolean
}
export type ScopeSearchHandler = (
query: string,
searchTerm: string,
locale?: string,
) => Promise<SearchResult[]> | SearchResult[]
export type ScopeDescriptor = {
/**
* Unique identifier for the scope (e.g. 'app', 'plugin')
*/
id: string
/**
* Shortcut to trigger this scope (e.g. '@app')
*/
shortcut: string
/**
* I18n key or string for the scope title
*/
title: string
/**
* Description for help text
*/
description: string
/**
* Search handler function
*/
search: ScopeSearchHandler
/**
* Predicate to check if this scope is available in current context
*/
isAvailable?: (context: ScopeContext) => boolean
}
type Listener = () => void
class ScopeRegistry {
private scopes: Map<string, ScopeDescriptor> = new Map()
private listeners: Set<Listener> = new Set()
private version = 0
register(scope: ScopeDescriptor) {
this.scopes.set(scope.id, scope)
this.notify()
}
unregister(id: string) {
if (this.scopes.delete(id))
this.notify()
}
getScope(id: string) {
return this.scopes.get(id)
}
getScopes(context: ScopeContext): ScopeDescriptor[] {
return Array.from(this.scopes.values())
.filter(scope => !scope.isAvailable || scope.isAvailable(context))
.sort((a, b) => a.shortcut.localeCompare(b.shortcut))
}
updateSearchHandler(id: string, search: ScopeSearchHandler) {
const scope = this.scopes.get(id)
if (!scope)
return
this.scopes.set(id, { ...scope, search })
this.notify()
}
getVersion() {
return this.version
}
subscribe(listener: Listener) {
this.listeners.add(listener)
return () => {
this.listeners.delete(listener)
}
}
private notify() {
this.version += 1
this.listeners.forEach(listener => listener())
}
}
export const scopeRegistry = new ScopeRegistry()
export const useScopeRegistry = (context: ScopeContext, initialize?: () => void) => {
initialize?.()
const subscribe = useCallback(
(listener: Listener) => scopeRegistry.subscribe(listener),
[],
)
const getSnapshot = useCallback(
() => scopeRegistry.getVersion(),
[],
)
const version = useSyncExternalStore(
subscribe,
getSnapshot,
getSnapshot,
)
return useMemo(
() => scopeRegistry.getScopes(context),
[version, context.isWorkflowPage, context.isRagPipelinePage, context.isAdmin],
)
}

View File

@ -44,12 +44,19 @@ 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,
@ -57,3 +64,5 @@ export type ActionItem = {
locale?: string,
) => (Promise<SearchResult[]> | SearchResult[])
}
export type { ScopeContext, ScopeDescriptor } from './scope-registry'

View File

@ -1,25 +1,40 @@
import type { ActionItem } from './types'
import type { ScopeSearchHandler } from './scope-registry'
import type { SearchResult } from './types'
import { ACTION_KEYS } from '../constants'
import { scopeRegistry } from './scope-registry'
// Create the workflow nodes action
export const workflowNodesAction: ActionItem = {
key: ACTION_KEYS.NODE,
shortcut: ACTION_KEYS.NODE,
title: 'Search Workflow Nodes',
description: 'Find and jump to nodes in the current workflow by name or type',
searchFn: undefined, // Will be set by useWorkflowSearch hook
search: async (_, searchTerm = '', _locale) => {
const scopeId = 'workflow-node'
const buildSearchHandler = (searchFn?: (searchTerm: string) => SearchResult[]): ScopeSearchHandler => {
return async (_, searchTerm = '', _locale) => {
try {
// Use the searchFn if available (set by useWorkflowSearch hook)
if (workflowNodesAction.searchFn)
return workflowNodesAction.searchFn(searchTerm)
// If not in workflow context, return empty array
if (searchFn)
return searchFn(searchTerm)
return []
}
catch (error) {
console.warn('Workflow nodes search failed:', error)
return []
}
},
}
}
export const setWorkflowNodesSearchFn = (fn: (searchTerm: string) => SearchResult[]) => {
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
}

View File

@ -1,5 +1,5 @@
import type { ActionItem } from './actions/types'
import { render, screen } from '@testing-library/react'
import type { ScopeDescriptor } from './actions/scope-registry'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Command } from 'cmdk'
import * as React from 'react'
@ -22,63 +22,229 @@ vi.mock('./actions/commands/registry', () => ({
},
}))
const createActions = (): Record<string, ActionItem> => ({
app: {
key: '@app',
type CommandSelectorProps = React.ComponentProps<typeof CommandSelector>
const mockScopes: ScopeDescriptor[] = [
{
id: 'app',
shortcut: '@app',
title: 'Apps',
title: 'Search Applications',
description: 'Search apps',
search: vi.fn(),
description: '',
} as ActionItem,
plugin: {
key: '@plugin',
},
{
id: 'knowledge',
shortcut: '@knowledge',
title: 'Search Knowledge Bases',
description: 'Search knowledge bases',
search: vi.fn(),
},
{
id: 'plugin',
shortcut: '@plugin',
title: 'Plugins',
title: 'Search Plugins',
description: 'Search plugins',
search: vi.fn(),
description: '',
} as ActionItem,
})
},
{
id: 'workflow-node',
shortcut: '@node',
title: 'Search Nodes',
description: 'Search workflow nodes',
search: vi.fn(),
},
]
const mockOnCommandSelect = vi.fn()
const mockOnCommandValueChange = vi.fn()
const buildCommandSelector = (props: Partial<CommandSelectorProps> = {}) => (
<Command>
<Command.List>
<CommandSelector
scopes={mockScopes}
onCommandSelect={mockOnCommandSelect}
{...props}
/>
</Command.List>
</Command>
)
const renderCommandSelector = (props: Partial<CommandSelectorProps> = {}) => {
return render(buildCommandSelector(props))
}
describe('CommandSelector', () => {
it('should list contextual search actions and notify selection', async () => {
const actions = createActions()
const onSelect = vi.fn()
render(
<Command>
<CommandSelector
actions={actions}
onCommandSelect={onSelect}
searchFilter="app"
originalQuery="@app"
/>
</Command>,
)
const actionButton = screen.getByText('app.gotoAnything.actions.searchApplicationsDesc')
await userEvent.click(actionButton)
expect(onSelect).toHaveBeenCalledWith('@app')
beforeEach(() => {
vi.clearAllMocks()
})
it('should render slash commands when query starts with slash', async () => {
const actions = createActions()
const onSelect = vi.fn()
describe('Basic Rendering', () => {
it('should render all scopes when no filter is provided', () => {
renderCommandSelector()
render(
<Command>
<CommandSelector
actions={actions}
onCommandSelect={onSelect}
searchFilter="zen"
originalQuery="/zen"
/>
</Command>,
)
expect(screen.getByText('@app')).toBeInTheDocument()
expect(screen.getByText('@knowledge')).toBeInTheDocument()
expect(screen.getByText('@plugin')).toBeInTheDocument()
expect(screen.getByText('@node')).toBeInTheDocument()
})
const slashItem = await screen.findByText('app.gotoAnything.actions.zenDesc')
await userEvent.click(slashItem)
it('should render empty filter as showing all scopes', () => {
renderCommandSelector({ searchFilter: '' })
expect(onSelect).toHaveBeenCalledWith('/zen')
expect(screen.getByText('@app')).toBeInTheDocument()
expect(screen.getByText('@knowledge')).toBeInTheDocument()
expect(screen.getByText('@plugin')).toBeInTheDocument()
expect(screen.getByText('@node')).toBeInTheDocument()
})
})
describe('Filtering Functionality', () => {
it('should filter scopes based on searchFilter - single match', () => {
renderCommandSelector({ searchFilter: 'k' })
expect(screen.queryByText('@app')).not.toBeInTheDocument()
expect(screen.getByText('@knowledge')).toBeInTheDocument()
expect(screen.queryByText('@plugin')).not.toBeInTheDocument()
expect(screen.queryByText('@node')).not.toBeInTheDocument()
})
it('should filter scopes with multiple matches', () => {
renderCommandSelector({ searchFilter: 'p' })
expect(screen.getByText('@app')).toBeInTheDocument()
expect(screen.queryByText('@knowledge')).not.toBeInTheDocument()
expect(screen.getByText('@plugin')).toBeInTheDocument()
expect(screen.queryByText('@node')).not.toBeInTheDocument()
})
it('should be case-insensitive when filtering', () => {
renderCommandSelector({ searchFilter: 'APP' })
expect(screen.getByText('@app')).toBeInTheDocument()
expect(screen.queryByText('@knowledge')).not.toBeInTheDocument()
})
it('should match partial strings', () => {
renderCommandSelector({ searchFilter: 'od' })
expect(screen.queryByText('@app')).not.toBeInTheDocument()
expect(screen.queryByText('@knowledge')).not.toBeInTheDocument()
expect(screen.queryByText('@plugin')).not.toBeInTheDocument()
expect(screen.getByText('@node')).toBeInTheDocument()
})
})
describe('Empty State', () => {
it('should show empty state when no matches found', () => {
renderCommandSelector({ searchFilter: 'xyz' })
expect(screen.queryByText('@app')).not.toBeInTheDocument()
expect(screen.queryByText('@knowledge')).not.toBeInTheDocument()
expect(screen.queryByText('@plugin')).not.toBeInTheDocument()
expect(screen.queryByText('@node')).not.toBeInTheDocument()
expect(screen.getByText('app.gotoAnything.noMatchingCommands')).toBeInTheDocument()
expect(screen.getByText('app.gotoAnything.tryDifferentSearch')).toBeInTheDocument()
})
it('should not show empty state when filter is empty', () => {
renderCommandSelector({ searchFilter: '' })
expect(screen.queryByText('app.gotoAnything.noMatchingCommands')).not.toBeInTheDocument()
})
})
describe('Selection and Highlight Management', () => {
it('should call onCommandValueChange when filter changes and first item differs', async () => {
const { rerender } = renderCommandSelector({
searchFilter: '',
commandValue: '@app',
onCommandValueChange: mockOnCommandValueChange,
})
rerender(buildCommandSelector({
searchFilter: 'k',
commandValue: '@app',
onCommandValueChange: mockOnCommandValueChange,
}))
await waitFor(() => {
expect(mockOnCommandValueChange).toHaveBeenCalledWith('@knowledge')
})
})
it('should not call onCommandValueChange if current value still exists', async () => {
const { rerender } = renderCommandSelector({
searchFilter: '',
commandValue: '@app',
onCommandValueChange: mockOnCommandValueChange,
})
rerender(buildCommandSelector({
searchFilter: 'a',
commandValue: '@app',
onCommandValueChange: mockOnCommandValueChange,
}))
await waitFor(() => {
expect(mockOnCommandValueChange).not.toHaveBeenCalled()
})
})
it('should handle onCommandSelect callback correctly', async () => {
const user = userEvent.setup()
renderCommandSelector({ searchFilter: 'k' })
await user.click(screen.getByText('@knowledge'))
expect(mockOnCommandSelect).toHaveBeenCalledWith('@knowledge')
})
})
describe('Edge Cases', () => {
it('should handle empty scopes array', () => {
renderCommandSelector({ scopes: [] })
expect(screen.getByText('app.gotoAnything.noMatchingCommands')).toBeInTheDocument()
})
it('should handle special characters in filter', () => {
renderCommandSelector({ searchFilter: '@' })
expect(screen.getByText('@app')).toBeInTheDocument()
expect(screen.getByText('@knowledge')).toBeInTheDocument()
expect(screen.getByText('@plugin')).toBeInTheDocument()
expect(screen.getByText('@node')).toBeInTheDocument()
})
it('should handle undefined onCommandValueChange gracefully', () => {
const { rerender } = renderCommandSelector({ searchFilter: '' })
expect(() => {
rerender(buildCommandSelector({ searchFilter: 'k' }))
}).not.toThrow()
})
})
describe('User Interactions', () => {
it('should list contextual scopes and notify selection', async () => {
const user = userEvent.setup()
renderCommandSelector({ searchFilter: 'app', originalQuery: '@app' })
await user.click(screen.getByText('app.gotoAnything.actions.searchApplicationsDesc'))
expect(mockOnCommandSelect).toHaveBeenCalledWith('@app')
})
it('should render slash commands when query starts with slash', async () => {
const user = userEvent.setup()
renderCommandSelector({ searchFilter: 'zen', originalQuery: '/zen' })
const slashItem = await screen.findByText('app.gotoAnything.actions.zenDesc')
await user.click(slashItem)
expect(mockOnCommandSelect).toHaveBeenCalledWith('/zen')
})
})
})

View File

@ -1,5 +1,5 @@
import type { FC } from 'react'
import type { ActionItem } from './actions/types'
import type { ScopeDescriptor } from './actions/scope-registry'
import { Command } from 'cmdk'
import { usePathname } from 'next/navigation'
import { useEffect, useMemo } from 'react'
@ -8,7 +8,7 @@ import { slashCommandRegistry } from './actions/commands/registry'
import { ACTION_KEYS, SCOPE_ACTION_I18N_MAP, SLASH_COMMAND_I18N_MAP } from './constants'
type Props = {
actions: Record<string, ActionItem>
scopes: ScopeDescriptor[]
onCommandSelect: (commandKey: string) => void
searchFilter?: string
commandValue?: string
@ -16,7 +16,7 @@ type Props = {
originalQuery?: string
}
const CommandSelector: FC<Props> = ({ actions, onCommandSelect, searchFilter, commandValue, onCommandValueChange, originalQuery }) => {
const CommandSelector: FC<Props> = ({ scopes, onCommandSelect, searchFilter, commandValue, onCommandValueChange, originalQuery }) => {
const { t } = useTranslation()
const pathname = usePathname()
@ -44,22 +44,29 @@ const CommandSelector: FC<Props> = ({ actions, onCommandSelect, searchFilter, co
}))
}, [isSlashMode, searchFilter, pathname])
const filteredActions = useMemo(() => {
const filteredScopes = useMemo(() => {
if (isSlashMode)
return []
return Object.values(actions).filter((action) => {
return scopes.filter((scope) => {
// Exclude slash action when in @ mode
if (action.key === ACTION_KEYS.SLASH)
if (scope.id === 'slash' || scope.shortcut === ACTION_KEYS.SLASH)
return false
if (!searchFilter)
return true
const filterLower = searchFilter.toLowerCase()
return action.shortcut.toLowerCase().includes(filterLower)
})
}, [actions, searchFilter, isSlashMode])
const allItems = isSlashMode ? slashCommands : filteredActions
// Match against shortcut or title
return scope.shortcut.toLowerCase().includes(searchFilter.toLowerCase())
|| scope.title.toLowerCase().includes(searchFilter.toLowerCase())
}).map(scope => ({
key: scope.shortcut, // Map to shortcut for UI display consistency
shortcut: scope.shortcut,
title: scope.title,
description: scope.description,
}))
}, [scopes, searchFilter, isSlashMode])
const allItems = isSlashMode ? slashCommands : filteredScopes
useEffect(() => {
if (allItems.length > 0 && onCommandValueChange) {

View File

@ -44,3 +44,24 @@ export const SCOPE_ACTION_I18N_MAP: Record<string, string> = {
'@knowledge': 'app.gotoAnything.actions.searchKnowledgeBasesDesc',
'@node': 'app.gotoAnything.actions.searchWorkflowNodesDesc',
} as const
/**
* Empty state i18n key mappings
*/
export const EMPTY_STATE_I18N_MAP: Record<string, string> = {
app: 'app.gotoAnything.emptyState.noAppsFound',
plugin: 'app.gotoAnything.emptyState.noPluginsFound',
knowledge: 'app.gotoAnything.emptyState.noKnowledgeBasesFound',
node: 'app.gotoAnything.emptyState.noWorkflowNodesFound',
} as const
/**
* Group heading i18n key mappings
*/
export const GROUP_HEADING_I18N_MAP: Record<string, string> = {
'app': 'app.gotoAnything.groups.apps',
'plugin': 'app.gotoAnything.groups.plugins',
'knowledge': 'app.gotoAnything.groups.knowledgeBases',
'workflow-node': 'app.gotoAnything.groups.workflowNodes',
'command': 'app.gotoAnything.groups.commands',
} as const

View File

@ -1,4 +1,5 @@
import type { ActionItem, SearchResult } from './actions/types'
import type { ScopeDescriptor } from './actions/scope-registry'
import type { SearchResult } from './actions/types'
import { act, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
@ -47,34 +48,38 @@ vi.mock('./context', () => ({
GotoAnythingProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}))
const createActionItem = (key: ActionItem['key'], shortcut: string): ActionItem => ({
key,
const createScope = (id: ScopeDescriptor['id'], shortcut: string): ScopeDescriptor => ({
id,
shortcut,
title: `${key} title`,
description: `${key} desc`,
action: vi.fn(),
title: `${id} title`,
description: `${id} desc`,
search: vi.fn(),
})
const actionsMock = {
slash: createActionItem('/', '/'),
app: createActionItem('@app', '@app'),
plugin: createActionItem('@plugin', '@plugin'),
}
const scopesMock = [
createScope('slash', '/'),
createScope('app', '@app'),
createScope('plugin', '@plugin'),
]
const createActionsMock = vi.fn(() => actionsMock)
const matchActionMock = vi.fn(() => undefined)
const searchAnythingMock = vi.fn(async () => mockQueryResult.data)
type MatchAction = typeof import('./actions').matchAction
type SearchAnything = typeof import('./actions').searchAnything
const useScopeRegistryMock = vi.fn(() => scopesMock)
const matchActionMock = vi.fn<MatchAction>(() => undefined)
const searchAnythingMock = vi.fn<SearchAnything>(async () => mockQueryResult.data)
const registerDefaultScopesMock = vi.fn()
vi.mock('./actions', () => ({
__esModule: true,
createActions: () => createActionsMock(),
matchAction: () => matchActionMock(),
searchAnything: () => searchAnythingMock(),
matchAction: (...args: Parameters<MatchAction>) => matchActionMock(...args),
searchAnything: (...args: Parameters<SearchAnything>) => searchAnythingMock(...args),
registerDefaultScopes: () => registerDefaultScopesMock(),
}))
vi.mock('./actions/commands', () => ({
SlashCommandProvider: () => null,
executeCommand: vi.fn(),
}))
vi.mock('./actions/commands/registry', () => ({
@ -85,6 +90,10 @@ vi.mock('./actions/commands/registry', () => ({
},
}))
vi.mock('./actions/scope-registry', () => ({
useScopeRegistry: () => useScopeRegistryMock(),
}))
vi.mock('@/app/components/workflow/utils/common', () => ({
getKeyboardKeyCodeBySystem: () => 'ctrl',
isEventTargetInputArea: () => false,

View File

@ -17,11 +17,12 @@ 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 { createActions, matchAction, searchAnything } from './actions'
import { SlashCommandProvider } from './actions/commands'
import { matchAction, registerDefaultScopes, searchAnything } 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 } from './constants'
import { ACTION_KEYS, EMPTY_STATE_I18N_MAP, GROUP_HEADING_I18N_MAP } from './constants'
import { GotoAnythingProvider, useGotoAnythingContext } from './context'
type Props = {
@ -39,11 +40,8 @@ const GotoAnything: FC<Props> = ({
const [cmdVal, setCmdVal] = useState<string>('_')
const inputRef = useRef<HTMLInputElement>(null)
// Filter actions based on context
const Actions = useMemo(() => {
// Create actions based on current page context
return createActions(isWorkflowPage, isRagPipelinePage)
}, [isWorkflowPage, isRagPipelinePage])
// Fetch scopes from registry based on context
const scopes = useScopeRegistry({ isWorkflowPage, isRagPipelinePage }, registerDefaultScopes)
const [activePlugin, setActivePlugin] = useState<Plugin>()
@ -80,8 +78,8 @@ const GotoAnything: FC<Props> = ({
})
const isCommandsMode = searchQuery.trim() === '@' || searchQuery.trim() === '/'
|| (searchQuery.trim().startsWith('@') && !matchAction(searchQuery.trim(), Actions))
|| (searchQuery.trim().startsWith('/') && !matchAction(searchQuery.trim(), Actions))
|| (searchQuery.trim().startsWith('@') && !matchAction(searchQuery.trim(), scopes))
|| (searchQuery.trim().startsWith('/') && !matchAction(searchQuery.trim(), scopes))
const searchMode = useMemo(() => {
if (isCommandsMode) {
@ -94,13 +92,16 @@ const GotoAnything: FC<Props> = ({
}
const query = searchQueryDebouncedValue.toLowerCase()
const action = matchAction(query, Actions)
const action = matchAction(query, scopes)
if (!action)
return 'general'
return action.key === ACTION_KEYS.SLASH ? '@command' : action.key
}, [searchQueryDebouncedValue, Actions, isCommandsMode, searchQuery])
if (action.id === 'slash' || action.shortcut === ACTION_KEYS.SLASH)
return '@command'
return action.shortcut
}, [searchQueryDebouncedValue, scopes, isCommandsMode, searchQuery])
const { data: searchResults = [], isLoading, isError, error } = useQuery(
{
@ -112,12 +113,12 @@ const GotoAnything: FC<Props> = ({
isWorkflowPage,
isRagPipelinePage,
defaultLocale,
Object.keys(Actions).sort().join(','),
scopes.map(s => s.id).sort().join(','),
],
queryFn: async () => {
const query = searchQueryDebouncedValue.toLowerCase()
const action = matchAction(query, Actions)
return await searchAnything(defaultLocale, query, action, Actions)
const scope = matchAction(query, scopes)
return await searchAnything(defaultLocale, query, scope, scopes)
},
enabled: !!searchQueryDebouncedValue && !isCommandsMode,
staleTime: 30000,
@ -167,9 +168,18 @@ const GotoAnything: FC<Props> = ({
break
}
// Execute slash commands
const action = Actions.slash
action?.action?.(result)
// Execute slash commands using the command bus
// This handles both direct execution and submenu commands with args
const { command, args } = result.data
// Try executing via command bus first (preferred for submenu commands with args)
// We can't easily check if it exists in bus without potentially running it if we were to try/catch
// but typically search results point to valid bus commands.
executeCommand(command, args)
// Note: We previously checked slashCommandRegistry handlers here, but search results
// should return executable command strings (like 'theme.set') that are registered in the bus.
// The registry is mainly for the top-level command matching (e.g. /theme).
break
}
case 'plugin':
@ -246,25 +256,19 @@ const GotoAnything: FC<Props> = ({
<div className="text-sm font-medium">
{isCommandSearch
? (() => {
const keyMap: Record<string, string> = {
app: 'app.gotoAnything.emptyState.noAppsFound',
plugin: 'app.gotoAnything.emptyState.noPluginsFound',
knowledge: 'app.gotoAnything.emptyState.noKnowledgeBasesFound',
node: 'app.gotoAnything.emptyState.noWorkflowNodesFound',
}
return t((keyMap[commandType] || 'app.gotoAnything.noResults') as any)
return t((EMPTY_STATE_I18N_MAP[commandType] || 'app.gotoAnything.noResults') as any)
})()
: t('app.gotoAnything.noResults')}
</div>
<div className="mt-1 text-xs text-text-quaternary">
{isCommandSearch
? t('app.gotoAnything.emptyState.tryDifferentTerm')
: t('app.gotoAnything.emptyState.trySpecificSearch', { shortcuts: Object.values(Actions).map(action => action.shortcut).join(', ') })}
: t('app.gotoAnything.emptyState.trySpecificSearch', { shortcuts: scopes.map(s => s.shortcut).join(', ') })}
</div>
</div>
</div>
)
}, [dedupedResults, searchQuery, Actions, searchMode, isLoading, isError, isCommandsMode])
}, [dedupedResults, searchQuery, scopes, searchMode, isLoading, isError, isCommandsMode])
const defaultUI = useMemo(() => {
if (searchQuery.trim())
@ -282,7 +286,7 @@ const GotoAnything: FC<Props> = ({
</div>
</div>
)
}, [searchQuery, Actions])
}, [searchQuery, scopes])
useEffect(() => {
if (show) {
@ -399,7 +403,7 @@ const GotoAnything: FC<Props> = ({
{isCommandsMode
? (
<CommandSelector
actions={Actions}
scopes={scopes}
onCommandSelect={handleCommandSelect}
searchFilter={searchQuery.trim().substring(1)}
commandValue={cmdVal}
@ -412,14 +416,7 @@ const GotoAnything: FC<Props> = ({
<Command.Group
key={groupIndex}
heading={(() => {
const typeMap: Record<string, string> = {
'app': 'app.gotoAnything.groups.apps',
'plugin': 'app.gotoAnything.groups.plugins',
'knowledge': 'app.gotoAnything.groups.knowledgeBases',
'workflow-node': 'app.gotoAnything.groups.workflowNodes',
'command': 'app.gotoAnything.groups.commands',
}
return t((typeMap[type] || `${type}s`) as any)
return t((GROUP_HEADING_I18N_MAP[type] || `${type}s`) as any)
})()}
className="p-2 capitalize text-text-secondary"
>

View File

@ -5,7 +5,7 @@ import type { LLMNodeType } from '@/app/components/workflow/nodes/llm/types'
import type { ToolNodeType } from '@/app/components/workflow/nodes/tool/types'
import type { CommonNodeType } from '@/app/components/workflow/types'
import { useCallback, useEffect, useMemo } from 'react'
import { ragPipelineNodesAction } from '@/app/components/goto-anything/actions/rag-pipeline-nodes'
import { setRagPipelineNodesSearchFn } from '@/app/components/goto-anything/actions/rag-pipeline-nodes'
import BlockIcon from '@/app/components/workflow/block-icon'
import { useNodesInteractions } from '@/app/components/workflow/hooks/use-nodes-interactions'
import { useGetToolIcon } from '@/app/components/workflow/hooks/use-tool-icon'
@ -153,16 +153,15 @@ export const useRagPipelineSearch = () => {
return results
}, [searchableNodes, calculateScore])
// Directly set the search function on the action object
// Directly set the search function using the setter
useEffect(() => {
if (searchableNodes.length > 0) {
// Set the search function directly on the action
ragPipelineNodesAction.searchFn = searchRagPipelineNodes
setRagPipelineNodesSearchFn(searchRagPipelineNodes)
}
return () => {
// Clean up when component unmounts
ragPipelineNodesAction.searchFn = undefined
setRagPipelineNodesSearchFn(() => [])
}
}, [searchableNodes, searchRagPipelineNodes])

View File

@ -5,7 +5,7 @@ import type { CommonNodeType } from '../types'
import type { Emoji } from '@/app/components/tools/types'
import { useCallback, useEffect, useMemo } from 'react'
import { useNodes } from 'reactflow'
import { workflowNodesAction } from '@/app/components/goto-anything/actions/workflow-nodes'
import { setWorkflowNodesSearchFn } from '@/app/components/goto-anything/actions/workflow-nodes'
import { CollectionType } from '@/app/components/tools/types'
import BlockIcon from '@/app/components/workflow/block-icon'
import {
@ -183,16 +183,15 @@ export const useWorkflowSearch = () => {
return results
}, [searchableNodes, calculateScore])
// Directly set the search function on the action object
// Directly set the search function using the setter
useEffect(() => {
if (searchableNodes.length > 0) {
// Set the search function directly on the action
workflowNodesAction.searchFn = searchWorkflowNodes
setWorkflowNodesSearchFn(searchWorkflowNodes)
}
return () => {
// Clean up when component unmounts
workflowNodesAction.searchFn = undefined
setWorkflowNodesSearchFn(() => [])
}
}, [searchableNodes, searchWorkflowNodes])

View File

@ -6,7 +6,7 @@
import type { Shape as WorkflowState } from '@/app/components/workflow/store/workflow'
import type { Edge, Node } from '@/app/components/workflow/types'
import { render, screen } from '@testing-library/react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Toast from '@/app/components/base/toast'
import { WorkflowContext } from '@/app/components/workflow/context'
@ -224,13 +224,14 @@ describe('VibePanel', () => {
// --------------------------------------------------------------------------
describe('User Interactions', () => {
it('should update instruction in store when typing', async () => {
const user = userEvent.setup()
const { workflowStore } = renderVibePanel()
const textarea = screen.getByPlaceholderText('workflow.vibe.missingInstruction')
await user.type(textarea, 'Build a vibe flow')
fireEvent.change(textarea, { target: { value: 'Build a vibe flow' } })
expect(workflowStore.getState().vibePanelInstruction).toBe('Build a vibe flow')
await waitFor(() => {
expect(workflowStore.getState().vibePanelInstruction).toBe('Build a vibe flow')
})
})
it('should dispatch command event with instruction when generate clicked', async () => {