| @@ -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<string, ActionItem>) => { | |||
| 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<string, ActionItem> = { | |||
| 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() | |||
| }) | |||
| }) | |||
| }) | |||
| @@ -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 ( | |||
| <div className="flex items-center gap-1 text-xs text-text-tertiary"> | |||
| <span>{searchMode === 'scopes' ? 'SCOPES' : 'COMMANDS'}</span> | |||
| </div> | |||
| ) | |||
| } | |||
| describe('Scope and Command Tags', () => { | |||
| describe('Tag Display Logic', () => { | |||
| it('should display SCOPES for @ actions', () => { | |||
| render(<TagDisplay searchMode="scopes" />) | |||
| expect(screen.getByText('SCOPES')).toBeInTheDocument() | |||
| expect(screen.queryByText('COMMANDS')).not.toBeInTheDocument() | |||
| }) | |||
| it('should display COMMANDS for / actions', () => { | |||
| render(<TagDisplay searchMode="commands" />) | |||
| expect(screen.getByText('COMMANDS')).toBeInTheDocument() | |||
| expect(screen.queryByText('SCOPES')).not.toBeInTheDocument() | |||
| }) | |||
| it('should not display any tag when searchMode is null', () => { | |||
| const { container } = render(<TagDisplay searchMode={null} />) | |||
| 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(<TagDisplay searchMode="scopes" />) | |||
| 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(<TagDisplay searchMode="scopes" />) | |||
| const scopesText = screen.getByText('SCOPES') | |||
| expect(scopesText.textContent).toBe('SCOPES') | |||
| render(<TagDisplay searchMode="commands" />) | |||
| 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 ( | |||
| <div> | |||
| <input value={query} readOnly /> | |||
| <TagDisplay searchMode={searchMode} /> | |||
| </div> | |||
| ) | |||
| } | |||
| it('should update tag when switching between @ and /', () => { | |||
| const { rerender } = render(<SearchComponent query="@app" />) | |||
| expect(screen.getByText('SCOPES')).toBeInTheDocument() | |||
| rerender(<SearchComponent query="/theme" />) | |||
| expect(screen.queryByText('SCOPES')).not.toBeInTheDocument() | |||
| expect(screen.getByText('COMMANDS')).toBeInTheDocument() | |||
| }) | |||
| it('should hide tag when clearing search', () => { | |||
| const { rerender } = render(<SearchComponent query="@app" />) | |||
| expect(screen.getByText('SCOPES')).toBeInTheDocument() | |||
| rerender(<SearchComponent query="" />) | |||
| expect(screen.queryByText('SCOPES')).not.toBeInTheDocument() | |||
| expect(screen.queryByText('COMMANDS')).not.toBeInTheDocument() | |||
| }) | |||
| it('should maintain correct tag during search refinement', () => { | |||
| const { rerender } = render(<SearchComponent query="@" />) | |||
| expect(screen.getByText('SCOPES')).toBeInTheDocument() | |||
| rerender(<SearchComponent query="@app" />) | |||
| expect(screen.getByText('SCOPES')).toBeInTheDocument() | |||
| rerender(<SearchComponent query="@app test" />) | |||
| expect(screen.getByText('SCOPES')).toBeInTheDocument() | |||
| }) | |||
| }) | |||
| }) | |||
| @@ -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) | |||
| }) | |||
| }) | |||
| }) | |||
| @@ -13,6 +13,12 @@ type AccountDeps = Record<string, never> | |||
| export const accountCommand: SlashCommandHandler<AccountDeps> = { | |||
| 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 [{ | |||
| @@ -13,6 +13,14 @@ type CommunityDeps = Record<string, never> | |||
| export const communityCommand: SlashCommandHandler<CommunityDeps> = { | |||
| 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', | |||
| @@ -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<string, never> | |||
| @@ -11,9 +12,19 @@ type DocDeps = Record<string, never> | |||
| /** | |||
| * Documentation command - Opens help documentation | |||
| */ | |||
| export const docCommand: SlashCommandHandler<DocDeps> = { | |||
| name: 'doc', | |||
| export const docsCommand: SlashCommandHandler<DocDeps> = { | |||
| 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<DocDeps> = { | |||
| 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') | |||
| }, | |||
| }) | |||
| @@ -13,6 +13,14 @@ type FeedbackDeps = Record<string, never> | |||
| export const feedbackCommand: SlashCommandHandler<FeedbackDeps> = { | |||
| 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', | |||
| @@ -31,6 +31,7 @@ export const languageCommand: SlashCommandHandler<LanguageDeps> = { | |||
| 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 | |||
| @@ -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<string, any>) => { | |||
| 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') | |||
| } | |||
| @@ -60,6 +60,7 @@ const buildThemeCommands = (query: string, locale?: string): CommandSearchResult | |||
| export const themeCommand: SlashCommandHandler<ThemeDeps> = { | |||
| 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 | |||
| @@ -15,7 +15,20 @@ export type SlashCommandHandler<TDeps = any> = { | |||
| 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<void> | |||
| /** | |||
| * Search command results (for 'submenu' mode or showing options) | |||
| * @param args Command arguments (part after removing command name) | |||
| * @param locale Current language | |||
| */ | |||
| @@ -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<string, ActionItem>) => { | |||
| 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) | |||
| @@ -79,8 +79,8 @@ const CommandSelector: FC<Props> = ({ actions, onCommandSelect, searchFilter, co | |||
| } | |||
| return ( | |||
| <div className="p-4"> | |||
| <div className="mb-3 text-left text-sm font-medium text-text-secondary"> | |||
| <div className="px-4 py-3"> | |||
| <div className="mb-2 text-left text-sm font-medium text-text-secondary"> | |||
| {isSlashMode ? t('app.gotoAnything.groups.commands') : t('app.gotoAnything.selectSearchType')} | |||
| </div> | |||
| <Command.Group className="space-y-1"> | |||
| @@ -89,7 +89,7 @@ const CommandSelector: FC<Props> = ({ 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<Props> = ({ 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) | |||
| @@ -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<Props> = ({ | |||
| || (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<Props> = ({ | |||
| } | |||
| 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<Props> = ({ | |||
| if (searchQuery.trim()) | |||
| return null | |||
| return (<div className="flex items-center justify-center py-12 text-center text-text-tertiary"> | |||
| return (<div className="flex items-center justify-center py-8 text-center text-text-tertiary"> | |||
| <div> | |||
| <div className='text-sm font-medium'>{t('app.gotoAnything.searchTitle')}</div> | |||
| <div className='mt-3 space-y-1 text-xs text-text-quaternary'> | |||
| @@ -274,13 +297,38 @@ const GotoAnything: FC<Props> = ({ | |||
| 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' && ( | |||
| <div className='flex items-center gap-1 rounded bg-blue-50 px-2 py-[2px] text-xs font-medium text-blue-600 dark:bg-blue-900/40 dark:text-blue-300'> | |||
| <span>{searchMode.replace('@', '').toUpperCase()}</span> | |||
| <span>{(() => { | |||
| if (searchMode === 'scopes') | |||
| return 'SCOPES' | |||
| else if (searchMode === 'commands') | |||
| return 'COMMANDS' | |||
| else | |||
| return searchMode.replace('@', '').toUpperCase() | |||
| })()}</span> | |||
| </div> | |||
| )} | |||
| </div> | |||
| @@ -294,7 +342,7 @@ const GotoAnything: FC<Props> = ({ | |||
| </div> | |||
| </div> | |||
| <Command.List className='max-h-[275px] min-h-[240px] overflow-y-auto'> | |||
| <Command.List className='h-[240px] overflow-y-auto'> | |||
| {isLoading && ( | |||
| <div className="flex items-center justify-center py-8 text-center text-text-tertiary"> | |||
| <div className="flex items-center gap-2"> | |||
| @@ -368,32 +416,52 @@ const GotoAnything: FC<Props> = ({ | |||
| )} | |||
| </Command.List> | |||
| {(!!searchResults.length || isError) && ( | |||
| <div className='border-t border-divider-subtle bg-components-panel-bg-blur px-4 py-2 text-xs text-text-tertiary'> | |||
| <div className='flex items-center justify-between'> | |||
| <span> | |||
| {isError ? ( | |||
| <span className='text-red-500'>{t('app.gotoAnything.someServicesUnavailable')}</span> | |||
| ) : ( | |||
| <> | |||
| {t('app.gotoAnything.resultCount', { count: searchResults.length })} | |||
| {searchMode !== 'general' && ( | |||
| <span className='ml-2 opacity-60'> | |||
| {t('app.gotoAnything.inScope', { scope: searchMode.replace('@', '') })} | |||
| </span> | |||
| )} | |||
| </> | |||
| )} | |||
| </span> | |||
| <span className='opacity-60'> | |||
| {searchMode !== 'general' | |||
| ? t('app.gotoAnything.clearToSearchAll') | |||
| : t('app.gotoAnything.useAtForSpecific') | |||
| } | |||
| </span> | |||
| </div> | |||
| {/* Always show footer to prevent height jumping */} | |||
| <div className='border-t border-divider-subtle bg-components-panel-bg-blur px-4 py-2 text-xs text-text-tertiary'> | |||
| <div className='flex min-h-[16px] items-center justify-between'> | |||
| {(!!searchResults.length || isError) ? ( | |||
| <> | |||
| <span> | |||
| {isError ? ( | |||
| <span className='text-red-500'>{t('app.gotoAnything.someServicesUnavailable')}</span> | |||
| ) : ( | |||
| <> | |||
| {t('app.gotoAnything.resultCount', { count: searchResults.length })} | |||
| {searchMode !== 'general' && ( | |||
| <span className='ml-2 opacity-60'> | |||
| {t('app.gotoAnything.inScope', { scope: searchMode.replace('@', '') })} | |||
| </span> | |||
| )} | |||
| </> | |||
| )} | |||
| </span> | |||
| <span className='opacity-60'> | |||
| {searchMode !== 'general' | |||
| ? t('app.gotoAnything.clearToSearchAll') | |||
| : t('app.gotoAnything.useAtForSpecific') | |||
| } | |||
| </span> | |||
| </> | |||
| ) : ( | |||
| <> | |||
| <span className='opacity-60'> | |||
| {isCommandsMode | |||
| ? t('app.gotoAnything.selectToNavigate') | |||
| : searchQuery.trim() | |||
| ? t('app.gotoAnything.searching') | |||
| : t('app.gotoAnything.startTyping') | |||
| } | |||
| </span> | |||
| <span className='opacity-60'> | |||
| {searchQuery.trim() || isCommandsMode | |||
| ? t('app.gotoAnything.tips') | |||
| : t('app.gotoAnything.pressEscToClose') | |||
| } | |||
| </span> | |||
| </> | |||
| )} | |||
| </div> | |||
| )} | |||
| </div> | |||
| </Command> | |||
| </div> | |||
| @@ -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', | |||
| @@ -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: '/を入力して、利用可能なすべてのコマンドを表示します。', | |||
| }, | |||
| } | |||
| @@ -265,6 +265,10 @@ const translation = { | |||
| inScope: '在 {{scope}}s 中', | |||
| clearToSearchAll: '清除 @ 以搜索全部', | |||
| useAtForSpecific: '使用 @ 进行特定类型搜索', | |||
| selectToNavigate: '选择以导航', | |||
| startTyping: '开始输入以搜索', | |||
| tips: '按 ↑↓ 导航', | |||
| pressEscToClose: '按 ESC 关闭', | |||
| selectSearchType: '选择搜索内容', | |||
| searchHint: '开始输入即可立即搜索所有内容', | |||
| commandHint: '输入 @ 按类别浏览', | |||