Feature/improve goto anything commands (#24091)

This commit is contained in:
GuanMu
2025-08-18 16:07:54 +08:00
committed by GitHub
parent 531e784a92
commit b0e58f9da7
15 changed files with 626 additions and 175 deletions

View File

@@ -0,0 +1,26 @@
export type CommandHandler = (args?: Record<string, any>) => void | Promise<void>
const handlers = new Map<string, CommandHandler>()
const registerCommand = (name: string, handler: CommandHandler) => {
handlers.set(name, handler)
}
const unregisterCommand = (name: string) => {
handlers.delete(name)
}
export const executeCommand = async (name: string, args?: Record<string, any>) => {
const handler = handlers.get(name)
if (!handler)
return
await handler(args)
}
export const registerCommands = (map: Record<string, CommandHandler>) => {
Object.entries(map).forEach(([name, handler]) => registerCommand(name, handler))
}
export const unregisterCommands = (names: string[]) => {
names.forEach(unregisterCommand)
}

View 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'

View File

@@ -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'])
},
}

View 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()

View 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
}

View File

@@ -0,0 +1,80 @@
import type { SlashCommandHandler } from './types'
import type { CommandSearchResult } from '../types'
import type { ReactNode } from '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 }[] = [
{
id: 'system',
titleKey: 'app.gotoAnything.actions.themeSystem',
descKey: 'app.gotoAnything.actions.themeSystemDesc',
icon: <RiComputerLine className='h-4 w-4 text-text-tertiary' />,
},
{
id: 'light',
titleKey: 'app.gotoAnything.actions.themeLight',
descKey: 'app.gotoAnything.actions.themeLightDesc',
icon: <RiSunLine className='h-4 w-4 text-text-tertiary' />,
},
{
id: 'dark',
titleKey: 'app.gotoAnything.actions.themeDark',
descKey: 'app.gotoAnything.actions.themeDarkDesc',
icon: <RiMoonLine className='h-4 w-4 text-text-tertiary' />,
},
]
const buildThemeCommands = (query: string, locale?: string): CommandSearchResult[] => {
const q = query.toLowerCase()
const list = THEME_ITEMS.filter(item =>
!q
|| i18n.t(item.titleKey, { lng: locale }).toLowerCase().includes(q)
|| item.id.includes(q),
)
return list.map(item => ({
id: item.id,
title: i18n.t(item.titleKey, { lng: locale }),
description: i18n.t(item.descKey, { lng: locale }),
type: 'command' as const,
icon: (
<div className='flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-panel-bg'>
{item.icon}
</div>
),
data: { command: 'theme.set', args: { value: item.id } },
}))
}
/**
* 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'])
},
}

View 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
}