fix: Multiple UX improvements for GotoAnything command palette (#25637)
This commit is contained in:
@@ -13,6 +13,12 @@ type AccountDeps = Record<string, never>
|
||||
export const accountCommand: SlashCommandHandler<AccountDeps> = {
|
||||
name: 'account',
|
||||
description: 'Navigate to account page',
|
||||
mode: 'direct',
|
||||
|
||||
// Direct execution function
|
||||
execute: () => {
|
||||
window.location.href = '/account'
|
||||
},
|
||||
|
||||
async search(args: string, locale: string = 'en') {
|
||||
return [{
|
||||
|
||||
@@ -13,6 +13,14 @@ type CommunityDeps = Record<string, never>
|
||||
export const communityCommand: SlashCommandHandler<CommunityDeps> = {
|
||||
name: 'community',
|
||||
description: 'Open community Discord',
|
||||
mode: 'direct',
|
||||
|
||||
// Direct execution function
|
||||
execute: () => {
|
||||
const url = 'https://discord.gg/5AEfbxcd9k'
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
},
|
||||
|
||||
async search(args: string, locale: string = 'en') {
|
||||
return [{
|
||||
id: 'community',
|
||||
|
||||
@@ -4,6 +4,7 @@ import { RiBookOpenLine } from '@remixicon/react'
|
||||
import i18n from '@/i18n-config/i18next-config'
|
||||
import { registerCommands, unregisterCommands } from './command-bus'
|
||||
import { defaultDocBaseUrl } from '@/context/i18n'
|
||||
import { getDocLanguage } from '@/i18n-config/language'
|
||||
|
||||
// Documentation command dependency types - no external dependencies needed
|
||||
type DocDeps = Record<string, never>
|
||||
@@ -11,9 +12,19 @@ type DocDeps = Record<string, never>
|
||||
/**
|
||||
* Documentation command - Opens help documentation
|
||||
*/
|
||||
export const docCommand: SlashCommandHandler<DocDeps> = {
|
||||
name: 'doc',
|
||||
export const docsCommand: SlashCommandHandler<DocDeps> = {
|
||||
name: 'docs',
|
||||
description: 'Open documentation',
|
||||
mode: 'direct',
|
||||
|
||||
// Direct execution function
|
||||
execute: () => {
|
||||
const currentLocale = i18n.language
|
||||
const docLanguage = getDocLanguage(currentLocale)
|
||||
const url = `${defaultDocBaseUrl}/${docLanguage}`
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
},
|
||||
|
||||
async search(args: string, locale: string = 'en') {
|
||||
return [{
|
||||
id: 'doc',
|
||||
@@ -32,7 +43,10 @@ export const docCommand: SlashCommandHandler<DocDeps> = {
|
||||
register(_deps: DocDeps) {
|
||||
registerCommands({
|
||||
'navigation.doc': async (_args) => {
|
||||
const url = `${defaultDocBaseUrl}`
|
||||
// Get the current language from i18n
|
||||
const currentLocale = i18n.language
|
||||
const docLanguage = getDocLanguage(currentLocale)
|
||||
const url = `${defaultDocBaseUrl}/${docLanguage}`
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
},
|
||||
})
|
||||
@@ -13,6 +13,14 @@ type FeedbackDeps = Record<string, never>
|
||||
export const feedbackCommand: SlashCommandHandler<FeedbackDeps> = {
|
||||
name: 'feedback',
|
||||
description: 'Open feedback discussions',
|
||||
mode: 'direct',
|
||||
|
||||
// Direct execution function
|
||||
execute: () => {
|
||||
const url = 'https://github.com/langgenius/dify/discussions/categories/feedbacks'
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
},
|
||||
|
||||
async search(args: string, locale: string = 'en') {
|
||||
return [{
|
||||
id: 'feedback',
|
||||
|
||||
@@ -31,6 +31,7 @@ export const languageCommand: SlashCommandHandler<LanguageDeps> = {
|
||||
name: 'language',
|
||||
aliases: ['lang'],
|
||||
description: 'Switch between different languages',
|
||||
mode: 'submenu', // Explicitly set submenu mode
|
||||
|
||||
async search(args: string, _locale: string = 'en') {
|
||||
// Return language options directly, regardless of parameters
|
||||
|
||||
@@ -8,7 +8,7 @@ import { setLocaleOnClient } from '@/i18n-config'
|
||||
import { themeCommand } from './theme'
|
||||
import { languageCommand } from './language'
|
||||
import { feedbackCommand } from './feedback'
|
||||
import { docCommand } from './doc'
|
||||
import { docsCommand } from './docs'
|
||||
import { communityCommand } from './community'
|
||||
import { accountCommand } from './account'
|
||||
import i18n from '@/i18n-config/i18next-config'
|
||||
@@ -35,7 +35,7 @@ export const registerSlashCommands = (deps: Record<string, any>) => {
|
||||
slashCommandRegistry.register(themeCommand, { setTheme: deps.setTheme })
|
||||
slashCommandRegistry.register(languageCommand, { setLocale: deps.setLocale })
|
||||
slashCommandRegistry.register(feedbackCommand, {})
|
||||
slashCommandRegistry.register(docCommand, {})
|
||||
slashCommandRegistry.register(docsCommand, {})
|
||||
slashCommandRegistry.register(communityCommand, {})
|
||||
slashCommandRegistry.register(accountCommand, {})
|
||||
}
|
||||
@@ -45,7 +45,7 @@ export const unregisterSlashCommands = () => {
|
||||
slashCommandRegistry.unregister('theme')
|
||||
slashCommandRegistry.unregister('language')
|
||||
slashCommandRegistry.unregister('feedback')
|
||||
slashCommandRegistry.unregister('doc')
|
||||
slashCommandRegistry.unregister('docs')
|
||||
slashCommandRegistry.unregister('community')
|
||||
slashCommandRegistry.unregister('account')
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ const buildThemeCommands = (query: string, locale?: string): CommandSearchResult
|
||||
export const themeCommand: SlashCommandHandler<ThemeDeps> = {
|
||||
name: 'theme',
|
||||
description: 'Switch between light and dark themes',
|
||||
mode: 'submenu', // Explicitly set submenu mode
|
||||
|
||||
async search(args: string, locale: string = 'en') {
|
||||
// Return theme options directly, regardless of parameters
|
||||
|
||||
@@ -15,7 +15,20 @@ export type SlashCommandHandler<TDeps = any> = {
|
||||
description: string
|
||||
|
||||
/**
|
||||
* Search command results
|
||||
* Command mode:
|
||||
* - 'direct': Execute immediately when selected (e.g., /docs, /community)
|
||||
* - 'submenu': Show submenu options (e.g., /theme, /language)
|
||||
*/
|
||||
mode?: 'direct' | 'submenu'
|
||||
|
||||
/**
|
||||
* Direct execution function for 'direct' mode commands
|
||||
* Called when the command is selected and should execute immediately
|
||||
*/
|
||||
execute?: () => void | Promise<void>
|
||||
|
||||
/**
|
||||
* Search command results (for 'submenu' mode or showing options)
|
||||
* @param args Command arguments (part after removing command name)
|
||||
* @param locale Current language
|
||||
*/
|
||||
|
||||
@@ -169,6 +169,7 @@ import { pluginAction } from './plugin'
|
||||
import { workflowNodesAction } from './workflow-nodes'
|
||||
import type { ActionItem, SearchResult } from './types'
|
||||
import { slashAction } from './commands'
|
||||
import { slashCommandRegistry } from './commands/registry'
|
||||
|
||||
export const Actions = {
|
||||
slash: slashAction,
|
||||
@@ -234,9 +235,23 @@ export const searchAnything = async (
|
||||
|
||||
export const matchAction = (query: string, actions: Record<string, ActionItem>) => {
|
||||
return Object.values(actions).find((action) => {
|
||||
// Special handling for slash commands to allow direct /theme, /lang
|
||||
if (action.key === '/')
|
||||
return query.startsWith('/')
|
||||
// Special handling for slash commands
|
||||
if (action.key === '/') {
|
||||
// Get all registered commands from the registry
|
||||
const allCommands = slashCommandRegistry.getAllCommands()
|
||||
|
||||
// Check if query matches any registered command
|
||||
return allCommands.some((cmd) => {
|
||||
const cmdPattern = `/${cmd.name}`
|
||||
|
||||
// For direct mode commands, don't match (keep in command selector)
|
||||
if (cmd.mode === 'direct')
|
||||
return false
|
||||
|
||||
// For submenu mode commands, match when complete command is entered
|
||||
return query === cmdPattern || query.startsWith(`${cmdPattern} `)
|
||||
})
|
||||
}
|
||||
|
||||
const reg = new RegExp(`^(${action.key}|${action.shortcut})(?:\\s|$)`)
|
||||
return reg.test(query)
|
||||
|
||||
@@ -79,8 +79,8 @@ const CommandSelector: FC<Props> = ({ actions, onCommandSelect, searchFilter, co
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<div className="mb-3 text-left text-sm font-medium text-text-secondary">
|
||||
<div className="px-4 py-3">
|
||||
<div className="mb-2 text-left text-sm font-medium text-text-secondary">
|
||||
{isSlashMode ? t('app.gotoAnything.groups.commands') : t('app.gotoAnything.selectSearchType')}
|
||||
</div>
|
||||
<Command.Group className="space-y-1">
|
||||
@@ -89,7 +89,7 @@ const CommandSelector: FC<Props> = ({ actions, onCommandSelect, searchFilter, co
|
||||
key={item.key}
|
||||
value={item.shortcut}
|
||||
className="flex cursor-pointer items-center rounded-md
|
||||
p-2.5
|
||||
p-2
|
||||
transition-all
|
||||
duration-150 hover:bg-state-base-hover aria-[selected=true]:bg-state-base-hover-alt"
|
||||
onSelect={() => onCommandSelect(item.shortcut)}
|
||||
@@ -105,7 +105,7 @@ const CommandSelector: FC<Props> = ({ actions, onCommandSelect, searchFilter, co
|
||||
'/language': 'app.gotoAnything.actions.languageChangeDesc',
|
||||
'/account': 'app.gotoAnything.actions.accountDesc',
|
||||
'/feedback': 'app.gotoAnything.actions.feedbackDesc',
|
||||
'/doc': 'app.gotoAnything.actions.docDesc',
|
||||
'/docs': 'app.gotoAnything.actions.docDesc',
|
||||
'/community': 'app.gotoAnything.actions.communityDesc',
|
||||
}
|
||||
return t(slashKeyMap[item.key] || item.description)
|
||||
|
||||
@@ -11,6 +11,7 @@ import { selectWorkflowNode } from '@/app/components/workflow/utils/node-navigat
|
||||
import { RiSearchLine } from '@remixicon/react'
|
||||
import { Actions as AllActions, type SearchResult, matchAction, searchAnything } from './actions'
|
||||
import { GotoAnythingProvider, useGotoAnythingContext } from './context'
|
||||
import { slashCommandRegistry } from './actions/commands/registry'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useGetLanguage } from '@/context/i18n'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -87,14 +88,21 @@ const GotoAnything: FC<Props> = ({
|
||||
|| (searchQuery.trim().startsWith('/') && !matchAction(searchQuery.trim(), Actions))
|
||||
|
||||
const searchMode = useMemo(() => {
|
||||
if (isCommandsMode) return 'commands'
|
||||
if (isCommandsMode) {
|
||||
// Distinguish between @ (scopes) and / (commands) mode
|
||||
if (searchQuery.trim().startsWith('@'))
|
||||
return 'scopes'
|
||||
else if (searchQuery.trim().startsWith('/'))
|
||||
return 'commands'
|
||||
return 'commands' // default fallback
|
||||
}
|
||||
|
||||
const query = searchQueryDebouncedValue.toLowerCase()
|
||||
const action = matchAction(query, Actions)
|
||||
return action
|
||||
? (action.key === '/' ? '@command' : action.key)
|
||||
: 'general'
|
||||
}, [searchQueryDebouncedValue, Actions, isCommandsMode])
|
||||
}, [searchQueryDebouncedValue, Actions, isCommandsMode, searchQuery])
|
||||
|
||||
const { data: searchResults = [], isLoading, isError, error } = useQuery(
|
||||
{
|
||||
@@ -124,6 +132,21 @@ const GotoAnything: FC<Props> = ({
|
||||
}
|
||||
|
||||
const handleCommandSelect = useCallback((commandKey: string) => {
|
||||
// Check if it's a slash command
|
||||
if (commandKey.startsWith('/')) {
|
||||
const commandName = commandKey.substring(1)
|
||||
const handler = slashCommandRegistry.findCommand(commandName)
|
||||
|
||||
// If it's a direct mode command, execute immediately
|
||||
if (handler?.mode === 'direct' && handler.execute) {
|
||||
handler.execute()
|
||||
setShow(false)
|
||||
setSearchQuery('')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, proceed with the normal flow (submenu mode)
|
||||
setSearchQuery(`${commandKey} `)
|
||||
clearSelection()
|
||||
setTimeout(() => {
|
||||
@@ -220,7 +243,7 @@ const GotoAnything: FC<Props> = ({
|
||||
if (searchQuery.trim())
|
||||
return null
|
||||
|
||||
return (<div className="flex items-center justify-center py-12 text-center text-text-tertiary">
|
||||
return (<div className="flex items-center justify-center py-8 text-center text-text-tertiary">
|
||||
<div>
|
||||
<div className='text-sm font-medium'>{t('app.gotoAnything.searchTitle')}</div>
|
||||
<div className='mt-3 space-y-1 text-xs text-text-quaternary'>
|
||||
@@ -274,13 +297,38 @@ const GotoAnything: FC<Props> = ({
|
||||
if (!e.target.value.startsWith('@') && !e.target.value.startsWith('/'))
|
||||
clearSelection()
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
const query = searchQuery.trim()
|
||||
// Check if it's a complete slash command
|
||||
if (query.startsWith('/')) {
|
||||
const commandName = query.substring(1).split(' ')[0]
|
||||
const handler = slashCommandRegistry.findCommand(commandName)
|
||||
|
||||
// If it's a direct mode command, execute immediately
|
||||
if (handler?.mode === 'direct' && handler.execute) {
|
||||
e.preventDefault()
|
||||
handler.execute()
|
||||
setShow(false)
|
||||
setSearchQuery('')
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
className='flex-1 !border-0 !bg-transparent !shadow-none'
|
||||
wrapperClassName='flex-1 !border-0 !bg-transparent'
|
||||
autoFocus
|
||||
/>
|
||||
{searchMode !== 'general' && (
|
||||
<div className='flex items-center gap-1 rounded bg-blue-50 px-2 py-[2px] text-xs font-medium text-blue-600 dark:bg-blue-900/40 dark:text-blue-300'>
|
||||
<span>{searchMode.replace('@', '').toUpperCase()}</span>
|
||||
<span>{(() => {
|
||||
if (searchMode === 'scopes')
|
||||
return 'SCOPES'
|
||||
else if (searchMode === 'commands')
|
||||
return 'COMMANDS'
|
||||
else
|
||||
return searchMode.replace('@', '').toUpperCase()
|
||||
})()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -294,7 +342,7 @@ const GotoAnything: FC<Props> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Command.List className='max-h-[275px] min-h-[240px] overflow-y-auto'>
|
||||
<Command.List className='h-[240px] overflow-y-auto'>
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-8 text-center text-text-tertiary">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -368,32 +416,52 @@ const GotoAnything: FC<Props> = ({
|
||||
)}
|
||||
</Command.List>
|
||||
|
||||
{(!!searchResults.length || isError) && (
|
||||
<div className='border-t border-divider-subtle bg-components-panel-bg-blur px-4 py-2 text-xs text-text-tertiary'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span>
|
||||
{isError ? (
|
||||
<span className='text-red-500'>{t('app.gotoAnything.someServicesUnavailable')}</span>
|
||||
) : (
|
||||
<>
|
||||
{t('app.gotoAnything.resultCount', { count: searchResults.length })}
|
||||
{searchMode !== 'general' && (
|
||||
<span className='ml-2 opacity-60'>
|
||||
{t('app.gotoAnything.inScope', { scope: searchMode.replace('@', '') })}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<span className='opacity-60'>
|
||||
{searchMode !== 'general'
|
||||
? t('app.gotoAnything.clearToSearchAll')
|
||||
: t('app.gotoAnything.useAtForSpecific')
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
{/* Always show footer to prevent height jumping */}
|
||||
<div className='border-t border-divider-subtle bg-components-panel-bg-blur px-4 py-2 text-xs text-text-tertiary'>
|
||||
<div className='flex min-h-[16px] items-center justify-between'>
|
||||
{(!!searchResults.length || isError) ? (
|
||||
<>
|
||||
<span>
|
||||
{isError ? (
|
||||
<span className='text-red-500'>{t('app.gotoAnything.someServicesUnavailable')}</span>
|
||||
) : (
|
||||
<>
|
||||
{t('app.gotoAnything.resultCount', { count: searchResults.length })}
|
||||
{searchMode !== 'general' && (
|
||||
<span className='ml-2 opacity-60'>
|
||||
{t('app.gotoAnything.inScope', { scope: searchMode.replace('@', '') })}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<span className='opacity-60'>
|
||||
{searchMode !== 'general'
|
||||
? t('app.gotoAnything.clearToSearchAll')
|
||||
: t('app.gotoAnything.useAtForSpecific')
|
||||
}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className='opacity-60'>
|
||||
{isCommandsMode
|
||||
? t('app.gotoAnything.selectToNavigate')
|
||||
: searchQuery.trim()
|
||||
? t('app.gotoAnything.searching')
|
||||
: t('app.gotoAnything.startTyping')
|
||||
}
|
||||
</span>
|
||||
<span className='opacity-60'>
|
||||
{searchQuery.trim() || isCommandsMode
|
||||
? t('app.gotoAnything.tips')
|
||||
: t('app.gotoAnything.pressEscToClose')
|
||||
}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Command>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user