Merge remote-tracking branch 'origin/main' into feat/queue-based-graph-engine

This commit is contained in:
-LAN- 2025-09-14 01:48:06 +08:00
commit efa5f35277
No known key found for this signature in database
GPG Key ID: 6BA0D108DED011FF
18 changed files with 779 additions and 50 deletions

View File

@ -4,7 +4,7 @@ from typing import Any
from uuid import uuid4
import pytest
from hypothesis import given
from hypothesis import given, settings
from hypothesis import strategies as st
from core.file import File, FileTransferMethod, FileType
@ -486,13 +486,14 @@ def _generate_file(draw) -> File:
def _scalar_value() -> st.SearchStrategy[int | float | str | File | None]:
return st.one_of(
st.none(),
st.integers(),
st.floats(),
st.text(),
st.integers(min_value=-(10**6), max_value=10**6),
st.floats(allow_nan=True, allow_infinity=False),
st.text(max_size=50),
_generate_file(),
)
@settings(max_examples=50)
@given(_scalar_value())
def test_build_segment_and_extract_values_for_scalar_types(value):
seg = variable_factory.build_segment(value)
@ -503,7 +504,8 @@ def test_build_segment_and_extract_values_for_scalar_types(value):
assert seg.value == value
@given(st.lists(_scalar_value()))
@settings(max_examples=50)
@given(values=st.lists(_scalar_value(), max_size=20))
def test_build_segment_and_extract_values_for_array_types(values):
seg = variable_factory.build_segment(values)
assert seg.value == values

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

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

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

View File

@ -13,6 +13,12 @@ type AccountDeps = Record<string, never>
export const accountCommand: SlashCommandHandler<AccountDeps> = {
name: 'account',
description: 'Navigate to account page',
mode: 'direct',
// Direct execution function
execute: () => {
window.location.href = '/account'
},
async search(args: string, locale: string = 'en') {
return [{

View File

@ -13,6 +13,14 @@ type CommunityDeps = Record<string, never>
export const communityCommand: SlashCommandHandler<CommunityDeps> = {
name: 'community',
description: 'Open community Discord',
mode: 'direct',
// Direct execution function
execute: () => {
const url = 'https://discord.gg/5AEfbxcd9k'
window.open(url, '_blank', 'noopener,noreferrer')
},
async search(args: string, locale: string = 'en') {
return [{
id: 'community',

View File

@ -4,6 +4,7 @@ import { RiBookOpenLine } from '@remixicon/react'
import i18n from '@/i18n-config/i18next-config'
import { registerCommands, unregisterCommands } from './command-bus'
import { defaultDocBaseUrl } from '@/context/i18n'
import { getDocLanguage } from '@/i18n-config/language'
// Documentation command dependency types - no external dependencies needed
type DocDeps = Record<string, never>
@ -11,9 +12,19 @@ type DocDeps = Record<string, never>
/**
* Documentation command - Opens help documentation
*/
export const docCommand: SlashCommandHandler<DocDeps> = {
name: 'doc',
export const docsCommand: SlashCommandHandler<DocDeps> = {
name: 'docs',
description: 'Open documentation',
mode: 'direct',
// Direct execution function
execute: () => {
const currentLocale = i18n.language
const docLanguage = getDocLanguage(currentLocale)
const url = `${defaultDocBaseUrl}/${docLanguage}`
window.open(url, '_blank', 'noopener,noreferrer')
},
async search(args: string, locale: string = 'en') {
return [{
id: 'doc',
@ -32,7 +43,10 @@ export const docCommand: SlashCommandHandler<DocDeps> = {
register(_deps: DocDeps) {
registerCommands({
'navigation.doc': async (_args) => {
const url = `${defaultDocBaseUrl}`
// Get the current language from i18n
const currentLocale = i18n.language
const docLanguage = getDocLanguage(currentLocale)
const url = `${defaultDocBaseUrl}/${docLanguage}`
window.open(url, '_blank', 'noopener,noreferrer')
},
})

View File

@ -13,6 +13,14 @@ type FeedbackDeps = Record<string, never>
export const feedbackCommand: SlashCommandHandler<FeedbackDeps> = {
name: 'feedback',
description: 'Open feedback discussions',
mode: 'direct',
// Direct execution function
execute: () => {
const url = 'https://github.com/langgenius/dify/discussions/categories/feedbacks'
window.open(url, '_blank', 'noopener,noreferrer')
},
async search(args: string, locale: string = 'en') {
return [{
id: 'feedback',

View File

@ -31,6 +31,7 @@ export const languageCommand: SlashCommandHandler<LanguageDeps> = {
name: 'language',
aliases: ['lang'],
description: 'Switch between different languages',
mode: 'submenu', // Explicitly set submenu mode
async search(args: string, _locale: string = 'en') {
// Return language options directly, regardless of parameters

View File

@ -8,7 +8,7 @@ import { setLocaleOnClient } from '@/i18n-config'
import { themeCommand } from './theme'
import { languageCommand } from './language'
import { feedbackCommand } from './feedback'
import { docCommand } from './doc'
import { docsCommand } from './docs'
import { communityCommand } from './community'
import { accountCommand } from './account'
import i18n from '@/i18n-config/i18next-config'
@ -35,7 +35,7 @@ export const registerSlashCommands = (deps: Record<string, any>) => {
slashCommandRegistry.register(themeCommand, { setTheme: deps.setTheme })
slashCommandRegistry.register(languageCommand, { setLocale: deps.setLocale })
slashCommandRegistry.register(feedbackCommand, {})
slashCommandRegistry.register(docCommand, {})
slashCommandRegistry.register(docsCommand, {})
slashCommandRegistry.register(communityCommand, {})
slashCommandRegistry.register(accountCommand, {})
}
@ -45,7 +45,7 @@ export const unregisterSlashCommands = () => {
slashCommandRegistry.unregister('theme')
slashCommandRegistry.unregister('language')
slashCommandRegistry.unregister('feedback')
slashCommandRegistry.unregister('doc')
slashCommandRegistry.unregister('docs')
slashCommandRegistry.unregister('community')
slashCommandRegistry.unregister('account')
}

View File

@ -60,6 +60,7 @@ const buildThemeCommands = (query: string, locale?: string): CommandSearchResult
export const themeCommand: SlashCommandHandler<ThemeDeps> = {
name: 'theme',
description: 'Switch between light and dark themes',
mode: 'submenu', // Explicitly set submenu mode
async search(args: string, locale: string = 'en') {
// Return theme options directly, regardless of parameters

View File

@ -15,7 +15,20 @@ export type SlashCommandHandler<TDeps = any> = {
description: string
/**
* Search command results
* Command mode:
* - 'direct': Execute immediately when selected (e.g., /docs, /community)
* - 'submenu': Show submenu options (e.g., /theme, /language)
*/
mode?: 'direct' | 'submenu'
/**
* Direct execution function for 'direct' mode commands
* Called when the command is selected and should execute immediately
*/
execute?: () => void | Promise<void>
/**
* Search command results (for 'submenu' mode or showing options)
* @param args Command arguments (part after removing command name)
* @param locale Current language
*/

View File

@ -169,6 +169,7 @@ import { pluginAction } from './plugin'
import { workflowNodesAction } from './workflow-nodes'
import type { ActionItem, SearchResult } from './types'
import { slashAction } from './commands'
import { slashCommandRegistry } from './commands/registry'
export const Actions = {
slash: slashAction,
@ -234,9 +235,23 @@ export const searchAnything = async (
export const matchAction = (query: string, actions: Record<string, ActionItem>) => {
return Object.values(actions).find((action) => {
// Special handling for slash commands to allow direct /theme, /lang
if (action.key === '/')
return query.startsWith('/')
// Special handling for slash commands
if (action.key === '/') {
// Get all registered commands from the registry
const allCommands = slashCommandRegistry.getAllCommands()
// Check if query matches any registered command
return allCommands.some((cmd) => {
const cmdPattern = `/${cmd.name}`
// For direct mode commands, don't match (keep in command selector)
if (cmd.mode === 'direct')
return false
// For submenu mode commands, match when complete command is entered
return query === cmdPattern || query.startsWith(`${cmdPattern} `)
})
}
const reg = new RegExp(`^(${action.key}|${action.shortcut})(?:\\s|$)`)
return reg.test(query)

View File

@ -79,8 +79,8 @@ const CommandSelector: FC<Props> = ({ actions, onCommandSelect, searchFilter, co
}
return (
<div className="p-4">
<div className="mb-3 text-left text-sm font-medium text-text-secondary">
<div className="px-4 py-3">
<div className="mb-2 text-left text-sm font-medium text-text-secondary">
{isSlashMode ? t('app.gotoAnything.groups.commands') : t('app.gotoAnything.selectSearchType')}
</div>
<Command.Group className="space-y-1">
@ -89,7 +89,7 @@ const CommandSelector: FC<Props> = ({ actions, onCommandSelect, searchFilter, co
key={item.key}
value={item.shortcut}
className="flex cursor-pointer items-center rounded-md
p-2.5
p-2
transition-all
duration-150 hover:bg-state-base-hover aria-[selected=true]:bg-state-base-hover-alt"
onSelect={() => onCommandSelect(item.shortcut)}
@ -105,7 +105,7 @@ const CommandSelector: FC<Props> = ({ actions, onCommandSelect, searchFilter, co
'/language': 'app.gotoAnything.actions.languageChangeDesc',
'/account': 'app.gotoAnything.actions.accountDesc',
'/feedback': 'app.gotoAnything.actions.feedbackDesc',
'/doc': 'app.gotoAnything.actions.docDesc',
'/docs': 'app.gotoAnything.actions.docDesc',
'/community': 'app.gotoAnything.actions.communityDesc',
}
return t(slashKeyMap[item.key] || item.description)

View File

@ -11,6 +11,7 @@ import { selectWorkflowNode } from '@/app/components/workflow/utils/node-navigat
import { RiSearchLine } from '@remixicon/react'
import { Actions as AllActions, type SearchResult, matchAction, searchAnything } from './actions'
import { GotoAnythingProvider, useGotoAnythingContext } from './context'
import { slashCommandRegistry } from './actions/commands/registry'
import { useQuery } from '@tanstack/react-query'
import { useGetLanguage } from '@/context/i18n'
import { useTranslation } from 'react-i18next'
@ -87,14 +88,21 @@ const GotoAnything: FC<Props> = ({
|| (searchQuery.trim().startsWith('/') && !matchAction(searchQuery.trim(), Actions))
const searchMode = useMemo(() => {
if (isCommandsMode) return 'commands'
if (isCommandsMode) {
// Distinguish between @ (scopes) and / (commands) mode
if (searchQuery.trim().startsWith('@'))
return 'scopes'
else if (searchQuery.trim().startsWith('/'))
return 'commands'
return 'commands' // default fallback
}
const query = searchQueryDebouncedValue.toLowerCase()
const action = matchAction(query, Actions)
return action
? (action.key === '/' ? '@command' : action.key)
: 'general'
}, [searchQueryDebouncedValue, Actions, isCommandsMode])
}, [searchQueryDebouncedValue, Actions, isCommandsMode, searchQuery])
const { data: searchResults = [], isLoading, isError, error } = useQuery(
{
@ -124,6 +132,21 @@ const GotoAnything: FC<Props> = ({
}
const handleCommandSelect = useCallback((commandKey: string) => {
// Check if it's a slash command
if (commandKey.startsWith('/')) {
const commandName = commandKey.substring(1)
const handler = slashCommandRegistry.findCommand(commandName)
// If it's a direct mode command, execute immediately
if (handler?.mode === 'direct' && handler.execute) {
handler.execute()
setShow(false)
setSearchQuery('')
return
}
}
// Otherwise, proceed with the normal flow (submenu mode)
setSearchQuery(`${commandKey} `)
clearSelection()
setTimeout(() => {
@ -220,7 +243,7 @@ const GotoAnything: FC<Props> = ({
if (searchQuery.trim())
return null
return (<div className="flex items-center justify-center py-12 text-center text-text-tertiary">
return (<div className="flex items-center justify-center py-8 text-center text-text-tertiary">
<div>
<div className='text-sm font-medium'>{t('app.gotoAnything.searchTitle')}</div>
<div className='mt-3 space-y-1 text-xs text-text-quaternary'>
@ -274,13 +297,38 @@ const GotoAnything: FC<Props> = ({
if (!e.target.value.startsWith('@') && !e.target.value.startsWith('/'))
clearSelection()
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
const query = searchQuery.trim()
// Check if it's a complete slash command
if (query.startsWith('/')) {
const commandName = query.substring(1).split(' ')[0]
const handler = slashCommandRegistry.findCommand(commandName)
// If it's a direct mode command, execute immediately
if (handler?.mode === 'direct' && handler.execute) {
e.preventDefault()
handler.execute()
setShow(false)
setSearchQuery('')
}
}
}
}}
className='flex-1 !border-0 !bg-transparent !shadow-none'
wrapperClassName='flex-1 !border-0 !bg-transparent'
autoFocus
/>
{searchMode !== 'general' && (
<div className='flex items-center gap-1 rounded bg-blue-50 px-2 py-[2px] text-xs font-medium text-blue-600 dark:bg-blue-900/40 dark:text-blue-300'>
<span>{searchMode.replace('@', '').toUpperCase()}</span>
<span>{(() => {
if (searchMode === 'scopes')
return 'SCOPES'
else if (searchMode === 'commands')
return 'COMMANDS'
else
return searchMode.replace('@', '').toUpperCase()
})()}</span>
</div>
)}
</div>
@ -294,7 +342,7 @@ const GotoAnything: FC<Props> = ({
</div>
</div>
<Command.List className='max-h-[275px] min-h-[240px] overflow-y-auto'>
<Command.List className='h-[240px] overflow-y-auto'>
{isLoading && (
<div className="flex items-center justify-center py-8 text-center text-text-tertiary">
<div className="flex items-center gap-2">
@ -368,32 +416,52 @@ const GotoAnything: FC<Props> = ({
)}
</Command.List>
{(!!searchResults.length || isError) && (
<div className='border-t border-divider-subtle bg-components-panel-bg-blur px-4 py-2 text-xs text-text-tertiary'>
<div className='flex items-center justify-between'>
<span>
{isError ? (
<span className='text-red-500'>{t('app.gotoAnything.someServicesUnavailable')}</span>
) : (
<>
{t('app.gotoAnything.resultCount', { count: searchResults.length })}
{searchMode !== 'general' && (
<span className='ml-2 opacity-60'>
{t('app.gotoAnything.inScope', { scope: searchMode.replace('@', '') })}
</span>
)}
</>
)}
</span>
<span className='opacity-60'>
{searchMode !== 'general'
? t('app.gotoAnything.clearToSearchAll')
: t('app.gotoAnything.useAtForSpecific')
}
</span>
</div>
{/* Always show footer to prevent height jumping */}
<div className='border-t border-divider-subtle bg-components-panel-bg-blur px-4 py-2 text-xs text-text-tertiary'>
<div className='flex min-h-[16px] items-center justify-between'>
{(!!searchResults.length || isError) ? (
<>
<span>
{isError ? (
<span className='text-red-500'>{t('app.gotoAnything.someServicesUnavailable')}</span>
) : (
<>
{t('app.gotoAnything.resultCount', { count: searchResults.length })}
{searchMode !== 'general' && (
<span className='ml-2 opacity-60'>
{t('app.gotoAnything.inScope', { scope: searchMode.replace('@', '') })}
</span>
)}
</>
)}
</span>
<span className='opacity-60'>
{searchMode !== 'general'
? t('app.gotoAnything.clearToSearchAll')
: t('app.gotoAnything.useAtForSpecific')
}
</span>
</>
) : (
<>
<span className='opacity-60'>
{isCommandsMode
? t('app.gotoAnything.selectToNavigate')
: searchQuery.trim()
? t('app.gotoAnything.searching')
: t('app.gotoAnything.startTyping')
}
</span>
<span className='opacity-60'>
{searchQuery.trim() || isCommandsMode
? t('app.gotoAnything.tips')
: t('app.gotoAnything.pressEscToClose')
}
</span>
</>
)}
</div>
)}
</div>
</Command>
</div>

View File

@ -266,6 +266,10 @@ const translation = {
inScope: 'in {{scope}}s',
clearToSearchAll: 'Clear @ to search all',
useAtForSpecific: 'Use @ for specific types',
selectToNavigate: 'Select to navigate',
startTyping: 'Start typing to search',
tips: 'Press ↑↓ to navigate',
pressEscToClose: 'Press ESC to close',
selectSearchType: 'Choose what to search for',
searchHint: 'Start typing to search everything instantly',
commandHint: 'Type @ to browse by category',

View File

@ -265,9 +265,14 @@ const translation = {
inScope: '{{scope}}s 内',
clearToSearchAll: '@ をクリアしてすべてを検索',
useAtForSpecific: '特定のタイプには @ を使用',
selectToNavigate: '選択してナビゲート',
startTyping: '入力を開始して検索',
tips: '↑↓ でナビゲート',
pressEscToClose: 'ESC で閉じる',
selectSearchType: '検索対象を選択',
searchHint: '入力を開始してすべてを瞬時に検索',
commandHint: '@ を入力してカテゴリ別に参照',
slashHint: '/ を入力してすべてのコマンドを表示',
actions: {
searchApplications: 'アプリケーションを検索',
searchApplicationsDesc: 'アプリケーションを検索してナビゲート',
@ -314,7 +319,6 @@ const translation = {
},
noMatchingCommands: '一致するコマンドが見つかりません',
tryDifferentSearch: '別の検索語句をお試しください',
slashHint: '/を入力して、利用可能なすべてのコマンドを表示します。',
},
}

View File

@ -265,6 +265,10 @@ const translation = {
inScope: '在 {{scope}}s 中',
clearToSearchAll: '清除 @ 以搜索全部',
useAtForSpecific: '使用 @ 进行特定类型搜索',
selectToNavigate: '选择以导航',
startTyping: '开始输入以搜索',
tips: '按 ↑↓ 导航',
pressEscToClose: '按 ESC 关闭',
selectSearchType: '选择搜索内容',
searchHint: '开始输入即可立即搜索所有内容',
commandHint: '输入 @ 按类别浏览',