Feature/improve goto anything commands (#24091)
This commit is contained in:
@@ -2,11 +2,11 @@ export type CommandHandler = (args?: Record<string, any>) => void | Promise<void
|
||||
|
||||
const handlers = new Map<string, CommandHandler>()
|
||||
|
||||
export const registerCommand = (name: string, handler: CommandHandler) => {
|
||||
const registerCommand = (name: string, handler: CommandHandler) => {
|
||||
handlers.set(name, handler)
|
||||
}
|
||||
|
||||
export const unregisterCommand = (name: string) => {
|
||||
const unregisterCommand = (name: string) => {
|
||||
handlers.delete(name)
|
||||
}
|
||||
|
15
web/app/components/goto-anything/actions/commands/index.ts
Normal file
15
web/app/components/goto-anything/actions/commands/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
// Command system exports
|
||||
export { slashAction } from './slash'
|
||||
export { registerSlashCommands, unregisterSlashCommands, SlashCommandProvider } from './slash'
|
||||
|
||||
// Command registry system (for extending with custom commands)
|
||||
export { slashCommandRegistry, SlashCommandRegistry } from './registry'
|
||||
export type { SlashCommandHandler } from './types'
|
||||
|
||||
// Command bus (for extending with custom commands)
|
||||
export {
|
||||
executeCommand,
|
||||
registerCommands,
|
||||
unregisterCommands,
|
||||
type CommandHandler,
|
||||
} from './command-bus'
|
@@ -0,0 +1,53 @@
|
||||
import type { SlashCommandHandler } from './types'
|
||||
import type { CommandSearchResult } from '../types'
|
||||
import { languages } from '@/i18n-config/language'
|
||||
import i18n from '@/i18n-config/i18next-config'
|
||||
import { registerCommands, unregisterCommands } from './command-bus'
|
||||
|
||||
// Language dependency types
|
||||
type LanguageDeps = {
|
||||
setLocale?: (locale: string) => Promise<void>
|
||||
}
|
||||
|
||||
const buildLanguageCommands = (query: string): CommandSearchResult[] => {
|
||||
const q = query.toLowerCase()
|
||||
const list = languages.filter(item => item.supported && (
|
||||
!q || item.name.toLowerCase().includes(q) || String(item.value).toLowerCase().includes(q)
|
||||
))
|
||||
return list.map(item => ({
|
||||
id: `lang-${item.value}`,
|
||||
title: item.name,
|
||||
description: i18n.t('app.gotoAnything.actions.languageChangeDesc'),
|
||||
type: 'command' as const,
|
||||
data: { command: 'i18n.set', args: { locale: item.value } },
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Language command handler
|
||||
* Integrates UI building, search, and registration logic
|
||||
*/
|
||||
export const languageCommand: SlashCommandHandler<LanguageDeps> = {
|
||||
name: 'language',
|
||||
aliases: ['lang'],
|
||||
description: 'Switch between different languages',
|
||||
|
||||
async search(args: string, _locale: string = 'en') {
|
||||
// Return language options directly, regardless of parameters
|
||||
return buildLanguageCommands(args)
|
||||
},
|
||||
|
||||
register(deps: LanguageDeps) {
|
||||
registerCommands({
|
||||
'i18n.set': async (args) => {
|
||||
const locale = args?.locale
|
||||
if (locale)
|
||||
await deps.setLocale?.(locale)
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
unregister() {
|
||||
unregisterCommands(['i18n.set'])
|
||||
},
|
||||
}
|
233
web/app/components/goto-anything/actions/commands/registry.ts
Normal file
233
web/app/components/goto-anything/actions/commands/registry.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import type { SlashCommandHandler } from './types'
|
||||
import type { CommandSearchResult } from '../types'
|
||||
|
||||
/**
|
||||
* Slash Command Registry System
|
||||
* Responsible for managing registration, lookup, and search of all slash commands
|
||||
*/
|
||||
export class SlashCommandRegistry {
|
||||
private commands = new Map<string, SlashCommandHandler>()
|
||||
private commandDeps = new Map<string, any>()
|
||||
|
||||
/**
|
||||
* Register command handler
|
||||
*/
|
||||
register<TDeps = any>(handler: SlashCommandHandler<TDeps>, deps?: TDeps) {
|
||||
// Register main command name
|
||||
this.commands.set(handler.name, handler)
|
||||
|
||||
// Register aliases
|
||||
if (handler.aliases) {
|
||||
handler.aliases.forEach((alias) => {
|
||||
this.commands.set(alias, handler)
|
||||
})
|
||||
}
|
||||
|
||||
// Store dependencies and call registration method
|
||||
if (deps) {
|
||||
this.commandDeps.set(handler.name, deps)
|
||||
handler.register?.(deps)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister command
|
||||
*/
|
||||
unregister(name: string) {
|
||||
const handler = this.commands.get(name)
|
||||
if (handler) {
|
||||
// Call the command's unregister method
|
||||
handler.unregister?.()
|
||||
|
||||
// Remove dependencies
|
||||
this.commandDeps.delete(handler.name)
|
||||
|
||||
// Remove main command name
|
||||
this.commands.delete(handler.name)
|
||||
|
||||
// Remove all aliases
|
||||
if (handler.aliases) {
|
||||
handler.aliases.forEach((alias) => {
|
||||
this.commands.delete(alias)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find command handler
|
||||
*/
|
||||
findCommand(commandName: string): SlashCommandHandler | undefined {
|
||||
return this.commands.get(commandName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Smart partial command matching
|
||||
* Prioritize alias matching, then match command name prefix
|
||||
*/
|
||||
private findBestPartialMatch(partialName: string): SlashCommandHandler | undefined {
|
||||
const lowerPartial = partialName.toLowerCase()
|
||||
|
||||
// First check if any alias starts with this
|
||||
const aliasMatch = this.findHandlerByAliasPrefix(lowerPartial)
|
||||
if (aliasMatch)
|
||||
return aliasMatch
|
||||
|
||||
// Then check if command name starts with this
|
||||
return this.findHandlerByNamePrefix(lowerPartial)
|
||||
}
|
||||
|
||||
/**
|
||||
* Find handler by alias prefix
|
||||
*/
|
||||
private findHandlerByAliasPrefix(prefix: string): SlashCommandHandler | undefined {
|
||||
for (const handler of this.getAllCommands()) {
|
||||
if (handler.aliases?.some(alias => alias.toLowerCase().startsWith(prefix)))
|
||||
return handler
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Find handler by name prefix
|
||||
*/
|
||||
private findHandlerByNamePrefix(prefix: string): SlashCommandHandler | undefined {
|
||||
return this.getAllCommands().find(handler =>
|
||||
handler.name.toLowerCase().startsWith(prefix),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered commands (deduplicated)
|
||||
*/
|
||||
getAllCommands(): SlashCommandHandler[] {
|
||||
const uniqueCommands = new Map<string, SlashCommandHandler>()
|
||||
this.commands.forEach((handler) => {
|
||||
uniqueCommands.set(handler.name, handler)
|
||||
})
|
||||
return Array.from(uniqueCommands.values())
|
||||
}
|
||||
|
||||
/**
|
||||
* Search commands
|
||||
* @param query Full query (e.g., "/theme dark" or "/lang en")
|
||||
* @param locale Current language
|
||||
*/
|
||||
async search(query: string, locale: string = 'en'): Promise<CommandSearchResult[]> {
|
||||
const trimmed = query.trim()
|
||||
|
||||
// Handle root level search "/"
|
||||
if (trimmed === '/' || !trimmed.replace('/', '').trim())
|
||||
return await this.getRootCommands()
|
||||
|
||||
// Parse command and arguments
|
||||
const afterSlash = trimmed.substring(1).trim()
|
||||
const spaceIndex = afterSlash.indexOf(' ')
|
||||
const commandName = spaceIndex === -1 ? afterSlash : afterSlash.substring(0, spaceIndex)
|
||||
const args = spaceIndex === -1 ? '' : afterSlash.substring(spaceIndex + 1).trim()
|
||||
|
||||
// First try exact match
|
||||
let handler = this.findCommand(commandName)
|
||||
if (handler) {
|
||||
try {
|
||||
return await handler.search(args, locale)
|
||||
}
|
||||
catch (error) {
|
||||
console.warn(`Command search failed for ${commandName}:`, error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// If no exact match, try smart partial matching
|
||||
handler = this.findBestPartialMatch(commandName)
|
||||
if (handler) {
|
||||
try {
|
||||
return await handler.search(args, locale)
|
||||
}
|
||||
catch (error) {
|
||||
console.warn(`Command search failed for ${handler.name}:`, error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// Finally perform fuzzy search
|
||||
return this.fuzzySearchCommands(afterSlash)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get root level command list
|
||||
*/
|
||||
private async getRootCommands(): Promise<CommandSearchResult[]> {
|
||||
const results: CommandSearchResult[] = []
|
||||
|
||||
// Generate a root level item for each command
|
||||
for (const handler of this.getAllCommands()) {
|
||||
results.push({
|
||||
id: `root-${handler.name}`,
|
||||
title: `/${handler.name}`,
|
||||
description: handler.description,
|
||||
type: 'command' as const,
|
||||
data: {
|
||||
command: `root.${handler.name}`,
|
||||
args: { name: handler.name },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* Fuzzy search commands
|
||||
*/
|
||||
private fuzzySearchCommands(query: string): CommandSearchResult[] {
|
||||
const lowercaseQuery = query.toLowerCase()
|
||||
const matches: CommandSearchResult[] = []
|
||||
|
||||
this.getAllCommands().forEach((handler) => {
|
||||
// Check if command name matches
|
||||
if (handler.name.toLowerCase().includes(lowercaseQuery)) {
|
||||
matches.push({
|
||||
id: `fuzzy-${handler.name}`,
|
||||
title: `/${handler.name}`,
|
||||
description: handler.description,
|
||||
type: 'command' as const,
|
||||
data: {
|
||||
command: `root.${handler.name}`,
|
||||
args: { name: handler.name },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Check if aliases match
|
||||
if (handler.aliases) {
|
||||
handler.aliases.forEach((alias) => {
|
||||
if (alias.toLowerCase().includes(lowercaseQuery)) {
|
||||
matches.push({
|
||||
id: `fuzzy-${alias}`,
|
||||
title: `/${alias}`,
|
||||
description: `${handler.description} (alias for /${handler.name})`,
|
||||
type: 'command' as const,
|
||||
data: {
|
||||
command: `root.${handler.name}`,
|
||||
args: { name: handler.name },
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return matches
|
||||
}
|
||||
|
||||
/**
|
||||
* Get command dependencies
|
||||
*/
|
||||
getCommandDependencies(commandName: string): any {
|
||||
return this.commandDeps.get(commandName)
|
||||
}
|
||||
}
|
||||
|
||||
// Global registry instance
|
||||
export const slashCommandRegistry = new SlashCommandRegistry()
|
52
web/app/components/goto-anything/actions/commands/slash.tsx
Normal file
52
web/app/components/goto-anything/actions/commands/slash.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
'use client'
|
||||
import { useEffect } from 'react'
|
||||
import type { ActionItem } from '../types'
|
||||
import { slashCommandRegistry } from './registry'
|
||||
import { executeCommand } from './command-bus'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { setLocaleOnClient } from '@/i18n-config'
|
||||
import { themeCommand } from './theme'
|
||||
import { languageCommand } from './language'
|
||||
import i18n from '@/i18n-config/i18next-config'
|
||||
|
||||
export const slashAction: ActionItem = {
|
||||
key: '/',
|
||||
shortcut: '/',
|
||||
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)
|
||||
},
|
||||
}
|
||||
|
||||
// Register/unregister default handlers for slash commands with external dependencies.
|
||||
export const registerSlashCommands = (deps: Record<string, any>) => {
|
||||
// Register command handlers to the registry system with their respective dependencies
|
||||
slashCommandRegistry.register(themeCommand, { setTheme: deps.setTheme })
|
||||
slashCommandRegistry.register(languageCommand, { setLocale: deps.setLocale })
|
||||
}
|
||||
|
||||
export const unregisterSlashCommands = () => {
|
||||
// Remove command handlers from registry system (automatically calls each command's unregister method)
|
||||
slashCommandRegistry.unregister('theme')
|
||||
slashCommandRegistry.unregister('language')
|
||||
}
|
||||
|
||||
export const SlashCommandProvider = () => {
|
||||
const theme = useTheme()
|
||||
useEffect(() => {
|
||||
registerSlashCommands({
|
||||
setTheme: theme.setTheme,
|
||||
setLocale: setLocaleOnClient,
|
||||
})
|
||||
return () => unregisterSlashCommands()
|
||||
}, [theme.setTheme])
|
||||
|
||||
return null
|
||||
}
|
@@ -1,7 +1,15 @@
|
||||
import type { CommandSearchResult } from './types'
|
||||
import type { SlashCommandHandler } from './types'
|
||||
import type { CommandSearchResult } from '../types'
|
||||
import type { ReactNode } from 'react'
|
||||
import { RiComputerLine, RiMoonLine, RiPaletteLine, RiSunLine } from '@remixicon/react'
|
||||
import React from 'react'
|
||||
import { RiComputerLine, RiMoonLine, RiSunLine } from '@remixicon/react'
|
||||
import i18n from '@/i18n-config/i18next-config'
|
||||
import { registerCommands, unregisterCommands } from './command-bus'
|
||||
|
||||
// Theme dependency types
|
||||
type ThemeDeps = {
|
||||
setTheme?: (value: 'light' | 'dark' | 'system') => void
|
||||
}
|
||||
|
||||
const THEME_ITEMS: { id: 'light' | 'dark' | 'system'; titleKey: string; descKey: string; icon: ReactNode }[] = [
|
||||
{
|
||||
@@ -24,7 +32,7 @@ const THEME_ITEMS: { id: 'light' | 'dark' | 'system'; titleKey: string; descKey:
|
||||
},
|
||||
]
|
||||
|
||||
export const buildThemeCommands = (query: string, locale?: string): CommandSearchResult[] => {
|
||||
const buildThemeCommands = (query: string, locale?: string): CommandSearchResult[] => {
|
||||
const q = query.toLowerCase()
|
||||
const list = THEME_ITEMS.filter(item =>
|
||||
!q
|
||||
@@ -45,17 +53,28 @@ export const buildThemeCommands = (query: string, locale?: string): CommandSearc
|
||||
}))
|
||||
}
|
||||
|
||||
export const buildThemeRootItem = (): CommandSearchResult => {
|
||||
return {
|
||||
id: 'category-theme',
|
||||
title: i18n.t('app.gotoAnything.actions.themeCategoryTitle'),
|
||||
description: i18n.t('app.gotoAnything.actions.themeCategoryDesc'),
|
||||
type: 'command',
|
||||
icon: (
|
||||
<div className='flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-panel-bg'>
|
||||
<RiPaletteLine className='h-4 w-4 text-text-tertiary' />
|
||||
</div>
|
||||
),
|
||||
data: { command: 'nav.search', args: { query: '@run theme ' } },
|
||||
}
|
||||
/**
|
||||
* Theme command handler
|
||||
* Integrates UI building, search, and registration logic
|
||||
*/
|
||||
export const themeCommand: SlashCommandHandler<ThemeDeps> = {
|
||||
name: 'theme',
|
||||
description: 'Switch between light and dark themes',
|
||||
|
||||
async search(args: string, locale: string = 'en') {
|
||||
// Return theme options directly, regardless of parameters
|
||||
return buildThemeCommands(args, locale)
|
||||
},
|
||||
|
||||
register(deps: ThemeDeps) {
|
||||
registerCommands({
|
||||
'theme.set': async (args) => {
|
||||
deps.setTheme?.(args?.value)
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
unregister() {
|
||||
unregisterCommands(['theme.set'])
|
||||
},
|
||||
}
|
33
web/app/components/goto-anything/actions/commands/types.ts
Normal file
33
web/app/components/goto-anything/actions/commands/types.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { CommandSearchResult } from '../types'
|
||||
|
||||
/**
|
||||
* Slash command handler interface
|
||||
* Each slash command should implement this interface
|
||||
*/
|
||||
export type SlashCommandHandler<TDeps = any> = {
|
||||
/** Command name (e.g., 'theme', 'language') */
|
||||
name: string
|
||||
|
||||
/** Command alias list (e.g., ['lang'] for language) */
|
||||
aliases?: string[]
|
||||
|
||||
/** Command description */
|
||||
description: string
|
||||
|
||||
/**
|
||||
* Search command results
|
||||
* @param args Command arguments (part after removing command name)
|
||||
* @param locale Current language
|
||||
*/
|
||||
search: (args: string, locale?: string) => Promise<CommandSearchResult[]>
|
||||
|
||||
/**
|
||||
* Called when registering command, passing external dependencies
|
||||
*/
|
||||
register?: (deps: TDeps) => void
|
||||
|
||||
/**
|
||||
* Called when unregistering command
|
||||
*/
|
||||
unregister?: () => void
|
||||
}
|
@@ -1,15 +1,180 @@
|
||||
/**
|
||||
* Goto Anything - Action System
|
||||
*
|
||||
* 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, etc.)
|
||||
*/
|
||||
|
||||
import { appAction } from './app'
|
||||
import { knowledgeAction } from './knowledge'
|
||||
import { pluginAction } from './plugin'
|
||||
import { workflowNodesAction } from './workflow-nodes'
|
||||
import type { ActionItem, SearchResult } from './types'
|
||||
import { commandAction } from './run'
|
||||
import { slashAction } from './commands'
|
||||
|
||||
export const Actions = {
|
||||
slash: slashAction,
|
||||
app: appAction,
|
||||
knowledge: knowledgeAction,
|
||||
plugin: pluginAction,
|
||||
run: commandAction,
|
||||
node: workflowNodesAction,
|
||||
}
|
||||
|
||||
@@ -29,11 +194,13 @@ export const searchAnything = async (
|
||||
}
|
||||
}
|
||||
|
||||
if (query.startsWith('@'))
|
||||
if (query.startsWith('@') || query.startsWith('/'))
|
||||
return []
|
||||
|
||||
const globalSearchActions = Object.values(Actions)
|
||||
|
||||
// Use Promise.allSettled to handle partial failures gracefully
|
||||
const searchPromises = Object.values(Actions).map(async (action) => {
|
||||
const searchPromises = globalSearchActions.map(async (action) => {
|
||||
try {
|
||||
const results = await action.search(query, query, locale)
|
||||
return { success: true, data: results, actionType: action.key }
|
||||
@@ -54,7 +221,7 @@ export const searchAnything = async (
|
||||
allResults.push(...result.value.data)
|
||||
}
|
||||
else {
|
||||
const actionKey = Object.values(Actions)[index]?.key || 'unknown'
|
||||
const actionKey = globalSearchActions[index]?.key || 'unknown'
|
||||
failedActions.push(actionKey)
|
||||
}
|
||||
})
|
||||
@@ -67,10 +234,15 @@ export const searchAnything = async (
|
||||
|
||||
export const matchAction = (query: string, actions: Record<string, ActionItem>) => {
|
||||
return Object.values(actions).find((action) => {
|
||||
// Special handling for slash commands to allow direct /theme, /lang
|
||||
if (action.key === '/')
|
||||
return query.startsWith('/')
|
||||
|
||||
const reg = new RegExp(`^(${action.key}|${action.shortcut})(?:\\s|$)`)
|
||||
return reg.test(query)
|
||||
})
|
||||
}
|
||||
|
||||
export * from './types'
|
||||
export * from './commands'
|
||||
export { appAction, knowledgeAction, pluginAction, workflowNodesAction }
|
||||
|
@@ -1,33 +0,0 @@
|
||||
import type { CommandSearchResult } from './types'
|
||||
import { languages } from '@/i18n-config/language'
|
||||
import { RiTranslate } from '@remixicon/react'
|
||||
import i18n from '@/i18n-config/i18next-config'
|
||||
|
||||
export const buildLanguageCommands = (query: string): CommandSearchResult[] => {
|
||||
const q = query.toLowerCase()
|
||||
const list = languages.filter(item => item.supported && (
|
||||
!q || item.name.toLowerCase().includes(q) || String(item.value).toLowerCase().includes(q)
|
||||
))
|
||||
return list.map(item => ({
|
||||
id: `lang-${item.value}`,
|
||||
title: item.name,
|
||||
description: i18n.t('app.gotoAnything.actions.languageChangeDesc'),
|
||||
type: 'command' as const,
|
||||
data: { command: 'i18n.set', args: { locale: item.value } },
|
||||
}))
|
||||
}
|
||||
|
||||
export const buildLanguageRootItem = (): CommandSearchResult => {
|
||||
return {
|
||||
id: 'category-language',
|
||||
title: i18n.t('app.gotoAnything.actions.languageCategoryTitle'),
|
||||
description: i18n.t('app.gotoAnything.actions.languageCategoryDesc'),
|
||||
type: 'command',
|
||||
icon: (
|
||||
<div className='flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-panel-bg'>
|
||||
<RiTranslate className='h-4 w-4 text-text-tertiary' />
|
||||
</div>
|
||||
),
|
||||
data: { command: 'nav.search', args: { query: '@run language ' } },
|
||||
}
|
||||
}
|
@@ -1,97 +0,0 @@
|
||||
'use client'
|
||||
import { useEffect } from 'react'
|
||||
import type { ActionItem, CommandSearchResult } from './types'
|
||||
import { buildLanguageCommands, buildLanguageRootItem } from './run-language'
|
||||
import { buildThemeCommands, buildThemeRootItem } from './run-theme'
|
||||
import i18n from '@/i18n-config/i18next-config'
|
||||
import { executeCommand, registerCommands, unregisterCommands } from './command-bus'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { setLocaleOnClient } from '@/i18n-config'
|
||||
|
||||
const rootParser = (query: string): CommandSearchResult[] => {
|
||||
const q = query.toLowerCase()
|
||||
const items: CommandSearchResult[] = []
|
||||
if (!q || 'theme'.includes(q))
|
||||
items.push(buildThemeRootItem())
|
||||
if (!q || 'language'.includes(q) || 'lang'.includes(q))
|
||||
items.push(buildLanguageRootItem())
|
||||
return items
|
||||
}
|
||||
|
||||
type RunContext = {
|
||||
setTheme?: (value: 'light' | 'dark' | 'system') => void
|
||||
setLocale?: (locale: string) => Promise<void>
|
||||
search?: (query: string) => void
|
||||
}
|
||||
|
||||
export const commandAction: ActionItem = {
|
||||
key: '@run',
|
||||
shortcut: '@run',
|
||||
title: i18n.t('app.gotoAnything.actions.runTitle'),
|
||||
description: i18n.t('app.gotoAnything.actions.runDesc'),
|
||||
action: (result) => {
|
||||
if (result.type !== 'command') return
|
||||
const { command, args } = result.data
|
||||
if (command === 'theme.set') {
|
||||
executeCommand('theme.set', args)
|
||||
return
|
||||
}
|
||||
if (command === 'i18n.set') {
|
||||
executeCommand('i18n.set', args)
|
||||
return
|
||||
}
|
||||
if (command === 'nav.search')
|
||||
executeCommand('nav.search', args)
|
||||
},
|
||||
search: async (_, searchTerm = '') => {
|
||||
const q = searchTerm.trim()
|
||||
if (q.startsWith('theme'))
|
||||
return buildThemeCommands(q.replace(/^theme\s*/, ''), i18n.language)
|
||||
if (q.startsWith('language') || q.startsWith('lang'))
|
||||
return buildLanguageCommands(q.replace(/^(language|lang)\s*/, ''))
|
||||
|
||||
// root categories
|
||||
return rootParser(q)
|
||||
},
|
||||
}
|
||||
|
||||
// Register/unregister default handlers for @run commands with external dependencies.
|
||||
export const registerRunCommands = (deps: {
|
||||
setTheme?: (value: 'light' | 'dark' | 'system') => void
|
||||
setLocale?: (locale: string) => Promise<void>
|
||||
search?: (query: string) => void
|
||||
}) => {
|
||||
registerCommands({
|
||||
'theme.set': async (args) => {
|
||||
deps.setTheme?.(args?.value)
|
||||
},
|
||||
'i18n.set': async (args) => {
|
||||
const locale = args?.locale
|
||||
if (locale)
|
||||
await deps.setLocale?.(locale)
|
||||
},
|
||||
'nav.search': (args) => {
|
||||
const q = args?.query
|
||||
if (q)
|
||||
deps.search?.(q)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const unregisterRunCommands = () => {
|
||||
unregisterCommands(['theme.set', 'i18n.set', 'nav.search'])
|
||||
}
|
||||
|
||||
export const RunCommandProvider = ({ onNavSearch }: { onNavSearch?: (q: string) => void }) => {
|
||||
const theme = useTheme()
|
||||
useEffect(() => {
|
||||
registerRunCommands({
|
||||
setTheme: theme.setTheme,
|
||||
setLocale: setLocaleOnClient,
|
||||
search: onNavSearch,
|
||||
})
|
||||
return () => unregisterRunCommands()
|
||||
}, [theme.setTheme, onNavSearch])
|
||||
|
||||
return null
|
||||
}
|
@@ -44,7 +44,7 @@ export type CommandSearchResult = {
|
||||
export type SearchResult = AppSearchResult | PluginSearchResult | KnowledgeSearchResult | WorkflowNodeSearchResult | CommandSearchResult
|
||||
|
||||
export type ActionItem = {
|
||||
key: '@app' | '@knowledge' | '@plugin' | '@node' | '@run'
|
||||
key: '@app' | '@knowledge' | '@plugin' | '@node' | '/'
|
||||
shortcut: string
|
||||
title: string | TypeWithI18N
|
||||
description: string
|
||||
|
@@ -69,10 +69,10 @@ const CommandSelector: FC<Props> = ({ actions, onCommandSelect, searchFilter, co
|
||||
<span className="ml-3 text-sm text-text-secondary">
|
||||
{(() => {
|
||||
const keyMap: Record<string, string> = {
|
||||
'/': 'app.gotoAnything.actions.slashDesc',
|
||||
'@app': 'app.gotoAnything.actions.searchApplicationsDesc',
|
||||
'@plugin': 'app.gotoAnything.actions.searchPluginsDesc',
|
||||
'@knowledge': 'app.gotoAnything.actions.searchKnowledgeBasesDesc',
|
||||
'@run': 'app.gotoAnything.actions.runDesc',
|
||||
'@node': 'app.gotoAnything.actions.searchWorkflowNodesDesc',
|
||||
}
|
||||
return t(keyMap[action.key])
|
||||
|
@@ -18,7 +18,7 @@ import InstallFromMarketplace from '../plugins/install-plugin/install-from-marke
|
||||
import type { Plugin } from '../plugins/types'
|
||||
import { Command } from 'cmdk'
|
||||
import CommandSelector from './command-selector'
|
||||
import { RunCommandProvider } from './actions/run'
|
||||
import { SlashCommandProvider } from './actions/commands'
|
||||
|
||||
type Props = {
|
||||
onHide?: () => void
|
||||
@@ -34,12 +34,7 @@ const GotoAnything: FC<Props> = ({
|
||||
const [searchQuery, setSearchQuery] = useState<string>('')
|
||||
const [cmdVal, setCmdVal] = useState<string>('_')
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const handleNavSearch = useCallback((q: string) => {
|
||||
setShow(true)
|
||||
setSearchQuery(q)
|
||||
setCmdVal('')
|
||||
requestAnimationFrame(() => inputRef.current?.focus())
|
||||
}, [])
|
||||
|
||||
// Filter actions based on context
|
||||
const Actions = useMemo(() => {
|
||||
// Create a filtered copy of actions based on current page context
|
||||
@@ -48,9 +43,8 @@ const GotoAnything: FC<Props> = ({
|
||||
return AllActions
|
||||
}
|
||||
else {
|
||||
// Exclude node action on non-workflow pages
|
||||
const { app, knowledge, plugin, run } = AllActions
|
||||
return { app, knowledge, plugin, run }
|
||||
const { app, knowledge, plugin, slash } = AllActions
|
||||
return { app, knowledge, plugin, slash }
|
||||
}
|
||||
}, [isWorkflowPage])
|
||||
|
||||
@@ -88,14 +82,18 @@ const GotoAnything: FC<Props> = ({
|
||||
wait: 300,
|
||||
})
|
||||
|
||||
const isCommandsMode = searchQuery.trim() === '@' || (searchQuery.trim().startsWith('@') && !matchAction(searchQuery.trim(), Actions))
|
||||
const isCommandsMode = searchQuery.trim() === '@' || searchQuery.trim() === '/'
|
||||
|| (searchQuery.trim().startsWith('@') && !matchAction(searchQuery.trim(), Actions))
|
||||
|| (searchQuery.trim().startsWith('/') && !matchAction(searchQuery.trim(), Actions))
|
||||
|
||||
const searchMode = useMemo(() => {
|
||||
if (isCommandsMode) return 'commands'
|
||||
|
||||
const query = searchQueryDebouncedValue.toLowerCase()
|
||||
const action = matchAction(query, Actions)
|
||||
return action ? action.key : 'general'
|
||||
return action
|
||||
? (action.key === '/' ? '@command' : action.key)
|
||||
: 'general'
|
||||
}, [searchQueryDebouncedValue, Actions, isCommandsMode])
|
||||
|
||||
const { data: searchResults = [], isLoading, isError, error } = useQuery(
|
||||
@@ -140,7 +138,8 @@ const GotoAnything: FC<Props> = ({
|
||||
|
||||
switch (result.type) {
|
||||
case 'command': {
|
||||
const action = Object.values(Actions).find(a => a.key === '@run')
|
||||
// Execute slash commands
|
||||
const action = Actions.slash
|
||||
action?.action?.(result)
|
||||
break
|
||||
}
|
||||
@@ -208,7 +207,7 @@ const GotoAnything: FC<Props> = ({
|
||||
</div>
|
||||
<div className='mt-1 text-xs text-text-quaternary'>
|
||||
{isCommandSearch
|
||||
? t('app.gotoAnything.emptyState.tryDifferentTerm', { mode: searchMode })
|
||||
? t('app.gotoAnything.emptyState.tryDifferentTerm')
|
||||
: t('app.gotoAnything.emptyState.trySpecificSearch', { shortcuts: Object.values(Actions).map(action => action.shortcut).join(', ') })
|
||||
}
|
||||
</div>
|
||||
@@ -242,6 +241,7 @@ const GotoAnything: FC<Props> = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<SlashCommandProvider />
|
||||
<Modal
|
||||
isShow={show}
|
||||
onClose={() => {
|
||||
@@ -270,7 +270,7 @@ const GotoAnything: FC<Props> = ({
|
||||
placeholder={t('app.gotoAnything.searchPlaceholder')}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value)
|
||||
if (!e.target.value.startsWith('@'))
|
||||
if (!e.target.value.startsWith('@') && !e.target.value.startsWith('/'))
|
||||
clearSelection()
|
||||
}}
|
||||
className='flex-1 !border-0 !bg-transparent !shadow-none'
|
||||
@@ -330,6 +330,7 @@ const GotoAnything: FC<Props> = ({
|
||||
'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`)
|
||||
})()} className='p-2 capitalize text-text-secondary'>
|
||||
@@ -395,7 +396,6 @@ const GotoAnything: FC<Props> = ({
|
||||
</div>
|
||||
|
||||
</Modal>
|
||||
<RunCommandProvider onNavSearch={handleNavSearch} />
|
||||
{
|
||||
activePlugin && (
|
||||
<InstallFromMarketplace
|
||||
|
Reference in New Issue
Block a user