fix: Multiple UX improvements for GotoAnything command palette (#25637)
This commit is contained in:
parent
a825f0f2b2
commit
36ab9974d2
17 changed files with 772 additions and 45 deletions
235
web/__tests__/goto-anything/match-action.test.ts
Normal file
235
web/__tests__/goto-anything/match-action.test.ts
Normal file
|
|
@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
134
web/__tests__/goto-anything/scope-command-tags.test.tsx
Normal file
134
web/__tests__/goto-anything/scope-command-tags.test.tsx
Normal file
|
|
@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
212
web/__tests__/goto-anything/slash-command-modes.test.tsx
Normal file
212
web/__tests__/goto-anything/slash-command-modes.test.tsx
Normal file
|
|
@ -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> = {
|
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 [{
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,14 @@ type CommunityDeps = Record<string, never>
|
||||||
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',
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { RiBookOpenLine } from '@remixicon/react'
|
||||||
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>
|
||||||
|
|
@ -11,9 +12,19 @@ type DocDeps = Record<string, never>
|
||||||
/**
|
/**
|
||||||
* Documentation command - Opens help documentation
|
* Documentation command - Opens help documentation
|
||||||
*/
|
*/
|
||||||
export const docCommand: SlashCommandHandler<DocDeps> = {
|
export const docsCommand: SlashCommandHandler<DocDeps> = {
|
||||||
name: 'doc',
|
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',
|
||||||
|
|
@ -32,7 +43,10 @@ export const docCommand: SlashCommandHandler<DocDeps> = {
|
||||||
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')
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
@ -13,6 +13,14 @@ type FeedbackDeps = Record<string, never>
|
||||||
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',
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ export const languageCommand: SlashCommandHandler<LanguageDeps> = {
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import { setLocaleOnClient } from '@/i18n-config'
|
||||||
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'
|
||||||
|
|
@ -35,7 +35,7 @@ export const registerSlashCommands = (deps: Record<string, any>) => {
|
||||||
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, {})
|
||||||
}
|
}
|
||||||
|
|
@ -45,7 +45,7 @@ export const unregisterSlashCommands = () => {
|
||||||
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')
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,7 @@ const buildThemeCommands = (query: string, locale?: string): CommandSearchResult
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,20 @@ export type SlashCommandHandler<TDeps = any> = {
|
||||||
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
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -169,6 +169,7 @@ import { pluginAction } from './plugin'
|
||||||
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,
|
||||||
|
|
@ -234,9 +235,23 @@ export const searchAnything = async (
|
||||||
|
|
||||||
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
|
// Special handling for slash commands
|
||||||
if (action.key === '/')
|
if (action.key === '/') {
|
||||||
return query.startsWith('/')
|
// 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)
|
||||||
|
|
|
||||||
|
|
@ -79,8 +79,8 @@ const CommandSelector: FC<Props> = ({ actions, onCommandSelect, searchFilter, co
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4">
|
<div className="px-4 py-3">
|
||||||
<div className="mb-3 text-left text-sm font-medium text-text-secondary">
|
<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">
|
||||||
|
|
@ -89,7 +89,7 @@ const CommandSelector: FC<Props> = ({ actions, onCommandSelect, searchFilter, co
|
||||||
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)}
|
||||||
|
|
@ -105,7 +105,7 @@ const CommandSelector: FC<Props> = ({ actions, onCommandSelect, searchFilter, co
|
||||||
'/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)
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { selectWorkflowNode } from '@/app/components/workflow/utils/node-navigat
|
||||||
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'
|
||||||
|
|
@ -87,14 +88,21 @@ const GotoAnything: FC<Props> = ({
|
||||||
|| (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(
|
||||||
{
|
{
|
||||||
|
|
@ -124,6 +132,21 @@ const GotoAnything: FC<Props> = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
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(() => {
|
||||||
|
|
@ -220,7 +243,7 @@ const GotoAnything: FC<Props> = ({
|
||||||
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'>
|
||||||
|
|
@ -274,13 +297,38 @@ const GotoAnything: FC<Props> = ({
|
||||||
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>
|
||||||
|
|
@ -294,7 +342,7 @@ const GotoAnything: FC<Props> = ({
|
||||||
</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">
|
||||||
|
|
@ -368,32 +416,52 @@ const GotoAnything: FC<Props> = ({
|
||||||
)}
|
)}
|
||||||
</Command.List>
|
</Command.List>
|
||||||
|
|
||||||
{(!!searchResults.length || isError) && (
|
{/* 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='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'>
|
<div className='flex min-h-[16px] items-center justify-between'>
|
||||||
<span>
|
{(!!searchResults.length || isError) ? (
|
||||||
{isError ? (
|
<>
|
||||||
<span className='text-red-500'>{t('app.gotoAnything.someServicesUnavailable')}</span>
|
<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.resultCount', { count: searchResults.length })}
|
||||||
{t('app.gotoAnything.inScope', { scope: searchMode.replace('@', '') })}
|
{searchMode !== 'general' && (
|
||||||
</span>
|
<span className='ml-2 opacity-60'>
|
||||||
)}
|
{t('app.gotoAnything.inScope', { scope: searchMode.replace('@', '') })}
|
||||||
</>
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</>
|
||||||
<span className='opacity-60'>
|
)}
|
||||||
{searchMode !== 'general'
|
</span>
|
||||||
? t('app.gotoAnything.clearToSearchAll')
|
<span className='opacity-60'>
|
||||||
: t('app.gotoAnything.useAtForSpecific')
|
{searchMode !== 'general'
|
||||||
}
|
? t('app.gotoAnything.clearToSearchAll')
|
||||||
</span>
|
: t('app.gotoAnything.useAtForSpecific')
|
||||||
</div>
|
}
|
||||||
|
</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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -266,6 +266,10 @@ const translation = {
|
||||||
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',
|
||||||
|
|
|
||||||
|
|
@ -265,9 +265,14 @@ const translation = {
|
||||||
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: 'アプリケーションを検索してナビゲート',
|
||||||
|
|
@ -314,7 +319,6 @@ const translation = {
|
||||||
},
|
},
|
||||||
noMatchingCommands: '一致するコマンドが見つかりません',
|
noMatchingCommands: '一致するコマンドが見つかりません',
|
||||||
tryDifferentSearch: '別の検索語句をお試しください',
|
tryDifferentSearch: '別の検索語句をお試しください',
|
||||||
slashHint: '/を入力して、利用可能なすべてのコマンドを表示します。',
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -265,6 +265,10 @@ const translation = {
|
||||||
inScope: '在 {{scope}}s 中',
|
inScope: '在 {{scope}}s 中',
|
||||||
clearToSearchAll: '清除 @ 以搜索全部',
|
clearToSearchAll: '清除 @ 以搜索全部',
|
||||||
useAtForSpecific: '使用 @ 进行特定类型搜索',
|
useAtForSpecific: '使用 @ 进行特定类型搜索',
|
||||||
|
selectToNavigate: '选择以导航',
|
||||||
|
startTyping: '开始输入以搜索',
|
||||||
|
tips: '按 ↑↓ 导航',
|
||||||
|
pressEscToClose: '按 ESC 关闭',
|
||||||
selectSearchType: '选择搜索内容',
|
selectSearchType: '选择搜索内容',
|
||||||
searchHint: '开始输入即可立即搜索所有内容',
|
searchHint: '开始输入即可立即搜索所有内容',
|
||||||
commandHint: '输入 @ 按类别浏览',
|
commandHint: '输入 @ 按类别浏览',
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue