refactor(web): rebuild goto anything dialog (#38914)

This commit is contained in:
yyh 2026-07-14 18:38:45 +08:00 committed by GitHub
parent c278148c8f
commit e2fccb604a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
81 changed files with 1889 additions and 7295 deletions

View File

@ -122,22 +122,6 @@
"count": 1
}
},
"web/__tests__/goto-anything/command-selector.test.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
},
"jsx_a11y/no-static-element-interactions": {
"count": 1
},
"typescript/no-explicit-any": {
"count": 2
}
},
"web/__tests__/goto-anything/search-error-handling.test.ts": {
"no-restricted-imports": {
"count": 1
}
},
"web/__tests__/i18n-upload-features.test.ts": {
"no-console": {
"count": 3
@ -3220,62 +3204,11 @@
"count": 2
}
},
"web/app/components/goto-anything/actions/commands/registry.ts": {
"typescript/no-explicit-any": {
"count": 3
}
},
"web/app/components/goto-anything/actions/commands/types.ts": {
"typescript/no-explicit-any": {
"count": 1
}
},
"web/app/components/goto-anything/actions/plugin.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/goto-anything/actions/recent-store.ts": {
"no-restricted-globals": {
"count": 2
}
},
"web/app/components/goto-anything/actions/types.ts": {
"typescript/no-explicit-any": {
"count": 2
}
},
"web/app/components/goto-anything/components/__tests__/search-input.spec.tsx": {
"jsx_a11y/no-autofocus": {
"count": 1
}
},
"web/app/components/goto-anything/components/index.ts": {
"no-barrel-files/no-barrel-files": {
"count": 4
}
},
"web/app/components/goto-anything/components/search-input.tsx": {
"jsx_a11y/no-autofocus": {
"count": 1
},
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/goto-anything/context.tsx": {
"eslint-react/set-state-in-effect": {
"count": 4
},
"react/only-export-components": {
"count": 1
}
},
"web/app/components/goto-anything/hooks/use-goto-anything-results.ts": {
"@tanstack/query/exhaustive-deps": {
"count": 1
}
},
"web/app/components/header/account-setting/data-source-page-new/__tests__/item.spec.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
@ -6846,7 +6779,7 @@
"count": 1
},
"typescript/no-explicit-any": {
"count": 7
"count": 6
}
},
"web/service/base.ts": {

View File

@ -1,5 +1,13 @@
import { render } from 'vitest-browser-react'
import { Dialog, DialogCloseButton, DialogContent, DialogDescription, DialogTitle } from '../index'
import {
createDialogHandle,
Dialog,
DialogCloseButton,
DialogContent,
DialogDescription,
DialogTitle,
DialogTrigger,
} from '../index'
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
@ -18,6 +26,26 @@ describe('Dialog wrapper', () => {
await expect.element(screen.getByRole('dialog')).toHaveTextContent('Dialog Title')
await expect.element(screen.getByRole('dialog')).toHaveTextContent('Dialog Description')
})
it('should connect a detached trigger to the dialog', async () => {
const handle = createDialogHandle()
const screen = await render(
<>
<DialogTrigger handle={handle}>Open dialog</DialogTrigger>
<Dialog handle={handle}>
<DialogContent>
<DialogTitle>Detached dialog</DialogTitle>
</DialogContent>
</Dialog>
</>,
)
await screen.getByRole('button', { name: 'Open dialog' }).click()
await expect
.element(screen.getByRole('dialog', { name: 'Detached dialog' }))
.toBeInTheDocument()
})
})
describe('Props', () => {

View File

@ -9,6 +9,7 @@ export const DialogTrigger = BaseDialog.Trigger
export const DialogTitle = BaseDialog.Title
export const DialogDescription = BaseDialog.Description
export const DialogPortal = BaseDialog.Portal
export const createDialogHandle = BaseDialog.createHandle
type DialogBackdropProps = Omit<BaseDialog.Backdrop.Props, 'className'> & {
className?: string

1687
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -142,7 +142,6 @@ catalog:
class-variance-authority: 0.7.1
cli-table3: 0.6.5
clsx: 2.1.1
cmdk: 1.1.1
code-inspector-plugin: 1.6.6
concurrently: ^10.0.3
copy-to-clipboard: 4.0.2

View File

@ -1,310 +0,0 @@
import type { ActionItem } from '../../app/components/goto-anything/actions/types'
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import CommandSelector from '../../app/components/goto-anything/command-selector'
vi.mock('cmdk', () => ({
Command: {
Group: ({ children, className }: any) => <div className={className}>{children}</div>,
Item: ({ children, onSelect, value, className }: any) => (
<div
className={className}
onClick={() => onSelect?.()}
data-value={value}
data-testid={`command-item-${value}`}
>
{children}
</div>
),
},
}))
describe('CommandSelector', () => {
const mockActions: Record<string, ActionItem> = {
app: {
key: '@app',
shortcut: '@app',
title: 'Search Applications',
description: 'Search apps',
search: vi.fn(),
},
knowledge: {
key: '@knowledge',
shortcut: '@kb',
title: 'Search Knowledge',
description: 'Search knowledge bases',
search: vi.fn(),
},
plugin: {
key: '@plugin',
shortcut: '@plugin',
title: 'Search Plugins',
description: 'Search plugins',
search: vi.fn(),
},
node: {
key: '@node',
shortcut: '@node',
title: 'Search Nodes',
description: 'Search workflow nodes',
search: vi.fn(),
},
}
const mockOnCommandSelect = vi.fn()
const mockOnCommandValueChange = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
describe('Basic Rendering', () => {
it('should render all actions when no filter is provided', () => {
render(<CommandSelector actions={mockActions} onCommandSelect={mockOnCommandSelect} />)
expect(screen.getByTestId('command-item-@app')).toBeInTheDocument()
expect(screen.getByTestId('command-item-@kb')).toBeInTheDocument()
expect(screen.getByTestId('command-item-@plugin')).toBeInTheDocument()
expect(screen.getByTestId('command-item-@node')).toBeInTheDocument()
})
it('should render empty filter as showing all actions', () => {
render(
<CommandSelector
actions={mockActions}
onCommandSelect={mockOnCommandSelect}
searchFilter=""
/>,
)
expect(screen.getByTestId('command-item-@app')).toBeInTheDocument()
expect(screen.getByTestId('command-item-@kb')).toBeInTheDocument()
expect(screen.getByTestId('command-item-@plugin')).toBeInTheDocument()
expect(screen.getByTestId('command-item-@node')).toBeInTheDocument()
})
})
describe('Filtering Functionality', () => {
it('should filter actions based on searchFilter - single match', () => {
render(
<CommandSelector
actions={mockActions}
onCommandSelect={mockOnCommandSelect}
searchFilter="k"
/>,
)
expect(screen.queryByTestId('command-item-@app')).not.toBeInTheDocument()
expect(screen.getByTestId('command-item-@kb')).toBeInTheDocument()
expect(screen.queryByTestId('command-item-@plugin')).not.toBeInTheDocument()
expect(screen.queryByTestId('command-item-@node')).not.toBeInTheDocument()
})
it('should filter actions with multiple matches', () => {
render(
<CommandSelector
actions={mockActions}
onCommandSelect={mockOnCommandSelect}
searchFilter="p"
/>,
)
expect(screen.getByTestId('command-item-@app')).toBeInTheDocument()
expect(screen.queryByTestId('command-item-@kb')).not.toBeInTheDocument()
expect(screen.getByTestId('command-item-@plugin')).toBeInTheDocument()
expect(screen.queryByTestId('command-item-@node')).not.toBeInTheDocument()
})
it('should be case-insensitive when filtering', () => {
render(
<CommandSelector
actions={mockActions}
onCommandSelect={mockOnCommandSelect}
searchFilter="APP"
/>,
)
expect(screen.getByTestId('command-item-@app')).toBeInTheDocument()
expect(screen.queryByTestId('command-item-@kb')).not.toBeInTheDocument()
})
it('should match partial strings', () => {
render(
<CommandSelector
actions={mockActions}
onCommandSelect={mockOnCommandSelect}
searchFilter="od"
/>,
)
expect(screen.queryByTestId('command-item-@app')).not.toBeInTheDocument()
expect(screen.queryByTestId('command-item-@kb')).not.toBeInTheDocument()
expect(screen.queryByTestId('command-item-@plugin')).not.toBeInTheDocument()
expect(screen.getByTestId('command-item-@node')).toBeInTheDocument()
})
})
describe('Empty State', () => {
it('should show empty state when no matches found', () => {
render(
<CommandSelector
actions={mockActions}
onCommandSelect={mockOnCommandSelect}
searchFilter="xyz"
/>,
)
expect(screen.queryByTestId('command-item-@app')).not.toBeInTheDocument()
expect(screen.queryByTestId('command-item-@kb')).not.toBeInTheDocument()
expect(screen.queryByTestId('command-item-@plugin')).not.toBeInTheDocument()
expect(screen.queryByTestId('command-item-@node')).not.toBeInTheDocument()
expect(screen.getByText('app.gotoAnything.noMatchingCommands')).toBeInTheDocument()
expect(screen.getByText('app.gotoAnything.tryDifferentSearch')).toBeInTheDocument()
})
it('should not show empty state when filter is empty', () => {
render(
<CommandSelector
actions={mockActions}
onCommandSelect={mockOnCommandSelect}
searchFilter=""
/>,
)
expect(screen.queryByText('app.gotoAnything.noMatchingCommands')).not.toBeInTheDocument()
})
})
describe('Selection and Highlight Management', () => {
it('should call onCommandValueChange when filter changes and first item differs', () => {
const { rerender } = render(
<CommandSelector
actions={mockActions}
onCommandSelect={mockOnCommandSelect}
searchFilter=""
commandValue="@app"
onCommandValueChange={mockOnCommandValueChange}
/>,
)
rerender(
<CommandSelector
actions={mockActions}
onCommandSelect={mockOnCommandSelect}
searchFilter="k"
commandValue="@app"
onCommandValueChange={mockOnCommandValueChange}
/>,
)
expect(mockOnCommandValueChange).toHaveBeenCalledWith('@kb')
})
it('should not call onCommandValueChange if current value still exists', () => {
const { rerender } = render(
<CommandSelector
actions={mockActions}
onCommandSelect={mockOnCommandSelect}
searchFilter=""
commandValue="@app"
onCommandValueChange={mockOnCommandValueChange}
/>,
)
rerender(
<CommandSelector
actions={mockActions}
onCommandSelect={mockOnCommandSelect}
searchFilter="a"
commandValue="@app"
onCommandValueChange={mockOnCommandValueChange}
/>,
)
expect(mockOnCommandValueChange).not.toHaveBeenCalled()
})
it('should handle onCommandSelect callback correctly', () => {
render(
<CommandSelector
actions={mockActions}
onCommandSelect={mockOnCommandSelect}
searchFilter="k"
/>,
)
const knowledgeItem = screen.getByTestId('command-item-@kb')
fireEvent.click(knowledgeItem)
expect(mockOnCommandSelect).toHaveBeenCalledWith('@kb')
})
})
describe('Edge Cases', () => {
it('should handle empty actions object', () => {
render(<CommandSelector actions={{}} onCommandSelect={mockOnCommandSelect} searchFilter="" />)
expect(screen.getByText('app.gotoAnything.noMatchingCommands')).toBeInTheDocument()
})
it('should handle special characters in filter', () => {
render(
<CommandSelector
actions={mockActions}
onCommandSelect={mockOnCommandSelect}
searchFilter="@"
/>,
)
expect(screen.getByTestId('command-item-@app')).toBeInTheDocument()
expect(screen.getByTestId('command-item-@kb')).toBeInTheDocument()
expect(screen.getByTestId('command-item-@plugin')).toBeInTheDocument()
expect(screen.getByTestId('command-item-@node')).toBeInTheDocument()
})
it('should handle undefined onCommandValueChange gracefully', () => {
const { rerender } = render(
<CommandSelector
actions={mockActions}
onCommandSelect={mockOnCommandSelect}
searchFilter=""
/>,
)
expect(() => {
rerender(
<CommandSelector
actions={mockActions}
onCommandSelect={mockOnCommandSelect}
searchFilter="k"
/>,
)
}).not.toThrow()
})
})
describe('Backward Compatibility', () => {
it('should work without searchFilter prop (backward compatible)', () => {
render(<CommandSelector actions={mockActions} onCommandSelect={mockOnCommandSelect} />)
expect(screen.getByTestId('command-item-@app')).toBeInTheDocument()
expect(screen.getByTestId('command-item-@kb')).toBeInTheDocument()
expect(screen.getByTestId('command-item-@plugin')).toBeInTheDocument()
expect(screen.getByTestId('command-item-@node')).toBeInTheDocument()
})
it('should work without commandValue and onCommandValueChange props', () => {
render(
<CommandSelector
actions={mockActions}
onCommandSelect={mockOnCommandSelect}
searchFilter="k"
/>,
)
expect(screen.getByTestId('command-item-@kb')).toBeInTheDocument()
expect(screen.queryByTestId('command-item-@app')).not.toBeInTheDocument()
})
})
})

View File

@ -1,234 +0,0 @@
import type { Mock } from 'vitest'
import type { ActionItem } from '../../app/components/goto-anything/actions/types'
// 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'
// Mock the entire actions module to avoid import issues
vi.mock('../../app/components/goto-anything/actions', () => ({
matchAction: vi.fn(),
}))
vi.mock('../../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 Mock).mockImplementation(actualMatchAction)
describe('matchAction Logic', () => {
const mockActions: Record<string, ActionItem> = {
app: {
key: '@app',
shortcut: '@a',
title: 'Search Applications',
description: 'Search apps',
search: vi.fn(),
},
knowledge: {
key: '@knowledge',
shortcut: '@kb',
title: 'Search Knowledge',
description: 'Search knowledge bases',
search: vi.fn(),
},
slash: {
key: '/',
shortcut: '/',
title: 'Commands',
description: 'Execute commands',
search: vi.fn(),
},
}
beforeEach(() => {
vi.clearAllMocks()
;(slashCommandRegistry.getAllCommands as 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 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 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 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 Mock).mockReturnValue([])
const result = matchAction('/anything', mockActions)
expect(result).toBeUndefined()
})
})
})

View File

@ -1,209 +0,0 @@
import type { MockedFunction } from 'vitest'
/**
* Test GotoAnything search error handling mechanisms
*
* Main validations:
* 1. @plugin search error handling when API fails
* 2. Regular search (without @prefix) error handling when API fails
* 3. Verify consistent error handling across different search types
* 4. Ensure errors don't propagate to UI layer causing "search failed"
*/
import { Actions, searchAnything } from '@/app/components/goto-anything/actions'
import { fetchAppList } from '@/service/apps'
import { postMarketplace } from '@/service/base'
import { fetchDatasets } from '@/service/datasets'
// Mock API functions
vi.mock('@/service/base', () => ({
postMarketplace: vi.fn(),
}))
vi.mock('@/service/apps', () => ({
fetchAppList: vi.fn(),
}))
vi.mock('@/service/datasets', () => ({
fetchDatasets: vi.fn(),
}))
const mockPostMarketplace = postMarketplace as MockedFunction<typeof postMarketplace>
const mockFetchAppList = fetchAppList as MockedFunction<typeof fetchAppList>
const mockFetchDatasets = fetchDatasets as MockedFunction<typeof fetchDatasets>
describe('GotoAnything Search Error Handling', () => {
beforeEach(() => {
vi.clearAllMocks()
// Suppress console.warn for clean test output
vi.spyOn(console, 'warn').mockImplementation(() => {
// Suppress console.warn for clean test output
})
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('@plugin search error handling', () => {
it('should return empty array when API fails instead of throwing error', async () => {
// Mock marketplace API failure (403 permission denied)
mockPostMarketplace.mockRejectedValue(new Error('HTTP 403: Forbidden'))
const pluginAction = Actions.plugin
// Directly call plugin action's search method
const result = await pluginAction.search('@plugin', 'test', 'en')
// Should return empty array instead of throwing error
expect(result).toEqual([])
expect(mockPostMarketplace).toHaveBeenCalledWith('/plugins/search/advanced', {
body: {
page: 1,
page_size: 10,
query: 'test',
type: 'plugin',
},
})
})
it('should return empty array when user has no plugin data', async () => {
// Mock marketplace returning empty data
mockPostMarketplace.mockResolvedValue({
data: { plugins: [] },
})
const pluginAction = Actions.plugin
const result = await pluginAction.search('@plugin', '', 'en')
expect(result).toEqual([])
})
it('should return empty array when API returns unexpected data structure', async () => {
// Mock API returning unexpected data structure
mockPostMarketplace.mockResolvedValue({
data: null,
})
const pluginAction = Actions.plugin
const result = await pluginAction.search('@plugin', 'test', 'en')
expect(result).toEqual([])
})
})
describe('Other search types error handling', () => {
it('@app search should return empty array when API fails', async () => {
// Mock app API failure
mockFetchAppList.mockRejectedValue(new Error('API Error'))
const appAction = Actions.app
const result = await appAction.search('@app', 'test', 'en')
expect(result).toEqual([])
})
it('@knowledge search should return empty array when API fails', async () => {
// Mock knowledge API failure
mockFetchDatasets.mockRejectedValue(new Error('API Error'))
const knowledgeAction = Actions.knowledge
const result = await knowledgeAction.search('@knowledge', 'test', 'en')
expect(result).toEqual([])
})
})
describe('Unified search entry error handling', () => {
it('regular search (without @prefix) should return successful results even when partial APIs fail', async () => {
// Set app and knowledge success, plugin failure
mockFetchAppList.mockResolvedValue({
data: [],
has_more: false,
limit: 10,
page: 1,
total: 0,
})
mockFetchDatasets.mockResolvedValue({
data: [],
has_more: false,
limit: 10,
page: 1,
total: 0,
})
mockPostMarketplace.mockRejectedValue(new Error('Plugin API failed'))
const result = await searchAnything('en', 'test')
// Should return successful results even if plugin search fails
expect(result).toEqual([])
expect(console.warn).toHaveBeenCalledWith('Plugin search failed:', expect.any(Error))
})
it('@plugin dedicated search should return empty array when API fails', async () => {
// Mock plugin API failure
mockPostMarketplace.mockRejectedValue(new Error('Plugin service unavailable'))
const pluginAction = Actions.plugin
const result = await searchAnything('en', '@plugin test', pluginAction)
// Should return empty array instead of throwing error
expect(result).toEqual([])
})
it('@app dedicated search should return empty array when API fails', async () => {
// Mock app API failure
mockFetchAppList.mockRejectedValue(new Error('App service unavailable'))
const appAction = Actions.app
const result = await searchAnything('en', '@app test', appAction)
expect(result).toEqual([])
})
})
describe('Error handling consistency validation', () => {
it('all search types should return empty array when encountering errors', async () => {
// Mock all APIs to fail
mockPostMarketplace.mockRejectedValue(new Error('Plugin API failed'))
mockFetchAppList.mockRejectedValue(new Error('App API failed'))
mockFetchDatasets.mockRejectedValue(new Error('Dataset API failed'))
const actions = [
{ name: '@plugin', action: Actions.plugin },
{ name: '@app', action: Actions.app },
{ name: '@knowledge', action: Actions.knowledge },
]
for (const { name, action } of actions) {
const result = await action.search(name, 'test', 'en')
expect(result).toEqual([])
}
})
})
describe('Edge case testing', () => {
it('empty search term should be handled properly', async () => {
mockPostMarketplace.mockResolvedValue({ data: { plugins: [] } })
const result = await searchAnything('en', '@plugin ', Actions.plugin)
expect(result).toEqual([])
})
it('network timeout should be handled correctly', async () => {
const timeoutError = new Error('Network timeout')
timeoutError.name = 'TimeoutError'
mockPostMarketplace.mockRejectedValue(timeoutError)
const result = await searchAnything('en', '@plugin test', Actions.plugin)
expect(result).toEqual([])
})
it('JSON parsing errors should be handled correctly', async () => {
const parseError = new SyntaxError('Unexpected token in JSON')
mockPostMarketplace.mockRejectedValue(parseError)
const result = await searchAnything('en', '@plugin test', Actions.plugin)
expect(result).toEqual([])
})
})
})

View File

@ -1,7 +1,7 @@
import type { ReactNode } from 'react'
import { Dialog, DialogPopup, DialogPortal, DialogTitle } from '@langgenius/dify-ui/dialog'
import { fireEvent, render, screen } from '@testing-library/react'
import { createStore, Provider as JotaiProvider } from 'jotai'
import { useGotoAnythingOpen } from '@/app/components/goto-anything/atoms'
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
import AppDetailTop from '../app-detail-top'
vi.mock('../toggle-button', () => ({
@ -26,54 +26,55 @@ vi.mock('../toggle-button', () => ({
),
}))
function GotoAnythingOpenProbe() {
const open = useGotoAnythingOpen()
return <div data-testid="goto-anything-open">{String(open)}</div>
}
const renderWithGotoAnythingStore = (ui: ReactNode) => {
const store = createStore()
return render(<JotaiProvider store={store}>{ui}</JotaiProvider>)
function TestGotoAnythingDialog() {
return (
<Dialog handle={gotoAnythingDialogHandle}>
<DialogPortal>
<DialogPopup>
<DialogTitle>Goto Anything</DialogTitle>
</DialogPopup>
</DialogPortal>
</Dialog>
)
}
describe('AppDetailTop', () => {
beforeEach(() => {
vi.clearAllMocks()
gotoAnythingDialogHandle.close()
})
it('links the combined home control to home', () => {
renderWithGotoAnythingStore(<AppDetailTop />)
render(<AppDetailTop />)
expect(screen.getByRole('link', { name: 'common.mainNav.home' })).toHaveAttribute('href', '/')
expect(screen.queryByRole('button', { name: 'common.operation.back' })).not.toBeInTheDocument()
})
it('links the Studio breadcrumb to the Studio page', () => {
renderWithGotoAnythingStore(<AppDetailTop />)
render(<AppDetailTop />)
expect(screen.getByRole('link', { name: 'common.menus.apps' })).toHaveAttribute('href', '/apps')
})
it('keeps the quick search action', () => {
renderWithGotoAnythingStore(
render(
<>
<AppDetailTop />
<GotoAnythingOpenProbe />
<TestGotoAnythingDialog />
</>,
)
expect(screen.getByTestId('goto-anything-open')).toHaveTextContent('false')
expect(screen.queryByRole('dialog', { name: 'Goto Anything' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'app.gotoAnything.searchTitle' }))
expect(screen.getByTestId('goto-anything-open')).toHaveTextContent('true')
expect(screen.getByRole('dialog', { name: 'Goto Anything' })).toBeInTheDocument()
})
it('renders the sidebar toggle action in the top right', () => {
const onToggle = vi.fn()
renderWithGotoAnythingStore(<AppDetailTop onToggle={onToggle} />)
render(<AppDetailTop onToggle={onToggle} />)
fireEvent.click(screen.getByTestId('toggle-button'))
expect(screen.getByTestId('toggle-button')).toHaveAttribute('data-expand', 'true')

View File

@ -1,7 +1,7 @@
import type { ReactNode } from 'react'
import { Dialog, DialogPopup, DialogPortal, DialogTitle } from '@langgenius/dify-ui/dialog'
import { fireEvent, render, screen } from '@testing-library/react'
import { createStore, Provider as JotaiProvider } from 'jotai'
import { useGotoAnythingOpen } from '@/app/components/goto-anything/atoms'
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
import DatasetDetailTop from '../dataset-detail-top'
vi.mock('../toggle-button', () => ({
@ -26,25 +26,26 @@ vi.mock('../toggle-button', () => ({
),
}))
function GotoAnythingOpenProbe() {
const open = useGotoAnythingOpen()
return <div data-testid="goto-anything-open">{String(open)}</div>
}
const renderWithGotoAnythingStore = (ui: ReactNode) => {
const store = createStore()
return render(<JotaiProvider store={store}>{ui}</JotaiProvider>)
function TestGotoAnythingDialog() {
return (
<Dialog handle={gotoAnythingDialogHandle}>
<DialogPortal>
<DialogPopup>
<DialogTitle>Goto Anything</DialogTitle>
</DialogPopup>
</DialogPortal>
</Dialog>
)
}
describe('DatasetDetailTop', () => {
beforeEach(() => {
vi.clearAllMocks()
gotoAnythingDialogHandle.close()
})
it('links the combined home control to home and labels the breadcrumb as datasets', () => {
renderWithGotoAnythingStore(<DatasetDetailTop />)
render(<DatasetDetailTop />)
expect(screen.getByRole('link', { name: 'common.mainNav.home' })).toHaveAttribute('href', '/')
expect(screen.getByRole('link', { name: 'common.menus.datasets' })).toHaveAttribute(
@ -55,23 +56,23 @@ describe('DatasetDetailTop', () => {
})
it('keeps the quick search action', () => {
renderWithGotoAnythingStore(
render(
<>
<DatasetDetailTop />
<GotoAnythingOpenProbe />
<TestGotoAnythingDialog />
</>,
)
expect(screen.getByTestId('goto-anything-open')).toHaveTextContent('false')
expect(screen.queryByRole('dialog', { name: 'Goto Anything' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'app.gotoAnything.searchTitle' }))
expect(screen.getByTestId('goto-anything-open')).toHaveTextContent('true')
expect(screen.getByRole('dialog', { name: 'Goto Anything' })).toBeInTheDocument()
})
it('renders the sidebar toggle action in the top right', () => {
const onToggle = vi.fn()
renderWithGotoAnythingStore(<DatasetDetailTop expand={false} onToggle={onToggle} />)
render(<DatasetDetailTop expand={false} onToggle={onToggle} />)
fireEvent.click(screen.getByTestId('toggle-button'))
expect(screen.getByTestId('toggle-button')).toHaveAttribute('data-expand', 'false')

View File

@ -1,11 +1,12 @@
'use client'
import { DialogTrigger } from '@langgenius/dify-ui/dialog'
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { formatForDisplay } from '@tanstack/react-hotkeys'
import { useTranslation } from 'react-i18next'
import SidebarLeftArrowIcon from '@/app/components/base/icons/src/vender/SidebarLeftArrowIcon'
import { useSetGotoAnythingOpen } from '@/app/components/goto-anything/atoms'
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
import Link from '@/next/link'
import ToggleButton from './toggle-button'
@ -18,7 +19,6 @@ const SEARCH_SHORTCUT = ['Mod', 'K']
const AppDetailTop = ({ expand = true, onToggle }: AppDetailTopProps) => {
const { t } = useTranslation()
const setGotoAnythingOpen = useSetGotoAnythingOpen()
if (!expand) {
return (
@ -62,14 +62,18 @@ const AppDetailTop = ({ expand = true, onToggle }: AppDetailTopProps) => {
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
aria-label={t(($) => $['gotoAnything.searchTitle'], { ns: 'app' })}
className="flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-[10px] text-text-tertiary transition-colors hover:bg-state-base-hover hover:text-text-secondary"
onClick={() => setGotoAnythingOpen(true)}
>
<span aria-hidden className="i-custom-vender-main-nav-quick-search size-4" />
</button>
<DialogTrigger
handle={gotoAnythingDialogHandle}
render={
<button
type="button"
aria-label={t(($) => $['gotoAnything.searchTitle'], { ns: 'app' })}
className="flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-[10px] text-text-tertiary transition-colors hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
>
<span aria-hidden className="i-custom-vender-main-nav-quick-search size-4" />
</button>
}
/>
}
/>
<TooltipContent

View File

@ -1,11 +1,12 @@
'use client'
import { DialogTrigger } from '@langgenius/dify-ui/dialog'
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { formatForDisplay } from '@tanstack/react-hotkeys'
import { useTranslation } from 'react-i18next'
import SidebarLeftArrowIcon from '@/app/components/base/icons/src/vender/SidebarLeftArrowIcon'
import { useSetGotoAnythingOpen } from '@/app/components/goto-anything/atoms'
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
import Link from '@/next/link'
import ToggleButton from './toggle-button'
@ -18,7 +19,6 @@ const SEARCH_SHORTCUT = ['Mod', 'K']
const DatasetDetailTop = ({ expand = true, onToggle }: DatasetDetailTopProps) => {
const { t } = useTranslation()
const setGotoAnythingOpen = useSetGotoAnythingOpen()
if (!expand) {
return (
@ -62,14 +62,18 @@ const DatasetDetailTop = ({ expand = true, onToggle }: DatasetDetailTopProps) =>
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
aria-label={t(($) => $['gotoAnything.searchTitle'], { ns: 'app' })}
className="flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-[10px] text-text-tertiary transition-colors hover:bg-state-base-hover hover:text-text-secondary"
onClick={() => setGotoAnythingOpen(true)}
>
<span aria-hidden className="i-custom-vender-main-nav-quick-search size-4" />
</button>
<DialogTrigger
handle={gotoAnythingDialogHandle}
render={
<button
type="button"
aria-label={t(($) => $['gotoAnything.searchTitle'], { ns: 'app' })}
className="flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-[10px] text-text-tertiary transition-colors hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
>
<span aria-hidden className="i-custom-vender-main-nav-quick-search size-4" />
</button>
}
/>
}
/>
<TooltipContent

View File

@ -0,0 +1,33 @@
# Goto Anything
Global command palette that coordinates detached dialog triggers, typed search, commands, and navigation.
## Internal Modules
- `actions`: Search actions, command registration, result conversion, and recent-item persistence.
- `components`: Goto Anything empty and footer presentation.
- `dialog-handle`: The detached Base UI Dialog handle shared by triggers and the lazy dialog root.
## State Ownership
- The detached Dialog handle owns open state and trigger focus restoration.
- The dialog component owns the transient search input and selected plugin installer state.
- TanStack Query owns remote search lifecycle and cache state for each generated query contract.
- Autocomplete owns option registration, highlighting, keyboard navigation, and item activation.
- ScrollArea Viewport is the only results scroll container; Autocomplete List remains the listbox.
## External Modules
- `app/components/app`: Application result icons and current application state used by workflow commands.
- `app/components/base`: Shared application, dataset, and form types and icons.
- `app/components/plugins`: Plugin installation permissions and installer UI.
- `app/components/rag-pipeline/goto-anything-search`: RAG pipeline node search owned by the RAG feature.
- `app/components/workflow/goto-anything-search`: Workflow node search owned by the workflow feature.
- `app/components/workflow/utils/node-navigation`: Workflow node navigation.
- `app/components/workflow/workflow-generator`: Workflow generator commands and state.
- `app/components/workflow/types`: Workflow node contracts.
- `config`: Feature preview configuration.
- `context/i18n`: Locale and documentation URL configuration.
- `i18n-config`: Client locale updates and supported languages.
- `types/app`: Application mode and icon contracts.
- `utils/app-redirection`: Application result navigation paths.

View File

@ -1,285 +0,0 @@
import type { ActionItem } from '../actions/types'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Command } from 'cmdk'
import * as React from 'react'
import CommandSelector from '../command-selector'
vi.mock('@/next/navigation', () => ({
usePathname: () => '/app',
}))
const slashCommandsMock = [
{
name: 'docs',
description: 'Docs',
mode: 'direct',
isAvailable: () => true,
},
]
vi.mock('../actions/commands/registry', () => ({
slashCommandRegistry: {
getAvailableCommands: () => slashCommandsMock,
},
}))
const createActions = (): Record<string, ActionItem> => ({
app: {
key: '@app',
shortcut: '@app',
title: 'Apps',
search: vi.fn(),
description: '',
} as ActionItem,
plugin: {
key: '@plugin',
shortcut: '@plugin',
title: 'Plugins',
search: vi.fn(),
description: '',
} as ActionItem,
})
describe('CommandSelector', () => {
it('should list contextual search actions and notify selection', async () => {
const actions = createActions()
const onSelect = vi.fn()
render(
<Command>
<CommandSelector
actions={actions}
onCommandSelect={onSelect}
searchFilter="app"
originalQuery="@app"
/>
</Command>,
)
const actionButton = screen.getByText('app.gotoAnything.actions.searchApplicationsDesc')
await userEvent.click(actionButton)
expect(onSelect).toHaveBeenCalledWith('@app')
})
it('should render slash commands when query starts with slash', async () => {
const actions = createActions()
const onSelect = vi.fn()
render(
<Command>
<CommandSelector
actions={actions}
onCommandSelect={onSelect}
searchFilter="docs"
originalQuery="/docs"
/>
</Command>,
)
const slashItem = await screen.findByText('app.gotoAnything.actions.docDesc')
await userEvent.click(slashItem)
expect(onSelect).toHaveBeenCalledWith('/docs')
})
it('should show all slash commands when no filter provided', () => {
const actions = createActions()
const onSelect = vi.fn()
render(
<Command>
<CommandSelector
actions={actions}
onCommandSelect={onSelect}
searchFilter=""
originalQuery="/"
/>
</Command>,
)
expect(screen.getByText('/docs')).toBeInTheDocument()
})
it('should exclude slash action when in @ mode', () => {
const actions = {
...createActions(),
slash: {
key: '/',
shortcut: '/',
title: 'Slash',
search: vi.fn(),
description: '',
} as ActionItem,
}
const onSelect = vi.fn()
render(
<Command>
<CommandSelector
actions={actions}
onCommandSelect={onSelect}
searchFilter=""
originalQuery="@"
/>
</Command>,
)
expect(screen.getByText('@app')).toBeInTheDocument()
expect(screen.queryByText('/')).not.toBeInTheDocument()
})
it('should show all actions when no filter in @ mode', () => {
const actions = createActions()
const onSelect = vi.fn()
render(
<Command>
<CommandSelector
actions={actions}
onCommandSelect={onSelect}
searchFilter=""
originalQuery="@"
/>
</Command>,
)
expect(screen.getByText('@app')).toBeInTheDocument()
expect(screen.getByText('@plugin')).toBeInTheDocument()
})
it('should set default command value when items exist but value does not', () => {
const actions = createActions()
const onSelect = vi.fn()
const onCommandValueChange = vi.fn()
render(
<Command>
<CommandSelector
actions={actions}
onCommandSelect={onSelect}
searchFilter=""
originalQuery="@"
commandValue="non-existent"
onCommandValueChange={onCommandValueChange}
/>
</Command>,
)
expect(onCommandValueChange).toHaveBeenCalledWith('@app')
})
it('should NOT set command value when value already exists in items', () => {
const actions = createActions()
const onSelect = vi.fn()
const onCommandValueChange = vi.fn()
render(
<Command>
<CommandSelector
actions={actions}
onCommandSelect={onSelect}
searchFilter=""
originalQuery="@"
commandValue="@app"
onCommandValueChange={onCommandValueChange}
/>
</Command>,
)
expect(onCommandValueChange).not.toHaveBeenCalled()
})
it('should show no matching commands message when filter has no results', () => {
const actions = createActions()
const onSelect = vi.fn()
render(
<Command>
<CommandSelector
actions={actions}
onCommandSelect={onSelect}
searchFilter="nonexistent"
originalQuery="@nonexistent"
/>
</Command>,
)
expect(screen.getByText('app.gotoAnything.noMatchingCommands')).toBeInTheDocument()
expect(screen.getByText('app.gotoAnything.tryDifferentSearch')).toBeInTheDocument()
})
it('should show no matching commands for slash mode with no results', () => {
const actions = createActions()
const onSelect = vi.fn()
render(
<Command>
<CommandSelector
actions={actions}
onCommandSelect={onSelect}
searchFilter="nonexistentcommand"
originalQuery="/nonexistentcommand"
/>
</Command>,
)
expect(screen.getByText('app.gotoAnything.noMatchingCommands')).toBeInTheDocument()
})
it('should render description for @ commands', () => {
const actions = createActions()
const onSelect = vi.fn()
render(
<Command>
<CommandSelector
actions={actions}
onCommandSelect={onSelect}
searchFilter=""
originalQuery="@"
/>
</Command>,
)
expect(screen.getByText('app.gotoAnything.actions.searchApplicationsDesc')).toBeInTheDocument()
expect(screen.getByText('app.gotoAnything.actions.searchPluginsDesc')).toBeInTheDocument()
})
it('should render group header for @ mode', () => {
const actions = createActions()
const onSelect = vi.fn()
render(
<Command>
<CommandSelector
actions={actions}
onCommandSelect={onSelect}
searchFilter=""
originalQuery="@"
/>
</Command>,
)
expect(screen.getByText('app.gotoAnything.selectSearchType')).toBeInTheDocument()
})
it('should render group header for slash mode', () => {
const actions = createActions()
const onSelect = vi.fn()
render(
<Command>
<CommandSelector
actions={actions}
onCommandSelect={onSelect}
searchFilter=""
originalQuery="/"
/>
</Command>,
)
expect(screen.getByText('app.gotoAnything.groups.commands')).toBeInTheDocument()
})
})

View File

@ -1,131 +0,0 @@
import { render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { GotoAnythingProvider, useGotoAnythingContext } from '../context'
let pathnameMock: string | null | undefined = '/'
vi.mock('@/next/navigation', () => ({
usePathname: () => pathnameMock,
}))
let isWorkflowPageMock = false
vi.mock('../../workflow/constants', () => ({
isInWorkflowPage: () => isWorkflowPageMock,
}))
const ContextConsumer = () => {
const { isWorkflowPage, isRagPipelinePage } = useGotoAnythingContext()
return (
<div data-testid="status">
{String(isWorkflowPage)}|{String(isRagPipelinePage)}
</div>
)
}
describe('GotoAnythingProvider', () => {
beforeEach(() => {
isWorkflowPageMock = false
pathnameMock = '/'
})
it('should set workflow page flag when workflow path detected', async () => {
isWorkflowPageMock = true
pathnameMock = '/app/123/workflow'
render(
<GotoAnythingProvider>
<ContextConsumer />
</GotoAnythingProvider>,
)
await waitFor(() => {
expect(screen.getByTestId('status')).toHaveTextContent('true|false')
})
})
it('should detect RAG pipeline path based on pathname', async () => {
pathnameMock = '/datasets/abc/pipeline'
render(
<GotoAnythingProvider>
<ContextConsumer />
</GotoAnythingProvider>,
)
await waitFor(() => {
expect(screen.getByTestId('status')).toHaveTextContent('false|true')
})
})
it('should set both flags to false when pathname is null', async () => {
pathnameMock = null
render(
<GotoAnythingProvider>
<ContextConsumer />
</GotoAnythingProvider>,
)
await waitFor(() => {
expect(screen.getByTestId('status')).toHaveTextContent('false|false')
})
})
it('should set both flags to false when pathname is undefined', async () => {
pathnameMock = undefined
render(
<GotoAnythingProvider>
<ContextConsumer />
</GotoAnythingProvider>,
)
await waitFor(() => {
expect(screen.getByTestId('status')).toHaveTextContent('false|false')
})
})
it('should set both flags to false for regular paths', async () => {
pathnameMock = '/apps'
render(
<GotoAnythingProvider>
<ContextConsumer />
</GotoAnythingProvider>,
)
await waitFor(() => {
expect(screen.getByTestId('status')).toHaveTextContent('false|false')
})
})
it('should NOT match non-pipeline dataset paths', async () => {
pathnameMock = '/datasets/abc/documents'
render(
<GotoAnythingProvider>
<ContextConsumer />
</GotoAnythingProvider>,
)
await waitFor(() => {
expect(screen.getByTestId('status')).toHaveTextContent('false|false')
})
})
})
describe('useGotoAnythingContext', () => {
it('should return default values when used outside provider', () => {
const TestComponent = () => {
const { isWorkflowPage, isRagPipelinePage } = useGotoAnythingContext()
return (
<div data-testid="context">
{String(isWorkflowPage)}|{String(isRagPipelinePage)}
</div>
)
}
render(<TestComponent />)
expect(screen.getByTestId('context')).toHaveTextContent('false|false')
})
})

View File

@ -1,9 +1,11 @@
import type { ReactNode } from 'react'
import type { ActionItem, SearchResult } from '../actions/types'
import { act, render, screen, waitFor } from '@testing-library/react'
import { DialogTrigger } from '@langgenius/dify-ui/dialog'
import { detectPlatform } from '@tanstack/react-hotkeys'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { createStore, Provider } from 'jotai'
import * as React from 'react'
import { gotoAnythingDialogHandle } from '../dialog-handle'
import { GotoAnything } from '../index'
type TestSearchResult = Omit<SearchResult, 'icon' | 'data'> & {
@ -19,57 +21,66 @@ vi.mock('@/next/navigation', () => ({
usePathname: () => '/',
}))
type KeyPressEvent = {
preventDefault: () => void
target?: EventTarget
}
type HotkeyRegistration = {
handler: (event: KeyPressEvent) => void
options?: { enabled?: boolean }
}
const hotkeyHandlers: Record<string, HotkeyRegistration> = {}
let debouncedSearchQuery: string | undefined
vi.mock('ahooks', () => ({
useDebounce: <T,>(value: T) => value,
useDebounce: <T,>(value: T) => (debouncedSearchQuery ?? value) as T,
}))
vi.mock('@tanstack/react-hotkeys', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-hotkeys')>()
return {
...actual,
useHotkey: (
hotkey: string,
handler: (event: KeyPressEvent) => void,
options?: HotkeyRegistration['options'],
) => {
hotkeyHandlers[hotkey] = { handler, options }
},
}
})
const isMac = detectPlatform() === 'mac'
const HOTKEY_ALIAS: Record<string, string> = {
'ctrl.k': 'Mod+K',
function triggerSearchShortcut(target: Document | HTMLElement = document) {
fireEvent.keyDown(target, {
key: 'k',
ctrlKey: !isMac,
metaKey: isMac,
})
}
const triggerKeyPress = (combo: string) => {
const hotkey = HOTKEY_ALIAS[combo] ?? combo
const registration = hotkeyHandlers[hotkey]
if (registration && registration.options?.enabled !== false) {
act(() => {
registration.handler({ preventDefault: vi.fn(), target: document.body })
})
}
type RemoteQueryState = {
data: TestSearchResult[]
isLoading: boolean
isError: boolean
error: Error | null
}
let mockQueryResult = {
data: [] as TestSearchResult[],
const emptyRemoteQueryState = (): RemoteQueryState => ({
data: [],
isLoading: false,
isError: false,
error: null as Error | null,
error: null,
})
let remoteQueryStates: Record<'app' | 'knowledge' | 'plugin', RemoteQueryState> = {
app: emptyRemoteQueryState(),
knowledge: emptyRemoteQueryState(),
plugin: emptyRemoteQueryState(),
}
let enabledRemoteQueryKeys: string[] = []
function setRemoteResults(results: TestSearchResult[]) {
results.forEach((result) => {
if (result.type === 'app' || result.type === 'knowledge' || result.type === 'plugin')
remoteQueryStates[result.type].data.push(result)
})
}
vi.mock('@tanstack/react-query', () => ({
useQuery: () => mockQueryResult,
useQuery: (options: { queryKey: [key: keyof typeof remoteQueryStates]; enabled?: boolean }) => {
if (options.enabled) enabledRemoteQueryKeys.push(options.queryKey[0])
return options.enabled ? remoteQueryStates[options.queryKey[0]] : emptyRemoteQueryState()
},
}))
vi.mock('../actions/app', () => ({
appSearchQueryOptions: () => ({ queryKey: ['app'] }),
}))
vi.mock('../actions/knowledge', () => ({
knowledgeSearchQueryOptions: () => ({ queryKey: ['knowledge'] }),
}))
vi.mock('../actions/plugin', () => ({
pluginSearchQueryOptions: () => ({ queryKey: ['plugin'] }),
}))
vi.mock(
'@/app/components/plugins/install-plugin/hooks/use-workspace-plugin-install-permission',
@ -81,35 +92,38 @@ vi.mock(
}),
)
const contextValue = { isWorkflowPage: false, isRagPipelinePage: false }
vi.mock('../context', () => ({
useGotoAnythingContext: () => contextValue,
GotoAnythingProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}))
const createActionItem = (key: ActionItem['key'], shortcut: string): ActionItem => ({
const createRemoteAction = (key: ActionItem['key'], shortcut: string): ActionItem => ({
key,
shortcut,
title: `${key} title`,
description: `${key} desc`,
action: vi.fn(),
search: vi.fn(),
source: 'remote',
})
const actionsMock = {
slash: createActionItem('/', '/'),
app: createActionItem('@app', '@app'),
plugin: createActionItem('@plugin', '@plugin'),
slash: {
key: '/',
shortcut: '/',
title: '/ title',
description: '/ desc',
source: 'local',
action: vi.fn(),
search: vi.fn(() => []),
} satisfies ActionItem,
app: createRemoteAction('@app', '@app'),
knowledge: createRemoteAction('@knowledge', '@kb'),
plugin: createRemoteAction('@plugin', '@plugin'),
}
const createActionsMock = vi.fn(() => actionsMock)
const matchActionMock = vi.fn(() => undefined)
const searchAnythingMock = vi.fn(async () => mockQueryResult.data)
const matchActionMock = vi.fn<
(query: string, actions: Record<string, ActionItem>) => ActionItem | undefined
>(() => undefined)
vi.mock('../actions', () => ({
createActions: () => createActionsMock(),
matchAction: () => matchActionMock(),
searchAnything: () => searchAnythingMock(),
getActionSearchTerm: (_query: string, action: ActionItem) => action.key,
matchAction: (query: string, actions: Record<string, ActionItem>) =>
matchActionMock(query, actions),
}))
vi.mock('../actions/commands/slash-provider', () => ({
@ -123,10 +137,11 @@ type MockSlashCommand = {
} | null
let mockFindCommand: MockSlashCommand = null
let mockAvailableCommands: Array<{ name: string; description: string }> = []
vi.mock('../actions/commands/registry', () => ({
slashCommandRegistry: {
findCommand: () => mockFindCommand,
getAvailableCommands: () => [],
getAvailableCommands: () => mockAvailableCommands,
getAllCommands: () => [],
},
}))
@ -153,40 +168,102 @@ vi.mock('../../plugins/install-plugin/install-from-marketplace', () => ({
),
}))
const renderGotoAnything = (ui: React.ReactElement) => {
const store = createStore()
return render(<Provider store={store}>{ui}</Provider>)
}
const renderGotoAnything = (ui: React.ReactElement) => render(ui)
describe('GotoAnything', () => {
beforeEach(() => {
routerPush.mockClear()
Object.keys(hotkeyHandlers).forEach((key) => delete hotkeyHandlers[key])
mockQueryResult = { data: [], isLoading: false, isError: false, error: null }
gotoAnythingDialogHandle.close()
remoteQueryStates = {
app: emptyRemoteQueryState(),
knowledge: emptyRemoteQueryState(),
plugin: emptyRemoteQueryState(),
}
debouncedSearchQuery = undefined
enabledRemoteQueryKeys = []
matchActionMock.mockReset()
searchAnythingMock.mockClear()
mockFindCommand = null
mockAvailableCommands = []
})
describe('modal behavior', () => {
it('should open modal via Ctrl+K shortcut', async () => {
renderGotoAnything(<GotoAnything />)
triggerKeyPress('ctrl.k')
triggerSearchShortcut()
await waitFor(() => {
expect(
screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder'),
screen.getByRole('dialog', { name: 'app.gotoAnything.searchTitle' }),
).toBeInTheDocument()
expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toHaveFocus()
})
})
it('should not open from an unrelated editable field', () => {
renderGotoAnything(
<>
<input aria-label="Unrelated field" />
<GotoAnything />
</>,
)
triggerSearchShortcut(screen.getByRole('textbox', { name: 'Unrelated field' }))
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it.each(['shiftKey', 'altKey'] as const)('should ignore Mod+K with %s', (extraModifier) => {
renderGotoAnything(<GotoAnything />)
fireEvent.keyDown(document, {
key: 'k',
ctrlKey: !isMac,
metaKey: isMac,
[extraModifier]: true,
})
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('should ignore K with the non-primary platform modifier', () => {
renderGotoAnything(<GotoAnything />)
fireEvent.keyDown(document, {
key: 'k',
ctrlKey: isMac,
metaKey: !isMac,
})
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('should restore focus to the detached trigger after Escape', async () => {
const user = userEvent.setup()
renderGotoAnything(
<>
<DialogTrigger
handle={gotoAnythingDialogHandle}
render={<button type="button">Search</button>}
/>
<GotoAnything />
</>,
)
const trigger = screen.getByRole('button', { name: 'Search' })
await user.click(trigger)
expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toHaveFocus()
await user.keyboard('{Escape}')
await waitFor(() => expect(trigger).toHaveFocus())
})
it('should close modal via ESC key', async () => {
const user = userEvent.setup()
renderGotoAnything(<GotoAnything />)
triggerKeyPress('ctrl.k')
triggerSearchShortcut()
await waitFor(() => {
expect(
screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder'),
@ -201,47 +278,25 @@ describe('GotoAnything', () => {
})
})
it('should toggle modal when pressing Ctrl+K twice', async () => {
it('should keep the modal open when pressing Ctrl+K again', async () => {
const user = userEvent.setup()
renderGotoAnything(<GotoAnything />)
triggerKeyPress('ctrl.k')
await waitFor(() => {
expect(
screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder'),
).toBeInTheDocument()
})
triggerSearchShortcut()
const input = await screen.findByPlaceholderText('app.gotoAnything.searchPlaceholder')
await user.type(input, 'workflow')
triggerKeyPress('ctrl.k')
await waitFor(() => {
expect(
screen.queryByPlaceholderText('app.gotoAnything.searchPlaceholder'),
).not.toBeInTheDocument()
})
})
triggerSearchShortcut()
it('should call onHide when modal closes', async () => {
const user = userEvent.setup()
const onHide = vi.fn()
renderGotoAnything(<GotoAnything onHide={onHide} />)
triggerKeyPress('ctrl.k')
await waitFor(() => {
expect(
screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder'),
).toBeInTheDocument()
})
await user.keyboard('{Escape}')
await waitFor(() => {
expect(onHide).toHaveBeenCalled()
})
expect(input).toHaveValue('workflow')
expect(input).toHaveFocus()
})
it('should reset search query when modal opens', async () => {
const user = userEvent.setup()
renderGotoAnything(<GotoAnything />)
triggerKeyPress('ctrl.k')
triggerSearchShortcut()
await waitFor(() => {
expect(
screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder'),
@ -258,7 +313,7 @@ describe('GotoAnything', () => {
).not.toBeInTheDocument()
})
triggerKeyPress('ctrl.k')
triggerSearchShortcut()
await waitFor(() => {
const newInput = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')
expect(newInput).toHaveValue('')
@ -269,25 +324,20 @@ describe('GotoAnything', () => {
describe('search functionality', () => {
it('should navigate to selected result', async () => {
const user = userEvent.setup()
mockQueryResult = {
data: [
{
id: 'app-1',
type: 'app',
title: 'Sample App',
description: 'desc',
path: '/apps/1',
icon: <div data-testid="icon">🧩</div>,
data: {},
},
],
isLoading: false,
isError: false,
error: null,
}
setRemoteResults([
{
id: 'app-1',
type: 'app',
title: 'Sample App',
description: 'desc',
path: '/apps/1',
icon: <div data-testid="icon">🧩</div>,
data: {},
},
])
renderGotoAnything(<GotoAnything />)
triggerKeyPress('ctrl.k')
triggerSearchShortcut()
await waitFor(() => {
expect(
@ -302,12 +352,84 @@ describe('GotoAnything', () => {
await user.click(result)
expect(routerPush).toHaveBeenCalledWith('/apps/1')
expect(routerPush).toHaveBeenCalledTimes(1)
})
it('should navigate the highlighted result with ArrowDown and Enter', async () => {
const user = userEvent.setup()
setRemoteResults([
{
id: 'app-1',
type: 'app',
title: 'Keyboard App',
path: '/apps/keyboard',
data: {},
},
])
renderGotoAnything(<GotoAnything />)
triggerSearchShortcut()
const input = await screen.findByRole('combobox', {
name: 'app.gotoAnything.searchTitle',
})
await user.type(input, 'keyboard')
await user.keyboard('{ArrowDown}{Enter}')
expect(routerPush).toHaveBeenCalledWith('/apps/keyboard')
expect(routerPush).toHaveBeenCalledTimes(1)
})
it('should loop from the last command to the first with ArrowDown', async () => {
const user = userEvent.setup()
mockAvailableCommands = [
{ name: 'theme', description: 'Change theme' },
{ name: 'language', description: 'Change language' },
]
renderGotoAnything(<GotoAnything />)
triggerSearchShortcut()
const input = await screen.findByRole('combobox', {
name: 'app.gotoAnything.searchTitle',
})
await user.type(input, '/')
const options = screen.getAllByRole('option')
expect(options).toHaveLength(2)
const [firstOption, secondOption] = options
if (!firstOption || !secondOption) throw new Error('Expected two command options')
await user.keyboard('{ArrowDown}')
expect(input).toHaveAttribute('aria-activedescendant', secondOption.id)
await user.keyboard('{ArrowDown}')
expect(input).toHaveAttribute('aria-activedescendant', firstOption.id)
})
it('should announce the displayed command count', async () => {
const user = userEvent.setup()
mockAvailableCommands = [
{ name: 'theme', description: 'Change theme' },
{ name: 'language', description: 'Change language' },
]
renderGotoAnything(<GotoAnything />)
triggerSearchShortcut()
const input = await screen.findByRole('combobox', {
name: 'app.gotoAnything.searchTitle',
})
await user.type(input, '/')
expect(screen.getByRole('status')).toHaveTextContent(
'app.gotoAnything.resultCount:{"count":2}',
)
})
it('should clear selection when typing without prefix', async () => {
const user = userEvent.setup()
renderGotoAnything(<GotoAnything />)
triggerKeyPress('ctrl.k')
triggerSearchShortcut()
await waitFor(() => {
expect(
@ -320,20 +442,36 @@ describe('GotoAnything', () => {
expect(input).toHaveValue('test query')
})
it('should not search providers with a stale scope prefix', async () => {
const user = userEvent.setup()
matchActionMock.mockImplementation((query: string) =>
query.startsWith('@app') ? actionsMock.app : undefined,
)
renderGotoAnything(<GotoAnything />)
triggerSearchShortcut()
const input = await screen.findByRole('combobox', {
name: 'app.gotoAnything.searchTitle',
})
await user.type(input, '@')
debouncedSearchQuery = '@'
enabledRemoteQueryKeys = []
await user.click(screen.getByText('@app'))
expect(input).toHaveValue('@app ')
expect(enabledRemoteQueryKeys).toEqual([])
})
})
describe('empty states', () => {
it('should show loading state', async () => {
const user = userEvent.setup()
mockQueryResult = {
data: [],
isLoading: true,
isError: false,
error: null,
}
remoteQueryStates.app.isLoading = true
renderGotoAnything(<GotoAnything />)
triggerKeyPress('ctrl.k')
triggerSearchShortcut()
await waitFor(() => {
expect(
@ -346,20 +484,21 @@ describe('GotoAnything', () => {
const searchingTexts = screen.getAllByText('app.gotoAnything.searching')
expect(searchingTexts.length).toBeGreaterThanOrEqual(1)
expect(screen.getByRole('status')).toHaveTextContent('app.gotoAnything.searching')
expect(document.querySelector('[aria-busy="true"]')).toBeInTheDocument()
})
it('should show error state', async () => {
const user = userEvent.setup()
const testError = new Error('Search failed')
mockQueryResult = {
data: [],
isLoading: false,
isError: true,
error: testError,
remoteQueryStates = {
app: { data: [], isLoading: false, isError: true, error: testError },
knowledge: { data: [], isLoading: false, isError: true, error: testError },
plugin: { data: [], isLoading: false, isError: true, error: testError },
}
renderGotoAnything(<GotoAnything />)
triggerKeyPress('ctrl.k')
triggerSearchShortcut()
await waitFor(() => {
expect(
@ -370,12 +509,47 @@ describe('GotoAnything', () => {
const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')
await user.type(input, 'search')
expect(screen.getByText('app.gotoAnything.searchFailed')).toBeInTheDocument()
expect(screen.getByRole('status')).toHaveTextContent('app.gotoAnything.searchFailed')
expect(screen.getAllByText('app.gotoAnything.searchFailed')).toHaveLength(2)
})
it('should preserve successful results when one provider fails', async () => {
const user = userEvent.setup()
setRemoteResults([
{
id: 'app-1',
type: 'app',
title: 'Available App',
path: '/apps/available',
data: {},
},
])
remoteQueryStates.plugin = {
data: [],
isLoading: false,
isError: true,
error: new Error('Marketplace unavailable'),
}
renderGotoAnything(<GotoAnything />)
triggerSearchShortcut()
const input = await screen.findByRole('combobox', {
name: 'app.gotoAnything.searchTitle',
})
await user.type(input, 'available')
expect(await screen.findByText('Available App')).toBeInTheDocument()
expect(screen.getByRole('status')).toHaveTextContent(
'app.gotoAnything.someServicesUnavailable',
)
expect(screen.getAllByText('app.gotoAnything.someServicesUnavailable')).toHaveLength(2)
expect(screen.queryByText('app.gotoAnything.searchFailed')).not.toBeInTheDocument()
})
it('should show default state when no query', async () => {
renderGotoAnything(<GotoAnything />)
triggerKeyPress('ctrl.k')
triggerSearchShortcut()
await waitFor(() => {
expect(
@ -383,20 +557,13 @@ describe('GotoAnything', () => {
).toBeInTheDocument()
})
expect(screen.getByText('app.gotoAnything.searchTitle')).toBeInTheDocument()
expect(screen.getAllByText('app.gotoAnything.searchTitle')).toHaveLength(2)
})
it('should show no results state when search returns empty', async () => {
const user = userEvent.setup()
mockQueryResult = {
data: [],
isLoading: false,
isError: false,
error: null,
}
renderGotoAnything(<GotoAnything />)
triggerKeyPress('ctrl.k')
triggerSearchShortcut()
await waitFor(() => {
expect(
@ -407,35 +574,30 @@ describe('GotoAnything', () => {
const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')
await user.type(input, 'nonexistent')
expect(screen.getByText('app.gotoAnything.noResults')).toBeInTheDocument()
expect(await screen.findByText('app.gotoAnything.noResults')).toBeInTheDocument()
})
})
describe('plugin installation', () => {
it('should open plugin installer when selecting plugin result', async () => {
const user = userEvent.setup()
mockQueryResult = {
data: [
{
id: 'plugin-1',
type: 'plugin',
title: 'Plugin Item',
description: 'desc',
path: '',
icon: <div />,
data: {
name: 'Plugin Item',
latest_package_identifier: 'pkg',
},
setRemoteResults([
{
id: 'plugin-1',
type: 'plugin',
title: 'Plugin Item',
description: 'desc',
path: '',
icon: <div />,
data: {
name: 'Plugin Item',
latest_package_identifier: 'pkg',
},
],
isLoading: false,
isError: false,
error: null,
}
},
])
renderGotoAnything(<GotoAnything />)
triggerKeyPress('ctrl.k')
triggerSearchShortcut()
await waitFor(() => {
expect(
@ -454,28 +616,23 @@ describe('GotoAnything', () => {
it('should close plugin installer via close button', async () => {
const user = userEvent.setup()
mockQueryResult = {
data: [
{
id: 'plugin-1',
type: 'plugin',
title: 'Plugin Item',
description: 'desc',
path: '',
icon: <div />,
data: {
name: 'Plugin Item',
latest_package_identifier: 'pkg',
},
setRemoteResults([
{
id: 'plugin-1',
type: 'plugin',
title: 'Plugin Item',
description: 'desc',
path: '',
icon: <div />,
data: {
name: 'Plugin Item',
latest_package_identifier: 'pkg',
},
],
isLoading: false,
isError: false,
error: null,
}
},
])
renderGotoAnything(<GotoAnything />)
triggerKeyPress('ctrl.k')
triggerSearchShortcut()
await waitFor(() => {
expect(
@ -499,28 +656,23 @@ describe('GotoAnything', () => {
it('should close plugin installer on success', async () => {
const user = userEvent.setup()
mockQueryResult = {
data: [
{
id: 'plugin-1',
type: 'plugin',
title: 'Plugin Item',
description: 'desc',
path: '',
icon: <div />,
data: {
name: 'Plugin Item',
latest_package_identifier: 'pkg',
},
setRemoteResults([
{
id: 'plugin-1',
type: 'plugin',
title: 'Plugin Item',
description: 'desc',
path: '',
icon: <div />,
data: {
name: 'Plugin Item',
latest_package_identifier: 'pkg',
},
],
isLoading: false,
isError: false,
error: null,
}
},
])
renderGotoAnything(<GotoAnything />)
triggerKeyPress('ctrl.k')
triggerSearchShortcut()
await waitFor(() => {
expect(
@ -552,9 +704,10 @@ describe('GotoAnything', () => {
execute: executeMock,
isAvailable: () => true,
}
mockAvailableCommands = [{ name: 'theme', description: 'Change theme' }]
renderGotoAnything(<GotoAnything />)
triggerKeyPress('ctrl.k')
triggerSearchShortcut()
await waitFor(() => {
expect(
@ -566,7 +719,7 @@ describe('GotoAnything', () => {
await user.type(input, '/theme')
await user.keyboard('{Enter}')
expect(executeMock).toHaveBeenCalled()
expect(executeMock).toHaveBeenCalledTimes(1)
})
it('should NOT execute unavailable slash command', async () => {
@ -579,7 +732,7 @@ describe('GotoAnything', () => {
}
renderGotoAnything(<GotoAnything />)
triggerKeyPress('ctrl.k')
triggerSearchShortcut()
await waitFor(() => {
expect(
@ -601,9 +754,10 @@ describe('GotoAnything', () => {
mode: 'submenu',
execute: executeMock,
}
mockAvailableCommands = [{ name: 'language', description: 'Change language' }]
renderGotoAnything(<GotoAnything />)
triggerKeyPress('ctrl.k')
triggerSearchShortcut()
await waitFor(() => {
expect(
@ -625,9 +779,10 @@ describe('GotoAnything', () => {
execute: vi.fn(),
isAvailable: () => true,
}
mockAvailableCommands = [{ name: 'theme', description: 'Change theme' }]
renderGotoAnything(<GotoAnything />)
triggerKeyPress('ctrl.k')
triggerSearchShortcut()
await waitFor(() => {
expect(
@ -650,25 +805,20 @@ describe('GotoAnything', () => {
describe('result navigation', () => {
it('should handle knowledge result navigation', async () => {
const user = userEvent.setup()
mockQueryResult = {
data: [
{
id: 'kb-1',
type: 'knowledge',
title: 'Knowledge Base',
description: 'desc',
path: '/datasets/kb-1',
icon: <div />,
data: {},
},
],
isLoading: false,
isError: false,
error: null,
}
setRemoteResults([
{
id: 'kb-1',
type: 'knowledge',
title: 'Knowledge Base',
description: 'desc',
path: '/datasets/kb-1',
icon: <div />,
data: {},
},
])
renderGotoAnything(<GotoAnything />)
triggerKeyPress('ctrl.k')
triggerSearchShortcut()
await waitFor(() => {
expect(
@ -687,25 +837,20 @@ describe('GotoAnything', () => {
it('should NOT navigate when result has no path', async () => {
const user = userEvent.setup()
mockQueryResult = {
data: [
{
id: 'item-1',
type: 'app',
title: 'No Path Item',
description: 'desc',
path: '',
icon: <div />,
data: {},
},
],
isLoading: false,
isError: false,
error: null,
}
setRemoteResults([
{
id: 'item-1',
type: 'app',
title: 'No Path Item',
description: 'desc',
path: '',
icon: <div />,
data: {},
},
])
renderGotoAnything(<GotoAnything />)
triggerKeyPress('ctrl.k')
triggerSearchShortcut()
await waitFor(() => {
expect(

View File

@ -1,159 +1,80 @@
import type { App } from '@/types/app'
import { appAction } from '../app'
import { appAction, appSearchQueryOptions } from '../app'
vi.mock('@/service/apps', () => ({
fetchAppList: vi.fn(),
const serviceMocks = vi.hoisted(() => ({ queryOptions: vi.fn((options) => options) }))
vi.mock('@/service/client', () => ({
consoleQuery: { apps: { get: { queryOptions: serviceMocks.queryOptions } } },
}))
vi.mock('@/utils/app-redirection', () => ({
getRedirectionPath: vi.fn((app: { id: string }) => `/app/${app.id}`),
}))
vi.mock('../../../app/type-selector', () => ({
AppTypeIcon: () => null,
}))
vi.mock('../../../app/type-selector', () => ({ AppTypeIcon: () => null }))
describe('appAction', () => {
beforeEach(() => {
vi.clearAllMocks()
type AppQueryResponse = Parameters<
NonNullable<ReturnType<typeof appSearchQueryOptions>['select']>
>[0]
type AppQueryItem = AppQueryResponse['data'][number]
function app(overrides: Partial<AppQueryItem> = {}): AppQueryItem {
return {
id: 'app-1',
name: 'My App',
description: 'A great app',
mode: 'chat',
icon: '',
icon_type: 'emoji',
icon_background: '',
icon_url: '',
...overrides,
} as AppQueryItem
}
function response(data: AppQueryItem[]): AppQueryResponse {
return { data, has_more: false, limit: 10, page: 1, total: data.length }
}
describe('app search query', () => {
beforeEach(() => vi.clearAllMocks())
it('exposes remote action metadata', () => {
expect(appAction).toMatchObject({ key: '@app', shortcut: '@app', source: 'remote' })
})
it('has correct metadata', () => {
expect(appAction.key).toBe('@app')
expect(appAction.shortcut).toBe('@app')
it('builds generated query options from the search term', () => {
appSearchQueryOptions('assistant', false)
expect(serviceMocks.queryOptions).toHaveBeenCalledWith(
expect.objectContaining({
input: { query: { page: 1, name: 'assistant' } },
retry: false,
select: expect.any(Function),
}),
)
})
it('returns parsed app results on success', async () => {
const { fetchAppList } = await import('@/service/apps')
vi.mocked(fetchAppList).mockResolvedValue({
data: [
{
id: 'app-1',
name: 'My App',
description: 'A great app',
mode: 'chat',
icon: '',
icon_type: 'emoji',
icon_background: '',
icon_url: '',
} as unknown as App,
],
has_more: false,
limit: 10,
page: 1,
total: 1,
})
it('selects plain app results for general search', () => {
const options = appSearchQueryOptions('my app', false)
const results = await appAction.search('@app test', 'test', 'en')
expect(options.select!(response([app(), app({ id: 'app-2', name: 'Other' })]))).toHaveLength(2)
})
expect(fetchAppList).toHaveBeenCalledWith({
url: 'apps',
params: { page: 1, name: 'test' },
})
expect(results).toHaveLength(5)
expect(results[0]).toMatchObject({
id: 'app-1',
title: 'My App',
type: 'app',
})
expect(results.slice(1).map((r) => r.id)).toEqual([
it('selects app sections only for scoped search', () => {
const chatOptions = appSearchQueryOptions('', true)
const workflowOptions = appSearchQueryOptions('', true)
expect(chatOptions.select!(response([app()])).map((result) => result.id)).toEqual([
'app-1',
'app-1:configuration',
'app-1:overview',
'app-1:logs',
'app-1:develop',
])
})
it('returns workflow sub-sections for workflow-mode apps', async () => {
const { fetchAppList } = await import('@/service/apps')
vi.mocked(fetchAppList).mockResolvedValue({
data: [
{
id: 'wf-1',
name: 'Flow',
description: '',
mode: 'workflow',
icon: '',
icon_type: 'emoji',
icon_background: '',
icon_url: '',
} as unknown as App,
],
has_more: false,
limit: 10,
page: 1,
total: 1,
})
const results = await appAction.search('@app', '', 'en')
expect(results).toHaveLength(4)
expect(results.slice(1).map((r) => r.id)).toEqual([
'wf-1:workflow',
'wf-1:overview',
'wf-1:logs',
])
})
it('returns apps without sub-sections for unscoped queries', async () => {
const { fetchAppList } = await import('@/service/apps')
vi.mocked(fetchAppList).mockResolvedValue({
data: [
{
id: 'app-1',
name: 'My App',
description: '',
mode: 'chat',
icon: '',
icon_type: 'emoji',
icon_background: '',
icon_url: '',
} as unknown as App,
{
id: 'app-2',
name: 'Other',
description: '',
mode: 'chat',
icon: '',
icon_type: 'emoji',
icon_background: '',
icon_url: '',
} as unknown as App,
],
has_more: false,
limit: 10,
page: 1,
total: 2,
})
const results = await appAction.search('my app', 'my app', 'en')
expect(results).toHaveLength(2)
expect(results.map((r) => r.id)).toEqual(['app-1', 'app-2'])
})
it('returns empty array when response has no data', async () => {
const { fetchAppList } = await import('@/service/apps')
vi.mocked(fetchAppList).mockResolvedValue({
data: [],
has_more: false,
limit: 10,
page: 1,
total: 0,
})
const results = await appAction.search('@app', '', 'en')
expect(results).toEqual([])
})
it('returns empty array on API failure', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const { fetchAppList } = await import('@/service/apps')
vi.mocked(fetchAppList).mockRejectedValue(new Error('network error'))
const results = await appAction.search('@app fail', 'fail', 'en')
expect(results).toEqual([])
expect(warnSpy).toHaveBeenCalledWith('App search failed:', expect.any(Error))
warnSpy.mockRestore()
expect(
workflowOptions.select!(response([app({ id: 'wf-1', mode: 'workflow' })])).map(
(result) => result.id,
),
).toEqual(['wf-1', 'wf-1:workflow', 'wf-1:overview', 'wf-1:logs'])
})
})

View File

@ -1,8 +1,6 @@
import type { ActionItem, SearchResult } from '../types'
import type { DataSet } from '@/models/datasets'
import type { App } from '@/types/app'
import type { ActionItem } from '../types'
import { slashCommandRegistry } from '../commands/registry'
import { createActions, matchAction, searchAnything } from '../index'
import { createActions, getActionSearchTerm, matchAction } from '../index'
vi.mock('../app', () => ({
appAction: {
@ -10,7 +8,7 @@ vi.mock('../app', () => ({
shortcut: '@app',
title: 'Apps',
description: 'Search apps',
search: vi.fn().mockResolvedValue([]),
source: 'remote',
} satisfies ActionItem,
}))
@ -20,7 +18,7 @@ vi.mock('../knowledge', () => ({
shortcut: '@kb',
title: 'Knowledge',
description: 'Search knowledge',
search: vi.fn().mockResolvedValue([]),
source: 'remote',
} satisfies ActionItem,
}))
@ -30,7 +28,7 @@ vi.mock('../plugin', () => ({
shortcut: '@plugin',
title: 'Plugins',
description: 'Search plugins',
search: vi.fn().mockResolvedValue([]),
source: 'remote',
} satisfies ActionItem,
}))
@ -40,7 +38,8 @@ vi.mock('../commands/slash', () => ({
shortcut: '/',
title: 'Commands',
description: 'Slash commands',
search: vi.fn().mockResolvedValue([]),
source: 'local',
search: vi.fn(() => []),
} satisfies ActionItem,
}))
@ -50,7 +49,8 @@ vi.mock('../workflow-nodes', () => ({
shortcut: '@node',
title: 'Workflow Nodes',
description: 'Search workflow nodes',
search: vi.fn().mockResolvedValue([]),
source: 'local',
search: vi.fn(() => []),
} satisfies ActionItem,
}))
@ -60,266 +60,66 @@ vi.mock('../rag-pipeline-nodes', () => ({
shortcut: '@node',
title: 'RAG Pipeline Nodes',
description: 'Search RAG nodes',
search: vi.fn().mockResolvedValue([]),
source: 'local',
search: vi.fn(() => []),
} satisfies ActionItem,
}))
vi.mock('../commands/registry')
describe('createActions', () => {
it('returns base actions when neither workflow nor rag-pipeline page', () => {
const actions = createActions(false, false)
expect(actions).toHaveProperty('slash')
expect(actions).toHaveProperty('app')
expect(actions).toHaveProperty('knowledge')
expect(actions).toHaveProperty('plugin')
expect(actions).not.toHaveProperty('node')
it('returns only global actions outside graph pages', () => {
expect(createActions(false, false)).toEqual(
expect.objectContaining({ slash: expect.any(Object), app: expect.any(Object) }),
)
expect(createActions(false, false)).not.toHaveProperty('node')
})
it('includes workflow nodes action on workflow pages', () => {
const actions = createActions(true, false) as Record<string, ActionItem>
expect(actions).toHaveProperty('node')
expect(actions.node!.title).toBe('Workflow Nodes')
it('uses the workflow-owned node action on workflow pages', () => {
expect((createActions(true, false) as Record<string, ActionItem>).node!.title).toBe(
'Workflow Nodes',
)
})
it('includes rag-pipeline nodes action on rag-pipeline pages', () => {
const actions = createActions(false, true) as Record<string, ActionItem>
expect(actions).toHaveProperty('node')
expect(actions.node!.title).toBe('RAG Pipeline Nodes')
})
it('rag-pipeline page takes priority over workflow page', () => {
const actions = createActions(true, true) as Record<string, ActionItem>
expect(actions.node!.title).toBe('RAG Pipeline Nodes')
it('uses the RAG-owned node action when both graph flags are true', () => {
expect((createActions(true, true) as Record<string, ActionItem>).node!.title).toBe(
'RAG Pipeline Nodes',
)
})
})
describe('searchAnything', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('getActionSearchTerm', () => {
it('removes either the action key or shortcut', () => {
const action = createActions(false, false).knowledge
it('delegates to specific action when actionItem is provided', async () => {
const mockResults: SearchResult[] = [
{ id: '1', title: 'App1', type: 'app', data: {} as unknown as App },
]
const action: ActionItem = {
key: '@app',
shortcut: '@app',
title: 'Apps',
description: 'Search apps',
search: vi.fn().mockResolvedValue(mockResults),
}
const results = await searchAnything('en', '@app myquery', action)
expect(action.search).toHaveBeenCalledWith('@app myquery', 'myquery', 'en')
expect(results).toEqual(mockResults)
})
it('strips action prefix from search term', async () => {
const action: ActionItem = {
key: '@knowledge',
shortcut: '@kb',
title: 'KB',
description: 'Search KB',
search: vi.fn().mockResolvedValue([]),
}
await searchAnything('en', '@kb hello', action)
expect(action.search).toHaveBeenCalledWith('@kb hello', 'hello', 'en')
})
it('returns empty for queries starting with @ without actionItem', async () => {
const results = await searchAnything('en', '@unknown')
expect(results).toEqual([])
})
it('returns empty for queries starting with / without actionItem', async () => {
const results = await searchAnything('en', '/theme')
expect(results).toEqual([])
})
it('handles action search failure gracefully', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const action: ActionItem = {
key: '@app',
shortcut: '@app',
title: 'Apps',
description: 'Search apps',
search: vi.fn().mockRejectedValue(new Error('network error')),
}
const results = await searchAnything('en', '@app test', action)
expect(results).toEqual([])
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('Search failed for @app'),
expect.any(Error),
)
warnSpy.mockRestore()
})
it('runs global search across all non-slash actions for plain queries', async () => {
const appResults: SearchResult[] = [
{ id: 'a1', title: 'My App', type: 'app', data: {} as unknown as App },
]
const kbResults: SearchResult[] = [
{ id: 'k1', title: 'My KB', type: 'knowledge', data: {} as unknown as DataSet },
]
const dynamicActions: Record<string, ActionItem> = {
slash: {
key: '/',
shortcut: '/',
title: 'Slash',
description: '',
search: vi.fn().mockResolvedValue([]),
},
app: {
key: '@app',
shortcut: '@app',
title: 'App',
description: '',
search: vi.fn().mockResolvedValue(appResults),
},
knowledge: {
key: '@knowledge',
shortcut: '@kb',
title: 'KB',
description: '',
search: vi.fn().mockResolvedValue(kbResults),
},
}
const results = await searchAnything('en', 'my query', undefined, dynamicActions)
expect(dynamicActions.slash!.search).not.toHaveBeenCalled()
expect(results).toHaveLength(2)
expect(results).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: 'a1' }),
expect.objectContaining({ id: 'k1' }),
]),
)
})
it('handles partial search failures in global search gracefully', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const dynamicActions: Record<string, ActionItem> = {
app: {
key: '@app',
shortcut: '@app',
title: 'App',
description: '',
search: vi.fn().mockRejectedValue(new Error('fail')),
},
knowledge: {
key: '@knowledge',
shortcut: '@kb',
title: 'KB',
description: '',
search: vi
.fn()
.mockResolvedValue([
{ id: 'k1', title: 'KB1', type: 'knowledge', data: {} as unknown as DataSet },
]),
},
}
const results = await searchAnything('en', 'query', undefined, dynamicActions)
expect(results).toHaveLength(1)
expect(results[0]!.id).toBe('k1')
expect(warnSpy).toHaveBeenCalled()
warnSpy.mockRestore()
expect(getActionSearchTerm('@knowledge vector store', action)).toBe('vector store')
expect(getActionSearchTerm('@kb vector store', action)).toBe('vector store')
})
})
describe('matchAction', () => {
const actions: Record<string, ActionItem> = {
app: { key: '@app', shortcut: '@app', title: 'App', description: '', search: vi.fn() },
knowledge: {
key: '@knowledge',
shortcut: '@kb',
title: 'KB',
description: '',
search: vi.fn(),
},
plugin: {
key: '@plugin',
shortcut: '@plugin',
title: 'Plugin',
description: '',
search: vi.fn(),
},
slash: { key: '/', shortcut: '/', title: 'Slash', description: '', search: vi.fn() },
}
const actions = createActions(false, false)
beforeEach(() => {
vi.clearAllMocks()
})
it('matches @app query', () => {
const result = matchAction('@app test', actions)
expect(result?.key).toBe('@app')
})
it('matches @kb shortcut', () => {
const result = matchAction('@kb test', actions)
expect(result?.key).toBe('@knowledge')
})
it('matches @plugin query', () => {
const result = matchAction('@plugin test', actions)
expect(result?.key).toBe('@plugin')
})
it('returns undefined for unmatched query', () => {
vi.mocked(slashCommandRegistry.getAllCommands).mockReturnValue([])
const result = matchAction('random query', actions)
expect(result).toBeUndefined()
})
describe('slash command matching', () => {
it('matches submenu command with full name', () => {
vi.mocked(slashCommandRegistry.getAllCommands).mockReturnValue([
{ name: 'theme', mode: 'submenu', description: '', search: vi.fn() },
])
it.each([
['@app query', '@app'],
['@kb query', '@knowledge'],
['@plugin query', '@plugin'],
])('matches %s', (query, key) => {
expect(matchAction(query, actions)?.key).toBe(key)
})
const result = matchAction('/theme', actions)
expect(result?.key).toBe('/')
})
it('matches complete submenu commands but leaves direct commands in the command picker', () => {
vi.mocked(slashCommandRegistry.getAllCommands).mockReturnValue([
{ name: 'theme', mode: 'submenu', description: '', search: vi.fn(() => []) },
{ name: 'docs', mode: 'direct', description: '', search: vi.fn(() => []) },
])
it('matches submenu command with args', () => {
vi.mocked(slashCommandRegistry.getAllCommands).mockReturnValue([
{ name: 'theme', mode: 'submenu', description: '', search: vi.fn() },
])
const result = matchAction('/theme dark', actions)
expect(result?.key).toBe('/')
})
it('does not match direct-mode commands', () => {
vi.mocked(slashCommandRegistry.getAllCommands).mockReturnValue([
{ name: 'docs', mode: 'direct', description: '', search: vi.fn() },
])
const result = matchAction('/docs', actions)
expect(result).toBeUndefined()
})
it('does not match partial slash command name', () => {
vi.mocked(slashCommandRegistry.getAllCommands).mockReturnValue([
{ name: 'theme', mode: 'submenu', description: '', search: vi.fn() },
])
const result = matchAction('/the', actions)
expect(result).toBeUndefined()
})
expect(matchAction('/theme dark', actions)?.key).toBe('/')
expect(matchAction('/docs', actions)).toBeUndefined()
expect(matchAction('/the', actions)).toBeUndefined()
})
})

View File

@ -1,110 +1,62 @@
import type { DataSet } from '@/models/datasets'
import { knowledgeAction } from '../knowledge'
import type {
DatasetListItemResponse,
DatasetListResponse,
} from '@dify/contracts/api/console/datasets/types.gen'
import { knowledgeAction, knowledgeSearchQueryOptions } from '../knowledge'
vi.mock('@/service/datasets', () => ({
fetchDatasets: vi.fn(),
const serviceMocks = vi.hoisted(() => ({ queryOptions: vi.fn((options) => options) }))
vi.mock('@/service/client', () => ({
consoleQuery: { datasets: { get: { queryOptions: serviceMocks.queryOptions } } },
}))
vi.mock('@langgenius/dify-ui/cn', () => ({
cn: (...args: string[]) => args.filter(Boolean).join(' '),
}))
function dataset(overrides: Partial<DatasetListItemResponse> = {}): DatasetListItemResponse {
return {
id: 'dataset-1',
name: 'Knowledge',
description: 'Description',
provider: 'vendor',
embedding_available: true,
...overrides,
} as DatasetListItemResponse
}
describe('knowledgeAction', () => {
beforeEach(() => {
vi.clearAllMocks()
})
function response(data: DatasetListItemResponse[]): DatasetListResponse {
return { data, has_more: false, limit: 10, page: 1, total: data.length }
}
it('has correct metadata', () => {
expect(knowledgeAction.key).toBe('@knowledge')
expect(knowledgeAction.shortcut).toBe('@kb')
})
describe('knowledge search query', () => {
beforeEach(() => vi.clearAllMocks())
it('returns parsed dataset results on success', async () => {
const { fetchDatasets } = await import('@/service/datasets')
vi.mocked(fetchDatasets).mockResolvedValue({
data: [
{
id: 'ds-1',
name: 'My Knowledge',
description: 'A KB',
provider: 'vendor',
embedding_available: true,
} as unknown as DataSet,
],
has_more: false,
limit: 10,
page: 1,
total: 1,
})
const results = await knowledgeAction.search('@knowledge query', 'query', 'en')
expect(fetchDatasets).toHaveBeenCalledWith({
url: '/datasets',
params: { page: 1, limit: 10, keyword: 'query' },
})
expect(results).toHaveLength(1)
expect(results[0]).toMatchObject({
id: 'ds-1',
title: 'My Knowledge',
type: 'knowledge',
it('exposes remote action metadata', () => {
expect(knowledgeAction).toMatchObject({
key: '@knowledge',
shortcut: '@kb',
source: 'remote',
})
})
it('generates correct path for external provider', async () => {
const { fetchDatasets } = await import('@/service/datasets')
vi.mocked(fetchDatasets).mockResolvedValue({
data: [
{
id: 'ds-ext',
name: 'External',
description: '',
provider: 'external',
embedding_available: true,
} as unknown as DataSet,
],
has_more: false,
limit: 10,
page: 1,
total: 1,
})
it('builds generated query options from the search term', () => {
knowledgeSearchQueryOptions('vector')
const results = await knowledgeAction.search('@knowledge', '', 'en')
expect(results[0]!.path).toBe('/datasets/ds-ext/hitTesting')
expect(serviceMocks.queryOptions).toHaveBeenCalledWith(
expect.objectContaining({
input: { query: { page: 1, limit: 10, keyword: 'vector' } },
retry: false,
select: expect.any(Function),
}),
)
})
it('generates correct path for non-external provider', async () => {
const { fetchDatasets } = await import('@/service/datasets')
vi.mocked(fetchDatasets).mockResolvedValue({
data: [
{
id: 'ds-2',
name: 'Internal',
description: '',
provider: 'vendor',
embedding_available: true,
} as unknown as DataSet,
],
has_more: false,
limit: 10,
page: 1,
total: 1,
})
it('selects paths from the dataset provider contract', () => {
const options = knowledgeSearchQueryOptions('')
const results = options.select!(
response([dataset(), dataset({ id: 'external', provider: 'external' })]),
)
const results = await knowledgeAction.search('@knowledge', '', 'en')
expect(results[0]!.path).toBe('/datasets/ds-2/documents')
})
it('returns empty array on API failure', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const { fetchDatasets } = await import('@/service/datasets')
vi.mocked(fetchDatasets).mockRejectedValue(new Error('fail'))
const results = await knowledgeAction.search('@knowledge', 'fail', 'en')
expect(results).toEqual([])
expect(warnSpy).toHaveBeenCalledWith('Knowledge search failed:', expect.any(Error))
warnSpy.mockRestore()
expect(results.map((result) => result.path)).toEqual([
'/datasets/dataset-1/documents',
'/datasets/external/hitTesting',
])
})
})

View File

@ -1,73 +1,43 @@
import type { SearchResult } from '../types'
import { ragPipelineNodesAction } from '../rag-pipeline-nodes'
import { workflowNodesAction } from '../workflow-nodes'
import {
findRagPipelineNodes,
registerRagPipelineNodeSearch,
} from '@/app/components/rag-pipeline/goto-anything-search'
import {
findWorkflowNodes,
registerWorkflowNodeSearch,
} from '@/app/components/workflow/goto-anything-search'
describe('workflowNodesAction', () => {
beforeEach(() => {
vi.clearAllMocks()
workflowNodesAction.searchFn = undefined
})
it('should return an empty result when no workflow search function is registered', async () => {
await expect(workflowNodesAction.search('@node llm', 'llm', 'en')).resolves.toEqual([])
})
it('should delegate to the injected workflow search function', async () => {
describe('workflow node search registry', () => {
it('registers, searches, and unregisters workflow nodes', () => {
const results: SearchResult[] = [
{ id: 'workflow-node-1', title: 'LLM', type: 'workflow-node', data: {} as never },
]
workflowNodesAction.searchFn = vi.fn().mockReturnValue(results)
const search = vi.fn().mockReturnValue(results)
const unregister = registerWorkflowNodeSearch(search)
await expect(workflowNodesAction.search('@node llm', 'llm', 'en')).resolves.toEqual(results)
expect(workflowNodesAction.searchFn).toHaveBeenCalledWith('llm')
})
expect(findWorkflowNodes('llm')).toEqual(results)
expect(search).toHaveBeenCalledWith('llm')
it('should warn and return an empty list when workflow node search throws', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
workflowNodesAction.searchFn = vi.fn(() => {
throw new Error('failed')
})
unregister()
await expect(workflowNodesAction.search('@node llm', 'llm', 'en')).resolves.toEqual([])
expect(warnSpy).toHaveBeenCalledWith('Workflow nodes search failed:', expect.any(Error))
warnSpy.mockRestore()
expect(findWorkflowNodes('llm')).toEqual([])
})
})
describe('ragPipelineNodesAction', () => {
beforeEach(() => {
vi.clearAllMocks()
ragPipelineNodesAction.searchFn = undefined
})
it('should return an empty result when no rag pipeline search function is registered', async () => {
await expect(ragPipelineNodesAction.search('@node embed', 'embed', 'en')).resolves.toEqual([])
})
it('should delegate to the injected rag pipeline search function', async () => {
describe('RAG pipeline node search registry', () => {
it('registers, searches, and unregisters RAG pipeline nodes', () => {
const results: SearchResult[] = [
{ id: 'rag-node-1', title: 'Retriever', type: 'workflow-node', data: {} as never },
]
ragPipelineNodesAction.searchFn = vi.fn().mockReturnValue(results)
const search = vi.fn().mockReturnValue(results)
const unregister = registerRagPipelineNodeSearch(search)
await expect(
ragPipelineNodesAction.search('@node retrieve', 'retrieve', 'en'),
).resolves.toEqual(results)
expect(ragPipelineNodesAction.searchFn).toHaveBeenCalledWith('retrieve')
})
expect(findRagPipelineNodes('retrieve')).toEqual(results)
expect(search).toHaveBeenCalledWith('retrieve')
it('should warn and return an empty list when rag pipeline node search throws', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
ragPipelineNodesAction.searchFn = vi.fn(() => {
throw new Error('failed')
})
unregister()
await expect(
ragPipelineNodesAction.search('@node retrieve', 'retrieve', 'en'),
).resolves.toEqual([])
expect(warnSpy).toHaveBeenCalledWith('RAG pipeline nodes search failed:', expect.any(Error))
warnSpy.mockRestore()
expect(findRagPipelineNodes('retrieve')).toEqual([])
})
})

View File

@ -1,37 +1,47 @@
import { pluginAction } from '../plugin'
import { pluginAction, pluginSearchQueryOptions } from '../plugin'
vi.mock('@/service/base', () => ({
postMarketplace: vi.fn(),
const serviceMocks = vi.hoisted(() => ({ queryOptions: vi.fn((options) => options) }))
vi.mock('@/service/client', () => ({
marketplaceQuery: { searchAdvanced: { queryOptions: serviceMocks.queryOptions } },
}))
vi.mock('@/i18n-config', () => ({
renderI18nObject: vi.fn((obj: Record<string, string> | string, locale: string) => {
if (typeof obj === 'string') return obj
return obj[locale] || obj.en_US || ''
}),
}))
vi.mock('../../../plugins/card/base/card-icon', () => ({
default: () => null,
renderI18nObject: vi.fn((value: Record<string, string> | string, locale: string) =>
typeof value === 'string' ? value : value[locale] || value.en_US || '',
),
}))
vi.mock('../../../plugins/card/base/card-icon', () => ({ default: () => null }))
vi.mock('../../../plugins/marketplace/utils', () => ({
getFormattedPlugin: vi.fn((plugin) => ({ ...plugin, icon: 'icon-url' })),
}))
describe('pluginAction', () => {
beforeEach(() => {
vi.clearAllMocks()
describe('plugin search query', () => {
beforeEach(() => vi.clearAllMocks())
it('exposes remote action metadata', () => {
expect(pluginAction).toMatchObject({ key: '@plugin', shortcut: '@plugin', source: 'remote' })
})
it('has correct metadata', () => {
expect(pluginAction.key).toBe('@plugin')
expect(pluginAction.shortcut).toBe('@plugin')
it('builds generated marketplace query options', () => {
pluginSearchQueryOptions('agent', 'en_US')
expect(serviceMocks.queryOptions).toHaveBeenCalledWith(
expect.objectContaining({
input: {
params: { kind: 'plugins' },
body: { page: 1, page_size: 10, query: 'agent' },
},
retry: false,
select: expect.any(Function),
}),
)
})
it('returns parsed plugin results on success', async () => {
const { postMarketplace } = await import('@/service/base')
vi.mocked(postMarketplace).mockResolvedValue({
it('selects formatted plugin results using the active locale', () => {
const options = pluginSearchQueryOptions('', 'en_US')
const results = options.select!({
data: {
plugins: [
{
@ -41,45 +51,15 @@ describe('pluginAction', () => {
icon: 'icon.png',
},
],
total: 1,
},
})
} as never)
const results = await pluginAction.search('@plugin', 'test', 'en_US')
expect(postMarketplace).toHaveBeenCalledWith('/plugins/search/advanced', {
body: { page: 1, page_size: 10, query: 'test', type: 'plugin' },
})
expect(results).toHaveLength(1)
expect(results[0]).toMatchObject({
id: 'plugin-1',
title: 'My Plugin',
type: 'plugin',
})
expect(results[0]).toMatchObject({ id: 'plugin-1', title: 'My Plugin', type: 'plugin' })
})
it('returns empty array when response has unexpected structure', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const { postMarketplace } = await import('@/service/base')
vi.mocked(postMarketplace).mockResolvedValue({ data: {} })
it('selects an empty list when marketplace returns no plugins', () => {
const options = pluginSearchQueryOptions('', 'en_US')
const results = await pluginAction.search('@plugin', 'test', 'en')
expect(results).toEqual([])
expect(warnSpy).toHaveBeenCalledWith(
'Plugin search: Unexpected response structure',
expect.anything(),
)
warnSpy.mockRestore()
})
it('returns empty array on API failure', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const { postMarketplace } = await import('@/service/base')
vi.mocked(postMarketplace).mockRejectedValue(new Error('fail'))
const results = await pluginAction.search('@plugin', 'fail', 'en')
expect(results).toEqual([])
expect(warnSpy).toHaveBeenCalledWith('Plugin search failed:', expect.any(Error))
warnSpy.mockRestore()
expect(options.select!({ data: {} } as never)).toEqual([])
})
})

View File

@ -1,30 +1,56 @@
import type { AppPartial } from '@dify/contracts/api/console/apps/types.gen'
import type { ActionItem, AppSearchResult, SearchResult } from './types'
import type { App } from '@/types/app'
import {
RiFileListLine,
RiLayoutLine,
RiLineChartLine,
RiNodeTree,
RiTerminalBoxLine,
} from '@remixicon/react'
import * as React from 'react'
import { fetchAppList } from '@/service/apps'
import type { AppIconType, AppModeEnum as AppMode } from '@/types/app'
import { consoleQuery } from '@/service/client'
import { AppModeEnum } from '@/types/app'
import { getRedirectionPath } from '@/utils/app-redirection'
import { AppTypeIcon } from '../../app/type-selector'
import AppIcon from '../../base/app-icon'
const WORKFLOW_MODES = new Set([AppModeEnum.WORKFLOW, AppModeEnum.ADVANCED_CHAT])
const APP_MODES = new Set<string>(Object.values(AppModeEnum))
const APP_ICON_TYPES = new Set<string>(['emoji', 'image', 'link'])
type AppSection = { id: string; label: string; path: string; icon: React.ElementType }
type AppSection = { id: string; label: string; path: string; iconClassName: string }
const getAppSections = (app: App): AppSection[] => {
function getAppMode(app: AppPartial): AppMode {
return APP_MODES.has(app.mode) ? (app.mode as AppMode) : AppModeEnum.CHAT
}
function getAppIconType(app: AppPartial): AppIconType | null {
return app.icon_type && APP_ICON_TYPES.has(app.icon_type) ? (app.icon_type as AppIconType) : null
}
function getAppPath(app: AppPartial) {
return getRedirectionPath({
id: app.id,
mode: getAppMode(app),
permission_keys: app.permission_keys,
})
}
const getAppSections = (app: AppPartial): AppSection[] => {
const base = `/app/${app.id}`
if (WORKFLOW_MODES.has(app.mode)) {
if (WORKFLOW_MODES.has(getAppMode(app))) {
return [
{ id: 'workflow', label: 'Workflow', path: `${base}/workflow`, icon: RiNodeTree },
{ id: 'overview', label: 'Overview', path: `${base}/overview`, icon: RiLineChartLine },
{ id: 'logs', label: 'Logs', path: `${base}/logs`, icon: RiFileListLine },
{
id: 'workflow',
label: 'Workflow',
path: `${base}/workflow`,
iconClassName: 'i-ri-node-tree size-4 text-text-tertiary',
},
{
id: 'overview',
label: 'Overview',
path: `${base}/overview`,
iconClassName: 'i-ri-line-chart-line size-4 text-text-tertiary',
},
{
id: 'logs',
label: 'Logs',
path: `${base}/logs`,
iconClassName: 'i-ri-file-list-line size-4 text-text-tertiary',
},
]
}
return [
@ -32,53 +58,68 @@ const getAppSections = (app: App): AppSection[] => {
id: 'configuration',
label: 'Configuration',
path: `${base}/configuration`,
icon: RiLayoutLine,
iconClassName: 'i-ri-layout-line size-4 text-text-tertiary',
},
{
id: 'overview',
label: 'Overview',
path: `${base}/overview`,
iconClassName: 'i-ri-line-chart-line size-4 text-text-tertiary',
},
{
id: 'logs',
label: 'Logs',
path: `${base}/logs`,
iconClassName: 'i-ri-file-list-line size-4 text-text-tertiary',
},
{
id: 'develop',
label: 'Develop',
path: `${base}/develop`,
iconClassName: 'i-ri-terminal-box-line size-4 text-text-tertiary',
},
{ id: 'overview', label: 'Overview', path: `${base}/overview`, icon: RiLineChartLine },
{ id: 'logs', label: 'Logs', path: `${base}/logs`, icon: RiFileListLine },
{ id: 'develop', label: 'Develop', path: `${base}/develop`, icon: RiTerminalBoxLine },
]
}
const appIcon = (app: App) => (
const appIcon = (app: AppPartial) => (
<div className="relative shrink-0">
<AppIcon
size="large"
iconType={app.icon_type}
icon={app.icon}
iconType={getAppIconType(app)}
icon={app.icon ?? undefined}
background={app.icon_background}
imageUrl={app.icon_url}
/>
<AppTypeIcon
wrapperClassName="absolute -bottom-0.5 -right-0.5 w-4 h-4 rounded-sm border border-divider-regular outline-solid outline-components-panel-on-panel-item-bg"
className="size-3"
type={app.mode}
type={getAppMode(app)}
/>
</div>
)
const parser = (apps: App[]): AppSearchResult[] => {
function getAppResults(apps: AppPartial[]): AppSearchResult[] {
return apps.map((app) => ({
id: app.id,
title: app.name,
description: app.description,
description: app.description ?? undefined,
type: 'app' as const,
path: getRedirectionPath(app),
path: getAppPath(app),
icon: appIcon(app),
data: app,
}))
}
// Generate sub-section results for matched apps when in scoped @app search
const parserWithSections = (apps: App[]): SearchResult[] => {
function getScopedAppResults(apps: AppPartial[]): SearchResult[] {
const results: SearchResult[] = []
for (const app of apps) {
results.push({
id: app.id,
title: app.name,
description: app.description,
description: app.description ?? undefined,
type: 'app' as const,
path: getRedirectionPath(app),
path: getAppPath(app),
icon: appIcon(app),
data: app,
})
@ -91,7 +132,7 @@ const parserWithSections = (apps: App[]): SearchResult[] => {
path: section.path,
icon: (
<div className="flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-panel-bg">
<section.icon className="size-4 text-text-tertiary" />
<span aria-hidden className={section.iconClassName} />
</div>
),
data: app,
@ -106,21 +147,19 @@ export const appAction: ActionItem = {
shortcut: '@app',
title: 'Search Applications',
description: 'Search and navigate to your applications',
search: async (query, searchTerm = '', _locale) => {
const isScoped = query.trimStart().startsWith('@app') || query.trimStart().startsWith('@App')
try {
const response = await fetchAppList({
url: 'apps',
params: {
page: 1,
name: searchTerm,
},
})
const apps = response?.data || []
return isScoped ? parserWithSections(apps) : parser(apps)
} catch (error) {
console.warn('App search failed:', error)
return []
}
},
source: 'remote',
}
export function appSearchQueryOptions(searchTerm: string, scoped: boolean) {
return consoleQuery.apps.get.queryOptions({
input: {
query: {
page: 1,
name: searchTerm,
},
},
retry: false,
select: (response) =>
scoped ? getScopedAppResults(response.data) : getAppResults(response.data),
})
}

View File

@ -1,12 +1,5 @@
import { executeCommand } from '../command-bus'
import { createCommand } from '../create'
// Stub the icon imports — these are React components we don't render here.
vi.mock('@remixicon/react', () => ({
RiChat3Line: () => null,
RiNodeTree: () => null,
RiSparkling2Line: () => null,
}))
// We spy on the store at module scope so the `create.open` handler that
// register() pushes into the command bus can be observed by the tests.
const mockOpenGenerator = vi.fn()

View File

@ -1,10 +1,5 @@
import { executeCommand } from '../command-bus'
import { refineCommand } from '../refine'
// Stub the icon import — it's a React component we don't render here.
vi.mock('@remixicon/react', () => ({
RiSparkling2Line: () => null,
}))
// Spy on the generator store so we can observe what /refine opens it with.
const mockOpenGenerator = vi.fn()
vi.mock('@/app/components/workflow/workflow-generator/store', () => ({

View File

@ -225,7 +225,9 @@ describe('SlashCommandRegistry', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const handler = createHandler({
name: 'broken',
search: vi.fn().mockRejectedValue(new Error('fail')),
search: vi.fn(() => {
throw new Error('fail')
}),
})
registry.register(handler)

View File

@ -91,6 +91,8 @@ describe('slashAction', () => {
{ id: 'theme', title: '/theme', type: 'command', data: { command: 'theme' } },
])
expect(slashAction.source).toBe('local')
if (slashAction.source !== 'local') throw new Error('Expected a local slash action')
const results = await slashAction.search('/theme dark', 'dark')
expect(mockSearch).toHaveBeenCalledWith('/theme dark', 'ja')

View File

@ -1,6 +1,4 @@
import type { SlashCommandHandler } from './types'
import { RiUser3Line } from '@remixicon/react'
import * as React from 'react'
import { getI18n } from 'react-i18next'
import { registerCommands, unregisterCommands } from './command-bus'
@ -20,7 +18,7 @@ export const accountCommand: SlashCommandHandler<AccountDeps> = {
window.location.href = '/account'
},
async search(args: string, locale: string = 'en') {
search(args: string, locale: string = 'en') {
const i18n = getI18n()
return [
{
@ -33,7 +31,7 @@ export const accountCommand: SlashCommandHandler<AccountDeps> = {
type: 'command' as const,
icon: (
<div className="flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-panel-bg">
<RiUser3Line className="size-4 text-text-tertiary" />
<span aria-hidden className="i-ri-user-3-line size-4 text-text-tertiary" />
</div>
),
data: { command: 'navigation.account', args: {} },

View File

@ -1,6 +1,4 @@
import type { SlashCommandHandler } from './types'
import { RiDiscordLine } from '@remixicon/react'
import * as React from 'react'
import { getI18n } from 'react-i18next'
import { registerCommands, unregisterCommands } from './command-bus'
@ -21,7 +19,7 @@ export const communityCommand: SlashCommandHandler<CommunityDeps> = {
window.open(url, '_blank', 'noopener,noreferrer')
},
async search(args: string, locale: string = 'en') {
search(args: string, locale: string = 'en') {
const i18n = getI18n()
return [
{
@ -33,7 +31,7 @@ export const communityCommand: SlashCommandHandler<CommunityDeps> = {
type: 'command' as const,
icon: (
<div className="flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-panel-bg">
<RiDiscordLine className="size-4 text-text-tertiary" />
<span aria-hidden className="i-ri-discord-line size-4 text-text-tertiary" />
</div>
),
data: { command: 'navigation.community', args: { url: 'https://discord.gg/5AEfbxcd9k' } },

View File

@ -1,7 +1,5 @@
import type { SlashCommandHandler } from './types'
import type { WorkflowGeneratorMode } from '@/app/components/workflow/workflow-generator/types'
import { RiChat3Line, RiNodeTree, RiSparkling2Line } from '@remixicon/react'
import * as React from 'react'
import { getI18n } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useWorkflowGeneratorStore } from '@/app/components/workflow/workflow-generator/store'
@ -18,7 +16,7 @@ type CreateOption = {
mode: WorkflowGeneratorMode
/** When set, the modal opens in auto-mode and the planner picks the app type. */
auto?: boolean
icon: React.ComponentType<{ className?: string }>
iconClassName: string
}
// `as const` keeps titleKey/descKey as literal types so the typed `i18n.t`
@ -30,7 +28,7 @@ const OPTIONS = [
descKey: 'gotoAnything.actions.createAutoDesc',
mode: 'advanced-chat',
auto: true,
icon: RiSparkling2Line,
iconClassName: 'i-ri-sparkling-2-line',
},
{
id: 'workflow',
@ -38,7 +36,7 @@ const OPTIONS = [
descKey: 'gotoAnything.actions.createWorkflowDesc',
mode: 'workflow',
auto: false,
icon: RiNodeTree,
iconClassName: 'i-ri-node-tree',
},
{
id: 'chatflow',
@ -46,7 +44,7 @@ const OPTIONS = [
descKey: 'gotoAnything.actions.createChatflowDesc',
mode: 'advanced-chat',
auto: false,
icon: RiChat3Line,
iconClassName: 'i-ri-chat-3-line',
},
] as const satisfies readonly CreateOption[]
@ -71,19 +69,17 @@ const OPTIONS = [
export const createCommand: SlashCommandHandler = {
name: 'create',
aliases: ['new', 'generate'],
// Fallback only — the palette localises the root row via the slashKeyMap in
// command-selector.tsx (gotoAnything.actions.createCategoryDesc).
description: getI18n().t(($) => $['gotoAnything.actions.createCategoryDesc'], { ns: 'app' }),
mode: 'submenu',
async search(args: string, locale?: string) {
search(args: string, locale?: string) {
const i18n = getI18n()
const tr = (key: (typeof OPTIONS)[number]['titleKey' | 'descKey']) =>
i18n.t(($) => $[key], { ns: 'app', lng: locale })
const renderIcon = (Icon: CreateOption['icon']) => (
const renderIcon = (iconClassName: string) => (
<div className="flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-panel-bg">
<Icon className="size-4 text-text-tertiary" />
<span aria-hidden className={`${iconClassName} size-4 text-text-tertiary`} />
</div>
)
@ -94,7 +90,7 @@ export const createCommand: SlashCommandHandler = {
// back to the option's static description otherwise.
description: instruction || tr(opt.descKey),
type: 'command' as const,
icon: renderIcon(opt.icon),
icon: renderIcon(opt.iconClassName),
data: { command: 'create.open', args: { mode: opt.mode, auto: !!opt.auto, instruction } },
})

View File

@ -1,6 +1,4 @@
import type { SlashCommandHandler } from './types'
import { RiBookOpenLine } from '@remixicon/react'
import * as React from 'react'
import { getI18n } from 'react-i18next'
import { defaultDocBaseUrl, getDocHomePath } from '@/context/i18n'
import { getDocLanguage } from '@/i18n-config/language'
@ -29,7 +27,7 @@ export const docsCommand: SlashCommandHandler<DocDeps> = {
window.open(getDocsHomeUrl(), '_blank', 'noopener,noreferrer')
},
async search(args: string, locale: string = 'en') {
search(args: string, locale: string = 'en') {
const i18n = getI18n()
return [
{
@ -41,7 +39,7 @@ export const docsCommand: SlashCommandHandler<DocDeps> = {
type: 'command' as const,
icon: (
<div className="flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-panel-bg">
<RiBookOpenLine className="size-4 text-text-tertiary" />
<span aria-hidden className="i-ri-book-open-line size-4 text-text-tertiary" />
</div>
),
data: { command: 'navigation.doc', args: {} },

View File

@ -1,6 +1,4 @@
import type { SlashCommandHandler } from './types'
import { RiFeedbackLine } from '@remixicon/react'
import * as React from 'react'
import { getI18n } from 'react-i18next'
import { registerCommands, unregisterCommands } from './command-bus'
@ -21,7 +19,7 @@ export const forumCommand: SlashCommandHandler<ForumDeps> = {
window.open(url, '_blank', 'noopener,noreferrer')
},
async search(args: string, locale: string = 'en') {
search(args: string, locale: string = 'en') {
const i18n = getI18n()
return [
{
@ -33,7 +31,7 @@ export const forumCommand: SlashCommandHandler<ForumDeps> = {
type: 'command' as const,
icon: (
<div className="flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-panel-bg">
<RiFeedbackLine className="size-4 text-text-tertiary" />
<span aria-hidden className="i-ri-feedback-line size-4 text-text-tertiary" />
</div>
),
data: { command: 'navigation.forum', args: { url: 'https://forum.dify.ai' } },

View File

@ -1,22 +1,13 @@
import type { SlashCommandHandler } from './types'
import {
RiApps2Line,
RiBookOpenLine,
RiCompassLine,
RiPlugLine,
RiToolsLine,
RiUserLine,
} from '@remixicon/react'
import * as React from 'react'
import { registerCommands, unregisterCommands } from './command-bus'
const NAV_ITEMS = [
{ id: 'apps', label: 'Apps', path: '/apps', icon: RiApps2Line },
{ id: 'datasets', label: 'Knowledge', path: '/datasets', icon: RiBookOpenLine },
{ id: 'plugins', label: 'Plugins', path: '/plugins', icon: RiPlugLine },
{ id: 'tools', label: 'Tools', path: '/tools', icon: RiToolsLine },
{ id: 'explore', label: 'Explore', path: '/explore', icon: RiCompassLine },
{ id: 'account', label: 'Account', path: '/account', icon: RiUserLine },
{ id: 'apps', label: 'Apps', path: '/apps', iconClassName: 'i-ri-apps-2-line' },
{ id: 'datasets', label: 'Knowledge', path: '/datasets', iconClassName: 'i-ri-book-open-line' },
{ id: 'plugins', label: 'Plugins', path: '/plugins', iconClassName: 'i-ri-plug-line' },
{ id: 'tools', label: 'Tools', path: '/tools', iconClassName: 'i-ri-tools-line' },
{ id: 'explore', label: 'Explore', path: '/explore', iconClassName: 'i-ri-compass-line' },
{ id: 'account', label: 'Account', path: '/account', iconClassName: 'i-ri-user-line' },
]
/**
@ -28,7 +19,7 @@ export const goCommand: SlashCommandHandler = {
description: 'Navigate to a section',
mode: 'submenu',
async search(args: string, _locale: string = 'en') {
search(args: string, _locale: string = 'en') {
const query = args.trim().toLowerCase()
const items = NAV_ITEMS.filter(
(item) => !query || item.id.includes(query) || item.label.toLowerCase().includes(query),
@ -40,7 +31,7 @@ export const goCommand: SlashCommandHandler = {
type: 'command' as const,
icon: (
<div className="flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-panel-bg">
<item.icon className="size-4 text-text-tertiary" />
<span aria-hidden className={`${item.iconClassName} size-4 text-text-tertiary`} />
</div>
),
data: { command: 'navigation.go', args: { path: item.path } },

View File

@ -36,7 +36,7 @@ export const languageCommand: SlashCommandHandler<LanguageDeps> = {
description: 'Switch between different languages',
mode: 'submenu', // Explicitly set submenu mode
async search(args: string, _locale: string = 'en') {
search(args: string, _locale: string = 'en') {
// Return language options directly, regardless of parameters
return buildLanguageCommands(args)
},

View File

@ -1,7 +1,5 @@
import type { SlashCommandHandler } from './types'
import type { WorkflowGeneratorMode } from '@/app/components/workflow/workflow-generator/types'
import { RiSparkling2Line } from '@remixicon/react'
import * as React from 'react'
import { getI18n } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useWorkflowGeneratorStore } from '@/app/components/workflow/workflow-generator/store'
@ -49,8 +47,6 @@ const openRefineGenerator = () => {
export const refineCommand: SlashCommandHandler = {
name: 'refine',
aliases: ['improve'],
// Fallback only — the palette localises the root row via the slashKeyMap in
// command-selector.tsx (gotoAnything.actions.refineCategoryDesc).
description: getI18n().t(($) => $['gotoAnything.actions.refineCategoryDesc'], { ns: 'app' }),
mode: 'direct',
@ -60,7 +56,7 @@ export const refineCommand: SlashCommandHandler = {
execute: openRefineGenerator,
async search(_args: string, locale?: string) {
search(_args: string, locale?: string) {
const i18n = getI18n()
return [
{
@ -73,7 +69,7 @@ export const refineCommand: SlashCommandHandler = {
type: 'command' as const,
icon: (
<div className="flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-panel-bg">
<RiSparkling2Line className="size-4 text-text-tertiary" />
<span aria-hidden className="i-ri-sparkling-2-line size-4 text-text-tertiary" />
</div>
),
data: { command: 'refine.open', args: {} },

View File

@ -1,18 +1,18 @@
import type { CommandSearchResult } from '../types'
import type { SlashCommandHandler } from './types'
import type { SlashCommand, SlashCommandHandler } from './types'
/**
* Slash Command Registry System
* Responsible for managing registration, lookup, and search of all slash commands
*/
export class SlashCommandRegistry {
private commands = new Map<string, SlashCommandHandler>()
private commandDeps = new Map<string, any>()
private commands = new Map<string, SlashCommand>()
private commandDeps = new Map<string, unknown>()
/**
* Register command handler
*/
register<TDeps = any>(handler: SlashCommandHandler<TDeps>, deps?: TDeps) {
register<TDeps>(handler: SlashCommandHandler<TDeps>, deps?: TDeps) {
// Register main command name
this.commands.set(handler.name, handler)
@ -57,7 +57,7 @@ export class SlashCommandRegistry {
/**
* Find command handler
*/
findCommand(commandName: string): SlashCommandHandler | undefined {
findCommand(commandName: string): SlashCommand | undefined {
return this.commands.get(commandName)
}
@ -65,7 +65,7 @@ export class SlashCommandRegistry {
* Smart partial command matching
* Prioritize alias matching, then match command name prefix
*/
private findBestPartialMatch(partialName: string): SlashCommandHandler | undefined {
private findBestPartialMatch(partialName: string): SlashCommand | undefined {
const lowerPartial = partialName.toLowerCase()
// First check if any alias starts with this
@ -80,7 +80,7 @@ export class SlashCommandRegistry {
/**
* Find handler by alias prefix
*/
private findHandlerByAliasPrefix(prefix: string): SlashCommandHandler | undefined {
private findHandlerByAliasPrefix(prefix: string): SlashCommand | undefined {
for (const handler of this.getAllCommands()) {
if (handler.aliases?.some((alias) => alias.toLowerCase().startsWith(prefix))) return handler
}
@ -90,15 +90,15 @@ export class SlashCommandRegistry {
/**
* Find handler by name prefix
*/
private findHandlerByNamePrefix(prefix: string): SlashCommandHandler | undefined {
private findHandlerByNamePrefix(prefix: string): SlashCommand | undefined {
return this.getAllCommands().find((handler) => handler.name.toLowerCase().startsWith(prefix))
}
/**
* Get all registered commands (deduplicated)
*/
getAllCommands(): SlashCommandHandler[] {
const uniqueCommands = new Map<string, SlashCommandHandler>()
getAllCommands(): SlashCommand[] {
const uniqueCommands = new Map<string, SlashCommand>()
this.commands.forEach((handler) => {
uniqueCommands.set(handler.name, handler)
})
@ -109,7 +109,7 @@ export class SlashCommandRegistry {
* Get all available commands in current context (deduplicated and filtered)
* Commands without isAvailable method are considered always available
*/
getAvailableCommands(): SlashCommandHandler[] {
getAvailableCommands(): SlashCommand[] {
return this.getAllCommands().filter((handler) => this.isCommandAvailable(handler))
}
@ -118,11 +118,11 @@ export class SlashCommandRegistry {
* @param query Full query (e.g., "/theme dark" or "/lang en")
* @param locale Current language
*/
async search(query: string, locale: string = 'en'): Promise<CommandSearchResult[]> {
search(query: string, locale: string = 'en'): CommandSearchResult[] {
const trimmed = query.trim()
// Handle root level search "/"
if (trimmed === '/' || !trimmed.replace('/', '').trim()) return await this.getRootCommands()
if (trimmed === '/' || !trimmed.replace('/', '').trim()) return this.getRootCommands()
// Parse command and arguments
const afterSlash = trimmed.substring(1).trim()
@ -134,7 +134,7 @@ export class SlashCommandRegistry {
let handler = this.findCommand(commandName)
if (handler && this.isCommandAvailable(handler)) {
try {
return await handler.search(args, locale)
return handler.search(args, locale)
} catch (error) {
console.warn(`Command search failed for ${commandName}:`, error)
return []
@ -145,7 +145,7 @@ export class SlashCommandRegistry {
handler = this.findBestPartialMatch(commandName)
if (handler && this.isCommandAvailable(handler)) {
try {
return await handler.search(args, locale)
return handler.search(args, locale)
} catch (error) {
console.warn(`Command search failed for ${handler.name}:`, error)
return []
@ -160,7 +160,7 @@ export class SlashCommandRegistry {
* Get root level command list
* Only shows commands that are available in current context
*/
private async getRootCommands(): Promise<CommandSearchResult[]> {
private getRootCommands(): CommandSearchResult[] {
return this.getAvailableCommands().map((handler) => ({
id: `root-${handler.name}`,
title: `/${handler.name}`,
@ -221,7 +221,7 @@ export class SlashCommandRegistry {
/**
* Get command dependencies
*/
getCommandDependencies(commandName: string): any {
getCommandDependencies(commandName: string): unknown {
return this.commandDeps.get(commandName)
}
@ -229,7 +229,7 @@ export class SlashCommandRegistry {
* Determine if a command is available in the current context.
* Defaults to true when a handler does not implement the guard.
*/
private isCommandAvailable(handler: SlashCommandHandler) {
private isCommandAvailable(handler: SlashCommand) {
return handler.isAvailable?.() ?? true
}
}

View File

@ -14,12 +14,13 @@ export const slashAction: ActionItem = {
const i18n = getI18n()
return i18n.t(($) => $['gotoAnything.actions.slashDesc'], { ns: 'app' })
},
source: 'local',
action: (result) => {
if (result.type !== 'command') return
const { command, args } = result.data
executeCommand(command, args)
},
search: async (query, _searchTerm = '') => {
search: (query, _searchTerm = '') => {
const i18n = getI18n()
// Delegate all search logic to the command registry system
return slashCommandRegistry.search(query, i18n.language)

View File

@ -1,7 +1,5 @@
import type { CommandSearchResult } from '../types'
import type { SlashCommandHandler } from './types'
import { RiComputerLine, RiMoonLine, RiSunLine } from '@remixicon/react'
import * as React from 'react'
import { getI18n } from 'react-i18next'
import { registerCommands, unregisterCommands } from './command-bus'
@ -15,19 +13,19 @@ const THEME_ITEMS = [
id: 'system',
titleKey: 'gotoAnything.actions.themeSystem',
descKey: 'gotoAnything.actions.themeSystemDesc',
icon: <RiComputerLine className="size-4 text-text-tertiary" />,
iconClassName: 'i-ri-computer-line',
},
{
id: 'light',
titleKey: 'gotoAnything.actions.themeLight',
descKey: 'gotoAnything.actions.themeLightDesc',
icon: <RiSunLine className="size-4 text-text-tertiary" />,
iconClassName: 'i-ri-sun-line',
},
{
id: 'dark',
titleKey: 'gotoAnything.actions.themeDark',
descKey: 'gotoAnything.actions.themeDarkDesc',
icon: <RiMoonLine className="size-4 text-text-tertiary" />,
iconClassName: 'i-ri-moon-line',
},
] as const
@ -50,7 +48,7 @@ const buildThemeCommands = (query: string, locale?: string): CommandSearchResult
type: 'command' as const,
icon: (
<div className="flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-panel-bg">
{item.icon}
<span aria-hidden className={`${item.iconClassName} size-4 text-text-tertiary`} />
</div>
),
data: { command: 'theme.set', args: { value: item.id } },
@ -66,7 +64,7 @@ export const themeCommand: SlashCommandHandler<ThemeDeps> = {
description: 'Switch between light and dark themes',
mode: 'submenu', // Explicitly set submenu mode
async search(args: string, locale: string = 'en') {
search(args: string, locale: string = 'en') {
// Return theme options directly, regardless of parameters
return buildThemeCommands(args, locale)
},

View File

@ -4,7 +4,7 @@ import type { CommandSearchResult } from '../types'
* Slash command handler interface
* Each slash command should implement this interface
*/
export type SlashCommandHandler<TDeps = any> = {
export type SlashCommandHandler<TDeps = unknown> = {
/** Command name (e.g., 'theme', 'language') */
name: string
@ -39,7 +39,7 @@ export type SlashCommandHandler<TDeps = any> = {
* @param args Command arguments (part after removing command name)
* @param locale Current language
*/
search: (args: string, locale?: string) => Promise<CommandSearchResult[]>
search: (args: string, locale?: string) => CommandSearchResult[]
/**
* Called when registering command, passing external dependencies
@ -51,3 +51,5 @@ export type SlashCommandHandler<TDeps = any> = {
*/
unregister?: () => void
}
export type SlashCommand = Omit<SlashCommandHandler, 'register'>

View File

@ -1,169 +1,4 @@
/**
* Goto Anything - Action System
*
* This file defines the action registry for the goto-anything search system.
* Actions handle different types of searches: apps, knowledge bases, plugins, workflow nodes, and commands.
*
* ## How to Add a New Slash Command
*
* 1. **Create Command Handler File** (in `./commands/` directory):
* ```typescript
* // commands/my-command.ts
* import type { SlashCommandHandler } from './types'
* import type { CommandSearchResult } from '../types'
* import { registerCommands, unregisterCommands } from './command-bus'
*
* interface MyCommandDeps {
* myService?: (data: any) => Promise<void>
* }
*
* export const myCommand: SlashCommandHandler<MyCommandDeps> = {
* name: 'mycommand',
* aliases: ['mc'], // Optional aliases
* description: 'My custom command description',
*
* async search(args: string, locale: string = 'en') {
* // Return search results based on args
* return [{
* id: 'my-result',
* title: 'My Command Result',
* description: 'Description of the result',
* type: 'command' as const,
* data: { command: 'my.action', args: { value: args } }
* }]
* },
*
* register(deps: MyCommandDeps) {
* registerCommands({
* 'my.action': async (args) => {
* await deps.myService?.(args?.value)
* }
* })
* },
*
* unregister() {
* unregisterCommands(['my.action'])
* }
* }
* ```
*
* **Example for Self-Contained Command (no external dependencies):**
* ```typescript
* // commands/calculator-command.ts
* export const calculatorCommand: SlashCommandHandler = {
* name: 'calc',
* aliases: ['calculator'],
* description: 'Simple calculator',
*
* async search(args: string) {
* if (!args.trim()) return []
* try {
* // Safe math evaluation (implement proper parser in real use)
* const result = Function('"use strict"; return (' + args + ')')()
* return [{
* id: 'calc-result',
* title: `${args} = ${result}`,
* description: 'Calculator result',
* type: 'command' as const,
* data: { command: 'calc.copy', args: { result: result.toString() } }
* }]
* } catch {
* return [{
* id: 'calc-error',
* title: 'Invalid expression',
* description: 'Please enter a valid math expression',
* type: 'command' as const,
* data: { command: 'calc.noop', args: {} }
* }]
* }
* },
*
* register() {
* registerCommands({
* 'calc.copy': (args) => navigator.clipboard.writeText(args.result),
* 'calc.noop': () => {} // No operation
* })
* },
*
* unregister() {
* unregisterCommands(['calc.copy', 'calc.noop'])
* }
* }
* ```
*
* 2. **Register Command** (in `./commands/slash.tsx`):
* ```typescript
* import { myCommand } from './my-command'
* import { calculatorCommand } from './calculator-command' // For self-contained commands
*
* export const registerSlashCommands = (deps: Record<string, any>) => {
* slashCommandRegistry.register(themeCommand, { setTheme: deps.setTheme })
* slashCommandRegistry.register(languageCommand, { setLocale: deps.setLocale })
* slashCommandRegistry.register(myCommand, { myService: deps.myService }) // With dependencies
* slashCommandRegistry.register(calculatorCommand) // Self-contained, no dependencies
* }
*
* export const unregisterSlashCommands = () => {
* slashCommandRegistry.unregister('theme')
* slashCommandRegistry.unregister('language')
* slashCommandRegistry.unregister('mycommand')
* slashCommandRegistry.unregister('calc') // Add this line
* }
* ```
*
*
* 3. **Update SlashCommandProvider** (in `./commands/slash.tsx`):
* ```typescript
* export const SlashCommandProvider = () => {
* const theme = useTheme()
* const myService = useMyService() // Add external dependency if needed
*
* useEffect(() => {
* registerSlashCommands({
* setTheme: theme.setTheme, // Required for theme command
* setLocale: setLocaleOnClient, // Required for language command
* myService: myService, // Required for your custom command
* // Note: calculatorCommand doesn't need dependencies, so not listed here
* })
* return () => unregisterSlashCommands()
* }, [theme.setTheme, myService]) // Update dependency array for all dynamic deps
*
* return null
* }
* ```
*
* **Note:** Self-contained commands (like calculator) don't require dependencies but are
* still registered through the same system for consistent lifecycle management.
*
* 4. **Usage**: Users can now type `/mycommand` or `/mc` to use your command
*
* ## Command System Architecture
* - Commands are registered via `SlashCommandRegistry`
* - Each command is self-contained with its own dependencies
* - Commands support aliases for easier access
* - Command execution is handled by the command bus system
* - All commands should be registered through `SlashCommandProvider` for consistent lifecycle management
*
* ## Command Types
* **Commands with External Dependencies:**
* - Require external services, APIs, or React hooks
* - Must provide dependencies in `SlashCommandProvider`
* - Example: theme commands (needs useTheme), API commands (needs service)
*
* **Self-Contained Commands:**
* - Pure logic operations, no external dependencies
* - Still recommended to register through `SlashCommandProvider` for consistency
* - Example: calculator, text manipulation commands
*
* ## Available Actions
* - `@app` - Search applications
* - `@knowledge` / `@kb` - Search knowledge bases
* - `@plugin` - Search plugins
* - `@node` - Search workflow nodes (workflow pages only)
* - `/` - Execute slash commands (theme, language, etc.)
*/
import type { ActionItem, SearchResult } from './types'
import type { ActionItem } from './types'
import { appAction } from './app'
import { slashCommandRegistry } from './commands/registry'
import { slashAction } from './commands/slash'
@ -172,120 +7,38 @@ import { pluginAction } from './plugin'
import { ragPipelineNodesAction } from './rag-pipeline-nodes'
import { workflowNodesAction } from './workflow-nodes'
// Create dynamic Actions based on context
export const createActions = (isWorkflowPage: boolean, isRagPipelinePage: boolean) => {
const baseActions = {
slash: slashAction,
app: appAction,
knowledge: knowledgeAction,
plugin: pluginAction,
}
// Add appropriate node search based on context
if (isRagPipelinePage) {
return {
...baseActions,
node: ragPipelineNodesAction,
}
} else if (isWorkflowPage) {
return {
...baseActions,
node: workflowNodesAction,
}
}
// Default actions without node search
return baseActions
}
// Legacy export for backward compatibility
export const Actions = {
const defaultActions = {
slash: slashAction,
app: appAction,
knowledge: knowledgeAction,
plugin: pluginAction,
node: workflowNodesAction,
} satisfies Record<string, ActionItem>
export function createActions(isWorkflowPage: boolean, isRagPipelinePage: boolean) {
if (isRagPipelinePage) return { ...defaultActions, node: ragPipelineNodesAction }
if (isWorkflowPage) return { ...defaultActions, node: workflowNodesAction }
return defaultActions
}
export const searchAnything = async (
locale: string,
query: string,
actionItem?: ActionItem,
dynamicActions?: Record<string, ActionItem>,
): Promise<SearchResult[]> => {
const trimmedQuery = query.trim()
if (actionItem) {
const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const prefixPattern = new RegExp(
`^(${escapeRegExp(actionItem.key)}|${escapeRegExp(actionItem.shortcut)})\\s*`,
)
const searchTerm = trimmedQuery.replace(prefixPattern, '').trim()
try {
return await actionItem.search(query, searchTerm, locale)
} catch (error) {
console.warn(`Search failed for ${actionItem.key}:`, error)
return []
}
}
if (trimmedQuery.startsWith('@') || trimmedQuery.startsWith('/')) return []
const globalSearchActions = Object.values(dynamicActions || Actions)
// Exclude slash commands from general search results
.filter((action) => action.key !== '/')
// Use Promise.allSettled to handle partial failures gracefully
const searchPromises = globalSearchActions.map(async (action) => {
try {
const results = await action.search(query, query, locale)
return { success: true, data: results, actionType: action.key }
} catch (error) {
console.warn(`Search failed for ${action.key}:`, error)
return { success: false, data: [], actionType: action.key, error }
}
})
const settledResults = await Promise.allSettled(searchPromises)
const allResults: SearchResult[] = []
const failedActions: string[] = []
settledResults.forEach((result, index) => {
if (result.status === 'fulfilled' && result.value.success) {
allResults.push(...result.value.data)
} else {
const actionKey = globalSearchActions[index]?.key || 'unknown'
failedActions.push(actionKey)
}
})
if (failedActions.length > 0)
console.warn(`Some search actions failed: ${failedActions.join(', ')}`)
return allResults
export function getActionSearchTerm(query: string, action: ActionItem) {
const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const prefixPattern = new RegExp(
`^(${escapeRegExp(action.key)}|${escapeRegExp(action.shortcut)})\\s*`,
)
return query.trim().replace(prefixPattern, '').trim()
}
export const matchAction = (query: string, actions: Record<string, ActionItem>) => {
export function matchAction(query: string, actions: Record<string, ActionItem>) {
return Object.values(actions).find((action) => {
// Special handling for slash commands
if (action.key === '/') {
// Get all registered commands from the registry
const allCommands = slashCommandRegistry.getAllCommands()
return slashCommandRegistry.getAllCommands().some((command) => {
if (command.mode === 'direct') return false
// 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 commandPattern = `/${command.name}`
return query === commandPattern || query.startsWith(`${commandPattern} `)
})
}
const reg = new RegExp(`^(${action.key}|${action.shortcut})(?:\\s|$)`)
return reg.test(query)
return new RegExp(`^(${action.key}|${action.shortcut})(?:\\s|$)`).test(query)
})
}

View File

@ -1,13 +1,13 @@
import type { DatasetListItemResponse } from '@dify/contracts/api/console/datasets/types.gen'
import type { ActionItem, KnowledgeSearchResult } from './types'
import type { DataSet } from '@/models/datasets'
import { cn } from '@langgenius/dify-ui/cn'
import { fetchDatasets } from '@/service/datasets'
import { consoleQuery } from '@/service/client'
import { Folder } from '../../base/icons/src/vender/solid/files'
const EXTERNAL_PROVIDER = 'external' as const
const isExternalProvider = (provider: string): boolean => provider === EXTERNAL_PROVIDER
const parser = (datasets: DataSet[]): KnowledgeSearchResult[] => {
function getKnowledgeResults(datasets: DatasetListItemResponse[]): KnowledgeSearchResult[] {
return datasets.map((dataset) => {
const path = isExternalProvider(dataset.provider)
? `/datasets/${dataset.id}/hitTesting`
@ -15,7 +15,7 @@ const parser = (datasets: DataSet[]): KnowledgeSearchResult[] => {
return {
id: dataset.id,
title: dataset.name,
description: dataset.description,
description: dataset.description ?? undefined,
type: 'knowledge' as const,
path,
icon: (
@ -38,22 +38,19 @@ export const knowledgeAction: ActionItem = {
shortcut: '@kb',
title: 'Search Knowledge Bases',
description: 'Search and navigate to your knowledge bases',
// action,
search: async (_, searchTerm = '', _locale) => {
try {
const response = await fetchDatasets({
url: '/datasets',
params: {
page: 1,
limit: 10,
keyword: searchTerm,
},
})
const datasets = response?.data || []
return parser(datasets)
} catch (error) {
console.warn('Knowledge search failed:', error)
return []
}
},
source: 'remote',
}
export function knowledgeSearchQueryOptions(searchTerm: string) {
return consoleQuery.datasets.get.queryOptions({
input: {
query: {
page: 1,
limit: 10,
keyword: searchTerm,
},
},
retry: false,
select: (response) => getKnowledgeResults(response.data),
})
}

View File

@ -1,8 +1,7 @@
import type { PluginsFromMarketplaceResponse } from '@dify/contracts/marketplace'
import type { Plugin } from '../../plugins/types'
import type { ActionItem, PluginSearchResult } from './types'
import { renderI18nObject } from '@/i18n-config'
import { postMarketplace } from '@/service/base'
import { marketplaceQuery } from '@/service/client'
import Icon from '../../plugins/card/base/card-icon'
import { getFormattedPlugin } from '../../plugins/marketplace/utils'
@ -24,30 +23,26 @@ export const pluginAction: ActionItem = {
shortcut: '@plugin',
title: 'Search Plugins',
description: 'Search and navigate to your plugins',
search: async (_, searchTerm = '', locale) => {
try {
const response = await postMarketplace<{ data: PluginsFromMarketplaceResponse }>(
'/plugins/search/advanced',
{
body: {
page: 1,
page_size: 10,
query: searchTerm,
type: 'plugin',
},
},
)
if (!response?.data?.plugins) {
console.warn('Plugin search: Unexpected response structure', response)
return []
}
const list = response.data.plugins.map((plugin) => getFormattedPlugin(plugin))
return parser(list, locale!)
} catch (error) {
console.warn('Plugin search failed:', error)
return []
}
},
source: 'remote',
}
export function pluginSearchQueryOptions(searchTerm: string, locale: string) {
return marketplaceQuery.searchAdvanced.queryOptions({
input: {
params: { kind: 'plugins' },
body: {
page: 1,
page_size: 10,
query: searchTerm,
},
},
retry: false,
select: (response) => {
const plugins = response.data?.plugins ?? []
return parser(
plugins.map((plugin) => getFormattedPlugin(plugin)),
locale,
)
},
})
}

View File

@ -1,19 +1,15 @@
import type { ActionItem } from './types'
import { findRagPipelineNodes } from '@/app/components/rag-pipeline/goto-anything-search'
// Create the RAG pipeline nodes action
export const ragPipelineNodesAction: ActionItem = {
key: '@node',
shortcut: '@node',
title: 'Search RAG Pipeline Nodes',
description: 'Find and jump to nodes in the current RAG pipeline by name or type',
searchFn: undefined, // Will be set by useRagPipelineSearch hook
search: async (_, searchTerm = '', _locale) => {
source: 'local',
search: (_, searchTerm = '', _locale) => {
try {
// Use the searchFn if available (set by useRagPipelineSearch hook)
if (ragPipelineNodesAction.searchFn) return ragPipelineNodesAction.searchFn(searchTerm)
// If not in RAG pipeline context, return empty array
return []
return findRagPipelineNodes(searchTerm)
} catch (error) {
console.warn('RAG pipeline nodes search failed:', error)
return []

View File

@ -1,13 +1,13 @@
import type { AppPartial } from '@dify/contracts/api/console/apps/types.gen'
import type { DatasetListItemResponse } from '@dify/contracts/api/console/datasets/types.gen'
import type { ReactNode } from 'react'
import type { TypeWithI18N } from '../../base/form/types'
import type { Plugin } from '../../plugins/types'
import type { CommonNodeType } from '../../workflow/types'
import type { DataSet } from '@/models/datasets'
import type { App } from '@/types/app'
type SearchResultType = 'app' | 'knowledge' | 'plugin' | 'workflow-node' | 'command' | 'recent'
type BaseSearchResult<T = any> = {
type BaseSearchResult<T> = {
id: string
title: string
description?: string
@ -19,7 +19,7 @@ type BaseSearchResult<T = any> = {
export type AppSearchResult = {
type: 'app'
} & BaseSearchResult<App>
} & BaseSearchResult<AppPartial>
export type PluginSearchResult = {
type: 'plugin'
@ -27,7 +27,7 @@ export type PluginSearchResult = {
export type KnowledgeSearchResult = {
type: 'knowledge'
} & BaseSearchResult<DataSet>
} & BaseSearchResult<DatasetListItemResponse>
type WorkflowNodeSearchResult = {
type: 'workflow-node'
@ -39,7 +39,7 @@ type WorkflowNodeSearchResult = {
export type CommandSearchResult = {
type: 'command'
} & BaseSearchResult<{ command: string; args?: Record<string, any> }>
} & BaseSearchResult<{ command: string; args?: Record<string, unknown> }>
export type RecentSearchResult = {
type: 'recent'
@ -54,16 +54,21 @@ export type SearchResult =
| CommandSearchResult
| RecentSearchResult
export type ActionItem = {
type ActionItemBase = {
key: '@app' | '@knowledge' | '@plugin' | '@node' | '/'
shortcut: string
title: string | TypeWithI18N
description: string
action?: (data: SearchResult) => void
searchFn?: (searchTerm: string) => SearchResult[]
search: (
query: string,
searchTerm: string,
locale?: string,
) => Promise<SearchResult[]> | SearchResult[]
}
type RemoteActionItem = ActionItemBase & {
source: 'remote'
}
type LocalActionItem = ActionItemBase & {
source: 'local'
search: (query: string, searchTerm: string, locale?: string) => SearchResult[]
}
export type ActionItem = RemoteActionItem | LocalActionItem

View File

@ -1,19 +1,15 @@
import type { ActionItem } from './types'
import { findWorkflowNodes } from '@/app/components/workflow/goto-anything-search'
// Create the workflow nodes action
export const workflowNodesAction: ActionItem = {
key: '@node',
shortcut: '@node',
title: 'Search Workflow Nodes',
description: 'Find and jump to nodes in the current workflow by name or type',
searchFn: undefined, // Will be set by useWorkflowSearch hook
search: async (_, searchTerm = '', _locale) => {
source: 'local',
search: (_, searchTerm = '', _locale) => {
try {
// Use the searchFn if available (set by useWorkflowSearch hook)
if (workflowNodesAction.searchFn) return workflowNodesAction.searchFn(searchTerm)
// If not in workflow context, return empty array
return []
return findWorkflowNodes(searchTerm)
} catch (error) {
console.warn('Workflow nodes search failed:', error)
return []

View File

@ -1,13 +0,0 @@
'use client'
import { atom, useAtomValue, useSetAtom } from 'jotai'
const gotoAnythingOpenAtom = atom(false)
export function useGotoAnythingOpen() {
return useAtomValue(gotoAnythingOpenAtom)
}
export function useSetGotoAnythingOpen() {
return useSetAtom(gotoAnythingOpenAtom)
}

View File

@ -1,147 +0,0 @@
import type { FC } from 'react'
import type { ActionItem } from './actions/types'
import { Command } from 'cmdk'
import { useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { slashCommandRegistry } from './actions/commands/registry'
type Props = Readonly<{
actions: Record<string, ActionItem>
onCommandSelect: (commandKey: string) => void
searchFilter?: string
commandValue?: string
onCommandValueChange?: (value: string) => void
originalQuery?: string
}>
const slashCommandDescriptionKeys = {
'/create': 'gotoAnything.actions.createCategoryDesc',
'/refine': 'gotoAnything.actions.refineCategoryDesc',
'/theme': 'gotoAnything.actions.themeCategoryDesc',
'/language': 'gotoAnything.actions.languageChangeDesc',
'/account': 'gotoAnything.actions.accountDesc',
'/feedback': 'gotoAnything.actions.feedbackDesc',
'/docs': 'gotoAnything.actions.docDesc',
'/community': 'gotoAnything.actions.communityDesc',
} as const
const actionDescriptionKeys = {
'@app': 'gotoAnything.actions.searchApplicationsDesc',
'@plugin': 'gotoAnything.actions.searchPluginsDesc',
'@knowledge': 'gotoAnything.actions.searchKnowledgeBasesDesc',
'@node': 'gotoAnything.actions.searchWorkflowNodesDesc',
} as const
const CommandSelector: FC<Props> = ({
actions,
onCommandSelect,
searchFilter,
commandValue,
onCommandValueChange,
originalQuery,
}) => {
const { t } = useTranslation()
// Check if we're in slash command mode
const isSlashMode = originalQuery?.trim().startsWith('/') || false
// Get slash commands from registry
const slashCommands = useMemo(() => {
if (!isSlashMode) return []
const availableCommands = slashCommandRegistry.getAvailableCommands()
const filter = searchFilter?.toLowerCase() || '' // searchFilter already has '/' removed
return availableCommands
.filter((cmd) => {
if (!filter) return true
return cmd.name.toLowerCase().includes(filter)
})
.map((cmd) => ({
key: `/${cmd.name}`,
shortcut: `/${cmd.name}`,
title: cmd.name,
description: cmd.description,
}))
}, [isSlashMode, searchFilter])
const filteredActions = useMemo(() => {
if (isSlashMode) return []
return Object.values(actions).filter((action) => {
// Exclude slash action when in @ mode
if (action.key === '/') return false
if (!searchFilter) return true
const filterLower = searchFilter.toLowerCase()
return action.shortcut.toLowerCase().includes(filterLower)
})
}, [actions, searchFilter, isSlashMode])
const allItems = isSlashMode ? slashCommands : filteredActions
useEffect(() => {
if (allItems.length > 0 && onCommandValueChange) {
const currentValueExists = allItems.some((item) => item.shortcut === commandValue)
if (!currentValueExists) onCommandValueChange(allItems[0]!.shortcut)
}
}, [allItems, commandValue, onCommandValueChange])
if (allItems.length === 0) {
return (
<div className="p-4">
<div className="flex items-center justify-center py-8 text-center text-text-tertiary">
<div>
<div className="text-sm font-medium text-text-tertiary">
{t(($) => $['gotoAnything.noMatchingCommands'], { ns: 'app' })}
</div>
<div className="mt-1 text-xs text-text-quaternary">
{t(($) => $['gotoAnything.tryDifferentSearch'], { ns: 'app' })}
</div>
</div>
</div>
</div>
)
}
return (
<div className="px-4 py-3">
<div className="mb-2 text-left text-sm font-medium text-text-secondary">
{isSlashMode
? t(($) => $['gotoAnything.groups.commands'], { ns: 'app' })
: t(($) => $['gotoAnything.selectSearchType'], { ns: 'app' })}
</div>
<Command.Group className="space-y-1">
{allItems.map((item) => (
<Command.Item
key={item.key}
value={item.shortcut}
className="flex cursor-pointer items-center rounded-md p-2 transition-all duration-150 hover:bg-state-base-hover aria-selected:bg-state-base-hover-alt"
onSelect={() => onCommandSelect(item.shortcut)}
>
<span className="min-w-18 text-left font-mono text-xs text-text-tertiary">
{item.shortcut}
</span>
<span className="ml-3 text-sm text-text-secondary">
{isSlashMode
? t(
($) =>
$[
slashCommandDescriptionKeys[
item.key as keyof typeof slashCommandDescriptionKeys
] || item.description
],
{ ns: 'app' },
)
: t(
($) => $[actionDescriptionKeys[item.key as keyof typeof actionDescriptionKeys]],
{ ns: 'app' },
)}
</span>
</Command.Item>
))}
</Command.Group>
</div>
)
}
export default CommandSelector

View File

@ -1,5 +1,5 @@
import { render, screen } from '@testing-library/react'
import EmptyState from '../empty-state'
import { EmptyState } from '../empty-state'
describe('EmptyState', () => {
describe('loading variant', () => {
@ -73,11 +73,11 @@ describe('EmptyState', () => {
})
it('should show specific search hint with shortcuts', () => {
const Actions = {
const actions = {
app: { key: '@app', shortcut: '@app' },
plugin: { key: '@plugin', shortcut: '@plugin' },
} as unknown as Record<string, import('../../actions/types').ActionItem>
render(<EmptyState variant="no-results" searchMode="general" Actions={Actions} />)
render(<EmptyState variant="no-results" searchMode="general" actions={actions} />)
expect(
screen.getByText(
@ -145,7 +145,7 @@ describe('EmptyState', () => {
expect(screen.getByText('app.gotoAnything.noResults')).toBeInTheDocument()
})
it('should use empty object as default Actions', () => {
it('should use empty object as default actions', () => {
render(<EmptyState variant="no-results" searchMode="general" />)
expect(

View File

@ -1,261 +1,53 @@
import { render, screen } from '@testing-library/react'
import Footer from '../footer'
import { Footer } from '../footer'
const defaultProps = {
resultCount: 0,
searchMode: 'general',
isLoading: false,
hasUnavailableServices: false,
isCommandsMode: false,
hasQuery: false,
}
describe('Footer', () => {
describe('left content', () => {
describe('when there are results', () => {
it('should show result count', () => {
render(
<Footer
resultCount={5}
searchMode="general"
isError={false}
isCommandsMode={false}
hasQuery={true}
/>,
)
it('shows the result count and active scope', () => {
render(<Footer {...defaultProps} resultCount={3} searchMode="@app" hasQuery />)
expect(screen.getByText('app.gotoAnything.resultCount:{"count":5}')).toBeInTheDocument()
})
it('should show scope when not in general mode', () => {
render(
<Footer
resultCount={3}
searchMode="@app"
isError={false}
isCommandsMode={false}
hasQuery={true}
/>,
)
expect(screen.getByText('app.gotoAnything.inScope:{"scope":"app"}')).toBeInTheDocument()
})
it('should NOT show scope when in general mode', () => {
render(
<Footer
resultCount={3}
searchMode="general"
isError={false}
isCommandsMode={false}
hasQuery={true}
/>,
)
expect(screen.queryByText(/inScope/)).not.toBeInTheDocument()
})
})
describe('when there is an error', () => {
it('should show error message', () => {
render(
<Footer
resultCount={0}
searchMode="general"
isError={true}
isCommandsMode={false}
hasQuery={true}
/>,
)
expect(screen.getByText('app.gotoAnything.someServicesUnavailable')).toBeInTheDocument()
})
it('should have red text styling', () => {
const { container } = render(
<Footer
resultCount={0}
searchMode="general"
isError={true}
isCommandsMode={false}
hasQuery={true}
/>,
)
const errorText = container.querySelector('.text-red-500')
expect(errorText).toBeInTheDocument()
})
it('should show error even with results', () => {
render(
<Footer
resultCount={5}
searchMode="general"
isError={true}
isCommandsMode={false}
hasQuery={true}
/>,
)
expect(screen.getByText('app.gotoAnything.someServicesUnavailable')).toBeInTheDocument()
})
})
describe('when no results and no error', () => {
it('should show select to navigate in commands mode', () => {
render(
<Footer
resultCount={0}
searchMode="general"
isError={false}
isCommandsMode={true}
hasQuery={false}
/>,
)
expect(screen.getByText('app.gotoAnything.selectToNavigate')).toBeInTheDocument()
})
it('should show searching when has query', () => {
render(
<Footer
resultCount={0}
searchMode="general"
isError={false}
isCommandsMode={false}
hasQuery={true}
/>,
)
expect(screen.getByText('app.gotoAnything.searching')).toBeInTheDocument()
})
it('should show start typing when no query', () => {
render(
<Footer
resultCount={0}
searchMode="general"
isError={false}
isCommandsMode={false}
hasQuery={false}
/>,
)
expect(screen.getByText('app.gotoAnything.startTyping')).toBeInTheDocument()
})
})
expect(screen.getByText('app.gotoAnything.resultCount:{"count":3}')).toBeInTheDocument()
expect(screen.getByText('app.gotoAnything.inScope:{"scope":"app"}')).toBeInTheDocument()
expect(screen.getByText('app.gotoAnything.clearToSearchAll')).toBeInTheDocument()
})
describe('right content', () => {
describe('when there are results or error', () => {
it('should show clear to search all when in specific mode', () => {
render(
<Footer
resultCount={5}
searchMode="@app"
isError={false}
isCommandsMode={false}
hasQuery={true}
/>,
)
it('reports partial provider failure even when results remain available', () => {
render(<Footer {...defaultProps} resultCount={2} hasUnavailableServices hasQuery />)
expect(screen.getByText('app.gotoAnything.clearToSearchAll')).toBeInTheDocument()
})
it('should show use @ for specific when in general mode', () => {
render(
<Footer
resultCount={5}
searchMode="general"
isError={false}
isCommandsMode={false}
hasQuery={true}
/>,
)
expect(screen.getByText('app.gotoAnything.useAtForSpecific')).toBeInTheDocument()
})
it('should show same hint when error', () => {
render(
<Footer
resultCount={0}
searchMode="general"
isError={true}
isCommandsMode={false}
hasQuery={true}
/>,
)
expect(screen.getByText('app.gotoAnything.useAtForSpecific')).toBeInTheDocument()
})
})
describe('when no results and no error', () => {
it('should show tips when has query', () => {
render(
<Footer
resultCount={0}
searchMode="general"
isError={false}
isCommandsMode={false}
hasQuery={true}
/>,
)
expect(screen.getByText('app.gotoAnything.tips')).toBeInTheDocument()
})
it('should show tips when in commands mode', () => {
render(
<Footer
resultCount={0}
searchMode="general"
isError={false}
isCommandsMode={true}
hasQuery={false}
/>,
)
expect(screen.getByText('app.gotoAnything.tips')).toBeInTheDocument()
})
it('should show press ESC to close when no query and not in commands mode', () => {
render(
<Footer
resultCount={0}
searchMode="general"
isError={false}
isCommandsMode={false}
hasQuery={false}
/>,
)
expect(screen.getByText('app.gotoAnything.pressEscToClose')).toBeInTheDocument()
})
})
expect(screen.getByText('app.gotoAnything.someServicesUnavailable')).toHaveClass('text-red-500')
expect(screen.getByText('app.gotoAnything.useAtForSpecific')).toBeInTheDocument()
})
describe('styling', () => {
it('should have border and background classes', () => {
const { container } = render(
<Footer
resultCount={0}
searchMode="general"
isError={false}
isCommandsMode={false}
hasQuery={false}
/>,
)
it('reports pending remote search', () => {
render(<Footer {...defaultProps} isLoading hasQuery />)
const footer = container.firstChild
expect(footer).toHaveClass('border-t', 'border-divider-subtle', 'bg-components-panel-bg-blur')
})
expect(screen.getByText('app.gotoAnything.searching')).toBeInTheDocument()
expect(screen.getByText('app.gotoAnything.tips')).toBeInTheDocument()
})
it('should have flex layout for content', () => {
const { container } = render(
<Footer
resultCount={0}
searchMode="general"
isError={false}
isCommandsMode={false}
hasQuery={false}
/>,
)
it('reports command selection mode', () => {
render(<Footer {...defaultProps} isCommandsMode />)
const flexContainer = container.querySelector('.flex.items-center.justify-between')
expect(flexContainer).toBeInTheDocument()
})
expect(screen.getByText('app.gotoAnything.selectToNavigate')).toBeInTheDocument()
})
it('shows the idle shortcut hint', () => {
const { container } = render(<Footer {...defaultProps} />)
expect(screen.getByText('app.gotoAnything.startTyping')).toBeInTheDocument()
expect(screen.getByText('app.gotoAnything.pressEscToClose')).toBeInTheDocument()
expect(container.firstChild).toHaveClass(
'border-t',
'border-divider-subtle',
'bg-components-panel-bg-blur',
)
})
})

View File

@ -1,71 +0,0 @@
import type { SearchResult } from '../../actions/types'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Command } from 'cmdk'
import ResultItem from '../result-item'
function renderInCommandRoot(ui: React.ReactElement) {
return render(<Command>{ui}</Command>)
}
function createResult(overrides: Partial<SearchResult> = {}): SearchResult {
return {
id: 'test-1',
title: 'Test Result',
type: 'app',
data: {},
...overrides,
} as SearchResult
}
describe('ResultItem', () => {
it('renders title', () => {
renderInCommandRoot(
<ResultItem result={createResult({ title: 'My App' })} onSelect={vi.fn()} />,
)
expect(screen.getByText('My App')).toBeInTheDocument()
})
it('renders description when provided', () => {
renderInCommandRoot(
<ResultItem result={createResult({ description: 'A great app' })} onSelect={vi.fn()} />,
)
expect(screen.getByText('A great app')).toBeInTheDocument()
})
it('does not render description when absent', () => {
const result = createResult()
delete (result as Record<string, unknown>).description
renderInCommandRoot(<ResultItem result={result} onSelect={vi.fn()} />)
expect(screen.getByText('Test Result')).toBeInTheDocument()
expect(screen.getByText('app')).toBeInTheDocument()
})
it('renders result type label', () => {
renderInCommandRoot(<ResultItem result={createResult({ type: 'plugin' })} onSelect={vi.fn()} />)
expect(screen.getByText('plugin')).toBeInTheDocument()
})
it('renders icon when provided', () => {
const icon = <span data-testid="custom-icon">icon</span>
renderInCommandRoot(<ResultItem result={createResult({ icon })} onSelect={vi.fn()} />)
expect(screen.getByTestId('custom-icon')).toBeInTheDocument()
})
it('calls onSelect when clicked', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
renderInCommandRoot(<ResultItem result={createResult()} onSelect={onSelect} />)
await user.click(screen.getByText('Test Result'))
expect(onSelect).toHaveBeenCalled()
})
})

View File

@ -1,76 +0,0 @@
import type { SearchResult } from '../../actions/types'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Command } from 'cmdk'
import ResultList from '../result-list'
function renderInCommandRoot(ui: React.ReactElement) {
return render(<Command>{ui}</Command>)
}
function createResult(overrides: Partial<SearchResult> = {}): SearchResult {
return {
id: 'test-1',
title: 'Result 1',
type: 'app',
data: {},
...overrides,
} as SearchResult
}
describe('ResultList', () => {
it('renders grouped results with headings', () => {
const grouped: Record<string, SearchResult[]> = {
app: [createResult({ id: 'a1', title: 'App One', type: 'app' })],
plugin: [createResult({ id: 'p1', title: 'Plugin One', type: 'plugin' })],
}
renderInCommandRoot(<ResultList groupedResults={grouped} onSelect={vi.fn()} />)
expect(screen.getByText('App One')).toBeInTheDocument()
expect(screen.getByText('Plugin One')).toBeInTheDocument()
})
it('renders multiple results in the same group', () => {
const grouped: Record<string, SearchResult[]> = {
app: [
createResult({ id: 'a1', title: 'App One', type: 'app' }),
createResult({ id: 'a2', title: 'App Two', type: 'app' }),
],
}
renderInCommandRoot(<ResultList groupedResults={grouped} onSelect={vi.fn()} />)
expect(screen.getByText('App One')).toBeInTheDocument()
expect(screen.getByText('App Two')).toBeInTheDocument()
})
it('calls onSelect with the correct result when clicked', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
const result = createResult({ id: 'a1', title: 'Click Me', type: 'app' })
renderInCommandRoot(<ResultList groupedResults={{ app: [result] }} onSelect={onSelect} />)
await user.click(screen.getByText('Click Me'))
expect(onSelect).toHaveBeenCalledWith(result)
})
it('renders empty when no grouped results provided', () => {
const { container } = renderInCommandRoot(<ResultList groupedResults={{}} onSelect={vi.fn()} />)
const groups = container.querySelectorAll('[cmdk-group]')
expect(groups).toHaveLength(0)
})
it('uses i18n keys for known group types', () => {
const grouped: Record<string, SearchResult[]> = {
command: [createResult({ id: 'c1', title: 'Cmd', type: 'command' })],
}
renderInCommandRoot(<ResultList groupedResults={grouped} onSelect={vi.fn()} />)
expect(screen.getByText('app.gotoAnything.groups.commands')).toBeInTheDocument()
})
})

View File

@ -1,191 +0,0 @@
import type { ChangeEvent, KeyboardEvent, RefObject } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import SearchInput from '../search-input'
vi.mock('@remixicon/react', () => ({
RiSearchLine: ({ className }: { className?: string }) => (
<svg data-testid="search-icon" className={className} />
),
}))
vi.mock('@/app/components/base/input', async () => {
const { forwardRef } = await import('react')
type MockInputProps = {
value?: string
placeholder?: string
onChange?: (e: ChangeEvent<HTMLInputElement>) => void
onKeyDown?: (e: KeyboardEvent<HTMLInputElement>) => void
className?: string
wrapperClassName?: string
autoFocus?: boolean
}
const MockInput = forwardRef<HTMLInputElement, MockInputProps>(
({ value, placeholder, onChange, onKeyDown, className, wrapperClassName, autoFocus }, ref) => (
<input
ref={ref}
value={value}
placeholder={placeholder}
onChange={onChange}
onKeyDown={onKeyDown}
className={className}
data-wrapper-class={wrapperClassName}
autoFocus={autoFocus}
data-testid="search-input"
/>
),
)
MockInput.displayName = 'MockInput'
return { default: MockInput }
})
describe('SearchInput', () => {
const defaultProps = {
inputRef: { current: null } as RefObject<HTMLInputElement | null>,
value: '',
onChange: vi.fn(),
searchMode: 'general',
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('rendering', () => {
it('should render search icon', () => {
render(<SearchInput {...defaultProps} />)
expect(screen.getByTestId('search-icon')).toBeInTheDocument()
})
it('should render input field', () => {
render(<SearchInput {...defaultProps} />)
expect(screen.getByTestId('search-input')).toBeInTheDocument()
})
it('should render shortcut keycaps', () => {
const { container } = render(<SearchInput {...defaultProps} />)
const keycaps = container.querySelectorAll('kbd')
expect(keycaps).toHaveLength(2)
expect(keycaps[1]).toHaveTextContent('K')
})
it('should use provided placeholder', () => {
render(<SearchInput {...defaultProps} placeholder="Custom placeholder" />)
expect(screen.getByPlaceholderText('Custom placeholder')).toBeInTheDocument()
})
it('should use default placeholder from translation', () => {
render(<SearchInput {...defaultProps} />)
expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument()
})
})
describe('mode label', () => {
it('should NOT show mode badge in general mode', () => {
render(<SearchInput {...defaultProps} searchMode="general" />)
expect(screen.queryByText('GENERAL')).not.toBeInTheDocument()
})
it('should show SCOPES label in scopes mode', () => {
render(<SearchInput {...defaultProps} searchMode="scopes" />)
expect(screen.getByText('SCOPES')).toBeInTheDocument()
})
it('should show COMMANDS label in commands mode', () => {
render(<SearchInput {...defaultProps} searchMode="commands" />)
expect(screen.getByText('COMMANDS')).toBeInTheDocument()
})
it('should show APP label in @app mode', () => {
render(<SearchInput {...defaultProps} searchMode="@app" />)
expect(screen.getByText('APP')).toBeInTheDocument()
})
it('should show PLUGIN label in @plugin mode', () => {
render(<SearchInput {...defaultProps} searchMode="@plugin" />)
expect(screen.getByText('PLUGIN')).toBeInTheDocument()
})
it('should show KNOWLEDGE label in @knowledge mode', () => {
render(<SearchInput {...defaultProps} searchMode="@knowledge" />)
expect(screen.getByText('KNOWLEDGE')).toBeInTheDocument()
})
it('should show NODE label in @node mode', () => {
render(<SearchInput {...defaultProps} searchMode="@node" />)
expect(screen.getByText('NODE')).toBeInTheDocument()
})
it('should uppercase custom mode label', () => {
render(<SearchInput {...defaultProps} searchMode="@custom" />)
expect(screen.getByText('CUSTOM')).toBeInTheDocument()
})
})
describe('input interactions', () => {
it('should call onChange when typing', () => {
const onChange = vi.fn()
render(<SearchInput {...defaultProps} onChange={onChange} />)
const input = screen.getByTestId('search-input')
fireEvent.change(input, { target: { value: 'test query' } })
expect(onChange).toHaveBeenCalledWith('test query')
})
it('should call onKeyDown when pressing keys', () => {
const onKeyDown = vi.fn()
render(<SearchInput {...defaultProps} onKeyDown={onKeyDown} />)
const input = screen.getByTestId('search-input')
fireEvent.keyDown(input, { key: 'Enter' })
expect(onKeyDown).toHaveBeenCalled()
})
it('should render with provided value', () => {
render(<SearchInput {...defaultProps} value="existing query" />)
expect(screen.getByDisplayValue('existing query')).toBeInTheDocument()
})
it('should NOT throw when onKeyDown is undefined', () => {
render(<SearchInput {...defaultProps} onKeyDown={undefined} />)
const input = screen.getByTestId('search-input')
expect(() => fireEvent.keyDown(input, { key: 'Enter' })).not.toThrow()
})
})
describe('styling', () => {
it('should have search icon styling', () => {
render(<SearchInput {...defaultProps} />)
const icon = screen.getByTestId('search-icon')
expect(icon).toHaveClass('size-4', 'text-text-quaternary')
})
it('should have mode badge styling when visible', () => {
const { container } = render(<SearchInput {...defaultProps} searchMode="@app" />)
const badge = container.querySelector('.bg-gray-100')
expect(badge).toBeInTheDocument()
expect(badge).toHaveClass('rounded-sm', 'px-2', 'text-xs', 'font-medium')
})
})
})

View File

@ -1,6 +1,5 @@
'use client'
import type { FC } from 'react'
import type { ActionItem } from '../actions/types'
import { useTranslation } from 'react-i18next'
@ -10,15 +9,15 @@ type EmptyStateProps = {
variant: EmptyStateVariant
searchMode?: string
error?: Error | null
Actions?: Record<string, ActionItem>
actions?: Record<string, ActionItem>
}
const EmptyState: FC<EmptyStateProps> = ({
export function EmptyState({
variant,
searchMode = 'general',
error,
Actions = {},
}) => {
actions = {},
}: EmptyStateProps) {
const { t } = useTranslation()
if (variant === 'loading') {
@ -67,7 +66,6 @@ const EmptyState: FC<EmptyStateProps> = ({
)
}
// variant === 'no-results'
const isCommandSearch = searchMode !== 'general'
const commandType = isCommandSearch ? searchMode.replace('@', '') : ''
@ -93,7 +91,7 @@ const EmptyState: FC<EmptyStateProps> = ({
return t(($) => $['gotoAnything.emptyState.tryDifferentTerm'], { ns: 'app' })
}
const shortcuts = Object.values(Actions)
const shortcuts = Object.values(actions)
.map((action) => action.shortcut)
.join(', ')
return t(($) => $['gotoAnything.emptyState.trySpecificSearch'], { ns: 'app', shortcuts })
@ -108,5 +106,3 @@ const EmptyState: FC<EmptyStateProps> = ({
</div>
)
}
export default EmptyState

View File

@ -1,35 +1,36 @@
'use client'
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
type FooterProps = {
resultCount: number
searchMode: string
isError: boolean
isLoading: boolean
hasUnavailableServices: boolean
isCommandsMode: boolean
hasQuery: boolean
}
const Footer: FC<FooterProps> = ({
export function Footer({
resultCount,
searchMode,
isError,
isLoading,
hasUnavailableServices,
isCommandsMode,
hasQuery,
}) => {
}: FooterProps) {
const { t } = useTranslation()
const renderLeftContent = () => {
if (resultCount > 0 || isError) {
if (isError) {
return (
<span className="text-red-500">
{t(($) => $['gotoAnything.someServicesUnavailable'], { ns: 'app' })}
</span>
)
}
if (hasUnavailableServices) {
return (
<span className="text-red-500">
{t(($) => $['gotoAnything.someServicesUnavailable'], { ns: 'app' })}
</span>
)
}
if (resultCount > 0) {
return (
<>
{t(($) => $['gotoAnything.resultCount'], { ns: 'app', count: resultCount })}
@ -50,7 +51,7 @@ const Footer: FC<FooterProps> = ({
{(() => {
if (isCommandsMode) return t(($) => $['gotoAnything.selectToNavigate'], { ns: 'app' })
if (hasQuery) return t(($) => $['gotoAnything.searching'], { ns: 'app' })
if (isLoading) return t(($) => $['gotoAnything.searching'], { ns: 'app' })
return t(($) => $['gotoAnything.startTyping'], { ns: 'app' })
})()}
@ -59,7 +60,7 @@ const Footer: FC<FooterProps> = ({
}
const renderRightContent = () => {
if (resultCount > 0 || isError) {
if (resultCount > 0 || hasUnavailableServices) {
return (
<span className="opacity-60">
{searchMode !== 'general'
@ -87,5 +88,3 @@ const Footer: FC<FooterProps> = ({
</div>
)
}
export default Footer

View File

@ -1,7 +0,0 @@
export { default as EmptyState } from './empty-state'
export { default as Footer } from './footer'
export { default as ResultList } from './result-list'
export { default as SearchInput } from './search-input'

View File

@ -1,32 +0,0 @@
'use client'
import type { FC } from 'react'
import type { SearchResult } from '../actions/types'
import { Command } from 'cmdk'
type ResultItemProps = {
result: SearchResult
onSelect: () => void
}
const ResultItem: FC<ResultItemProps> = ({ result, onSelect }) => {
return (
<Command.Item
key={`${result.type}-${result.id}`}
value={`${result.type}-${result.id}`}
className="flex cursor-pointer items-center gap-3 rounded-md p-3 will-change-[background-color] hover:bg-state-base-hover aria-selected:bg-state-base-hover-alt data-[selected=true]:bg-state-base-hover-alt"
onSelect={onSelect}
>
{result.icon}
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-text-secondary">{result.title}</div>
{result.description && (
<div className="mt-0.5 truncate text-xs text-text-quaternary">{result.description}</div>
)}
</div>
<div className="text-xs text-text-quaternary capitalize">{result.type}</div>
</Command.Item>
)
}
export default ResultItem

View File

@ -1,50 +0,0 @@
'use client'
import type { FC } from 'react'
import type { SearchResult } from '../actions/types'
import { Command } from 'cmdk'
import { useTranslation } from 'react-i18next'
import ResultItem from './result-item'
type ResultListProps = {
groupedResults: Record<string, SearchResult[]>
onSelect: (result: SearchResult) => void
}
const ResultList: FC<ResultListProps> = ({ groupedResults, onSelect }) => {
const { t } = useTranslation()
const getGroupHeading = (type: string) => {
const typeMap = {
app: 'gotoAnything.groups.apps',
plugin: 'gotoAnything.groups.plugins',
knowledge: 'gotoAnything.groups.knowledgeBases',
'workflow-node': 'gotoAnything.groups.workflowNodes',
command: 'gotoAnything.groups.commands',
recent: 'gotoAnything.groups.recent',
} as const
return t(($) => $[typeMap[type as keyof typeof typeMap] || `${type}s`], { ns: 'app' })
}
return (
<>
{Object.entries(groupedResults).map(([type, results]) => (
<Command.Group
key={type}
heading={getGroupHeading(type)}
className="p-2 text-text-secondary capitalize"
>
{results.map((result) => (
<ResultItem
key={`${result.type}-${result.id}`}
result={result}
onSelect={() => onSelect(result)}
/>
))}
</Command.Group>
))}
</>
)
}
export default ResultList

View File

@ -1,64 +0,0 @@
'use client'
import type { FC, KeyboardEvent, RefObject } from 'react'
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
import { RiSearchLine } from '@remixicon/react'
import { formatForDisplay } from '@tanstack/react-hotkeys'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
type SearchInputProps = {
inputRef: RefObject<HTMLInputElement | null>
value: string
onChange: (value: string) => void
onKeyDown?: (e: KeyboardEvent<HTMLInputElement>) => void
searchMode: string
placeholder?: string
}
const SearchInput: FC<SearchInputProps> = ({
inputRef,
value,
onChange,
onKeyDown,
searchMode,
placeholder,
}) => {
const { t } = useTranslation()
const getModeLabel = () => {
if (searchMode === 'scopes') return 'SCOPES'
else if (searchMode === 'commands') return 'COMMANDS'
else return searchMode.replace('@', '').toUpperCase()
}
return (
<div className="flex items-center gap-3 border-b border-divider-subtle bg-components-panel-bg-blur px-4 py-3">
<RiSearchLine className="size-4 text-text-quaternary" />
<div className="flex flex-1 items-center gap-2">
<Input
ref={inputRef}
value={value}
placeholder={placeholder || t(($) => $['gotoAnything.searchPlaceholder'], { ns: 'app' })}
onChange={(e) => onChange(e.target.value)}
onKeyDown={onKeyDown}
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-sm bg-gray-100 px-2 py-[2px] text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-300">
<span>{getModeLabel()}</span>
</div>
)}
</div>
<KbdGroup>
{['Mod', 'K'].map((key) => (
<Kbd key={key}>{formatForDisplay(key)}</Kbd>
))}
</KbdGroup>
</div>
)
}
export default SearchInput

View File

@ -1,68 +0,0 @@
'use client'
import type { ReactNode } from 'react'
import * as React from 'react'
import { createContext, use, useEffect, useState } from 'react'
import { usePathname } from '@/next/navigation'
import { isInWorkflowPage } from '../workflow/constants'
/**
* Interface for the GotoAnything context
*/
type GotoAnythingContextType = {
/**
* Whether the current page is a workflow page
*/
isWorkflowPage: boolean
/**
* Whether the current page is a RAG pipeline page
*/
isRagPipelinePage: boolean
}
// Create context with default values
const GotoAnythingContext = createContext<GotoAnythingContextType>({
isWorkflowPage: false,
isRagPipelinePage: false,
})
/**
* Hook to use the GotoAnything context
*/
export const useGotoAnythingContext = () => use(GotoAnythingContext)
type GotoAnythingProviderProps = {
children: ReactNode
}
/**
* Provider component for GotoAnything context
*/
export const GotoAnythingProvider: React.FC<GotoAnythingProviderProps> = ({ children }) => {
const [isWorkflowPage, setIsWorkflowPage] = useState(false)
const [isRagPipelinePage, setIsRagPipelinePage] = useState(false)
const pathname = usePathname()
// Update context based on current pathname using more robust route matching
useEffect(() => {
if (!pathname) {
setIsWorkflowPage(false)
setIsRagPipelinePage(false)
return
}
// Workflow pages: /app/[appId]/workflow or /workflow/[token] (shared)
const isWorkflow = isInWorkflowPage()
// RAG Pipeline pages: /datasets/[datasetId]/pipeline
const isRagPipeline = /^\/datasets\/[^/]+\/pipeline$/.test(pathname)
setIsWorkflowPage(isWorkflow)
setIsRagPipelinePage(isRagPipeline)
}, [pathname])
return (
<GotoAnythingContext.Provider value={{ isWorkflowPage, isRagPipelinePage }}>
{children}
</GotoAnythingContext.Provider>
)
}

View File

@ -0,0 +1,5 @@
'use client'
import { createDialogHandle } from '@langgenius/dify-ui/dialog'
export const gotoAnythingDialogHandle = createDialogHandle()

View File

@ -1,213 +0,0 @@
import type { ReactNode } from 'react'
import { act, renderHook } from '@testing-library/react'
import { createStore, Provider } from 'jotai'
import { createElement } from 'react'
import { useGotoAnythingModal } from '../use-goto-anything-modal'
type KeyPressEvent = {
preventDefault: () => void
target?: EventTarget
}
type HotkeyRegistration = {
handler: (event: KeyPressEvent) => void
options?: { enabled?: boolean; ignoreInputs?: boolean }
}
const hotkeyHandlers: Record<string, HotkeyRegistration> = {}
vi.mock('@tanstack/react-hotkeys', () => ({
useHotkey: (
hotkey: string,
handler: (event: KeyPressEvent) => void,
options?: HotkeyRegistration['options'],
) => {
hotkeyHandlers[hotkey] = { handler, options }
},
}))
const triggerHotkey = (hotkey: string, event: KeyPressEvent) => {
const registration = hotkeyHandlers[hotkey]
if (registration?.options?.enabled === false) return
registration?.handler(event)
}
const renderGotoAnythingModalHook = () => {
const store = createStore()
const wrapper = ({ children }: { children: ReactNode }) =>
createElement(Provider, { store }, children)
return renderHook(() => useGotoAnythingModal(), { wrapper })
}
describe('useGotoAnythingModal', () => {
beforeEach(() => {
Object.keys(hotkeyHandlers).forEach((key) => delete hotkeyHandlers[key])
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
describe('initialization', () => {
it('should initialize with open=false', () => {
const { result } = renderGotoAnythingModalHook()
expect(result.current.open).toBe(false)
})
it('should provide inputRef initialized to null', () => {
const { result } = renderGotoAnythingModalHook()
expect(result.current.inputRef).toBeDefined()
expect(result.current.inputRef.current).toBe(null)
})
it('should provide onOpenChange function', () => {
const { result } = renderGotoAnythingModalHook()
expect(typeof result.current.onOpenChange).toBe('function')
})
})
describe('keyboard shortcuts', () => {
it('should toggle open state when Mod+K is triggered', () => {
const { result } = renderGotoAnythingModalHook()
expect(result.current.open).toBe(false)
act(() => {
triggerHotkey('Mod+K', { preventDefault: vi.fn(), target: document.body })
})
expect(result.current.open).toBe(true)
})
it('should toggle back to closed when Mod+K is triggered twice', () => {
const { result } = renderGotoAnythingModalHook()
act(() => {
triggerHotkey('Mod+K', { preventDefault: vi.fn(), target: document.body })
})
expect(result.current.open).toBe(true)
act(() => {
triggerHotkey('Mod+K', { preventDefault: vi.fn(), target: document.body })
})
expect(result.current.open).toBe(false)
})
it('should let the hotkey library ignore inputs when the modal is closed', () => {
renderGotoAnythingModalHook()
expect(hotkeyHandlers['Mod+K']?.options?.ignoreInputs).toBe(true)
})
it('should not register a separate escape hotkey', () => {
renderGotoAnythingModalHook()
expect(hotkeyHandlers.Escape).toBeUndefined()
})
it('should call preventDefault when Mod+K is triggered', () => {
renderGotoAnythingModalHook()
const preventDefaultMock = vi.fn()
act(() => {
triggerHotkey('Mod+K', { preventDefault: preventDefaultMock, target: document.body })
})
expect(preventDefaultMock).toHaveBeenCalled()
})
})
describe('onOpenChange', () => {
it('should accept boolean value', () => {
const { result } = renderGotoAnythingModalHook()
act(() => {
result.current.onOpenChange(true)
})
expect(result.current.open).toBe(true)
act(() => {
result.current.onOpenChange(false)
})
expect(result.current.open).toBe(false)
})
})
describe('focus management', () => {
it('should call requestAnimationFrame when modal opens', () => {
const rafSpy = vi.spyOn(window, 'requestAnimationFrame')
const { result } = renderGotoAnythingModalHook()
act(() => {
result.current.onOpenChange(true)
})
expect(rafSpy).toHaveBeenCalled()
rafSpy.mockRestore()
})
it('should not call requestAnimationFrame when modal closes', () => {
const { result } = renderGotoAnythingModalHook()
act(() => {
result.current.onOpenChange(true)
})
const rafSpy = vi.spyOn(window, 'requestAnimationFrame')
act(() => {
result.current.onOpenChange(false)
})
expect(rafSpy).not.toHaveBeenCalled()
rafSpy.mockRestore()
})
it('should focus input when modal opens and inputRef.current exists', () => {
const originalRAF = window.requestAnimationFrame
window.requestAnimationFrame = (callback: FrameRequestCallback) => {
callback(0)
return 0
}
const { result } = renderGotoAnythingModalHook()
const mockFocus = vi.fn()
const mockInput = { focus: mockFocus } as unknown as HTMLInputElement
Object.defineProperty(result.current.inputRef, 'current', {
value: mockInput,
writable: true,
})
act(() => {
result.current.onOpenChange(true)
})
expect(mockFocus).toHaveBeenCalled()
window.requestAnimationFrame = originalRAF
})
it('should not throw when inputRef.current is null when modal opens', () => {
const originalRAF = window.requestAnimationFrame
window.requestAnimationFrame = (callback: FrameRequestCallback) => {
callback(0)
return 0
}
const { result } = renderGotoAnythingModalHook()
act(() => {
result.current.onOpenChange(true)
})
expect(result.current.open).toBe(true)
window.requestAnimationFrame = originalRAF
})
})
})

View File

@ -1,496 +0,0 @@
import type * as React from 'react'
import type { Plugin } from '../../../plugins/types'
import type { CommonNodeType } from '../../../workflow/types'
import type { DataSet } from '@/models/datasets'
import type { App } from '@/types/app'
import { act, renderHook } from '@testing-library/react'
import { useGotoAnythingNavigation } from '../use-goto-anything-navigation'
const mockRouterPush = vi.fn()
const mockSelectWorkflowNode = vi.fn()
const mockAddRecentItem = vi.fn()
type MockCommandResult = {
mode: string
execute?: () => void
} | null
let mockFindCommandResult: MockCommandResult = null
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
push: mockRouterPush,
}),
}))
vi.mock('@/app/components/workflow/utils/node-navigation', () => ({
selectWorkflowNode: (...args: unknown[]) => mockSelectWorkflowNode(...args),
}))
vi.mock('../../actions/commands/registry', () => ({
slashCommandRegistry: {
findCommand: () => mockFindCommandResult,
},
}))
vi.mock('../../actions/recent-store', () => ({
addRecentItem: (...args: unknown[]) => mockAddRecentItem(...args),
}))
const createMockActionItem = (
key: '@app' | '@knowledge' | '@plugin' | '@node' | '/',
extra: Record<string, unknown> = {},
) => ({
key,
shortcut: key,
title: `${key} title`,
description: `${key} description`,
search: vi.fn().mockResolvedValue([]),
...extra,
})
const createMockOptions = (overrides = {}) => ({
Actions: {
slash: createMockActionItem('/', { action: vi.fn() }),
app: createMockActionItem('@app'),
},
setSearchQuery: vi.fn(),
clearSelection: vi.fn(),
inputRef: { current: { focus: vi.fn() } } as unknown as React.RefObject<HTMLInputElement>,
onClose: vi.fn(),
...overrides,
})
describe('useGotoAnythingNavigation', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFindCommandResult = null
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
describe('initialization', () => {
it('should return handleCommandSelect function', () => {
const { result } = renderHook(() => useGotoAnythingNavigation(createMockOptions()))
expect(typeof result.current.handleCommandSelect).toBe('function')
})
it('should return handleNavigate function', () => {
const { result } = renderHook(() => useGotoAnythingNavigation(createMockOptions()))
expect(typeof result.current.handleNavigate).toBe('function')
})
it('should initialize activePlugin as undefined', () => {
const { result } = renderHook(() => useGotoAnythingNavigation(createMockOptions()))
expect(result.current.activePlugin).toBeUndefined()
})
it('should return setActivePlugin function', () => {
const { result } = renderHook(() => useGotoAnythingNavigation(createMockOptions()))
expect(typeof result.current.setActivePlugin).toBe('function')
})
})
describe('handleCommandSelect', () => {
it('should execute direct mode slash command immediately', () => {
const execute = vi.fn()
mockFindCommandResult = { mode: 'direct', execute }
const options = createMockOptions()
const { result } = renderHook(() => useGotoAnythingNavigation(options))
act(() => {
result.current.handleCommandSelect('/theme')
})
expect(execute).toHaveBeenCalled()
expect(options.onClose).toHaveBeenCalled()
expect(options.setSearchQuery).toHaveBeenCalledWith('')
})
it('should NOT execute when handler has no execute function', () => {
mockFindCommandResult = { mode: 'direct', execute: undefined }
const options = createMockOptions()
const { result } = renderHook(() => useGotoAnythingNavigation(options))
act(() => {
result.current.handleCommandSelect('/theme')
})
expect(options.onClose).not.toHaveBeenCalled()
expect(options.setSearchQuery).toHaveBeenCalledWith('/theme ')
})
it('should proceed with submenu mode for non-direct commands', () => {
mockFindCommandResult = { mode: 'submenu' }
const options = createMockOptions()
const { result } = renderHook(() => useGotoAnythingNavigation(options))
act(() => {
result.current.handleCommandSelect('/language')
})
expect(options.setSearchQuery).toHaveBeenCalledWith('/language ')
expect(options.clearSelection).toHaveBeenCalled()
})
it('should handle @ commands (scopes)', () => {
const options = createMockOptions()
const { result } = renderHook(() => useGotoAnythingNavigation(options))
act(() => {
result.current.handleCommandSelect('@app')
})
expect(options.setSearchQuery).toHaveBeenCalledWith('@app ')
expect(options.clearSelection).toHaveBeenCalled()
})
it('should focus input after setting search query', () => {
const focusMock = vi.fn()
const options = createMockOptions({
inputRef: { current: { focus: focusMock } },
})
const { result } = renderHook(() => useGotoAnythingNavigation(options))
act(() => {
result.current.handleCommandSelect('@app')
})
act(() => {
vi.runAllTimers()
})
expect(focusMock).toHaveBeenCalled()
})
it('should handle null handler from registry', () => {
mockFindCommandResult = null
const options = createMockOptions()
const { result } = renderHook(() => useGotoAnythingNavigation(options))
act(() => {
result.current.handleCommandSelect('/unknown')
})
expect(options.setSearchQuery).toHaveBeenCalledWith('/unknown ')
})
})
describe('handleNavigate', () => {
it('should navigate to path for default result types', () => {
const options = createMockOptions()
const { result } = renderHook(() => useGotoAnythingNavigation(options))
act(() => {
result.current.handleNavigate({
id: '1',
type: 'app' as const,
title: 'My App',
path: '/apps/1',
data: { id: '1', name: 'My App' } as unknown as App,
})
})
expect(options.onClose).toHaveBeenCalled()
expect(options.setSearchQuery).toHaveBeenCalledWith('')
expect(mockRouterPush).toHaveBeenCalledWith('/apps/1')
})
it('should NOT call router.push when path is empty', () => {
const options = createMockOptions()
const { result } = renderHook(() => useGotoAnythingNavigation(options))
act(() => {
result.current.handleNavigate({
id: '1',
type: 'app' as const,
title: 'My App',
path: '',
data: { id: '1', name: 'My App' } as unknown as App,
})
})
expect(mockRouterPush).not.toHaveBeenCalled()
})
it('should execute slash command action for command type', () => {
const actionMock = vi.fn()
const options = createMockOptions({
Actions: {
slash: { key: '/', shortcut: '/', action: actionMock },
},
})
const { result } = renderHook(() => useGotoAnythingNavigation(options))
const commandResult = {
id: 'cmd-1',
type: 'command' as const,
title: 'Theme Dark',
data: { command: 'theme.set', args: { theme: 'dark' } },
}
act(() => {
result.current.handleNavigate(commandResult)
})
expect(actionMock).toHaveBeenCalledWith(commandResult)
})
it('should set activePlugin for plugin type', () => {
const options = createMockOptions()
const pluginData = {
name: 'My Plugin',
latest_package_identifier: 'pkg',
} as unknown as Plugin
const { result } = renderHook(() => useGotoAnythingNavigation(options))
act(() => {
result.current.handleNavigate({
id: 'plugin-1',
type: 'plugin' as const,
title: 'My Plugin',
data: pluginData,
})
})
expect(result.current.activePlugin).toEqual(pluginData)
})
it('should select workflow node for workflow-node type', () => {
const options = createMockOptions()
const { result } = renderHook(() => useGotoAnythingNavigation(options))
act(() => {
result.current.handleNavigate({
id: 'node-1',
type: 'workflow-node' as const,
title: 'Start Node',
metadata: { nodeId: 'node-123', nodeData: {} as CommonNodeType },
data: { id: 'node-1' } as unknown as CommonNodeType,
})
})
expect(mockSelectWorkflowNode).toHaveBeenCalledWith('node-123', true)
})
it('should NOT select workflow node when metadata.nodeId is missing', () => {
const options = createMockOptions()
const { result } = renderHook(() => useGotoAnythingNavigation(options))
act(() => {
result.current.handleNavigate({
id: 'node-1',
type: 'workflow-node' as const,
title: 'Start Node',
metadata: undefined,
data: { id: 'node-1' } as unknown as CommonNodeType,
})
})
expect(mockSelectWorkflowNode).not.toHaveBeenCalled()
})
it('should handle knowledge type (default case with path)', () => {
const options = createMockOptions()
const { result } = renderHook(() => useGotoAnythingNavigation(options))
act(() => {
result.current.handleNavigate({
id: 'kb-1',
type: 'knowledge' as const,
title: 'My Knowledge Base',
path: '/datasets/kb-1',
data: { id: 'kb-1', name: 'My Knowledge Base' } as unknown as DataSet,
})
})
expect(mockRouterPush).toHaveBeenCalledWith('/datasets/kb-1')
})
it('should record app navigation to recent history', () => {
const options = createMockOptions()
const { result } = renderHook(() => useGotoAnythingNavigation(options))
act(() => {
result.current.handleNavigate({
id: 'app-1',
type: 'app' as const,
title: 'My App',
description: 'Desc',
path: '/app/app-1',
data: { id: 'app-1', name: 'My App' } as unknown as App,
})
})
expect(mockAddRecentItem).toHaveBeenCalledWith({
id: 'app-1',
title: 'My App',
description: 'Desc',
path: '/app/app-1',
originalType: 'app',
})
})
it('should record knowledge navigation to recent history', () => {
const options = createMockOptions()
const { result } = renderHook(() => useGotoAnythingNavigation(options))
act(() => {
result.current.handleNavigate({
id: 'kb-1',
type: 'knowledge' as const,
title: 'My KB',
path: '/datasets/kb-1',
data: { id: 'kb-1', name: 'My KB' } as unknown as DataSet,
})
})
expect(mockAddRecentItem).toHaveBeenCalledWith(
expect.objectContaining({ id: 'kb-1', originalType: 'knowledge' }),
)
})
it('should NOT record to recent history when path is missing', () => {
const options = createMockOptions()
const { result } = renderHook(() => useGotoAnythingNavigation(options))
act(() => {
result.current.handleNavigate({
id: 'app-1',
type: 'app' as const,
title: 'My App',
path: '',
data: { id: 'app-1', name: 'My App' } as unknown as App,
})
})
expect(mockAddRecentItem).not.toHaveBeenCalled()
})
it('should navigate for recent type without recording again', () => {
const options = createMockOptions()
const { result } = renderHook(() => useGotoAnythingNavigation(options))
act(() => {
result.current.handleNavigate({
id: 'recent-app-1',
type: 'recent' as const,
originalType: 'app',
title: 'My App',
path: '/app/app-1',
data: { path: '/app/app-1' },
})
})
expect(mockRouterPush).toHaveBeenCalledWith('/app/app-1')
expect(mockAddRecentItem).not.toHaveBeenCalled()
})
it('should NOT call router.push for recent type when path is missing', () => {
const options = createMockOptions()
const { result } = renderHook(() => useGotoAnythingNavigation(options))
act(() => {
result.current.handleNavigate({
id: 'recent-app-1',
type: 'recent' as const,
originalType: 'app',
title: 'My App',
data: { path: '' },
})
})
expect(mockRouterPush).not.toHaveBeenCalled()
})
})
describe('setActivePlugin', () => {
it('should update activePlugin state', () => {
const { result } = renderHook(() => useGotoAnythingNavigation(createMockOptions()))
const plugin = {
name: 'Test Plugin',
latest_package_identifier: 'test-pkg',
} as unknown as Plugin
act(() => {
result.current.setActivePlugin(plugin)
})
expect(result.current.activePlugin).toEqual(plugin)
})
it('should clear activePlugin when set to undefined', () => {
const { result } = renderHook(() => useGotoAnythingNavigation(createMockOptions()))
act(() => {
result.current.setActivePlugin({
name: 'Plugin',
latest_package_identifier: 'pkg',
} as unknown as Plugin)
})
expect(result.current.activePlugin).toBeDefined()
act(() => {
result.current.setActivePlugin(undefined)
})
expect(result.current.activePlugin).toBeUndefined()
})
})
describe('edge cases', () => {
it('should handle undefined inputRef.current', () => {
const options = createMockOptions({
inputRef: { current: null },
})
const { result } = renderHook(() => useGotoAnythingNavigation(options))
act(() => {
result.current.handleCommandSelect('@app')
})
act(() => {
vi.runAllTimers()
})
})
it('should handle missing slash action', () => {
const options = createMockOptions({
Actions: {},
})
const { result } = renderHook(() => useGotoAnythingNavigation(options))
act(() => {
result.current.handleNavigate({
id: 'cmd-1',
type: 'command' as const,
title: 'Command',
data: { command: 'test-command' },
})
})
})
})
})

View File

@ -1,453 +0,0 @@
import type { SearchResult } from '../../actions/types'
import { renderHook } from '@testing-library/react'
import { useGotoAnythingResults } from '../use-goto-anything-results'
type MockQueryResult = {
data: Array<{ id: string; type: string; title: string }> | undefined
isLoading: boolean
isError: boolean
error: Error | null
}
type UseQueryOptions = {
queryFn: () => Promise<SearchResult[]>
}
let mockQueryResult: MockQueryResult = { data: [], isLoading: false, isError: false, error: null }
let capturedQueryFn: (() => Promise<SearchResult[]>) | null = null
vi.mock('@tanstack/react-query', () => ({
useQuery: (options: UseQueryOptions) => {
capturedQueryFn = options.queryFn
return mockQueryResult
},
}))
const mockMatchAction = vi.fn()
const mockSearchAnything = vi.fn()
const mockGetRecentItems = vi.fn(() => [] as Array<Record<string, unknown>>)
vi.mock('../../actions', () => ({
matchAction: (...args: unknown[]) => mockMatchAction(...args),
searchAnything: (...args: unknown[]) => mockSearchAnything(...args),
}))
vi.mock('../../actions/recent-store', () => ({
getRecentItems: () => mockGetRecentItems(),
}))
const createMockActionItem = (key: '@app' | '@knowledge' | '@plugin' | '@node' | '/') => ({
key,
shortcut: key,
title: `${key} title`,
description: `${key} description`,
search: vi.fn().mockResolvedValue([]),
})
const createMockOptions = (overrides = {}) => ({
searchQueryDebouncedValue: '',
searchMode: 'general',
isCommandsMode: false,
Actions: { app: createMockActionItem('@app') },
isWorkflowPage: false,
isRagPipelinePage: false,
cmdVal: '_',
setCmdVal: vi.fn(),
...overrides,
})
describe('useGotoAnythingResults', () => {
beforeEach(() => {
mockQueryResult = { data: [], isLoading: false, isError: false, error: null }
capturedQueryFn = null
mockMatchAction.mockReset()
mockSearchAnything.mockReset()
mockGetRecentItems.mockReturnValue([])
})
describe('initialization', () => {
it('should return empty arrays when no results', () => {
const { result } = renderHook(() => useGotoAnythingResults(createMockOptions()))
expect(result.current.searchResults).toEqual([])
expect(result.current.dedupedResults).toEqual([])
expect(result.current.groupedResults).toEqual({})
})
it('should return loading state', () => {
mockQueryResult = { data: [], isLoading: true, isError: false, error: null }
const { result } = renderHook(() => useGotoAnythingResults(createMockOptions()))
expect(result.current.isLoading).toBe(true)
})
it('should return error state', () => {
const error = new Error('Test error')
mockQueryResult = { data: [], isLoading: false, isError: true, error }
const { result } = renderHook(() => useGotoAnythingResults(createMockOptions()))
expect(result.current.isError).toBe(true)
expect(result.current.error).toBe(error)
})
})
describe('dedupedResults', () => {
it('should remove duplicate results', () => {
mockQueryResult = {
data: [
{ id: '1', type: 'app', title: 'App 1' },
{ id: '1', type: 'app', title: 'App 1 Duplicate' },
{ id: '2', type: 'app', title: 'App 2' },
],
isLoading: false,
isError: false,
error: null,
}
const { result } = renderHook(() => useGotoAnythingResults(createMockOptions()))
expect(result.current.dedupedResults).toHaveLength(2)
expect(result.current.dedupedResults[0]!.id).toBe('1')
expect(result.current.dedupedResults[1]!.id).toBe('2')
})
it('should keep first occurrence when duplicates exist', () => {
mockQueryResult = {
data: [
{ id: '1', type: 'app', title: 'First' },
{ id: '1', type: 'app', title: 'Second' },
],
isLoading: false,
isError: false,
error: null,
}
const { result } = renderHook(() => useGotoAnythingResults(createMockOptions()))
expect(result.current.dedupedResults).toHaveLength(1)
expect(result.current.dedupedResults[0]!.title).toBe('First')
})
it('should handle different types with same id', () => {
mockQueryResult = {
data: [
{ id: '1', type: 'app', title: 'App' },
{ id: '1', type: 'plugin', title: 'Plugin' },
],
isLoading: false,
isError: false,
error: null,
}
const { result } = renderHook(() => useGotoAnythingResults(createMockOptions()))
expect(result.current.dedupedResults).toHaveLength(2)
})
})
describe('groupedResults', () => {
it('should group results by type', () => {
mockQueryResult = {
data: [
{ id: '1', type: 'app', title: 'App 1' },
{ id: '2', type: 'app', title: 'App 2' },
{ id: '3', type: 'plugin', title: 'Plugin 1' },
],
isLoading: false,
isError: false,
error: null,
}
const { result } = renderHook(() => useGotoAnythingResults(createMockOptions()))
expect(result.current.groupedResults.app).toHaveLength(2)
expect(result.current.groupedResults.plugin).toHaveLength(1)
})
it('should handle single type', () => {
mockQueryResult = {
data: [
{ id: '1', type: 'knowledge', title: 'KB 1' },
{ id: '2', type: 'knowledge', title: 'KB 2' },
],
isLoading: false,
isError: false,
error: null,
}
const { result } = renderHook(() => useGotoAnythingResults(createMockOptions()))
expect(Object.keys(result.current.groupedResults)).toEqual(['knowledge'])
expect(result.current.groupedResults.knowledge).toHaveLength(2)
})
it('should return empty object when no results', () => {
mockQueryResult = { data: [], isLoading: false, isError: false, error: null }
const { result } = renderHook(() => useGotoAnythingResults(createMockOptions()))
expect(result.current.groupedResults).toEqual({})
})
})
describe('auto-select first result', () => {
it('should call setCmdVal when results change and current value does not exist', () => {
const setCmdVal = vi.fn()
mockQueryResult = {
data: [{ id: '1', type: 'app', title: 'App 1' }],
isLoading: false,
isError: false,
error: null,
}
renderHook(() =>
useGotoAnythingResults(
createMockOptions({
cmdVal: 'non-existent',
setCmdVal,
}),
),
)
expect(setCmdVal).toHaveBeenCalledWith('app-1')
})
it('should NOT call setCmdVal when in commands mode', () => {
const setCmdVal = vi.fn()
mockQueryResult = {
data: [{ id: '1', type: 'app', title: 'App 1' }],
isLoading: false,
isError: false,
error: null,
}
renderHook(() =>
useGotoAnythingResults(
createMockOptions({
isCommandsMode: true,
setCmdVal,
}),
),
)
expect(setCmdVal).not.toHaveBeenCalled()
})
it('should NOT call setCmdVal when results are empty', () => {
const setCmdVal = vi.fn()
mockQueryResult = { data: [], isLoading: false, isError: false, error: null }
renderHook(() =>
useGotoAnythingResults(
createMockOptions({
setCmdVal,
}),
),
)
expect(setCmdVal).not.toHaveBeenCalled()
})
it('should NOT call setCmdVal when current value exists in results', () => {
const setCmdVal = vi.fn()
mockQueryResult = {
data: [
{ id: '1', type: 'app', title: 'App 1' },
{ id: '2', type: 'app', title: 'App 2' },
],
isLoading: false,
isError: false,
error: null,
}
renderHook(() =>
useGotoAnythingResults(
createMockOptions({
cmdVal: 'app-2',
setCmdVal,
}),
),
)
expect(setCmdVal).not.toHaveBeenCalled()
})
})
describe('error handling', () => {
it('should return error as Error | null', () => {
const error = new Error('Search failed')
mockQueryResult = { data: [], isLoading: false, isError: true, error }
const { result } = renderHook(() => useGotoAnythingResults(createMockOptions()))
expect(result.current.error).toBeInstanceOf(Error)
expect(result.current.error?.message).toBe('Search failed')
})
it('should return null error when no error', () => {
mockQueryResult = { data: [], isLoading: false, isError: false, error: null }
const { result } = renderHook(() => useGotoAnythingResults(createMockOptions()))
expect(result.current.error).toBeNull()
})
})
describe('searchResults', () => {
it('should return raw search results', () => {
const mockData = [
{ id: '1', type: 'app', title: 'App 1' },
{ id: '2', type: 'plugin', title: 'Plugin 1' },
]
mockQueryResult = { data: mockData, isLoading: false, isError: false, error: null }
const { result } = renderHook(() => useGotoAnythingResults(createMockOptions()))
expect(result.current.searchResults).toEqual(mockData)
})
it('should default to empty array when data is undefined', () => {
mockQueryResult = { data: undefined, isLoading: false, isError: false, error: null }
const { result } = renderHook(() => useGotoAnythingResults(createMockOptions()))
expect(result.current.searchResults).toEqual([])
})
})
describe('recent results', () => {
it('surfaces recent items when the search query is empty', () => {
mockGetRecentItems.mockReturnValue([
{
id: 'app-1',
title: 'My App',
description: 'Desc',
path: '/app/app-1',
originalType: 'app',
},
{ id: 'kb-1', title: 'My KB', path: '/datasets/kb-1', originalType: 'knowledge' },
])
const { result } = renderHook(() =>
useGotoAnythingResults(
createMockOptions({
searchQueryDebouncedValue: '',
}),
),
)
expect(result.current.dedupedResults).toHaveLength(2)
expect(result.current.dedupedResults[0]).toMatchObject({
id: 'recent-app-1',
type: 'recent',
originalType: 'app',
path: '/app/app-1',
data: { path: '/app/app-1' },
})
expect(result.current.groupedResults.recent).toHaveLength(2)
})
it('does not surface recent items when a query is active', () => {
mockGetRecentItems.mockReturnValue([
{ id: 'app-1', title: 'My App', path: '/app/app-1', originalType: 'app' },
])
mockQueryResult = {
data: [{ id: 's1', type: 'app', title: 'Searched' }],
isLoading: false,
isError: false,
error: null,
}
const { result } = renderHook(() =>
useGotoAnythingResults(
createMockOptions({
searchQueryDebouncedValue: 'foo',
}),
),
)
expect(result.current.dedupedResults.map((r) => r.id)).toEqual(['s1'])
})
it('does not surface recent items in commands mode', () => {
mockGetRecentItems.mockReturnValue([
{ id: 'app-1', title: 'My App', path: '/app/app-1', originalType: 'app' },
])
const { result } = renderHook(() =>
useGotoAnythingResults(
createMockOptions({
isCommandsMode: true,
}),
),
)
expect(result.current.dedupedResults).toEqual([])
})
})
describe('queryFn execution', () => {
it('should call matchAction with lowercased query', async () => {
const mockActions = { app: createMockActionItem('@app') }
mockMatchAction.mockReturnValue({ key: '@app' })
mockSearchAnything.mockResolvedValue([])
renderHook(() =>
useGotoAnythingResults(
createMockOptions({
searchQueryDebouncedValue: 'TEST QUERY',
Actions: mockActions,
}),
),
)
expect(capturedQueryFn).toBeDefined()
await capturedQueryFn!()
expect(mockMatchAction).toHaveBeenCalledWith('test query', mockActions)
})
it('should call searchAnything with correct parameters', async () => {
const mockActions = { app: createMockActionItem('@app') }
const mockAction = { key: '@app' }
mockMatchAction.mockReturnValue(mockAction)
mockSearchAnything.mockResolvedValue([{ id: '1', type: 'app', title: 'Result' }])
renderHook(() =>
useGotoAnythingResults(
createMockOptions({
searchQueryDebouncedValue: 'My Query',
Actions: mockActions,
}),
),
)
expect(capturedQueryFn).toBeDefined()
const result = await capturedQueryFn!()
expect(mockSearchAnything).toHaveBeenCalledWith('en_US', 'my query', mockAction, mockActions)
expect(result).toEqual([{ id: '1', type: 'app', title: 'Result' }])
})
it('should handle searchAnything returning results', async () => {
const expectedResults = [
{ id: '1', type: 'app', title: 'App 1' },
{ id: '2', type: 'plugin', title: 'Plugin 1' },
]
mockMatchAction.mockReturnValue(null)
mockSearchAnything.mockResolvedValue(expectedResults)
renderHook(() =>
useGotoAnythingResults(
createMockOptions({
searchQueryDebouncedValue: 'search term',
}),
),
)
expect(capturedQueryFn).toBeDefined()
const result = await capturedQueryFn!()
expect(result).toEqual(expectedResults)
})
})
})

View File

@ -1,298 +0,0 @@
import type { ActionItem } from '../../actions/types'
import { act, renderHook } from '@testing-library/react'
import { useGotoAnythingSearch } from '../use-goto-anything-search'
let mockContextValue = { isWorkflowPage: false, isRagPipelinePage: false }
let mockMatchActionResult: Partial<ActionItem> | undefined
vi.mock('ahooks', () => ({
useDebounce: <T>(value: T) => value,
}))
vi.mock('../../context', () => ({
useGotoAnythingContext: () => mockContextValue,
}))
vi.mock('../../actions', () => ({
createActions: (isWorkflowPage: boolean, isRagPipelinePage: boolean) => {
const base = {
slash: { key: '/', shortcut: '/' },
app: { key: '@app', shortcut: '@app' },
knowledge: { key: '@knowledge', shortcut: '@kb' },
}
if (isWorkflowPage) {
return { ...base, node: { key: '@node', shortcut: '@node' } }
}
if (isRagPipelinePage) {
return { ...base, ragNode: { key: '@node', shortcut: '@node' } }
}
return base
},
matchAction: () => mockMatchActionResult,
}))
describe('useGotoAnythingSearch', () => {
beforeEach(() => {
mockContextValue = { isWorkflowPage: false, isRagPipelinePage: false }
mockMatchActionResult = undefined
})
describe('initialization', () => {
it('should initialize with empty search query', () => {
const { result } = renderHook(() => useGotoAnythingSearch())
expect(result.current.searchQuery).toBe('')
})
it('should initialize cmdVal with "_"', () => {
const { result } = renderHook(() => useGotoAnythingSearch())
expect(result.current.cmdVal).toBe('_')
})
it('should initialize searchMode as "general"', () => {
const { result } = renderHook(() => useGotoAnythingSearch())
expect(result.current.searchMode).toBe('general')
})
it('should initialize isCommandsMode as false', () => {
const { result } = renderHook(() => useGotoAnythingSearch())
expect(result.current.isCommandsMode).toBe(false)
})
it('should provide setSearchQuery function', () => {
const { result } = renderHook(() => useGotoAnythingSearch())
expect(typeof result.current.setSearchQuery).toBe('function')
})
it('should provide setCmdVal function', () => {
const { result } = renderHook(() => useGotoAnythingSearch())
expect(typeof result.current.setCmdVal).toBe('function')
})
it('should provide clearSelection function', () => {
const { result } = renderHook(() => useGotoAnythingSearch())
expect(typeof result.current.clearSelection).toBe('function')
})
})
describe('Actions', () => {
it('should provide Actions based on context', () => {
const { result } = renderHook(() => useGotoAnythingSearch())
expect(result.current.Actions).toBeDefined()
expect(typeof result.current.Actions).toBe('object')
})
it('should include node action when on workflow page', () => {
mockContextValue = { isWorkflowPage: true, isRagPipelinePage: false }
const { result } = renderHook(() => useGotoAnythingSearch())
expect(result.current.Actions.node).toBeDefined()
})
it('should include ragNode action when on RAG pipeline page', () => {
mockContextValue = { isWorkflowPage: false, isRagPipelinePage: true }
const { result } = renderHook(() => useGotoAnythingSearch())
expect(result.current.Actions.ragNode).toBeDefined()
})
it('should not include node actions when on regular page', () => {
mockContextValue = { isWorkflowPage: false, isRagPipelinePage: false }
const { result } = renderHook(() => useGotoAnythingSearch())
expect(result.current.Actions.node).toBeUndefined()
expect(result.current.Actions.ragNode).toBeUndefined()
})
})
describe('isCommandsMode', () => {
it('should return true when query is exactly "@"', () => {
const { result } = renderHook(() => useGotoAnythingSearch())
act(() => {
result.current.setSearchQuery('@')
})
expect(result.current.isCommandsMode).toBe(true)
})
it('should return true when query is exactly "/"', () => {
const { result } = renderHook(() => useGotoAnythingSearch())
act(() => {
result.current.setSearchQuery('/')
})
expect(result.current.isCommandsMode).toBe(true)
})
it('should return true when query starts with "@" and no action matches', () => {
mockMatchActionResult = undefined
const { result } = renderHook(() => useGotoAnythingSearch())
act(() => {
result.current.setSearchQuery('@unknown')
})
expect(result.current.isCommandsMode).toBe(true)
})
it('should return true when query starts with "/" and no action matches', () => {
mockMatchActionResult = undefined
const { result } = renderHook(() => useGotoAnythingSearch())
act(() => {
result.current.setSearchQuery('/unknown')
})
expect(result.current.isCommandsMode).toBe(true)
})
it('should return false when query starts with "@" and action matches', () => {
mockMatchActionResult = { key: '@app', shortcut: '@app' }
const { result } = renderHook(() => useGotoAnythingSearch())
act(() => {
result.current.setSearchQuery('@app test')
})
expect(result.current.isCommandsMode).toBe(false)
})
it('should return false for regular search query', () => {
mockMatchActionResult = undefined
const { result } = renderHook(() => useGotoAnythingSearch())
act(() => {
result.current.setSearchQuery('hello world')
})
expect(result.current.isCommandsMode).toBe(false)
})
})
describe('searchMode', () => {
it('should return "general" when query is empty', () => {
const { result } = renderHook(() => useGotoAnythingSearch())
expect(result.current.searchMode).toBe('general')
})
it('should return "scopes" when in commands mode and query starts with "@"', () => {
mockMatchActionResult = undefined
const { result } = renderHook(() => useGotoAnythingSearch())
act(() => {
result.current.setSearchQuery('@')
})
expect(result.current.searchMode).toBe('scopes')
})
it('should return "commands" when in commands mode and query starts with "/"', () => {
mockMatchActionResult = undefined
const { result } = renderHook(() => useGotoAnythingSearch())
act(() => {
result.current.setSearchQuery('/')
})
expect(result.current.searchMode).toBe('commands')
})
it('should return "general" when no action matches', () => {
mockMatchActionResult = undefined
const { result } = renderHook(() => useGotoAnythingSearch())
act(() => {
result.current.setSearchQuery('hello')
})
expect(result.current.searchMode).toBe('general')
})
it('should return action key when action matches', () => {
mockMatchActionResult = { key: '@app', shortcut: '@app' }
const { result } = renderHook(() => useGotoAnythingSearch())
act(() => {
result.current.setSearchQuery('@app test')
})
expect(result.current.searchMode).toBe('@app')
})
it('should return "@command" when action key is "/"', () => {
mockMatchActionResult = { key: '/', shortcut: '/' }
const { result } = renderHook(() => useGotoAnythingSearch())
act(() => {
result.current.setSearchQuery('/theme dark')
})
expect(result.current.searchMode).toBe('@command')
})
})
describe('clearSelection', () => {
it('should reset cmdVal to "_"', () => {
const { result } = renderHook(() => useGotoAnythingSearch())
act(() => {
result.current.setCmdVal('app-1')
})
expect(result.current.cmdVal).toBe('app-1')
act(() => {
result.current.clearSelection()
})
expect(result.current.cmdVal).toBe('_')
})
})
describe('setSearchQuery', () => {
it('should update search query', () => {
const { result } = renderHook(() => useGotoAnythingSearch())
act(() => {
result.current.setSearchQuery('test query')
})
expect(result.current.searchQuery).toBe('test query')
})
it('should handle empty string', () => {
const { result } = renderHook(() => useGotoAnythingSearch())
act(() => {
result.current.setSearchQuery('test')
})
expect(result.current.searchQuery).toBe('test')
act(() => {
result.current.setSearchQuery('')
})
expect(result.current.searchQuery).toBe('')
})
})
describe('setCmdVal', () => {
it('should update cmdVal', () => {
const { result } = renderHook(() => useGotoAnythingSearch())
act(() => {
result.current.setCmdVal('plugin-2')
})
expect(result.current.cmdVal).toBe('plugin-2')
})
})
describe('searchQueryDebouncedValue', () => {
it('should return trimmed debounced value', () => {
const { result } = renderHook(() => useGotoAnythingSearch())
act(() => {
result.current.setSearchQuery(' test ')
})
expect(result.current.searchQueryDebouncedValue).toBe('test')
})
})
})

View File

@ -1,43 +0,0 @@
'use client'
import type { RefObject } from 'react'
import { useHotkey } from '@tanstack/react-hotkeys'
import { useEffect, useRef } from 'react'
import { useGotoAnythingOpen, useSetGotoAnythingOpen } from '../atoms'
type UseGotoAnythingModalReturn = {
open: boolean
onOpenChange: (open: boolean) => void
inputRef: RefObject<HTMLInputElement | null>
}
export const useGotoAnythingModal = (): UseGotoAnythingModalReturn => {
const open = useGotoAnythingOpen()
const setOpen = useSetGotoAnythingOpen()
const inputRef = useRef<HTMLInputElement>(null)
useHotkey(
'Mod+K',
(e) => {
e.preventDefault()
setOpen((prev) => !prev)
},
{
ignoreInputs: !open,
},
)
useEffect(() => {
if (open) {
requestAnimationFrame(() => {
inputRef.current?.focus()
})
}
}, [open])
return {
open,
onOpenChange: setOpen,
inputRef,
}
}

View File

@ -1,109 +0,0 @@
'use client'
import type { RefObject } from 'react'
import type { Plugin } from '../../plugins/types'
import type { ActionItem, SearchResult } from '../actions/types'
import { useCallback, useState } from 'react'
import { selectWorkflowNode } from '@/app/components/workflow/utils/node-navigation'
import { useRouter } from '@/next/navigation'
import { slashCommandRegistry } from '../actions/commands/registry'
import { addRecentItem } from '../actions/recent-store'
type UseGotoAnythingNavigationReturn = {
handleCommandSelect: (commandKey: string) => void
handleNavigate: (result: SearchResult) => void
activePlugin: Plugin | undefined
setActivePlugin: (plugin: Plugin | undefined) => void
}
type UseGotoAnythingNavigationOptions = {
Actions: Record<string, ActionItem>
setSearchQuery: (query: string) => void
clearSelection: () => void
inputRef: RefObject<HTMLInputElement | null>
onClose: () => void
}
export const useGotoAnythingNavigation = (
options: UseGotoAnythingNavigationOptions,
): UseGotoAnythingNavigationReturn => {
const { Actions, setSearchQuery, clearSelection, inputRef, onClose } = options
const router = useRouter()
const [activePlugin, setActivePlugin] = useState<Plugin>()
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()
onClose()
setSearchQuery('')
return
}
}
// Otherwise, proceed with the normal flow (submenu mode)
setSearchQuery(`${commandKey} `)
clearSelection()
setTimeout(() => {
inputRef.current?.focus()
}, 0)
},
[onClose, setSearchQuery, clearSelection, inputRef],
)
// Handle navigation to selected result
const handleNavigate = useCallback(
(result: SearchResult) => {
onClose()
setSearchQuery('')
switch (result.type) {
case 'command': {
// Execute slash commands
const action = Actions.slash
action?.action?.(result)
break
}
case 'plugin':
setActivePlugin(result.data)
break
case 'workflow-node':
// Handle workflow node selection and navigation
if (result.metadata?.nodeId) selectWorkflowNode(result.metadata.nodeId, true)
break
case 'recent':
if (result.path) router.push(result.path)
break
default:
// Record to recent history for app and knowledge results
if ((result.type === 'app' || result.type === 'knowledge') && result.path) {
addRecentItem({
id: result.id,
title: result.title,
description: result.description,
path: result.path,
originalType: result.type,
})
}
if (result.path) router.push(result.path)
}
},
[router, Actions, onClose, setSearchQuery],
)
return {
handleCommandSelect,
handleNavigate,
activePlugin,
setActivePlugin,
}
}

View File

@ -1,147 +0,0 @@
'use client'
import type { ActionItem, RecentSearchResult, SearchResult } from '../actions/types'
import { RiTimeLine } from '@remixicon/react'
import { useQuery } from '@tanstack/react-query'
import * as React from 'react'
import { useEffect, useMemo } from 'react'
import { useGetLanguage } from '@/context/i18n'
import { matchAction, searchAnything } from '../actions'
import { getRecentItems } from '../actions/recent-store'
type UseGotoAnythingResultsReturn = {
searchResults: SearchResult[]
dedupedResults: SearchResult[]
groupedResults: Record<string, SearchResult[]>
isLoading: boolean
isError: boolean
error: Error | null
}
type UseGotoAnythingResultsOptions = {
searchQueryDebouncedValue: string
searchMode: string
isCommandsMode: boolean
Actions: Record<string, ActionItem>
isWorkflowPage: boolean
isRagPipelinePage: boolean
cmdVal: string
setCmdVal: (val: string) => void
}
export const useGotoAnythingResults = (
options: UseGotoAnythingResultsOptions,
): UseGotoAnythingResultsReturn => {
const {
searchQueryDebouncedValue,
searchMode,
isCommandsMode,
Actions,
isWorkflowPage,
isRagPipelinePage,
cmdVal,
setCmdVal,
} = options
const defaultLocale = useGetLanguage()
// Use action keys as stable cache key instead of the full Actions object
// (Actions contains functions which are not serializable)
const actionKeys = useMemo(() => Object.keys(Actions).sort(), [Actions])
const {
data: searchResults = [],
isLoading,
isError,
error,
} = useQuery({
queryKey: [
'goto-anything',
'search-result',
searchQueryDebouncedValue,
searchMode,
isWorkflowPage,
isRagPipelinePage,
defaultLocale,
actionKeys,
],
queryFn: async () => {
const query = searchQueryDebouncedValue.toLowerCase()
const action = matchAction(query, Actions)
return await searchAnything(defaultLocale, query, action, Actions)
},
enabled: !!searchQueryDebouncedValue && !isCommandsMode,
staleTime: 30000,
gcTime: 300000,
})
// Build recent items to show when search is empty
const recentResults = useMemo((): RecentSearchResult[] => {
if (searchQueryDebouncedValue || isCommandsMode) return []
return getRecentItems().map((item) => ({
id: `recent-${item.id}`,
title: item.title,
description: item.description,
type: 'recent' as const,
originalType: item.originalType,
path: item.path,
icon: React.createElement(
'div',
{
className:
'flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-panel-bg',
},
React.createElement(RiTimeLine, { className: 'h-4 w-4 text-text-tertiary' }),
),
data: { path: item.path },
}))
}, [searchQueryDebouncedValue, isCommandsMode])
const dedupedResults = useMemo(() => {
const allResults = recentResults.length ? recentResults : searchResults
const seen = new Set<string>()
return allResults.filter((result) => {
const key = `${result.type}-${result.id}`
if (seen.has(key)) return false
seen.add(key)
return true
})
}, [searchResults, recentResults])
// Group results by type
const groupedResults = useMemo(
() =>
dedupedResults.reduce(
(acc, result) => {
if (!acc[result.type]) acc[result.type] = []
acc[result.type]!.push(result)
return acc
},
{} as Record<string, SearchResult[]>,
),
[dedupedResults],
)
// Auto-select first result when results change
useEffect(() => {
if (isCommandsMode) return
if (!dedupedResults.length) return
const currentValueExists = dedupedResults.some(
(result) => `${result.type}-${result.id}` === cmdVal,
)
if (!currentValueExists) setCmdVal(`${dedupedResults[0]!.type}-${dedupedResults[0]!.id}`)
}, [isCommandsMode, dedupedResults, cmdVal, setCmdVal])
return {
searchResults,
dedupedResults,
groupedResults,
isLoading,
isError,
error: error as Error | null,
}
}

View File

@ -1,77 +0,0 @@
'use client'
import type { ActionItem } from '../actions/types'
import { useDebounce } from 'ahooks'
import { useCallback, useMemo, useState } from 'react'
import { createActions, matchAction } from '../actions'
import { useGotoAnythingContext } from '../context'
type UseGotoAnythingSearchReturn = {
searchQuery: string
setSearchQuery: (query: string) => void
searchQueryDebouncedValue: string
searchMode: string
isCommandsMode: boolean
cmdVal: string
setCmdVal: (val: string) => void
clearSelection: () => void
Actions: Record<string, ActionItem>
}
export const useGotoAnythingSearch = (): UseGotoAnythingSearchReturn => {
const { isWorkflowPage, isRagPipelinePage } = useGotoAnythingContext()
const [searchQuery, setSearchQuery] = useState<string>('')
const [cmdVal, setCmdVal] = useState<string>('_')
// Filter actions based on context
const Actions = useMemo(() => {
return createActions(isWorkflowPage, isRagPipelinePage)
}, [isWorkflowPage, isRagPipelinePage])
const searchQueryDebouncedValue = useDebounce(searchQuery.trim(), {
wait: 300,
})
const isCommandsMode = useMemo(() => {
const trimmed = searchQuery.trim()
return (
trimmed === '@' ||
trimmed === '/' ||
(trimmed.startsWith('@') && !matchAction(trimmed, Actions)) ||
(trimmed.startsWith('/') && !matchAction(trimmed, Actions))
)
}, [searchQuery, Actions])
const searchMode = useMemo(() => {
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)
if (!action) return 'general'
return action.key === '/' ? '@command' : action.key
}, [searchQueryDebouncedValue, Actions, isCommandsMode, searchQuery])
// Prevent automatic selection of the first option when cmdVal is not set
const clearSelection = useCallback(() => {
setCmdVal('_')
}, [])
return {
searchQuery,
setSearchQuery,
searchQueryDebouncedValue,
searchMode,
isCommandsMode,
cmdVal,
setCmdVal,
clearSelection,
Actions,
}
}

View File

@ -1,191 +1,599 @@
'use client'
import type { FC, KeyboardEvent } from 'react'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import { Command } from 'cmdk'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import type { AutocompleteChangeEventDetails } from '@langgenius/dify-ui/autocomplete'
import type { Plugin } from '../plugins/types'
import type { ActionItem, RecentSearchResult, SearchResult } from './actions/types'
import {
Autocomplete,
AutocompleteCollection,
AutocompleteGroup,
AutocompleteGroupLabel,
AutocompleteInput,
AutocompleteInputGroup,
AutocompleteItem,
AutocompleteList,
AutocompleteStatus,
} from '@langgenius/dify-ui/autocomplete'
import {
Dialog,
DialogBackdrop,
DialogCloseButton,
DialogPopup,
DialogPortal,
DialogTitle,
} from '@langgenius/dify-ui/dialog'
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
import {
ScrollAreaContent,
ScrollAreaRoot,
ScrollAreaScrollbar,
ScrollAreaThumb,
ScrollAreaViewport,
} from '@langgenius/dify-ui/scroll-area'
import { formatForDisplay, useHotkey } from '@tanstack/react-hotkeys'
import { useQuery } from '@tanstack/react-query'
import { useDebounce } from 'ahooks'
import { useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { selectWorkflowNode } from '@/app/components/workflow/utils/node-navigation'
import { useGetLanguage } from '@/context/i18n'
import { usePathname, useRouter } from '@/next/navigation'
import { PluginInstallPermissionProvider } from '../plugins/install-plugin/components/plugin-install-permission-provider'
import useWorkspacePluginInstallPermission from '../plugins/install-plugin/hooks/use-workspace-plugin-install-permission'
import InstallFromMarketplace from '../plugins/install-plugin/install-from-marketplace'
import { createActions, getActionSearchTerm, matchAction } from './actions'
import { appSearchQueryOptions } from './actions/app'
import { slashCommandRegistry } from './actions/commands/registry'
import { SlashCommandProvider } from './actions/commands/slash-provider'
import CommandSelector from './command-selector'
import { EmptyState, Footer, ResultList, SearchInput } from './components'
import { GotoAnythingProvider, useGotoAnythingContext } from './context'
import { useGotoAnythingModal } from './hooks/use-goto-anything-modal'
import { useGotoAnythingNavigation } from './hooks/use-goto-anything-navigation'
import { useGotoAnythingResults } from './hooks/use-goto-anything-results'
import { useGotoAnythingSearch } from './hooks/use-goto-anything-search'
import { knowledgeSearchQueryOptions } from './actions/knowledge'
import { pluginSearchQueryOptions } from './actions/plugin'
import { addRecentItem, getRecentItems } from './actions/recent-store'
import { EmptyState } from './components/empty-state'
import { Footer } from './components/footer'
import { gotoAnythingDialogHandle } from './dialog-handle'
type Props = Readonly<{
onHide?: () => void
}>
const appWorkflowPathPattern = /^\/app\/[^/]+\/workflow$/
const sharedWorkflowPathPattern = /^\/workflow\/[^/]+$/
const ragPipelinePathPattern = /^\/datasets\/[^/]+\/pipeline$/
const searchHotkey = 'Mod+K'
const searchShortcut = searchHotkey.split('+')
const GotoAnythingDialog: FC<Props> = ({ onHide }) => {
const { t } = useTranslation()
const { isWorkflowPage, isRagPipelinePage } = useGotoAnythingContext()
const { canInstallPlugin, currentDifyVersion } = useWorkspacePluginInstallPermission()
const prevShowRef = useRef(false)
type CommandOption = {
kind: 'command-option'
shortcut: string
description: string
}
// Search state management (called first so setSearchQuery is available)
const {
searchQuery,
setSearchQuery,
searchQueryDebouncedValue,
searchMode,
isCommandsMode,
cmdVal,
setCmdVal,
clearSelection,
Actions,
} = useGotoAnythingSearch()
type GotoAnythingOption = CommandOption | SearchResult
// Modal state management
const { open, onOpenChange, inputRef } = useGotoAnythingModal()
const slashCommandDescriptionKeys = {
'/create': 'gotoAnything.actions.createCategoryDesc',
'/refine': 'gotoAnything.actions.refineCategoryDesc',
'/theme': 'gotoAnything.actions.themeCategoryDesc',
'/language': 'gotoAnything.actions.languageChangeDesc',
'/account': 'gotoAnything.actions.accountDesc',
'/feedback': 'gotoAnything.actions.feedbackDesc',
'/docs': 'gotoAnything.actions.docDesc',
'/community': 'gotoAnything.actions.communityDesc',
} as const
// Reset state when modal opens/closes
useEffect(() => {
if (open && !prevShowRef.current) {
// Modal just opened - reset search
setSearchQuery('')
} else if (!open && prevShowRef.current) {
// Modal just closed
setSearchQuery('')
clearSelection()
onHide?.()
}
prevShowRef.current = open
}, [open, setSearchQuery, clearSelection, onHide])
const actionDescriptionKeys = {
'@app': 'gotoAnything.actions.searchApplicationsDesc',
'@plugin': 'gotoAnything.actions.searchPluginsDesc',
'@knowledge': 'gotoAnything.actions.searchKnowledgeBasesDesc',
'@node': 'gotoAnything.actions.searchWorkflowNodesDesc',
} as const
// Results fetching and processing
const { dedupedResults, groupedResults, isLoading, isError, error } = useGotoAnythingResults({
searchQueryDebouncedValue,
searchMode,
isCommandsMode,
Actions,
isWorkflowPage,
isRagPipelinePage,
cmdVal,
setCmdVal,
const groupLabelKeys = {
app: 'gotoAnything.groups.apps',
plugin: 'gotoAnything.groups.plugins',
knowledge: 'gotoAnything.groups.knowledgeBases',
'workflow-node': 'gotoAnything.groups.workflowNodes',
command: 'gotoAnything.groups.commands',
recent: 'gotoAnything.groups.recent',
} as const
function getCommandOptions(actions: Record<string, ActionItem>, query: string): CommandOption[] {
const trimmedQuery = query.trim()
const filter = trimmedQuery.slice(1).toLowerCase()
if (trimmedQuery.startsWith('/')) {
return slashCommandRegistry
.getAvailableCommands()
.filter((command) => !filter || command.name.toLowerCase().includes(filter))
.map((command) => ({
kind: 'command-option',
shortcut: `/${command.name}`,
description: command.description,
}))
}
return Object.values(actions)
.filter((action) => action.key !== '/')
.filter((action) => !filter || action.shortcut.toLowerCase().includes(filter))
.map((action) => ({
kind: 'command-option',
shortcut: action.shortcut,
description: action.description,
}))
}
function isCommandOption(option: GotoAnythingOption): option is CommandOption {
return 'kind' in option && option.kind === 'command-option'
}
function optionToInputValue(option: GotoAnythingOption) {
return isCommandOption(option) ? `${option.shortcut} ` : option.title
}
function isEditableShortcutTarget(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) return false
return target.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)
}
function getSearchModeLabel(searchMode: string) {
if (searchMode === 'scopes') return 'SCOPES'
if (searchMode === 'commands') return 'COMMANDS'
return searchMode.replace('@', '').toUpperCase()
}
function getSearchMode(
searchQuery: string,
isCommandsMode: boolean,
actions: Record<string, ActionItem>,
) {
if (isCommandsMode) return searchQuery.trim().startsWith('@') ? 'scopes' : 'commands'
const action = matchAction(searchQuery.trim().toLowerCase(), actions)
if (!action) return 'general'
return action.key === '/' ? '@command' : action.key
}
function isCommandSelectionQuery(query: string, actions: Record<string, ActionItem>) {
const trimmedQuery = query.trim()
if (trimmedQuery === '@' || trimmedQuery === '/') return true
return (
(trimmedQuery.startsWith('@') || trimmedQuery.startsWith('/')) &&
!matchAction(trimmedQuery, actions)
)
}
function getRecentSearchResults(): RecentSearchResult[] {
return getRecentItems().map((item) => ({
id: `recent-${item.id}`,
title: item.title,
description: item.description,
type: 'recent',
originalType: item.originalType,
path: item.path,
icon: (
<div className="flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-panel-bg">
<span aria-hidden className="i-ri-time-line size-4 text-text-tertiary" />
</div>
),
data: { path: item.path },
}))
}
function dedupeSearchResults(results: SearchResult[]) {
const seen = new Set<string>()
return results.filter((result) => {
const key = `${result.type}-${result.id}`
if (seen.has(key)) return false
seen.add(key)
return true
})
}
// Navigation handlers
const { handleCommandSelect, handleNavigate, activePlugin, setActivePlugin } =
useGotoAnythingNavigation({
Actions,
setSearchQuery,
clearSelection,
inputRef,
onClose: () => onOpenChange(false),
function groupSearchResults(results: SearchResult[]) {
return results.reduce<Record<string, SearchResult[]>>((groups, result) => {
const group = groups[result.type] ?? []
group.push(result)
groups[result.type] = group
return groups
}, {})
}
function GotoAnythingDialog() {
const { t } = useTranslation()
const pathname = usePathname()
const router = useRouter()
const defaultLocale = useGetLanguage()
const isWorkflowPage =
appWorkflowPathPattern.test(pathname) || sharedWorkflowPathPattern.test(pathname)
const isRagPipelinePage = ragPipelinePathPattern.test(pathname)
const { canInstallPlugin, currentDifyVersion } = useWorkspacePluginInstallPermission()
const [searchQuery, setSearchQuery] = useState('')
const [activePlugin, setActivePlugin] = useState<Plugin>()
const inputRef = useRef<HTMLInputElement>(null)
const actions = useMemo(
() => createActions(isWorkflowPage, isRagPipelinePage),
[isWorkflowPage, isRagPipelinePage],
)
const trimmedSearchQuery = searchQuery.trim()
const isCommandsMode = isCommandSelectionQuery(searchQuery, actions)
const searchMode = getSearchMode(searchQuery, isCommandsMode, actions)
const debouncedSearchQuery = useDebounce(searchQuery, { wait: 300 })
const normalizedDebouncedQuery = debouncedSearchQuery.trim().toLowerCase()
const isDebouncedCommandsMode = isCommandSelectionQuery(debouncedSearchQuery, actions)
const debouncedAction = matchAction(normalizedDebouncedQuery, actions)
const debouncedSearchTerm = debouncedAction
? getActionSearchTerm(normalizedDebouncedQuery, debouncedAction)
: normalizedDebouncedQuery
const remoteSearchEnabled = Boolean(normalizedDebouncedQuery) && !isDebouncedCommandsMode
const appSearchEnabled =
remoteSearchEnabled && (!debouncedAction || debouncedAction.key === '@app')
const knowledgeSearchEnabled =
remoteSearchEnabled && (!debouncedAction || debouncedAction.key === '@knowledge')
const pluginSearchEnabled =
remoteSearchEnabled && (!debouncedAction || debouncedAction.key === '@plugin')
const appSearchQuery = useQuery({
...appSearchQueryOptions(debouncedSearchTerm, debouncedAction?.key === '@app'),
enabled: appSearchEnabled,
})
const knowledgeSearchQuery = useQuery({
...knowledgeSearchQueryOptions(debouncedSearchTerm),
enabled: knowledgeSearchEnabled,
})
const pluginSearchQuery = useQuery({
...pluginSearchQueryOptions(debouncedSearchTerm, defaultLocale),
enabled: pluginSearchEnabled,
})
const localSearchResults = useMemo(() => {
if (!trimmedSearchQuery || isCommandsMode) return []
const normalizedQuery = trimmedSearchQuery.toLowerCase()
const action = matchAction(normalizedQuery, actions)
if (action?.source === 'local') {
return action.search(
normalizedQuery,
getActionSearchTerm(normalizedQuery, action),
defaultLocale,
)
}
if (action) return []
return Object.values(actions).flatMap((candidate) => {
if (candidate.source !== 'local' || candidate.key === '/') return []
return candidate.search(normalizedQuery, normalizedQuery, defaultLocale)
})
}, [actions, defaultLocale, isCommandsMode, trimmedSearchQuery])
const activeRemoteQueries = [
appSearchEnabled ? appSearchQuery : undefined,
knowledgeSearchEnabled ? knowledgeSearchQuery : undefined,
pluginSearchEnabled ? pluginSearchQuery : undefined,
].filter((query) => query !== undefined)
const isDebouncing = remoteSearchEnabled && searchQuery.trim() !== debouncedSearchQuery.trim()
const isLoading = isDebouncing || activeRemoteQueries.some((query) => query.isLoading)
const failedRemoteQueries = activeRemoteQueries.filter((query) => query.isError)
const isError =
activeRemoteQueries.length > 0 && failedRemoteQueries.length === activeRemoteQueries.length
const hasUnavailableServices = failedRemoteQueries.length > 0
const queryError = failedRemoteQueries[0]?.error
const error = queryError instanceof Error ? queryError : null
const remoteSearchResults = isDebouncing
? []
: activeRemoteQueries.flatMap((query) => query.data ?? [])
const searchResults = [...localSearchResults, ...remoteSearchResults]
const recentResults = trimmedSearchQuery || isCommandsMode ? [] : getRecentSearchResults()
const dedupedResults = dedupeSearchResults(recentResults.length ? recentResults : searchResults)
const groupedResults = groupSearchResults(dedupedResults)
function resetSearch() {
setSearchQuery('')
}
useHotkey(
searchHotkey,
(event) => {
if (event.defaultPrevented) return
if (!gotoAnythingDialogHandle.isOpen && isEditableShortcutTarget(event.target)) return
event.preventDefault()
event.stopPropagation()
if (!gotoAnythingDialogHandle.isOpen) gotoAnythingDialogHandle.open(null)
},
{
ignoreInputs: false,
preventDefault: false,
stopPropagation: false,
},
)
function handleCommandSelect(commandKey: string) {
if (commandKey.startsWith('/')) {
const handler = slashCommandRegistry.findCommand(commandKey.slice(1))
if (handler?.mode === 'direct' && handler.execute) {
handler.execute()
gotoAnythingDialogHandle.close()
return
}
}
setSearchQuery(`${commandKey} `)
}
function handleNavigate(result: SearchResult) {
gotoAnythingDialogHandle.close()
switch (result.type) {
case 'command':
actions.slash.action?.(result)
break
case 'plugin':
setActivePlugin(result.data)
break
case 'workflow-node':
if (result.metadata?.nodeId) selectWorkflowNode(result.metadata.nodeId, true)
break
case 'recent':
if (result.path) router.push(result.path)
break
default:
if ((result.type === 'app' || result.type === 'knowledge') && result.path) {
addRecentItem({
id: result.id,
title: result.title,
description: result.description,
path: result.path,
originalType: result.type,
})
}
if (result.path) router.push(result.path)
}
}
function handleAutocompleteOpenChange(
nextOpen: boolean,
eventDetails: AutocompleteChangeEventDetails,
) {
if (!nextOpen && eventDetails.reason === 'escape-key') gotoAnythingDialogHandle.close()
}
function handleAutocompleteValueChange(
nextValue: string,
eventDetails: AutocompleteChangeEventDetails,
) {
if (eventDetails.reason !== 'item-press') setSearchQuery(nextValue)
}
function selectOption(option: GotoAnythingOption) {
if (isCommandOption(option)) handleCommandSelect(option.shortcut)
else handleNavigate(option)
}
const commandOptions = getCommandOptions(actions, searchQuery)
const autocompleteOptions: GotoAnythingOption[] = isCommandsMode ? commandOptions : dedupedResults
const visibleOptions = isLoading || isError ? [] : autocompleteOptions
const autocompleteResultCount = visibleOptions.length
const isSlashMode = searchQuery.trim().startsWith('/')
let autocompleteStatus: string | null = null
if (isLoading) autocompleteStatus = t(($) => $['gotoAnything.searching'], { ns: 'app' })
else if (isError) autocompleteStatus = t(($) => $['gotoAnything.searchFailed'], { ns: 'app' })
else if (hasUnavailableServices)
autocompleteStatus = t(($) => $['gotoAnything.someServicesUnavailable'], { ns: 'app' })
else if (trimmedSearchQuery)
autocompleteStatus = t(($) => $['gotoAnything.resultCount'], {
ns: 'app',
count: autocompleteResultCount,
})
// Handle search input change
const handleSearchChange = useCallback(
(value: string) => {
setSearchQuery(value)
if (!value.startsWith('@') && !value.startsWith('/')) clearSelection()
},
[setSearchQuery, clearSelection],
)
// Handle search input keydown for slash commands
const handleSearchKeyDown = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
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
const isAvailable = handler?.isAvailable?.() ?? true
if (handler?.mode === 'direct' && handler.execute && isAvailable) {
e.preventDefault()
handler.execute()
onOpenChange(false)
setSearchQuery('')
}
}
}
},
[searchQuery, onOpenChange, setSearchQuery],
)
// Determine which empty state to show
const emptyStateVariant = useMemo(() => {
if (isLoading) return 'loading'
if (isError) return 'error'
if (!searchQuery.trim()) {
// Show default hint only when there are no recent items to display
return dedupedResults.length === 0 ? 'default' : null
}
if (dedupedResults.length === 0 && !isCommandsMode) return 'no-results'
return null
}, [isLoading, isError, searchQuery, dedupedResults.length, isCommandsMode])
let emptyStateVariant: 'loading' | 'error' | 'default' | 'no-results' | null = null
if (isLoading) emptyStateVariant = 'loading'
else if (isError) emptyStateVariant = 'error'
else if (!trimmedSearchQuery && autocompleteResultCount === 0) emptyStateVariant = 'default'
else if (autocompleteResultCount === 0 && !isCommandsMode) emptyStateVariant = 'no-results'
return (
<>
<SlashCommandProvider />
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[480px]! overflow-hidden p-0!">
<Command
className="outline-hidden"
value={cmdVal}
onValueChange={setCmdVal}
disablePointerSelection
loop
<Dialog handle={gotoAnythingDialogHandle} onOpenChange={resetSearch}>
<DialogPortal>
<DialogBackdrop />
<DialogPopup
initialFocus={inputRef}
className="fixed top-1/2 left-1/2 max-h-[80dvh] w-[480px]! max-w-[calc(100vw-2rem)] -translate-x-1/2 -translate-y-1/2 overflow-hidden p-0!"
>
<SearchInput
inputRef={inputRef}
<DialogTitle className="sr-only">
{t(($) => $['gotoAnything.searchTitle'], { ns: 'app' })}
</DialogTitle>
<Autocomplete<GotoAnythingOption>
items={visibleOptions}
value={searchQuery}
onChange={handleSearchChange}
onKeyDown={handleSearchKeyDown}
searchMode={searchMode}
placeholder={t(($) => $['gotoAnything.searchPlaceholder'], { ns: 'app' })}
onValueChange={handleAutocompleteValueChange}
onOpenChange={handleAutocompleteOpenChange}
itemToStringValue={optionToInputValue}
filter={null}
open
inline
autoHighlight="always"
keepHighlight
loopFocus
>
<AutocompleteInputGroup
size="medium"
className="h-auto gap-3 rounded-none border-0 border-b border-divider-subtle bg-components-panel-bg-blur px-4 py-3 shadow-none focus-within:border-divider-subtle focus-within:bg-components-panel-bg-blur focus-within:shadow-none hover:border-divider-subtle hover:bg-components-panel-bg-blur data-focused:border-divider-subtle data-focused:bg-components-panel-bg-blur data-focused:shadow-none"
>
<span aria-hidden className="i-ri-search-line size-4 text-text-quaternary" />
<div className="flex min-w-0 flex-1 items-center gap-2">
<AutocompleteInput
ref={inputRef}
size="medium"
aria-label={t(($) => $['gotoAnything.searchTitle'], { ns: 'app' })}
placeholder={t(($) => $['gotoAnything.searchPlaceholder'], { ns: 'app' })}
className="px-0"
/>
{searchMode !== 'general' && (
<div className="flex items-center gap-1 rounded-sm bg-gray-100 px-2 py-[2px] text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-300">
<span>{getSearchModeLabel(searchMode)}</span>
</div>
)}
</div>
<KbdGroup>
{searchShortcut.map((key) => (
<Kbd key={key}>{formatForDisplay(key)}</Kbd>
))}
</KbdGroup>
</AutocompleteInputGroup>
<AutocompleteStatus className="sr-only">{autocompleteStatus}</AutocompleteStatus>
<ScrollAreaRoot
aria-busy={isLoading || undefined}
className="relative h-[240px] min-h-0 overflow-hidden"
>
<ScrollAreaViewport className="scroll-py-1 overscroll-contain">
<ScrollAreaContent
className="min-h-full w-full max-w-full"
style={{ minWidth: '100%' }}
>
{emptyStateVariant === 'loading' && <EmptyState variant="loading" />}
{emptyStateVariant === 'error' && <EmptyState variant="error" error={error} />}
{!isLoading && !isError && isCommandsMode && autocompleteResultCount === 0 && (
<div className="flex items-center justify-center py-8 text-center text-text-tertiary">
<div>
<div className="text-sm font-medium text-text-tertiary">
{t(($) => $['gotoAnything.noMatchingCommands'], { ns: 'app' })}
</div>
<div className="mt-1 text-xs text-text-quaternary">
{t(($) => $['gotoAnything.tryDifferentSearch'], { ns: 'app' })}
</div>
</div>
</div>
)}
{!isLoading && !isError && isCommandsMode && autocompleteResultCount > 0 && (
<AutocompleteList className="max-h-none overflow-visible p-0">
<AutocompleteGroup items={commandOptions}>
<AutocompleteGroupLabel className="px-4 pt-3 pb-2 text-left text-sm font-medium text-text-secondary">
{isSlashMode
? t(($) => $['gotoAnything.groups.commands'], { ns: 'app' })
: t(($) => $['gotoAnything.selectSearchType'], { ns: 'app' })}
</AutocompleteGroupLabel>
<AutocompleteCollection>
{(option: CommandOption) => (
<AutocompleteItem
key={option.shortcut}
value={option}
className="mx-4 p-2"
onClick={() => selectOption(option)}
>
<span className="min-w-18 text-left font-mono text-xs text-text-tertiary">
{option.shortcut}
</span>
<span className="ml-3 text-sm text-text-secondary">
{isSlashMode
? t(
($) =>
$[
slashCommandDescriptionKeys[
option.shortcut as keyof typeof slashCommandDescriptionKeys
] || option.description
],
{ ns: 'app' },
)
: t(
($) =>
$[
actionDescriptionKeys[
option.shortcut as keyof typeof actionDescriptionKeys
]
],
{ ns: 'app' },
)}
</span>
</AutocompleteItem>
)}
</AutocompleteCollection>
</AutocompleteGroup>
</AutocompleteList>
)}
{!isLoading && !isError && !isCommandsMode && emptyStateVariant && (
<EmptyState
variant={emptyStateVariant}
searchMode={searchMode}
actions={actions}
/>
)}
{!isLoading &&
!isError &&
!isCommandsMode &&
!emptyStateVariant &&
autocompleteResultCount > 0 && (
<AutocompleteList className="max-h-none overflow-visible p-0">
{Object.entries(groupedResults).map(([type, results]) => (
<AutocompleteGroup key={type} items={results}>
<AutocompleteGroupLabel className="px-4 pt-3 pb-2 text-text-secondary capitalize">
{t(
($) =>
$[
groupLabelKeys[type as keyof typeof groupLabelKeys] ||
`${type}s`
],
{ ns: 'app' },
)}
</AutocompleteGroupLabel>
<AutocompleteCollection>
{(result: SearchResult) => (
<AutocompleteItem
key={`${result.type}-${result.id}`}
value={result}
className="mx-2 gap-3 p-3"
onClick={() => selectOption(result)}
>
{result.icon}
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-text-secondary">
{result.title}
</div>
{result.description && (
<div className="mt-0.5 truncate text-xs text-text-quaternary">
{result.description}
</div>
)}
</div>
<div className="text-xs text-text-quaternary capitalize">
{result.type}
</div>
</AutocompleteItem>
)}
</AutocompleteCollection>
</AutocompleteGroup>
))}
</AutocompleteList>
)}
</ScrollAreaContent>
</ScrollAreaViewport>
<ScrollAreaScrollbar>
<ScrollAreaThumb />
</ScrollAreaScrollbar>
</ScrollAreaRoot>
<Footer
resultCount={autocompleteResultCount}
searchMode={searchMode}
isLoading={isLoading}
hasUnavailableServices={hasUnavailableServices}
isCommandsMode={isCommandsMode}
hasQuery={!!searchQuery.trim()}
/>
</Autocomplete>
<DialogCloseButton
className="sr-only"
aria-label={t(($) => $['operation.close'], { ns: 'common' })}
/>
<Command.List className="h-[240px] overflow-y-auto">
{emptyStateVariant === 'loading' && <EmptyState variant="loading" />}
{emptyStateVariant === 'error' && <EmptyState variant="error" error={error} />}
{!isLoading && !isError && (
<>
{isCommandsMode ? (
<CommandSelector
actions={Actions}
onCommandSelect={handleCommandSelect}
searchFilter={searchQuery.trim().substring(1)}
commandValue={cmdVal}
onCommandValueChange={setCmdVal}
originalQuery={searchQuery.trim()}
/>
) : (
<ResultList groupedResults={groupedResults} onSelect={handleNavigate} />
)}
{!isCommandsMode && emptyStateVariant === 'no-results' && (
<EmptyState variant="no-results" searchMode={searchMode} Actions={Actions} />
)}
{!isCommandsMode && emptyStateVariant === 'default' && (
<EmptyState variant="default" />
)}
</>
)}
</Command.List>
<Footer
resultCount={dedupedResults.length}
searchMode={searchMode}
isError={isError}
isCommandsMode={isCommandsMode}
hasQuery={!!searchQuery.trim()}
/>
</Command>
</DialogContent>
</DialogPopup>
</DialogPortal>
</Dialog>
{activePlugin && canInstallPlugin && (
@ -205,10 +613,6 @@ const GotoAnythingDialog: FC<Props> = ({ onHide }) => {
)
}
export const GotoAnything: FC<Props> = (props) => {
return (
<GotoAnythingProvider>
<GotoAnythingDialog {...props} />
</GotoAnythingProvider>
)
export function GotoAnything() {
return <GotoAnythingDialog />
}

View File

@ -5,8 +5,9 @@ import type { ModalContextState } from '@/context/modal-context'
import type { ProviderContextState } from '@/context/provider-context'
import type { ICurrentWorkspace, IWorkspace } from '@/models/common'
import type { InstalledApp } from '@/models/explore'
import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { createStore, Provider as JotaiProvider } from 'jotai'
import { Provider as JotaiProvider } from 'jotai'
import {
createTestQueryClient,
renderWithSystemFeatures,
@ -14,7 +15,7 @@ import {
import { Plan } from '@/app/components/billing/type'
import { DETAIL_SIDEBAR_STORAGE_KEY } from '@/app/components/detail-sidebar/storage'
import { LEARN_DIFY_HIDDEN_STORAGE_KEY } from '@/app/components/explore/learn-dify/storage'
import { useGotoAnythingOpen } from '@/app/components/goto-anything/atoms'
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
@ -263,7 +264,7 @@ const defaultMainNavSystemFeatures: MainNavSystemFeatures = {
const renderMainNav = (
systemFeatures: MainNavSystemFeatures = defaultMainNavSystemFeatures,
options: { store?: ReturnType<typeof createStore>; extra?: ReactNode } = {},
options: { extra?: ReactNode } = {},
) => {
const queryClient = createTestQueryClient()
const currentAppContext = mockAppContextState.current ?? appContextValue
@ -282,7 +283,7 @@ const renderMainNav = (
},
}
return renderWithSystemFeatures(
<JotaiProvider store={options.store}>
<JotaiProvider>
<MainNav />
{options.extra}
</JotaiProvider>,
@ -290,14 +291,10 @@ const renderMainNav = (
)
}
function GotoAnythingOpenProbe() {
const open = useGotoAnythingOpen()
return <div data-testid="goto-anything-open">{String(open)}</div>
}
describe('MainNav', () => {
beforeEach(() => {
vi.clearAllMocks()
gotoAnythingDialogHandle.close()
localStorage.clear()
mockPathname = '/apps'
mockInstalledApps = []
@ -686,14 +683,20 @@ describe('MainNav', () => {
)
})
it('opens goto anything from the search button', () => {
const store = createStore()
it('opens goto anything from the search button', async () => {
renderMainNav(undefined, {
extra: (
<Dialog handle={gotoAnythingDialogHandle}>
<DialogContent>
<DialogTitle>Goto Anything</DialogTitle>
</DialogContent>
</Dialog>
),
})
renderMainNav(undefined, { store, extra: <GotoAnythingOpenProbe /> })
expect(screen.getByTestId('goto-anything-open')).toHaveTextContent('false')
fireEvent.click(screen.getByRole('button', { name: 'app.gotoAnything.searchTitle' }))
expect(screen.getByTestId('goto-anything-open')).toHaveTextContent('true')
expect(await screen.findByRole('dialog', { name: 'Goto Anything' })).toBeInTheDocument()
})
it('shows Learn Dify switch in help menu and restores it from localStorage', async () => {

View File

@ -1,22 +1,26 @@
'use client'
import { DialogTrigger } from '@langgenius/dify-ui/dialog'
import { Kbd } from '@langgenius/dify-ui/kbd'
import { formatForDisplay } from '@tanstack/react-hotkeys'
import { useTranslation } from 'react-i18next'
import { useSetGotoAnythingOpen } from '@/app/components/goto-anything/atoms'
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
const searchShortcut = ['Mod', 'K']
export function MainNavSearchButton() {
const { t } = useTranslation()
const setGotoAnythingOpen = useSetGotoAnythingOpen()
return (
<button
type="button"
aria-label={t(($) => $['gotoAnything.searchTitle'], { ns: 'app' })}
className="flex h-8 items-center gap-1.5 overflow-hidden rounded-[10px] p-2 text-text-tertiary transition-colors hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
onClick={() => setGotoAnythingOpen(true)}
<DialogTrigger
handle={gotoAnythingDialogHandle}
render={
<button
type="button"
aria-label={t(($) => $['gotoAnything.searchTitle'], { ns: 'app' })}
className="flex h-8 items-center gap-1.5 overflow-hidden rounded-[10px] p-2 text-text-tertiary transition-colors hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
/>
}
>
<span aria-hidden className="i-custom-vender-main-nav-quick-search h-4 w-4" />
<Kbd className="h-4.5 min-w-0 rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-1 py-0.5 system-2xs-medium-uppercase text-text-tertiary">
@ -24,6 +28,6 @@ export function MainNavSearchButton() {
<span key={key}>{formatForDisplay(key)}</span>
))}
</Kbd>
</button>
</DialogTrigger>
)
}

View File

@ -0,0 +1,17 @@
import type { SearchResult } from '@/app/components/goto-anything/actions/types'
type SearchRagPipelineNodes = (query: string) => SearchResult[]
let searchRagPipelineNodes: SearchRagPipelineNodes | undefined
export function registerRagPipelineNodeSearch(search: SearchRagPipelineNodes) {
searchRagPipelineNodes = search
return () => {
if (searchRagPipelineNodes === search) searchRagPipelineNodes = undefined
}
}
export function findRagPipelineNodes(query: string) {
return searchRagPipelineNodes?.(query) ?? []
}

View File

@ -1,6 +1,7 @@
import { renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { BlockEnum } from '@/app/components/workflow/types'
import { findRagPipelineNodes } from '../../goto-anything-search'
import { useRagPipelineSearch } from '../use-rag-pipeline-search'
const mockNodes: Array<{ id: string; data: Record<string, unknown> }> = []
@ -23,20 +24,6 @@ vi.mock('@/app/components/workflow/block-icon', () => ({
default: () => null,
}))
type MockSearchResult = {
title: string
type: string
description?: string
metadata?: { nodeId: string }
}
const mockRagPipelineNodesAction = vi.hoisted(() => {
return { searchFn: undefined as undefined | ((query: string) => MockSearchResult[]) }
})
vi.mock('@/app/components/goto-anything/actions/rag-pipeline-nodes', () => ({
ragPipelineNodesAction: mockRagPipelineNodesAction,
}))
const mockCleanupListener = vi.fn()
vi.mock('@/app/components/workflow/utils/node-navigation', () => ({
setupNodeSelectionListener: () => mockCleanupListener,
@ -46,7 +33,6 @@ describe('useRagPipelineSearch', () => {
beforeEach(() => {
vi.clearAllMocks()
mockNodes.length = 0
mockRagPipelineNodesAction.searchFn = undefined
})
afterEach(() => {
@ -65,15 +51,21 @@ describe('useRagPipelineSearch', () => {
data: { type: BlockEnum.LLM, title: 'LLM Node', desc: '' },
})
renderHook(() => useRagPipelineSearch())
const { unmount } = renderHook(() => useRagPipelineSearch())
expect(mockRagPipelineNodesAction.searchFn).toBeDefined()
expect(findRagPipelineNodes('LLM')).toEqual([
expect.objectContaining({ id: 'node-1', title: 'LLM Node' }),
])
unmount()
})
it('should not register search function when no nodes', () => {
renderHook(() => useRagPipelineSearch())
it('should expose empty search results when no nodes exist', () => {
const { unmount } = renderHook(() => useRagPipelineSearch())
expect(mockRagPipelineNodesAction.searchFn).toBeUndefined()
expect(findRagPipelineNodes('LLM')).toEqual([])
unmount()
})
it('should cleanup search function on unmount', () => {
@ -84,11 +76,11 @@ describe('useRagPipelineSearch', () => {
const { unmount } = renderHook(() => useRagPipelineSearch())
expect(mockRagPipelineNodesAction.searchFn).toBeDefined()
expect(findRagPipelineNodes('Start')).not.toEqual([])
unmount()
expect(mockRagPipelineNodesAction.searchFn).toBeUndefined()
expect(findRagPipelineNodes('Start')).toEqual([])
})
it('should setup node selection listener', () => {
@ -136,8 +128,7 @@ describe('useRagPipelineSearch', () => {
it('should find nodes by title', () => {
renderHook(() => useRagPipelineSearch())
const searchFn = mockRagPipelineNodesAction.searchFn!
const results = searchFn('GPT')
const results = findRagPipelineNodes('GPT')
expect(results.length).toBeGreaterThan(0)
expect(results[0]!.title).toBe('GPT Model')
@ -146,8 +137,7 @@ describe('useRagPipelineSearch', () => {
it('should find nodes by type', () => {
renderHook(() => useRagPipelineSearch())
const searchFn = mockRagPipelineNodesAction.searchFn!
const results = searchFn(BlockEnum.LLM)
const results = findRagPipelineNodes(BlockEnum.LLM)
expect(results.some((r) => r.title === 'GPT Model')).toBe(true)
})
@ -155,8 +145,7 @@ describe('useRagPipelineSearch', () => {
it('should find nodes by description', () => {
renderHook(() => useRagPipelineSearch())
const searchFn = mockRagPipelineNodesAction.searchFn!
const results = searchFn('knowledge')
const results = findRagPipelineNodes('knowledge')
expect(results.some((r) => r.title === 'Knowledge Base')).toBe(true)
})
@ -164,8 +153,7 @@ describe('useRagPipelineSearch', () => {
it('should return all nodes when search term is empty', () => {
renderHook(() => useRagPipelineSearch())
const searchFn = mockRagPipelineNodesAction.searchFn!
const results = searchFn('')
const results = findRagPipelineNodes('')
expect(results.length).toBe(4)
})
@ -173,8 +161,7 @@ describe('useRagPipelineSearch', () => {
it('should sort by alphabetical order when no search term', () => {
renderHook(() => useRagPipelineSearch())
const searchFn = mockRagPipelineNodesAction.searchFn!
const results = searchFn('')
const results = findRagPipelineNodes('')
const titles = results.map((r) => r.title)
const sortedTitles = [...titles].sort((a, b) => a.localeCompare(b))
@ -184,8 +171,7 @@ describe('useRagPipelineSearch', () => {
it('should sort by relevance score when search term provided', () => {
renderHook(() => useRagPipelineSearch())
const searchFn = mockRagPipelineNodesAction.searchFn!
const results = searchFn('Search')
const results = findRagPipelineNodes('Search')
expect(results[0]!.title).toBe('Web Search')
})
@ -193,8 +179,7 @@ describe('useRagPipelineSearch', () => {
it('should return empty array when no nodes match', () => {
renderHook(() => useRagPipelineSearch())
const searchFn = mockRagPipelineNodesAction.searchFn!
const results = searchFn('nonexistent-xyz-12345')
const results = findRagPipelineNodes('nonexistent-xyz-12345')
expect(results).toEqual([])
})
@ -202,8 +187,7 @@ describe('useRagPipelineSearch', () => {
it('should enhance Tool node description from tool_description', () => {
renderHook(() => useRagPipelineSearch())
const searchFn = mockRagPipelineNodesAction.searchFn!
const results = searchFn('web')
const results = findRagPipelineNodes('web')
const toolResult = results.find((r) => r.title === 'Web Search')
expect(toolResult).toBeDefined()
@ -213,18 +197,18 @@ describe('useRagPipelineSearch', () => {
it('should include metadata with nodeId', () => {
renderHook(() => useRagPipelineSearch())
const searchFn = mockRagPipelineNodesAction.searchFn!
const results = searchFn('Start')
const results = findRagPipelineNodes('Start')
const startResult = results.find((r) => r.title === 'Start Node')
expect(startResult?.type).toBe('workflow-node')
if (startResult?.type !== 'workflow-node') throw new Error('Expected a workflow node result')
expect(startResult?.metadata?.nodeId).toBe('node-4')
})
it('should set result type as workflow-node', () => {
renderHook(() => useRagPipelineSearch())
const searchFn = mockRagPipelineNodesAction.searchFn!
const results = searchFn('Start')
const results = findRagPipelineNodes('Start')
expect(results[0]!.type).toBe('workflow-node')
})

View File

@ -5,7 +5,7 @@ import type { LLMNodeType } from '@/app/components/workflow/nodes/llm/types'
import type { ToolNodeType } from '@/app/components/workflow/nodes/tool/types'
import type { CommonNodeType } from '@/app/components/workflow/types'
import { useCallback, useEffect, useMemo } from 'react'
import { ragPipelineNodesAction } from '@/app/components/goto-anything/actions/rag-pipeline-nodes'
import { registerRagPipelineNodeSearch } from '@/app/components/rag-pipeline/goto-anything-search'
import BlockIcon from '@/app/components/workflow/block-icon'
import { useNodesInteractions } from '@/app/components/workflow/hooks/use-nodes-interactions'
import { useGetToolIcon } from '@/app/components/workflow/hooks/use-tool-icon'
@ -107,7 +107,7 @@ export const useRagPipelineSearch = () => {
)
// Create search function for RAG pipeline nodes
const searchRagPipelineNodes = useCallback(
const findRagPipelineNodes = useCallback(
(query: string) => {
if (!searchableNodes.length) return []
@ -154,18 +154,9 @@ export const useRagPipelineSearch = () => {
[searchableNodes, calculateScore],
)
// Directly set the search function on the action object
useEffect(() => {
if (searchableNodes.length > 0) {
// Set the search function directly on the action
ragPipelineNodesAction.searchFn = searchRagPipelineNodes
}
return () => {
// Clean up when component unmounts
ragPipelineNodesAction.searchFn = undefined
}
}, [searchableNodes, searchRagPipelineNodes])
return registerRagPipelineNodeSearch(findRagPipelineNodes)
}, [findRagPipelineNodes])
// Set up node selection event listener using the utility function
useEffect(() => {

View File

@ -1,11 +1,12 @@
'use client'
import { DialogTrigger } from '@langgenius/dify-ui/dialog'
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { formatForDisplay } from '@tanstack/react-hotkeys'
import { useTranslation } from 'react-i18next'
import SidebarLeftArrowIcon from '@/app/components/base/icons/src/vender/SidebarLeftArrowIcon'
import { useSetGotoAnythingOpen } from '@/app/components/goto-anything/atoms'
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
import Link from '@/next/link'
import { useRouter } from '@/next/navigation'
import ToggleButton from '../../app-sidebar/toggle-button'
@ -20,7 +21,6 @@ const SEARCH_SHORTCUT = ['Mod', 'K']
const SnippetDetailTop = ({ expand = true, onToggle }: SnippetDetailTopProps) => {
const { t } = useTranslation()
const router = useRouter()
const setGotoAnythingOpen = useSetGotoAnythingOpen()
if (!expand) {
return (
@ -68,14 +68,18 @@ const SnippetDetailTop = ({ expand = true, onToggle }: SnippetDetailTopProps) =>
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
aria-label={t(($) => $['gotoAnything.searchTitle'], { ns: 'app' })}
className="flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-[10px] text-text-tertiary transition-colors hover:bg-state-base-hover hover:text-text-secondary"
onClick={() => setGotoAnythingOpen(true)}
>
<span aria-hidden className="i-custom-vender-main-nav-quick-search size-4" />
</button>
<DialogTrigger
handle={gotoAnythingDialogHandle}
render={
<button
type="button"
aria-label={t(($) => $['gotoAnything.searchTitle'], { ns: 'app' })}
className="flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-[10px] text-text-tertiary transition-colors hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
>
<span aria-hidden className="i-custom-vender-main-nav-quick-search size-4" />
</button>
}
/>
}
/>
<TooltipContent

View File

@ -0,0 +1,17 @@
import type { SearchResult } from '@/app/components/goto-anything/actions/types'
type SearchWorkflowNodes = (query: string) => SearchResult[]
let searchWorkflowNodes: SearchWorkflowNodes | undefined
export function registerWorkflowNodeSearch(search: SearchWorkflowNodes) {
searchWorkflowNodes = search
return () => {
if (searchWorkflowNodes === search) searchWorkflowNodes = undefined
}
}
export function findWorkflowNodes(query: string) {
return searchWorkflowNodes?.(query) ?? []
}

View File

@ -1,7 +1,7 @@
import type { CommonNodeType, Node, ToolWithProvider } from '../../types'
import { act, renderHook } from '@testing-library/react'
import { workflowNodesAction } from '@/app/components/goto-anything/actions/workflow-nodes'
import { CollectionType } from '@/app/components/tools/types'
import { findWorkflowNodes } from '../../goto-anything-search'
import { BlockEnum } from '../../types'
import { useWorkflowSearch } from '../use-workflow-search'
@ -49,7 +49,6 @@ describe('useWorkflowSearch', () => {
beforeEach(() => {
vi.clearAllMocks()
runtimeNodes.length = 0
workflowNodesAction.searchFn = undefined
})
it('registers workflow node search results with tool icons and llm metadata scoring', async () => {
@ -89,17 +88,17 @@ describe('useWorkflowSearch', () => {
const { unmount } = renderHook(() => useWorkflowSearch())
const llmResults = await workflowNodesAction.search('', 'gpt')
const llmResults = findWorkflowNodes('gpt')
expect(llmResults.map((item) => item.id)).toEqual(['llm-1'])
expect(llmResults[0]?.title).toBe('Writer')
const toolResults = await workflowNodesAction.search('', 'search')
const toolResults = findWorkflowNodes('search')
expect(toolResults.map((item) => item.id)).toEqual(['tool-1'])
expect(toolResults[0]?.description).toBe('Search the web')
unmount()
expect(workflowNodesAction.searchFn).toBeUndefined()
expect(findWorkflowNodes('gpt')).toEqual([])
})
it('binds the node selection listener to handleNodeSelect', () => {

View File

@ -5,9 +5,9 @@ import type { CommonNodeType } from '../types'
import type { Emoji } from '@/app/components/tools/types'
import { useCallback, useEffect, useMemo } from 'react'
import { useNodes } from 'reactflow'
import { workflowNodesAction } from '@/app/components/goto-anything/actions/workflow-nodes'
import { CollectionType } from '@/app/components/tools/types'
import BlockIcon from '@/app/components/workflow/block-icon'
import { registerWorkflowNodeSearch } from '@/app/components/workflow/goto-anything-search'
import {
useAllBuiltInTools,
useAllCustomTools,
@ -135,7 +135,7 @@ export const useWorkflowSearch = () => {
)
// Create search function for workflow nodes
const searchWorkflowNodes = useCallback(
const findWorkflowNodes = useCallback(
(query: string) => {
if (!searchableNodes.length) return []
@ -182,18 +182,9 @@ export const useWorkflowSearch = () => {
[searchableNodes, calculateScore],
)
// Directly set the search function on the action object
useEffect(() => {
if (searchableNodes.length > 0) {
// Set the search function directly on the action
workflowNodesAction.searchFn = searchWorkflowNodes
}
return () => {
// Clean up when component unmounts
workflowNodesAction.searchFn = undefined
}
}, [searchableNodes, searchWorkflowNodes])
return registerWorkflowNodeSearch(findWorkflowNodes)
}, [findWorkflowNodes])
// Set up node selection event listener using the utility function
useEffect(() => {

View File

@ -5,6 +5,7 @@ import type { ComponentProps } from 'react'
import type { AgentDetailSectionKey } from './section'
import type { NavIcon } from '@/app/components/app-sidebar/nav-link'
import { cn } from '@langgenius/dify-ui/cn'
import { DialogTrigger } from '@langgenius/dify-ui/dialog'
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { formatForDisplay } from '@tanstack/react-hotkeys'
@ -15,7 +16,7 @@ import ToggleButton from '@/app/components/app-sidebar/toggle-button'
import AppIcon from '@/app/components/base/app-icon'
import Divider from '@/app/components/base/divider'
import SidebarLeftArrowIcon from '@/app/components/base/icons/src/vender/SidebarLeftArrowIcon'
import { useSetGotoAnythingOpen } from '@/app/components/goto-anything/atoms'
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
import Link from '@/next/link'
import { usePathname } from '@/next/navigation'
import { consoleQuery } from '@/service/client'
@ -86,7 +87,6 @@ const getAgentDetailNavigation = (agentId: string): AgentDetailNavItem[] => [
export function AgentDetailTop({ expand = true, onToggle }: AgentDetailTopProps) {
const { t: tApp } = useTranslation('app')
const { t: tCommon } = useTranslation('common')
const setGotoAnythingOpen = useSetGotoAnythingOpen()
if (!expand) {
return (
@ -125,14 +125,18 @@ export function AgentDetailTop({ expand = true, onToggle }: AgentDetailTopProps)
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
aria-label={tApp(($) => $['gotoAnything.searchTitle'])}
className="flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-[10px] text-text-tertiary transition-colors hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
onClick={() => setGotoAnythingOpen(true)}
>
<span aria-hidden className="i-custom-vender-main-nav-quick-search size-4" />
</button>
<DialogTrigger
handle={gotoAnythingDialogHandle}
render={
<button
type="button"
aria-label={tApp(($) => $['gotoAnything.searchTitle'])}
className="flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-[10px] text-text-tertiary transition-colors hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
>
<span aria-hidden className="i-custom-vender-main-nav-quick-search size-4" />
</button>
}
/>
}
/>
<TooltipContent

View File

@ -4,6 +4,7 @@ import type { ComponentProps, PropsWithoutRef } from 'react'
import type { InstanceDetailTabKey } from './tabs'
import type { NavIcon } from '@/app/components/app-sidebar/nav-link'
import { cn } from '@langgenius/dify-ui/cn'
import { DialogTrigger } from '@langgenius/dify-ui/dialog'
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { formatForDisplay } from '@tanstack/react-hotkeys'
@ -14,7 +15,7 @@ import ToggleButton from '@/app/components/app-sidebar/toggle-button'
import Divider from '@/app/components/base/divider'
import SidebarLeftArrowIcon from '@/app/components/base/icons/src/vender/SidebarLeftArrowIcon'
import { SkeletonContainer, SkeletonRectangle } from '@/app/components/base/skeleton'
import { useSetGotoAnythingOpen } from '@/app/components/goto-anything/atoms'
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
import Link from '@/next/link'
import { usePathname } from '@/next/navigation'
import { DeploymentActionsMenu } from '../deployment-actions'
@ -192,7 +193,6 @@ export function DeploymentDetailTop({
onToggle?: () => void
}) {
const { t } = useTranslation()
const setGotoAnythingOpen = useSetGotoAnythingOpen()
if (!expand) {
return (
@ -231,14 +231,18 @@ export function DeploymentDetailTop({
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
aria-label={t(($) => $['gotoAnything.searchTitle'], { ns: 'app' })}
className="flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-[10px] text-text-tertiary transition-colors hover:bg-state-base-hover hover:text-text-secondary"
onClick={() => setGotoAnythingOpen(true)}
>
<span aria-hidden className="i-custom-vender-main-nav-quick-search size-4" />
</button>
<DialogTrigger
handle={gotoAnythingDialogHandle}
render={
<button
type="button"
aria-label={t(($) => $['gotoAnything.searchTitle'], { ns: 'app' })}
className="flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-[10px] text-text-tertiary transition-colors hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
>
<span aria-hidden className="i-custom-vender-main-nav-quick-search size-4" />
</button>
}
/>
}
/>
<TooltipContent

View File

@ -73,7 +73,6 @@
"abcjs": "catalog:",
"ahooks": "catalog:",
"class-variance-authority": "catalog:",
"cmdk": "catalog:",
"copy-to-clipboard": "catalog:",
"cron-parser": "catalog:",
"dayjs": "catalog:",

View File

@ -1,7 +1,6 @@
import type { TracingProvider } from '@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/type'
import type {
AppDetailResponse,
AppListResponse,
CreateApiKeyResponse,
DSLImportMode,
DSLImportResponse,
@ -15,16 +14,6 @@ import type { CommonResponse } from '@/models/common'
import type { AppIconType, AppModeEnum, ModelConfig } from '@/types/app'
import { del, get, patch, post, put } from './base'
export const fetchAppList = ({
url,
params,
}: {
url: string
params?: Record<string, any>
}): Promise<AppListResponse> => {
return get<AppListResponse>(url, { params })
}
export const fetchAppDetail = ({
url,
id,