From b0e58f9da738f7cf767afa096aa84d7553f4a1e7 Mon Sep 17 00:00:00 2001 From: GuanMu Date: Mon, 18 Aug 2025 16:07:54 +0800 Subject: [PATCH] Feature/improve goto anything commands (#24091) --- .../actions/{ => commands}/command-bus.ts | 4 +- .../goto-anything/actions/commands/index.ts | 15 ++ .../actions/commands/language.tsx | 53 ++++ .../actions/commands/registry.ts | 233 ++++++++++++++++++ .../goto-anything/actions/commands/slash.tsx | 52 ++++ .../{run-theme.tsx => commands/theme.tsx} | 51 ++-- .../goto-anything/actions/commands/types.ts | 33 +++ .../components/goto-anything/actions/index.ts | 182 +++++++++++++- .../goto-anything/actions/run-language.tsx | 33 --- .../components/goto-anything/actions/run.tsx | 97 -------- .../components/goto-anything/actions/types.ts | 2 +- .../goto-anything/command-selector.tsx | 2 +- web/app/components/goto-anything/index.tsx | 32 +-- web/i18n/en-US/app.ts | 6 +- web/i18n/zh-Hans/app.ts | 6 +- 15 files changed, 626 insertions(+), 175 deletions(-) rename web/app/components/goto-anything/actions/{ => commands}/command-bus.ts (82%) create mode 100644 web/app/components/goto-anything/actions/commands/index.ts create mode 100644 web/app/components/goto-anything/actions/commands/language.tsx create mode 100644 web/app/components/goto-anything/actions/commands/registry.ts create mode 100644 web/app/components/goto-anything/actions/commands/slash.tsx rename web/app/components/goto-anything/actions/{run-theme.tsx => commands/theme.tsx} (57%) create mode 100644 web/app/components/goto-anything/actions/commands/types.ts delete mode 100644 web/app/components/goto-anything/actions/run-language.tsx delete mode 100644 web/app/components/goto-anything/actions/run.tsx diff --git a/web/app/components/goto-anything/actions/command-bus.ts b/web/app/components/goto-anything/actions/commands/command-bus.ts similarity index 82% rename from web/app/components/goto-anything/actions/command-bus.ts rename to web/app/components/goto-anything/actions/commands/command-bus.ts index 6ff7e81a2..001a5c9e3 100644 --- a/web/app/components/goto-anything/actions/command-bus.ts +++ b/web/app/components/goto-anything/actions/commands/command-bus.ts @@ -2,11 +2,11 @@ export type CommandHandler = (args?: Record) => void | Promise() -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) } diff --git a/web/app/components/goto-anything/actions/commands/index.ts b/web/app/components/goto-anything/actions/commands/index.ts new file mode 100644 index 000000000..c708fc391 --- /dev/null +++ b/web/app/components/goto-anything/actions/commands/index.ts @@ -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' diff --git a/web/app/components/goto-anything/actions/commands/language.tsx b/web/app/components/goto-anything/actions/commands/language.tsx new file mode 100644 index 000000000..7cdd82c30 --- /dev/null +++ b/web/app/components/goto-anything/actions/commands/language.tsx @@ -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 +} + +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 = { + 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']) + }, +} diff --git a/web/app/components/goto-anything/actions/commands/registry.ts b/web/app/components/goto-anything/actions/commands/registry.ts new file mode 100644 index 000000000..192bf6c73 --- /dev/null +++ b/web/app/components/goto-anything/actions/commands/registry.ts @@ -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() + private commandDeps = new Map() + + /** + * Register command handler + */ + register(handler: SlashCommandHandler, 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() + 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 { + 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 { + 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() diff --git a/web/app/components/goto-anything/actions/commands/slash.tsx b/web/app/components/goto-anything/actions/commands/slash.tsx new file mode 100644 index 000000000..46467aed1 --- /dev/null +++ b/web/app/components/goto-anything/actions/commands/slash.tsx @@ -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) => { + // 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 +} diff --git a/web/app/components/goto-anything/actions/run-theme.tsx b/web/app/components/goto-anything/actions/commands/theme.tsx similarity index 57% rename from web/app/components/goto-anything/actions/run-theme.tsx rename to web/app/components/goto-anything/actions/commands/theme.tsx index 9f72844ee..3513fdd1d 100644 --- a/web/app/components/goto-anything/actions/run-theme.tsx +++ b/web/app/components/goto-anything/actions/commands/theme.tsx @@ -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: ( -
- -
- ), - data: { command: 'nav.search', args: { query: '@run theme ' } }, - } +/** + * Theme command handler + * Integrates UI building, search, and registration logic + */ +export const themeCommand: SlashCommandHandler = { + 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']) + }, } diff --git a/web/app/components/goto-anything/actions/commands/types.ts b/web/app/components/goto-anything/actions/commands/types.ts new file mode 100644 index 000000000..30ee13e6c --- /dev/null +++ b/web/app/components/goto-anything/actions/commands/types.ts @@ -0,0 +1,33 @@ +import type { CommandSearchResult } from '../types' + +/** + * Slash command handler interface + * Each slash command should implement this interface + */ +export type SlashCommandHandler = { + /** 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 + + /** + * Called when registering command, passing external dependencies + */ + register?: (deps: TDeps) => void + + /** + * Called when unregistering command + */ + unregister?: () => void +} diff --git a/web/app/components/goto-anything/actions/index.ts b/web/app/components/goto-anything/actions/index.ts index 0f25194db..e0e2b3e08 100644 --- a/web/app/components/goto-anything/actions/index.ts +++ b/web/app/components/goto-anything/actions/index.ts @@ -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 + * } + * + * export const myCommand: SlashCommandHandler = { + * 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) => { + * 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) => { 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 } diff --git a/web/app/components/goto-anything/actions/run-language.tsx b/web/app/components/goto-anything/actions/run-language.tsx deleted file mode 100644 index 0076fec0a..000000000 --- a/web/app/components/goto-anything/actions/run-language.tsx +++ /dev/null @@ -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: ( -
- -
- ), - data: { command: 'nav.search', args: { query: '@run language ' } }, - } -} diff --git a/web/app/components/goto-anything/actions/run.tsx b/web/app/components/goto-anything/actions/run.tsx deleted file mode 100644 index 624cf942d..000000000 --- a/web/app/components/goto-anything/actions/run.tsx +++ /dev/null @@ -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 - 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 - 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 -} diff --git a/web/app/components/goto-anything/actions/types.ts b/web/app/components/goto-anything/actions/types.ts index a95e28eec..883d7cdf7 100644 --- a/web/app/components/goto-anything/actions/types.ts +++ b/web/app/components/goto-anything/actions/types.ts @@ -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 diff --git a/web/app/components/goto-anything/command-selector.tsx b/web/app/components/goto-anything/command-selector.tsx index 6ec179c3e..7bf47723e 100644 --- a/web/app/components/goto-anything/command-selector.tsx +++ b/web/app/components/goto-anything/command-selector.tsx @@ -69,10 +69,10 @@ const CommandSelector: FC = ({ actions, onCommandSelect, searchFilter, co {(() => { const keyMap: Record = { + '/': '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]) diff --git a/web/app/components/goto-anything/index.tsx b/web/app/components/goto-anything/index.tsx index 22e666154..cd4795735 100644 --- a/web/app/components/goto-anything/index.tsx +++ b/web/app/components/goto-anything/index.tsx @@ -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 = ({ const [searchQuery, setSearchQuery] = useState('') const [cmdVal, setCmdVal] = useState('_') const inputRef = useRef(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 = ({ 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 = ({ 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 = ({ 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 = ({
{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(', ') }) }
@@ -242,6 +241,7 @@ const GotoAnything: FC = ({ return ( <> + { @@ -270,7 +270,7 @@ const GotoAnything: FC = ({ 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 = ({ '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 = ({ - { activePlugin && (