| 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() | |||||
| }) | |||||
| }) | |||||
| }) |
| 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() | |||||
| }) | |||||
| }) | |||||
| }) |
| 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) | |||||
| }) | |||||
| }) | |||||
| }) |
| export const accountCommand: SlashCommandHandler<AccountDeps> = { | export const accountCommand: SlashCommandHandler<AccountDeps> = { | ||||
| name: 'account', | name: 'account', | ||||
| description: 'Navigate to account page', | description: 'Navigate to account page', | ||||
| mode: 'direct', | |||||
| // Direct execution function | |||||
| execute: () => { | |||||
| window.location.href = '/account' | |||||
| }, | |||||
| async search(args: string, locale: string = 'en') { | async search(args: string, locale: string = 'en') { | ||||
| return [{ | return [{ |
| export const communityCommand: SlashCommandHandler<CommunityDeps> = { | export const communityCommand: SlashCommandHandler<CommunityDeps> = { | ||||
| name: 'community', | name: 'community', | ||||
| description: 'Open community Discord', | 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') { | async search(args: string, locale: string = 'en') { | ||||
| return [{ | return [{ | ||||
| id: 'community', | id: 'community', |
| import i18n from '@/i18n-config/i18next-config' | import i18n from '@/i18n-config/i18next-config' | ||||
| import { registerCommands, unregisterCommands } from './command-bus' | import { registerCommands, unregisterCommands } from './command-bus' | ||||
| import { defaultDocBaseUrl } from '@/context/i18n' | import { defaultDocBaseUrl } from '@/context/i18n' | ||||
| import { getDocLanguage } from '@/i18n-config/language' | |||||
| // Documentation command dependency types - no external dependencies needed | // Documentation command dependency types - no external dependencies needed | ||||
| type DocDeps = Record<string, never> | type DocDeps = Record<string, never> | ||||
| /** | /** | ||||
| * Documentation command - Opens help documentation | * Documentation command - Opens help documentation | ||||
| */ | */ | ||||
| export const docCommand: SlashCommandHandler<DocDeps> = { | |||||
| name: 'doc', | |||||
| export const docsCommand: SlashCommandHandler<DocDeps> = { | |||||
| name: 'docs', | |||||
| description: 'Open documentation', | 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') { | async search(args: string, locale: string = 'en') { | ||||
| return [{ | return [{ | ||||
| id: 'doc', | id: 'doc', | ||||
| register(_deps: DocDeps) { | register(_deps: DocDeps) { | ||||
| registerCommands({ | registerCommands({ | ||||
| 'navigation.doc': async (_args) => { | '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') | window.open(url, '_blank', 'noopener,noreferrer') | ||||
| }, | }, | ||||
| }) | }) |
| export const feedbackCommand: SlashCommandHandler<FeedbackDeps> = { | export const feedbackCommand: SlashCommandHandler<FeedbackDeps> = { | ||||
| name: 'feedback', | name: 'feedback', | ||||
| description: 'Open feedback discussions', | 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') { | async search(args: string, locale: string = 'en') { | ||||
| return [{ | return [{ | ||||
| id: 'feedback', | id: 'feedback', |
| name: 'language', | name: 'language', | ||||
| aliases: ['lang'], | aliases: ['lang'], | ||||
| description: 'Switch between different languages', | description: 'Switch between different languages', | ||||
| mode: 'submenu', // Explicitly set submenu mode | |||||
| async search(args: string, _locale: string = 'en') { | async search(args: string, _locale: string = 'en') { | ||||
| // Return language options directly, regardless of parameters | // Return language options directly, regardless of parameters |
| import { themeCommand } from './theme' | import { themeCommand } from './theme' | ||||
| import { languageCommand } from './language' | import { languageCommand } from './language' | ||||
| import { feedbackCommand } from './feedback' | import { feedbackCommand } from './feedback' | ||||
| import { docCommand } from './doc' | |||||
| import { docsCommand } from './docs' | |||||
| import { communityCommand } from './community' | import { communityCommand } from './community' | ||||
| import { accountCommand } from './account' | import { accountCommand } from './account' | ||||
| import i18n from '@/i18n-config/i18next-config' | import i18n from '@/i18n-config/i18next-config' | ||||
| slashCommandRegistry.register(themeCommand, { setTheme: deps.setTheme }) | slashCommandRegistry.register(themeCommand, { setTheme: deps.setTheme }) | ||||
| slashCommandRegistry.register(languageCommand, { setLocale: deps.setLocale }) | slashCommandRegistry.register(languageCommand, { setLocale: deps.setLocale }) | ||||
| slashCommandRegistry.register(feedbackCommand, {}) | slashCommandRegistry.register(feedbackCommand, {}) | ||||
| slashCommandRegistry.register(docCommand, {}) | |||||
| slashCommandRegistry.register(docsCommand, {}) | |||||
| slashCommandRegistry.register(communityCommand, {}) | slashCommandRegistry.register(communityCommand, {}) | ||||
| slashCommandRegistry.register(accountCommand, {}) | slashCommandRegistry.register(accountCommand, {}) | ||||
| } | } | ||||
| slashCommandRegistry.unregister('theme') | slashCommandRegistry.unregister('theme') | ||||
| slashCommandRegistry.unregister('language') | slashCommandRegistry.unregister('language') | ||||
| slashCommandRegistry.unregister('feedback') | slashCommandRegistry.unregister('feedback') | ||||
| slashCommandRegistry.unregister('doc') | |||||
| slashCommandRegistry.unregister('docs') | |||||
| slashCommandRegistry.unregister('community') | slashCommandRegistry.unregister('community') | ||||
| slashCommandRegistry.unregister('account') | slashCommandRegistry.unregister('account') | ||||
| } | } |
| export const themeCommand: SlashCommandHandler<ThemeDeps> = { | export const themeCommand: SlashCommandHandler<ThemeDeps> = { | ||||
| name: 'theme', | name: 'theme', | ||||
| description: 'Switch between light and dark themes', | description: 'Switch between light and dark themes', | ||||
| mode: 'submenu', // Explicitly set submenu mode | |||||
| async search(args: string, locale: string = 'en') { | async search(args: string, locale: string = 'en') { | ||||
| // Return theme options directly, regardless of parameters | // Return theme options directly, regardless of parameters |
| description: string | 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 args Command arguments (part after removing command name) | ||||
| * @param locale Current language | * @param locale Current language | ||||
| */ | */ |
| import { workflowNodesAction } from './workflow-nodes' | import { workflowNodesAction } from './workflow-nodes' | ||||
| import type { ActionItem, SearchResult } from './types' | import type { ActionItem, SearchResult } from './types' | ||||
| import { slashAction } from './commands' | import { slashAction } from './commands' | ||||
| import { slashCommandRegistry } from './commands/registry' | |||||
| export const Actions = { | export const Actions = { | ||||
| slash: slashAction, | slash: slashAction, | ||||
| export const matchAction = (query: string, actions: Record<string, ActionItem>) => { | export const matchAction = (query: string, actions: Record<string, ActionItem>) => { | ||||
| return Object.values(actions).find((action) => { | 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|$)`) | const reg = new RegExp(`^(${action.key}|${action.shortcut})(?:\\s|$)`) | ||||
| return reg.test(query) | return reg.test(query) |
| } | } | ||||
| return ( | 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')} | {isSlashMode ? t('app.gotoAnything.groups.commands') : t('app.gotoAnything.selectSearchType')} | ||||
| </div> | </div> | ||||
| <Command.Group className="space-y-1"> | <Command.Group className="space-y-1"> | ||||
| key={item.key} | key={item.key} | ||||
| value={item.shortcut} | value={item.shortcut} | ||||
| className="flex cursor-pointer items-center rounded-md | className="flex cursor-pointer items-center rounded-md | ||||
| p-2.5 | |||||
| p-2 | |||||
| transition-all | transition-all | ||||
| duration-150 hover:bg-state-base-hover aria-[selected=true]:bg-state-base-hover-alt" | duration-150 hover:bg-state-base-hover aria-[selected=true]:bg-state-base-hover-alt" | ||||
| onSelect={() => onCommandSelect(item.shortcut)} | onSelect={() => onCommandSelect(item.shortcut)} | ||||
| '/language': 'app.gotoAnything.actions.languageChangeDesc', | '/language': 'app.gotoAnything.actions.languageChangeDesc', | ||||
| '/account': 'app.gotoAnything.actions.accountDesc', | '/account': 'app.gotoAnything.actions.accountDesc', | ||||
| '/feedback': 'app.gotoAnything.actions.feedbackDesc', | '/feedback': 'app.gotoAnything.actions.feedbackDesc', | ||||
| '/doc': 'app.gotoAnything.actions.docDesc', | |||||
| '/docs': 'app.gotoAnything.actions.docDesc', | |||||
| '/community': 'app.gotoAnything.actions.communityDesc', | '/community': 'app.gotoAnything.actions.communityDesc', | ||||
| } | } | ||||
| return t(slashKeyMap[item.key] || item.description) | return t(slashKeyMap[item.key] || item.description) |
| import { RiSearchLine } from '@remixicon/react' | import { RiSearchLine } from '@remixicon/react' | ||||
| import { Actions as AllActions, type SearchResult, matchAction, searchAnything } from './actions' | import { Actions as AllActions, type SearchResult, matchAction, searchAnything } from './actions' | ||||
| import { GotoAnythingProvider, useGotoAnythingContext } from './context' | import { GotoAnythingProvider, useGotoAnythingContext } from './context' | ||||
| import { slashCommandRegistry } from './actions/commands/registry' | |||||
| import { useQuery } from '@tanstack/react-query' | import { useQuery } from '@tanstack/react-query' | ||||
| import { useGetLanguage } from '@/context/i18n' | import { useGetLanguage } from '@/context/i18n' | ||||
| import { useTranslation } from 'react-i18next' | import { useTranslation } from 'react-i18next' | ||||
| || (searchQuery.trim().startsWith('/') && !matchAction(searchQuery.trim(), Actions)) | || (searchQuery.trim().startsWith('/') && !matchAction(searchQuery.trim(), Actions)) | ||||
| const searchMode = useMemo(() => { | 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 query = searchQueryDebouncedValue.toLowerCase() | ||||
| const action = matchAction(query, Actions) | const action = matchAction(query, Actions) | ||||
| return action | return action | ||||
| ? (action.key === '/' ? '@command' : action.key) | ? (action.key === '/' ? '@command' : action.key) | ||||
| : 'general' | : 'general' | ||||
| }, [searchQueryDebouncedValue, Actions, isCommandsMode]) | |||||
| }, [searchQueryDebouncedValue, Actions, isCommandsMode, searchQuery]) | |||||
| const { data: searchResults = [], isLoading, isError, error } = useQuery( | const { data: searchResults = [], isLoading, isError, error } = useQuery( | ||||
| { | { | ||||
| } | } | ||||
| const handleCommandSelect = useCallback((commandKey: string) => { | 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} `) | setSearchQuery(`${commandKey} `) | ||||
| clearSelection() | clearSelection() | ||||
| setTimeout(() => { | setTimeout(() => { | ||||
| if (searchQuery.trim()) | if (searchQuery.trim()) | ||||
| return null | 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> | ||||
| <div className='text-sm font-medium'>{t('app.gotoAnything.searchTitle')}</div> | <div className='text-sm font-medium'>{t('app.gotoAnything.searchTitle')}</div> | ||||
| <div className='mt-3 space-y-1 text-xs text-text-quaternary'> | <div className='mt-3 space-y-1 text-xs text-text-quaternary'> | ||||
| if (!e.target.value.startsWith('@') && !e.target.value.startsWith('/')) | if (!e.target.value.startsWith('@') && !e.target.value.startsWith('/')) | ||||
| clearSelection() | 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' | className='flex-1 !border-0 !bg-transparent !shadow-none' | ||||
| wrapperClassName='flex-1 !border-0 !bg-transparent' | wrapperClassName='flex-1 !border-0 !bg-transparent' | ||||
| autoFocus | autoFocus | ||||
| /> | /> | ||||
| {searchMode !== 'general' && ( | {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'> | <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> | ||||
| )} | )} | ||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| <Command.List className='max-h-[275px] min-h-[240px] overflow-y-auto'> | |||||
| <Command.List className='h-[240px] overflow-y-auto'> | |||||
| {isLoading && ( | {isLoading && ( | ||||
| <div className="flex items-center justify-center py-8 text-center text-text-tertiary"> | <div className="flex items-center justify-center py-8 text-center text-text-tertiary"> | ||||
| <div className="flex items-center gap-2"> | <div className="flex items-center gap-2"> | ||||
| )} | )} | ||||
| </Command.List> | </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> | ||||
| )} | |||||
| </div> | |||||
| </Command> | </Command> | ||||
| </div> | </div> | ||||
| inScope: 'in {{scope}}s', | inScope: 'in {{scope}}s', | ||||
| clearToSearchAll: 'Clear @ to search all', | clearToSearchAll: 'Clear @ to search all', | ||||
| useAtForSpecific: 'Use @ for specific types', | 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', | selectSearchType: 'Choose what to search for', | ||||
| searchHint: 'Start typing to search everything instantly', | searchHint: 'Start typing to search everything instantly', | ||||
| commandHint: 'Type @ to browse by category', | commandHint: 'Type @ to browse by category', |
| inScope: '{{scope}}s 内', | inScope: '{{scope}}s 内', | ||||
| clearToSearchAll: '@ をクリアしてすべてを検索', | clearToSearchAll: '@ をクリアしてすべてを検索', | ||||
| useAtForSpecific: '特定のタイプには @ を使用', | useAtForSpecific: '特定のタイプには @ を使用', | ||||
| selectToNavigate: '選択してナビゲート', | |||||
| startTyping: '入力を開始して検索', | |||||
| tips: '↑↓ でナビゲート', | |||||
| pressEscToClose: 'ESC で閉じる', | |||||
| selectSearchType: '検索対象を選択', | selectSearchType: '検索対象を選択', | ||||
| searchHint: '入力を開始してすべてを瞬時に検索', | searchHint: '入力を開始してすべてを瞬時に検索', | ||||
| commandHint: '@ を入力してカテゴリ別に参照', | commandHint: '@ を入力してカテゴリ別に参照', | ||||
| slashHint: '/ を入力してすべてのコマンドを表示', | |||||
| actions: { | actions: { | ||||
| searchApplications: 'アプリケーションを検索', | searchApplications: 'アプリケーションを検索', | ||||
| searchApplicationsDesc: 'アプリケーションを検索してナビゲート', | searchApplicationsDesc: 'アプリケーションを検索してナビゲート', | ||||
| }, | }, | ||||
| noMatchingCommands: '一致するコマンドが見つかりません', | noMatchingCommands: '一致するコマンドが見つかりません', | ||||
| tryDifferentSearch: '別の検索語句をお試しください', | tryDifferentSearch: '別の検索語句をお試しください', | ||||
| slashHint: '/を入力して、利用可能なすべてのコマンドを表示します。', | |||||
| }, | }, | ||||
| } | } | ||||
| inScope: '在 {{scope}}s 中', | inScope: '在 {{scope}}s 中', | ||||
| clearToSearchAll: '清除 @ 以搜索全部', | clearToSearchAll: '清除 @ 以搜索全部', | ||||
| useAtForSpecific: '使用 @ 进行特定类型搜索', | useAtForSpecific: '使用 @ 进行特定类型搜索', | ||||
| selectToNavigate: '选择以导航', | |||||
| startTyping: '开始输入以搜索', | |||||
| tips: '按 ↑↓ 导航', | |||||
| pressEscToClose: '按 ESC 关闭', | |||||
| selectSearchType: '选择搜索内容', | selectSearchType: '选择搜索内容', | ||||
| searchHint: '开始输入即可立即搜索所有内容', | searchHint: '开始输入即可立即搜索所有内容', | ||||
| commandHint: '输入 @ 按类别浏览', | commandHint: '输入 @ 按类别浏览', |