From 36ab9974d2900498aa012a4206599838e09bb1cf Mon Sep 17 00:00:00 2001 From: lyzno1 <92089059+lyzno1@users.noreply.github.com> Date: Sat, 13 Sep 2025 21:03:42 +0800 Subject: [PATCH] fix: Multiple UX improvements for GotoAnything command palette (#25637) --- .../goto-anything/match-action.test.ts | 235 ++++++++++++++++++ .../goto-anything/scope-command-tags.test.tsx | 134 ++++++++++ .../slash-command-modes.test.tsx | 212 ++++++++++++++++ .../actions/commands/account.tsx | 6 + .../actions/commands/community.tsx | 8 + .../actions/commands/{doc.tsx => docs.tsx} | 20 +- .../actions/commands/feedback.tsx | 8 + .../actions/commands/language.tsx | 1 + .../goto-anything/actions/commands/slash.tsx | 6 +- .../goto-anything/actions/commands/theme.tsx | 1 + .../goto-anything/actions/commands/types.ts | 15 +- .../components/goto-anything/actions/index.ts | 21 +- .../goto-anything/command-selector.tsx | 8 +- web/app/components/goto-anything/index.tsx | 128 +++++++--- web/i18n/en-US/app.ts | 4 + web/i18n/ja-JP/app.ts | 6 +- web/i18n/zh-Hans/app.ts | 4 + 17 files changed, 772 insertions(+), 45 deletions(-) create mode 100644 web/__tests__/goto-anything/match-action.test.ts create mode 100644 web/__tests__/goto-anything/scope-command-tags.test.tsx create mode 100644 web/__tests__/goto-anything/slash-command-modes.test.tsx rename web/app/components/goto-anything/actions/commands/{doc.tsx => docs.tsx} (68%) diff --git a/web/__tests__/goto-anything/match-action.test.ts b/web/__tests__/goto-anything/match-action.test.ts new file mode 100644 index 0000000000..3df9c0d533 --- /dev/null +++ b/web/__tests__/goto-anything/match-action.test.ts @@ -0,0 +1,235 @@ +import type { ActionItem } from '../../app/components/goto-anything/actions/types' + +// Mock the entire actions module to avoid import issues +jest.mock('../../app/components/goto-anything/actions', () => ({ + matchAction: jest.fn(), +})) + +jest.mock('../../app/components/goto-anything/actions/commands/registry') + +// Import after mocking to get mocked version +import { matchAction } from '../../app/components/goto-anything/actions' +import { slashCommandRegistry } from '../../app/components/goto-anything/actions/commands/registry' + +// Implement the actual matchAction logic for testing +const actualMatchAction = (query: string, actions: Record) => { + const result = Object.values(actions).find((action) => { + // Special handling for slash commands + if (action.key === '/') { + // Get all registered commands from the registry + const allCommands = slashCommandRegistry.getAllCommands() + + // Check if query matches any registered command + 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|$)`) + return reg.test(query) + }) + return result +} + +// Replace mock with actual implementation +;(matchAction as jest.Mock).mockImplementation(actualMatchAction) + +describe('matchAction Logic', () => { + const mockActions: Record = { + app: { + key: '@app', + shortcut: '@a', + title: 'Search Applications', + description: 'Search apps', + search: jest.fn(), + }, + knowledge: { + key: '@knowledge', + shortcut: '@kb', + title: 'Search Knowledge', + description: 'Search knowledge bases', + search: jest.fn(), + }, + slash: { + key: '/', + shortcut: '/', + title: 'Commands', + description: 'Execute commands', + search: jest.fn(), + }, + } + + beforeEach(() => { + jest.clearAllMocks() + ;(slashCommandRegistry.getAllCommands as jest.Mock).mockReturnValue([ + { name: 'docs', mode: 'direct' }, + { name: 'community', mode: 'direct' }, + { name: 'feedback', mode: 'direct' }, + { name: 'account', mode: 'direct' }, + { name: 'theme', mode: 'submenu' }, + { name: 'language', mode: 'submenu' }, + ]) + }) + + describe('@ Actions Matching', () => { + it('should match @app with key', () => { + const result = matchAction('@app', mockActions) + expect(result).toBe(mockActions.app) + }) + + it('should match @app with shortcut', () => { + const result = matchAction('@a', mockActions) + expect(result).toBe(mockActions.app) + }) + + it('should match @knowledge with key', () => { + const result = matchAction('@knowledge', mockActions) + expect(result).toBe(mockActions.knowledge) + }) + + it('should match @knowledge with shortcut @kb', () => { + const result = matchAction('@kb', mockActions) + expect(result).toBe(mockActions.knowledge) + }) + + it('should match with text after action', () => { + const result = matchAction('@app search term', mockActions) + expect(result).toBe(mockActions.app) + }) + + it('should not match partial @ actions', () => { + const result = matchAction('@ap', mockActions) + expect(result).toBeUndefined() + }) + }) + + describe('Slash Commands Matching', () => { + describe('Direct Mode Commands', () => { + it('should not match direct mode commands', () => { + const result = matchAction('/docs', mockActions) + expect(result).toBeUndefined() + }) + + it('should not match direct mode with arguments', () => { + const result = matchAction('/docs something', mockActions) + expect(result).toBeUndefined() + }) + + it('should not match any direct mode command', () => { + expect(matchAction('/community', mockActions)).toBeUndefined() + expect(matchAction('/feedback', mockActions)).toBeUndefined() + expect(matchAction('/account', mockActions)).toBeUndefined() + }) + }) + + describe('Submenu Mode Commands', () => { + it('should match submenu mode commands exactly', () => { + const result = matchAction('/theme', mockActions) + expect(result).toBe(mockActions.slash) + }) + + it('should match submenu mode with arguments', () => { + const result = matchAction('/theme dark', mockActions) + expect(result).toBe(mockActions.slash) + }) + + it('should match all submenu commands', () => { + expect(matchAction('/language', mockActions)).toBe(mockActions.slash) + expect(matchAction('/language en', mockActions)).toBe(mockActions.slash) + }) + }) + + describe('Slash Without Command', () => { + it('should not match single slash', () => { + const result = matchAction('/', mockActions) + expect(result).toBeUndefined() + }) + + it('should not match unregistered commands', () => { + const result = matchAction('/unknown', mockActions) + expect(result).toBeUndefined() + }) + }) + }) + + describe('Edge Cases', () => { + it('should handle empty query', () => { + const result = matchAction('', mockActions) + expect(result).toBeUndefined() + }) + + it('should handle whitespace only', () => { + const result = matchAction(' ', mockActions) + expect(result).toBeUndefined() + }) + + it('should handle regular text without actions', () => { + const result = matchAction('search something', mockActions) + expect(result).toBeUndefined() + }) + + it('should handle special characters', () => { + const result = matchAction('#tag', mockActions) + expect(result).toBeUndefined() + }) + + it('should handle multiple @ or /', () => { + expect(matchAction('@@app', mockActions)).toBeUndefined() + expect(matchAction('//theme', mockActions)).toBeUndefined() + }) + }) + + describe('Mode-based Filtering', () => { + it('should filter direct mode commands from matching', () => { + ;(slashCommandRegistry.getAllCommands as jest.Mock).mockReturnValue([ + { name: 'test', mode: 'direct' }, + ]) + + const result = matchAction('/test', mockActions) + expect(result).toBeUndefined() + }) + + it('should allow submenu mode commands to match', () => { + ;(slashCommandRegistry.getAllCommands as jest.Mock).mockReturnValue([ + { name: 'test', mode: 'submenu' }, + ]) + + const result = matchAction('/test', mockActions) + expect(result).toBe(mockActions.slash) + }) + + it('should treat undefined mode as submenu', () => { + ;(slashCommandRegistry.getAllCommands as jest.Mock).mockReturnValue([ + { name: 'test' }, // No mode specified + ]) + + const result = matchAction('/test', mockActions) + expect(result).toBe(mockActions.slash) + }) + }) + + describe('Registry Integration', () => { + it('should call getAllCommands when matching slash', () => { + matchAction('/theme', mockActions) + expect(slashCommandRegistry.getAllCommands).toHaveBeenCalled() + }) + + it('should not call getAllCommands for @ actions', () => { + matchAction('@app', mockActions) + expect(slashCommandRegistry.getAllCommands).not.toHaveBeenCalled() + }) + + it('should handle empty command list', () => { + ;(slashCommandRegistry.getAllCommands as jest.Mock).mockReturnValue([]) + const result = matchAction('/anything', mockActions) + expect(result).toBeUndefined() + }) + }) +}) diff --git a/web/__tests__/goto-anything/scope-command-tags.test.tsx b/web/__tests__/goto-anything/scope-command-tags.test.tsx new file mode 100644 index 0000000000..339e259a06 --- /dev/null +++ b/web/__tests__/goto-anything/scope-command-tags.test.tsx @@ -0,0 +1,134 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import '@testing-library/jest-dom' + +// Type alias for search mode +type SearchMode = 'scopes' | 'commands' | null + +// Mock component to test tag display logic +const TagDisplay: React.FC<{ searchMode: SearchMode }> = ({ searchMode }) => { + if (!searchMode) return null + + return ( +
+ {searchMode === 'scopes' ? 'SCOPES' : 'COMMANDS'} +
+ ) +} + +describe('Scope and Command Tags', () => { + describe('Tag Display Logic', () => { + it('should display SCOPES for @ actions', () => { + render() + expect(screen.getByText('SCOPES')).toBeInTheDocument() + expect(screen.queryByText('COMMANDS')).not.toBeInTheDocument() + }) + + it('should display COMMANDS for / actions', () => { + render() + expect(screen.getByText('COMMANDS')).toBeInTheDocument() + expect(screen.queryByText('SCOPES')).not.toBeInTheDocument() + }) + + it('should not display any tag when searchMode is null', () => { + const { container } = render() + expect(container.firstChild).toBeNull() + }) + }) + + describe('Search Mode Detection', () => { + const getSearchMode = (query: string): SearchMode => { + if (query.startsWith('@')) return 'scopes' + if (query.startsWith('/')) return 'commands' + return null + } + + it('should detect scopes mode for @ queries', () => { + expect(getSearchMode('@app')).toBe('scopes') + expect(getSearchMode('@knowledge')).toBe('scopes') + expect(getSearchMode('@plugin')).toBe('scopes') + expect(getSearchMode('@node')).toBe('scopes') + }) + + it('should detect commands mode for / queries', () => { + expect(getSearchMode('/theme')).toBe('commands') + expect(getSearchMode('/language')).toBe('commands') + expect(getSearchMode('/docs')).toBe('commands') + }) + + it('should return null for regular queries', () => { + expect(getSearchMode('')).toBe(null) + expect(getSearchMode('search term')).toBe(null) + expect(getSearchMode('app')).toBe(null) + }) + + it('should handle queries with spaces', () => { + expect(getSearchMode('@app search')).toBe('scopes') + expect(getSearchMode('/theme dark')).toBe('commands') + }) + }) + + describe('Tag Styling', () => { + it('should apply correct styling classes', () => { + const { container } = render() + const tagContainer = container.querySelector('.flex.items-center.gap-1.text-xs.text-text-tertiary') + expect(tagContainer).toBeInTheDocument() + }) + + it('should use hardcoded English text', () => { + // Verify that tags are hardcoded and not using i18n + render() + const scopesText = screen.getByText('SCOPES') + expect(scopesText.textContent).toBe('SCOPES') + + render() + const commandsText = screen.getByText('COMMANDS') + expect(commandsText.textContent).toBe('COMMANDS') + }) + }) + + describe('Integration with Search States', () => { + const SearchComponent: React.FC<{ query: string }> = ({ query }) => { + let searchMode: SearchMode = null + + if (query.startsWith('@')) searchMode = 'scopes' + else if (query.startsWith('/')) searchMode = 'commands' + + return ( +
+ + +
+ ) + } + + it('should update tag when switching between @ and /', () => { + const { rerender } = render() + expect(screen.getByText('SCOPES')).toBeInTheDocument() + + rerender() + expect(screen.queryByText('SCOPES')).not.toBeInTheDocument() + expect(screen.getByText('COMMANDS')).toBeInTheDocument() + }) + + it('should hide tag when clearing search', () => { + const { rerender } = render() + expect(screen.getByText('SCOPES')).toBeInTheDocument() + + rerender() + expect(screen.queryByText('SCOPES')).not.toBeInTheDocument() + expect(screen.queryByText('COMMANDS')).not.toBeInTheDocument() + }) + + it('should maintain correct tag during search refinement', () => { + const { rerender } = render() + expect(screen.getByText('SCOPES')).toBeInTheDocument() + + rerender() + expect(screen.getByText('SCOPES')).toBeInTheDocument() + + rerender() + expect(screen.getByText('SCOPES')).toBeInTheDocument() + }) + }) +}) diff --git a/web/__tests__/goto-anything/slash-command-modes.test.tsx b/web/__tests__/goto-anything/slash-command-modes.test.tsx new file mode 100644 index 0000000000..f8126958fc --- /dev/null +++ b/web/__tests__/goto-anything/slash-command-modes.test.tsx @@ -0,0 +1,212 @@ +import '@testing-library/jest-dom' +import { slashCommandRegistry } from '../../app/components/goto-anything/actions/commands/registry' +import type { SlashCommandHandler } from '../../app/components/goto-anything/actions/commands/types' + +// Mock the registry +jest.mock('../../app/components/goto-anything/actions/commands/registry') + +describe('Slash Command Dual-Mode System', () => { + const mockDirectCommand: SlashCommandHandler = { + name: 'docs', + description: 'Open documentation', + mode: 'direct', + execute: jest.fn(), + search: jest.fn().mockResolvedValue([ + { + id: 'docs', + title: 'Documentation', + description: 'Open documentation', + type: 'command' as const, + data: { command: 'navigation.docs', args: {} }, + }, + ]), + register: jest.fn(), + unregister: jest.fn(), + } + + const mockSubmenuCommand: SlashCommandHandler = { + name: 'theme', + description: 'Change theme', + mode: 'submenu', + search: jest.fn().mockResolvedValue([ + { + id: 'theme-light', + title: 'Light Theme', + description: 'Switch to light theme', + type: 'command' as const, + data: { command: 'theme.set', args: { theme: 'light' } }, + }, + { + id: 'theme-dark', + title: 'Dark Theme', + description: 'Switch to dark theme', + type: 'command' as const, + data: { command: 'theme.set', args: { theme: 'dark' } }, + }, + ]), + register: jest.fn(), + unregister: jest.fn(), + } + + beforeEach(() => { + jest.clearAllMocks() + ;(slashCommandRegistry as any).findCommand = jest.fn((name: string) => { + if (name === 'docs') return mockDirectCommand + if (name === 'theme') return mockSubmenuCommand + return null + }) + ;(slashCommandRegistry as any).getAllCommands = jest.fn(() => [ + mockDirectCommand, + mockSubmenuCommand, + ]) + }) + + describe('Direct Mode Commands', () => { + it('should execute immediately when selected', () => { + const mockSetShow = jest.fn() + const mockSetSearchQuery = jest.fn() + + // Simulate command selection + const handler = slashCommandRegistry.findCommand('docs') + expect(handler?.mode).toBe('direct') + + if (handler?.mode === 'direct' && handler.execute) { + handler.execute() + mockSetShow(false) + mockSetSearchQuery('') + } + + expect(mockDirectCommand.execute).toHaveBeenCalled() + expect(mockSetShow).toHaveBeenCalledWith(false) + expect(mockSetSearchQuery).toHaveBeenCalledWith('') + }) + + it('should not enter submenu for direct mode commands', () => { + const handler = slashCommandRegistry.findCommand('docs') + expect(handler?.mode).toBe('direct') + expect(handler?.execute).toBeDefined() + }) + + it('should close modal after execution', () => { + const mockModalClose = jest.fn() + + const handler = slashCommandRegistry.findCommand('docs') + if (handler?.mode === 'direct' && handler.execute) { + handler.execute() + mockModalClose() + } + + expect(mockModalClose).toHaveBeenCalled() + }) + }) + + describe('Submenu Mode Commands', () => { + it('should show options instead of executing immediately', async () => { + const handler = slashCommandRegistry.findCommand('theme') + expect(handler?.mode).toBe('submenu') + + const results = await handler?.search('', 'en') + expect(results).toHaveLength(2) + expect(results?.[0].title).toBe('Light Theme') + expect(results?.[1].title).toBe('Dark Theme') + }) + + it('should not have execute function for submenu mode', () => { + const handler = slashCommandRegistry.findCommand('theme') + expect(handler?.mode).toBe('submenu') + expect(handler?.execute).toBeUndefined() + }) + + it('should keep modal open for selection', () => { + const mockModalClose = jest.fn() + + const handler = slashCommandRegistry.findCommand('theme') + // For submenu mode, modal should not close immediately + expect(handler?.mode).toBe('submenu') + expect(mockModalClose).not.toHaveBeenCalled() + }) + }) + + describe('Mode Detection and Routing', () => { + it('should correctly identify direct mode commands', () => { + const commands = slashCommandRegistry.getAllCommands() + const directCommands = commands.filter(cmd => cmd.mode === 'direct') + const submenuCommands = commands.filter(cmd => cmd.mode === 'submenu') + + expect(directCommands).toContainEqual(expect.objectContaining({ name: 'docs' })) + expect(submenuCommands).toContainEqual(expect.objectContaining({ name: 'theme' })) + }) + + it('should handle missing mode property gracefully', () => { + const commandWithoutMode: SlashCommandHandler = { + name: 'test', + description: 'Test command', + search: jest.fn(), + register: jest.fn(), + unregister: jest.fn(), + } + + ;(slashCommandRegistry as any).findCommand = jest.fn(() => commandWithoutMode) + + const handler = slashCommandRegistry.findCommand('test') + // Default behavior should be submenu when mode is not specified + expect(handler?.mode).toBeUndefined() + expect(handler?.execute).toBeUndefined() + }) + }) + + describe('Enter Key Handling', () => { + // Helper function to simulate key handler behavior + const createKeyHandler = () => { + return (commandKey: string) => { + if (commandKey.startsWith('/')) { + const commandName = commandKey.substring(1) + const handler = slashCommandRegistry.findCommand(commandName) + if (handler?.mode === 'direct' && handler.execute) { + handler.execute() + return true // Indicates handled + } + } + return false + } + } + + it('should trigger direct execution on Enter for direct mode', () => { + const keyHandler = createKeyHandler() + const handled = keyHandler('/docs') + expect(handled).toBe(true) + expect(mockDirectCommand.execute).toHaveBeenCalled() + }) + + it('should not trigger direct execution for submenu mode', () => { + const keyHandler = createKeyHandler() + const handled = keyHandler('/theme') + expect(handled).toBe(false) + expect(mockSubmenuCommand.search).not.toHaveBeenCalled() + }) + }) + + describe('Command Registration', () => { + it('should register both direct and submenu commands', () => { + mockDirectCommand.register?.({}) + mockSubmenuCommand.register?.({ setTheme: jest.fn() }) + + expect(mockDirectCommand.register).toHaveBeenCalled() + expect(mockSubmenuCommand.register).toHaveBeenCalled() + }) + + it('should handle unregistration for both command types', () => { + // Test unregister for direct command + mockDirectCommand.unregister?.() + expect(mockDirectCommand.unregister).toHaveBeenCalled() + + // Test unregister for submenu command + mockSubmenuCommand.unregister?.() + expect(mockSubmenuCommand.unregister).toHaveBeenCalled() + + // Verify both were called independently + expect(mockDirectCommand.unregister).toHaveBeenCalledTimes(1) + expect(mockSubmenuCommand.unregister).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/web/app/components/goto-anything/actions/commands/account.tsx b/web/app/components/goto-anything/actions/commands/account.tsx index 2a3834b0d0..65fadb54c8 100644 --- a/web/app/components/goto-anything/actions/commands/account.tsx +++ b/web/app/components/goto-anything/actions/commands/account.tsx @@ -13,6 +13,12 @@ type AccountDeps = Record export const accountCommand: SlashCommandHandler = { name: 'account', description: 'Navigate to account page', + mode: 'direct', + + // Direct execution function + execute: () => { + window.location.href = '/account' + }, async search(args: string, locale: string = 'en') { return [{ diff --git a/web/app/components/goto-anything/actions/commands/community.tsx b/web/app/components/goto-anything/actions/commands/community.tsx index 559608fb48..af59fd1127 100644 --- a/web/app/components/goto-anything/actions/commands/community.tsx +++ b/web/app/components/goto-anything/actions/commands/community.tsx @@ -13,6 +13,14 @@ type CommunityDeps = Record export const communityCommand: SlashCommandHandler = { name: 'community', description: 'Open community Discord', + mode: 'direct', + + // Direct execution function + execute: () => { + const url = 'https://discord.gg/5AEfbxcd9k' + window.open(url, '_blank', 'noopener,noreferrer') + }, + async search(args: string, locale: string = 'en') { return [{ id: 'community', diff --git a/web/app/components/goto-anything/actions/commands/doc.tsx b/web/app/components/goto-anything/actions/commands/docs.tsx similarity index 68% rename from web/app/components/goto-anything/actions/commands/doc.tsx rename to web/app/components/goto-anything/actions/commands/docs.tsx index ae38389af9..2939481348 100644 --- a/web/app/components/goto-anything/actions/commands/doc.tsx +++ b/web/app/components/goto-anything/actions/commands/docs.tsx @@ -4,6 +4,7 @@ import { RiBookOpenLine } from '@remixicon/react' import i18n from '@/i18n-config/i18next-config' import { registerCommands, unregisterCommands } from './command-bus' import { defaultDocBaseUrl } from '@/context/i18n' +import { getDocLanguage } from '@/i18n-config/language' // Documentation command dependency types - no external dependencies needed type DocDeps = Record @@ -11,9 +12,19 @@ type DocDeps = Record /** * Documentation command - Opens help documentation */ -export const docCommand: SlashCommandHandler = { - name: 'doc', +export const docsCommand: SlashCommandHandler = { + name: 'docs', description: 'Open documentation', + mode: 'direct', + + // Direct execution function + execute: () => { + const currentLocale = i18n.language + const docLanguage = getDocLanguage(currentLocale) + const url = `${defaultDocBaseUrl}/${docLanguage}` + window.open(url, '_blank', 'noopener,noreferrer') + }, + async search(args: string, locale: string = 'en') { return [{ id: 'doc', @@ -32,7 +43,10 @@ export const docCommand: SlashCommandHandler = { register(_deps: DocDeps) { registerCommands({ 'navigation.doc': async (_args) => { - const url = `${defaultDocBaseUrl}` + // Get the current language from i18n + const currentLocale = i18n.language + const docLanguage = getDocLanguage(currentLocale) + const url = `${defaultDocBaseUrl}/${docLanguage}` window.open(url, '_blank', 'noopener,noreferrer') }, }) diff --git a/web/app/components/goto-anything/actions/commands/feedback.tsx b/web/app/components/goto-anything/actions/commands/feedback.tsx index 9306ffd893..cce0aeb5f4 100644 --- a/web/app/components/goto-anything/actions/commands/feedback.tsx +++ b/web/app/components/goto-anything/actions/commands/feedback.tsx @@ -13,6 +13,14 @@ type FeedbackDeps = Record export const feedbackCommand: SlashCommandHandler = { name: 'feedback', description: 'Open feedback discussions', + mode: 'direct', + + // Direct execution function + execute: () => { + const url = 'https://github.com/langgenius/dify/discussions/categories/feedbacks' + window.open(url, '_blank', 'noopener,noreferrer') + }, + async search(args: string, locale: string = 'en') { return [{ id: 'feedback', diff --git a/web/app/components/goto-anything/actions/commands/language.tsx b/web/app/components/goto-anything/actions/commands/language.tsx index 7cdd82c301..9af378ceef 100644 --- a/web/app/components/goto-anything/actions/commands/language.tsx +++ b/web/app/components/goto-anything/actions/commands/language.tsx @@ -31,6 +31,7 @@ export const languageCommand: SlashCommandHandler = { name: 'language', aliases: ['lang'], description: 'Switch between different languages', + mode: 'submenu', // Explicitly set submenu mode async search(args: string, _locale: string = 'en') { // Return language options directly, regardless of parameters diff --git a/web/app/components/goto-anything/actions/commands/slash.tsx b/web/app/components/goto-anything/actions/commands/slash.tsx index 6571d0e316..e0d03d5019 100644 --- a/web/app/components/goto-anything/actions/commands/slash.tsx +++ b/web/app/components/goto-anything/actions/commands/slash.tsx @@ -8,7 +8,7 @@ import { setLocaleOnClient } from '@/i18n-config' import { themeCommand } from './theme' import { languageCommand } from './language' import { feedbackCommand } from './feedback' -import { docCommand } from './doc' +import { docsCommand } from './docs' import { communityCommand } from './community' import { accountCommand } from './account' import i18n from '@/i18n-config/i18next-config' @@ -35,7 +35,7 @@ export const registerSlashCommands = (deps: Record) => { slashCommandRegistry.register(themeCommand, { setTheme: deps.setTheme }) slashCommandRegistry.register(languageCommand, { setLocale: deps.setLocale }) slashCommandRegistry.register(feedbackCommand, {}) - slashCommandRegistry.register(docCommand, {}) + slashCommandRegistry.register(docsCommand, {}) slashCommandRegistry.register(communityCommand, {}) slashCommandRegistry.register(accountCommand, {}) } @@ -45,7 +45,7 @@ export const unregisterSlashCommands = () => { slashCommandRegistry.unregister('theme') slashCommandRegistry.unregister('language') slashCommandRegistry.unregister('feedback') - slashCommandRegistry.unregister('doc') + slashCommandRegistry.unregister('docs') slashCommandRegistry.unregister('community') slashCommandRegistry.unregister('account') } diff --git a/web/app/components/goto-anything/actions/commands/theme.tsx b/web/app/components/goto-anything/actions/commands/theme.tsx index 3513fdd1df..ccba50f57d 100644 --- a/web/app/components/goto-anything/actions/commands/theme.tsx +++ b/web/app/components/goto-anything/actions/commands/theme.tsx @@ -60,6 +60,7 @@ const buildThemeCommands = (query: string, locale?: string): CommandSearchResult export const themeCommand: SlashCommandHandler = { name: 'theme', description: 'Switch between light and dark themes', + mode: 'submenu', // Explicitly set submenu mode async search(args: string, locale: string = 'en') { // Return theme options directly, regardless of parameters diff --git a/web/app/components/goto-anything/actions/commands/types.ts b/web/app/components/goto-anything/actions/commands/types.ts index 30ee13e6cf..75f8a8c1d6 100644 --- a/web/app/components/goto-anything/actions/commands/types.ts +++ b/web/app/components/goto-anything/actions/commands/types.ts @@ -15,7 +15,20 @@ export type SlashCommandHandler = { description: string /** - * Search command results + * Command mode: + * - 'direct': Execute immediately when selected (e.g., /docs, /community) + * - 'submenu': Show submenu options (e.g., /theme, /language) + */ + mode?: 'direct' | 'submenu' + + /** + * Direct execution function for 'direct' mode commands + * Called when the command is selected and should execute immediately + */ + execute?: () => void | Promise + + /** + * Search command results (for 'submenu' mode or showing options) * @param args Command arguments (part after removing command name) * @param locale Current language */ diff --git a/web/app/components/goto-anything/actions/index.ts b/web/app/components/goto-anything/actions/index.ts index 92ee22f9ec..62bf9cc04c 100644 --- a/web/app/components/goto-anything/actions/index.ts +++ b/web/app/components/goto-anything/actions/index.ts @@ -169,6 +169,7 @@ import { pluginAction } from './plugin' import { workflowNodesAction } from './workflow-nodes' import type { ActionItem, SearchResult } from './types' import { slashAction } from './commands' +import { slashCommandRegistry } from './commands/registry' export const Actions = { slash: slashAction, @@ -234,9 +235,23 @@ 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('/') + // Special handling for slash commands + if (action.key === '/') { + // Get all registered commands from the registry + const allCommands = slashCommandRegistry.getAllCommands() + + // Check if query matches any registered command + 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|$)`) return reg.test(query) diff --git a/web/app/components/goto-anything/command-selector.tsx b/web/app/components/goto-anything/command-selector.tsx index 37d5cd6e70..a79edf4d4c 100644 --- a/web/app/components/goto-anything/command-selector.tsx +++ b/web/app/components/goto-anything/command-selector.tsx @@ -79,8 +79,8 @@ const CommandSelector: FC = ({ actions, onCommandSelect, searchFilter, co } return ( -
-
+
+
{isSlashMode ? t('app.gotoAnything.groups.commands') : t('app.gotoAnything.selectSearchType')}
@@ -89,7 +89,7 @@ const CommandSelector: FC = ({ actions, onCommandSelect, searchFilter, co key={item.key} value={item.shortcut} className="flex cursor-pointer items-center rounded-md - p-2.5 + p-2 transition-all duration-150 hover:bg-state-base-hover aria-[selected=true]:bg-state-base-hover-alt" onSelect={() => onCommandSelect(item.shortcut)} @@ -105,7 +105,7 @@ const CommandSelector: FC = ({ actions, onCommandSelect, searchFilter, co '/language': 'app.gotoAnything.actions.languageChangeDesc', '/account': 'app.gotoAnything.actions.accountDesc', '/feedback': 'app.gotoAnything.actions.feedbackDesc', - '/doc': 'app.gotoAnything.actions.docDesc', + '/docs': 'app.gotoAnything.actions.docDesc', '/community': 'app.gotoAnything.actions.communityDesc', } return t(slashKeyMap[item.key] || item.description) diff --git a/web/app/components/goto-anything/index.tsx b/web/app/components/goto-anything/index.tsx index cd883ac216..be1b2c5c7c 100644 --- a/web/app/components/goto-anything/index.tsx +++ b/web/app/components/goto-anything/index.tsx @@ -11,6 +11,7 @@ import { selectWorkflowNode } from '@/app/components/workflow/utils/node-navigat import { RiSearchLine } from '@remixicon/react' import { Actions as AllActions, type SearchResult, matchAction, searchAnything } from './actions' import { GotoAnythingProvider, useGotoAnythingContext } from './context' +import { slashCommandRegistry } from './actions/commands/registry' import { useQuery } from '@tanstack/react-query' import { useGetLanguage } from '@/context/i18n' import { useTranslation } from 'react-i18next' @@ -87,14 +88,21 @@ const GotoAnything: FC = ({ || (searchQuery.trim().startsWith('/') && !matchAction(searchQuery.trim(), Actions)) const searchMode = useMemo(() => { - if (isCommandsMode) return 'commands' + if (isCommandsMode) { + // Distinguish between @ (scopes) and / (commands) mode + if (searchQuery.trim().startsWith('@')) + return 'scopes' + else if (searchQuery.trim().startsWith('/')) + return 'commands' + return 'commands' // default fallback + } const query = searchQueryDebouncedValue.toLowerCase() const action = matchAction(query, Actions) return action ? (action.key === '/' ? '@command' : action.key) : 'general' - }, [searchQueryDebouncedValue, Actions, isCommandsMode]) + }, [searchQueryDebouncedValue, Actions, isCommandsMode, searchQuery]) const { data: searchResults = [], isLoading, isError, error } = useQuery( { @@ -124,6 +132,21 @@ const GotoAnything: FC = ({ } const handleCommandSelect = useCallback((commandKey: string) => { + // Check if it's a slash command + if (commandKey.startsWith('/')) { + const commandName = commandKey.substring(1) + const handler = slashCommandRegistry.findCommand(commandName) + + // If it's a direct mode command, execute immediately + if (handler?.mode === 'direct' && handler.execute) { + handler.execute() + setShow(false) + setSearchQuery('') + return + } + } + + // Otherwise, proceed with the normal flow (submenu mode) setSearchQuery(`${commandKey} `) clearSelection() setTimeout(() => { @@ -220,7 +243,7 @@ const GotoAnything: FC = ({ if (searchQuery.trim()) return null - return (
+ return (
{t('app.gotoAnything.searchTitle')}
@@ -274,13 +297,38 @@ const GotoAnything: FC = ({ if (!e.target.value.startsWith('@') && !e.target.value.startsWith('/')) clearSelection() }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + const query = searchQuery.trim() + // Check if it's a complete slash command + if (query.startsWith('/')) { + const commandName = query.substring(1).split(' ')[0] + const handler = slashCommandRegistry.findCommand(commandName) + + // If it's a direct mode command, execute immediately + if (handler?.mode === 'direct' && handler.execute) { + e.preventDefault() + handler.execute() + setShow(false) + setSearchQuery('') + } + } + } + }} className='flex-1 !border-0 !bg-transparent !shadow-none' wrapperClassName='flex-1 !border-0 !bg-transparent' autoFocus /> {searchMode !== 'general' && (
- {searchMode.replace('@', '').toUpperCase()} + {(() => { + if (searchMode === 'scopes') + return 'SCOPES' + else if (searchMode === 'commands') + return 'COMMANDS' + else + return searchMode.replace('@', '').toUpperCase() + })()}
)}
@@ -294,7 +342,7 @@ const GotoAnything: FC = ({
- + {isLoading && (
@@ -368,32 +416,52 @@ const GotoAnything: FC = ({ )} - {(!!searchResults.length || isError) && ( -
-
- - {isError ? ( - {t('app.gotoAnything.someServicesUnavailable')} - ) : ( - <> - {t('app.gotoAnything.resultCount', { count: searchResults.length })} - {searchMode !== 'general' && ( - - {t('app.gotoAnything.inScope', { scope: searchMode.replace('@', '') })} - - )} - - )} - - - {searchMode !== 'general' - ? t('app.gotoAnything.clearToSearchAll') - : t('app.gotoAnything.useAtForSpecific') - } - -
+ {/* Always show footer to prevent height jumping */} +
+
+ {(!!searchResults.length || isError) ? ( + <> + + {isError ? ( + {t('app.gotoAnything.someServicesUnavailable')} + ) : ( + <> + {t('app.gotoAnything.resultCount', { count: searchResults.length })} + {searchMode !== 'general' && ( + + {t('app.gotoAnything.inScope', { scope: searchMode.replace('@', '') })} + + )} + + )} + + + {searchMode !== 'general' + ? t('app.gotoAnything.clearToSearchAll') + : t('app.gotoAnything.useAtForSpecific') + } + + + ) : ( + <> + + {isCommandsMode + ? t('app.gotoAnything.selectToNavigate') + : searchQuery.trim() + ? t('app.gotoAnything.searching') + : t('app.gotoAnything.startTyping') + } + + + {searchQuery.trim() || isCommandsMode + ? t('app.gotoAnything.tips') + : t('app.gotoAnything.pressEscToClose') + } + + + )}
- )} +
diff --git a/web/i18n/en-US/app.ts b/web/i18n/en-US/app.ts index b11d06449d..6e1a63f62a 100644 --- a/web/i18n/en-US/app.ts +++ b/web/i18n/en-US/app.ts @@ -266,6 +266,10 @@ const translation = { inScope: 'in {{scope}}s', clearToSearchAll: 'Clear @ to search all', useAtForSpecific: 'Use @ for specific types', + selectToNavigate: 'Select to navigate', + startTyping: 'Start typing to search', + tips: 'Press ↑↓ to navigate', + pressEscToClose: 'Press ESC to close', selectSearchType: 'Choose what to search for', searchHint: 'Start typing to search everything instantly', commandHint: 'Type @ to browse by category', diff --git a/web/i18n/ja-JP/app.ts b/web/i18n/ja-JP/app.ts index 5d8deee448..75447799bf 100644 --- a/web/i18n/ja-JP/app.ts +++ b/web/i18n/ja-JP/app.ts @@ -265,9 +265,14 @@ const translation = { inScope: '{{scope}}s 内', clearToSearchAll: '@ をクリアしてすべてを検索', useAtForSpecific: '特定のタイプには @ を使用', + selectToNavigate: '選択してナビゲート', + startTyping: '入力を開始して検索', + tips: '↑↓ でナビゲート', + pressEscToClose: 'ESC で閉じる', selectSearchType: '検索対象を選択', searchHint: '入力を開始してすべてを瞬時に検索', commandHint: '@ を入力してカテゴリ別に参照', + slashHint: '/ を入力してすべてのコマンドを表示', actions: { searchApplications: 'アプリケーションを検索', searchApplicationsDesc: 'アプリケーションを検索してナビゲート', @@ -314,7 +319,6 @@ const translation = { }, noMatchingCommands: '一致するコマンドが見つかりません', tryDifferentSearch: '別の検索語句をお試しください', - slashHint: '/を入力して、利用可能なすべてのコマンドを表示します。', }, } diff --git a/web/i18n/zh-Hans/app.ts b/web/i18n/zh-Hans/app.ts index 9a5eee8614..b0eef860fd 100644 --- a/web/i18n/zh-Hans/app.ts +++ b/web/i18n/zh-Hans/app.ts @@ -265,6 +265,10 @@ const translation = { inScope: '在 {{scope}}s 中', clearToSearchAll: '清除 @ 以搜索全部', useAtForSpecific: '使用 @ 进行特定类型搜索', + selectToNavigate: '选择以导航', + startTyping: '开始输入以搜索', + tips: '按 ↑↓ 导航', + pressEscToClose: '按 ESC 关闭', selectSearchType: '选择搜索内容', searchHint: '开始输入即可立即搜索所有内容', commandHint: '输入 @ 按类别浏览',