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 "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": { "web/__tests__/i18n-upload-features.test.ts": {
"no-console": { "no-console": {
"count": 3 "count": 3
@ -3220,62 +3204,11 @@
"count": 2 "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": { "web/app/components/goto-anything/actions/recent-store.ts": {
"no-restricted-globals": { "no-restricted-globals": {
"count": 2 "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": { "web/app/components/header/account-setting/data-source-page-new/__tests__/item.spec.tsx": {
"jsx_a11y/click-events-have-key-events": { "jsx_a11y/click-events-have-key-events": {
"count": 1 "count": 1
@ -6846,7 +6779,7 @@
"count": 1 "count": 1
}, },
"typescript/no-explicit-any": { "typescript/no-explicit-any": {
"count": 7 "count": 6
} }
}, },
"web/service/base.ts": { "web/service/base.ts": {

View File

@ -1,5 +1,13 @@
import { render } from 'vitest-browser-react' 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 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 Title')
await expect.element(screen.getByRole('dialog')).toHaveTextContent('Dialog Description') 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', () => { describe('Props', () => {

View File

@ -9,6 +9,7 @@ export const DialogTrigger = BaseDialog.Trigger
export const DialogTitle = BaseDialog.Title export const DialogTitle = BaseDialog.Title
export const DialogDescription = BaseDialog.Description export const DialogDescription = BaseDialog.Description
export const DialogPortal = BaseDialog.Portal export const DialogPortal = BaseDialog.Portal
export const createDialogHandle = BaseDialog.createHandle
type DialogBackdropProps = Omit<BaseDialog.Backdrop.Props, 'className'> & { type DialogBackdropProps = Omit<BaseDialog.Backdrop.Props, 'className'> & {
className?: string 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 class-variance-authority: 0.7.1
cli-table3: 0.6.5 cli-table3: 0.6.5
clsx: 2.1.1 clsx: 2.1.1
cmdk: 1.1.1
code-inspector-plugin: 1.6.6 code-inspector-plugin: 1.6.6
concurrently: ^10.0.3 concurrently: ^10.0.3
copy-to-clipboard: 4.0.2 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 type { ReactNode } from 'react'
import { Dialog, DialogPopup, DialogPortal, DialogTitle } from '@langgenius/dify-ui/dialog'
import { fireEvent, render, screen } from '@testing-library/react' import { fireEvent, render, screen } from '@testing-library/react'
import { createStore, Provider as JotaiProvider } from 'jotai' import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
import { useGotoAnythingOpen } from '@/app/components/goto-anything/atoms'
import AppDetailTop from '../app-detail-top' import AppDetailTop from '../app-detail-top'
vi.mock('../toggle-button', () => ({ vi.mock('../toggle-button', () => ({
@ -26,54 +26,55 @@ vi.mock('../toggle-button', () => ({
), ),
})) }))
function GotoAnythingOpenProbe() { function TestGotoAnythingDialog() {
const open = useGotoAnythingOpen() return (
<Dialog handle={gotoAnythingDialogHandle}>
return <div data-testid="goto-anything-open">{String(open)}</div> <DialogPortal>
} <DialogPopup>
<DialogTitle>Goto Anything</DialogTitle>
const renderWithGotoAnythingStore = (ui: ReactNode) => { </DialogPopup>
const store = createStore() </DialogPortal>
</Dialog>
return render(<JotaiProvider store={store}>{ui}</JotaiProvider>) )
} }
describe('AppDetailTop', () => { describe('AppDetailTop', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
gotoAnythingDialogHandle.close()
}) })
it('links the combined home control to home', () => { it('links the combined home control to home', () => {
renderWithGotoAnythingStore(<AppDetailTop />) render(<AppDetailTop />)
expect(screen.getByRole('link', { name: 'common.mainNav.home' })).toHaveAttribute('href', '/') expect(screen.getByRole('link', { name: 'common.mainNav.home' })).toHaveAttribute('href', '/')
expect(screen.queryByRole('button', { name: 'common.operation.back' })).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: 'common.operation.back' })).not.toBeInTheDocument()
}) })
it('links the Studio breadcrumb to the Studio page', () => { it('links the Studio breadcrumb to the Studio page', () => {
renderWithGotoAnythingStore(<AppDetailTop />) render(<AppDetailTop />)
expect(screen.getByRole('link', { name: 'common.menus.apps' })).toHaveAttribute('href', '/apps') expect(screen.getByRole('link', { name: 'common.menus.apps' })).toHaveAttribute('href', '/apps')
}) })
it('keeps the quick search action', () => { it('keeps the quick search action', () => {
renderWithGotoAnythingStore( render(
<> <>
<AppDetailTop /> <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' })) 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', () => { it('renders the sidebar toggle action in the top right', () => {
const onToggle = vi.fn() const onToggle = vi.fn()
renderWithGotoAnythingStore(<AppDetailTop onToggle={onToggle} />) render(<AppDetailTop onToggle={onToggle} />)
fireEvent.click(screen.getByTestId('toggle-button')) fireEvent.click(screen.getByTestId('toggle-button'))
expect(screen.getByTestId('toggle-button')).toHaveAttribute('data-expand', 'true') expect(screen.getByTestId('toggle-button')).toHaveAttribute('data-expand', 'true')

View File

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

View File

@ -1,11 +1,12 @@
'use client' 'use client'
import { DialogTrigger } from '@langgenius/dify-ui/dialog'
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd' import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { formatForDisplay } from '@tanstack/react-hotkeys' import { formatForDisplay } from '@tanstack/react-hotkeys'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import SidebarLeftArrowIcon from '@/app/components/base/icons/src/vender/SidebarLeftArrowIcon' 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 Link from '@/next/link'
import ToggleButton from './toggle-button' import ToggleButton from './toggle-button'
@ -18,7 +19,6 @@ const SEARCH_SHORTCUT = ['Mod', 'K']
const AppDetailTop = ({ expand = true, onToggle }: AppDetailTopProps) => { const AppDetailTop = ({ expand = true, onToggle }: AppDetailTopProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const setGotoAnythingOpen = useSetGotoAnythingOpen()
if (!expand) { if (!expand) {
return ( return (
@ -62,14 +62,18 @@ const AppDetailTop = ({ expand = true, onToggle }: AppDetailTopProps) => {
<Tooltip> <Tooltip>
<TooltipTrigger <TooltipTrigger
render={ render={
<button <DialogTrigger
type="button" handle={gotoAnythingDialogHandle}
aria-label={t(($) => $['gotoAnything.searchTitle'], { ns: 'app' })} render={
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" <button
onClick={() => setGotoAnythingOpen(true)} type="button"
> aria-label={t(($) => $['gotoAnything.searchTitle'], { ns: 'app' })}
<span aria-hidden className="i-custom-vender-main-nav-quick-search size-4" /> 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"
</button> >
<span aria-hidden className="i-custom-vender-main-nav-quick-search size-4" />
</button>
}
/>
} }
/> />
<TooltipContent <TooltipContent

View File

@ -1,11 +1,12 @@
'use client' 'use client'
import { DialogTrigger } from '@langgenius/dify-ui/dialog'
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd' import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { formatForDisplay } from '@tanstack/react-hotkeys' import { formatForDisplay } from '@tanstack/react-hotkeys'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import SidebarLeftArrowIcon from '@/app/components/base/icons/src/vender/SidebarLeftArrowIcon' 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 Link from '@/next/link'
import ToggleButton from './toggle-button' import ToggleButton from './toggle-button'
@ -18,7 +19,6 @@ const SEARCH_SHORTCUT = ['Mod', 'K']
const DatasetDetailTop = ({ expand = true, onToggle }: DatasetDetailTopProps) => { const DatasetDetailTop = ({ expand = true, onToggle }: DatasetDetailTopProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const setGotoAnythingOpen = useSetGotoAnythingOpen()
if (!expand) { if (!expand) {
return ( return (
@ -62,14 +62,18 @@ const DatasetDetailTop = ({ expand = true, onToggle }: DatasetDetailTopProps) =>
<Tooltip> <Tooltip>
<TooltipTrigger <TooltipTrigger
render={ render={
<button <DialogTrigger
type="button" handle={gotoAnythingDialogHandle}
aria-label={t(($) => $['gotoAnything.searchTitle'], { ns: 'app' })} render={
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" <button
onClick={() => setGotoAnythingOpen(true)} type="button"
> aria-label={t(($) => $['gotoAnything.searchTitle'], { ns: 'app' })}
<span aria-hidden className="i-custom-vender-main-nav-quick-search size-4" /> 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"
</button> >
<span aria-hidden className="i-custom-vender-main-nav-quick-search size-4" />
</button>
}
/>
} }
/> />
<TooltipContent <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 { ReactNode } from 'react'
import type { ActionItem, SearchResult } from '../actions/types' 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 userEvent from '@testing-library/user-event'
import { createStore, Provider } from 'jotai'
import * as React from 'react' import * as React from 'react'
import { gotoAnythingDialogHandle } from '../dialog-handle'
import { GotoAnything } from '../index' import { GotoAnything } from '../index'
type TestSearchResult = Omit<SearchResult, 'icon' | 'data'> & { type TestSearchResult = Omit<SearchResult, 'icon' | 'data'> & {
@ -19,57 +21,66 @@ vi.mock('@/next/navigation', () => ({
usePathname: () => '/', usePathname: () => '/',
})) }))
type KeyPressEvent = { let debouncedSearchQuery: string | undefined
preventDefault: () => void
target?: EventTarget
}
type HotkeyRegistration = {
handler: (event: KeyPressEvent) => void
options?: { enabled?: boolean }
}
const hotkeyHandlers: Record<string, HotkeyRegistration> = {}
vi.mock('ahooks', () => ({ vi.mock('ahooks', () => ({
useDebounce: <T,>(value: T) => value, useDebounce: <T,>(value: T) => (debouncedSearchQuery ?? value) as T,
})) }))
vi.mock('@tanstack/react-hotkeys', async (importOriginal) => { const isMac = detectPlatform() === 'mac'
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 HOTKEY_ALIAS: Record<string, string> = { function triggerSearchShortcut(target: Document | HTMLElement = document) {
'ctrl.k': 'Mod+K', fireEvent.keyDown(target, {
key: 'k',
ctrlKey: !isMac,
metaKey: isMac,
})
} }
const triggerKeyPress = (combo: string) => { type RemoteQueryState = {
const hotkey = HOTKEY_ALIAS[combo] ?? combo data: TestSearchResult[]
const registration = hotkeyHandlers[hotkey] isLoading: boolean
if (registration && registration.options?.enabled !== false) { isError: boolean
act(() => { error: Error | null
registration.handler({ preventDefault: vi.fn(), target: document.body })
})
}
} }
let mockQueryResult = { const emptyRemoteQueryState = (): RemoteQueryState => ({
data: [] as TestSearchResult[], data: [],
isLoading: false, isLoading: false,
isError: 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', () => ({ 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( vi.mock(
'@/app/components/plugins/install-plugin/hooks/use-workspace-plugin-install-permission', '@/app/components/plugins/install-plugin/hooks/use-workspace-plugin-install-permission',
@ -81,35 +92,38 @@ vi.mock(
}), }),
) )
const contextValue = { isWorkflowPage: false, isRagPipelinePage: false } const createRemoteAction = (key: ActionItem['key'], shortcut: string): ActionItem => ({
vi.mock('../context', () => ({
useGotoAnythingContext: () => contextValue,
GotoAnythingProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}))
const createActionItem = (key: ActionItem['key'], shortcut: string): ActionItem => ({
key, key,
shortcut, shortcut,
title: `${key} title`, title: `${key} title`,
description: `${key} desc`, description: `${key} desc`,
action: vi.fn(), source: 'remote',
search: vi.fn(),
}) })
const actionsMock = { const actionsMock = {
slash: createActionItem('/', '/'), slash: {
app: createActionItem('@app', '@app'), key: '/',
plugin: createActionItem('@plugin', '@plugin'), 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 createActionsMock = vi.fn(() => actionsMock)
const matchActionMock = vi.fn(() => undefined) const matchActionMock = vi.fn<
const searchAnythingMock = vi.fn(async () => mockQueryResult.data) (query: string, actions: Record<string, ActionItem>) => ActionItem | undefined
>(() => undefined)
vi.mock('../actions', () => ({ vi.mock('../actions', () => ({
createActions: () => createActionsMock(), createActions: () => createActionsMock(),
matchAction: () => matchActionMock(), getActionSearchTerm: (_query: string, action: ActionItem) => action.key,
searchAnything: () => searchAnythingMock(), matchAction: (query: string, actions: Record<string, ActionItem>) =>
matchActionMock(query, actions),
})) }))
vi.mock('../actions/commands/slash-provider', () => ({ vi.mock('../actions/commands/slash-provider', () => ({
@ -123,10 +137,11 @@ type MockSlashCommand = {
} | null } | null
let mockFindCommand: MockSlashCommand = null let mockFindCommand: MockSlashCommand = null
let mockAvailableCommands: Array<{ name: string; description: string }> = []
vi.mock('../actions/commands/registry', () => ({ vi.mock('../actions/commands/registry', () => ({
slashCommandRegistry: { slashCommandRegistry: {
findCommand: () => mockFindCommand, findCommand: () => mockFindCommand,
getAvailableCommands: () => [], getAvailableCommands: () => mockAvailableCommands,
getAllCommands: () => [], getAllCommands: () => [],
}, },
})) }))
@ -153,40 +168,102 @@ vi.mock('../../plugins/install-plugin/install-from-marketplace', () => ({
), ),
})) }))
const renderGotoAnything = (ui: React.ReactElement) => { const renderGotoAnything = (ui: React.ReactElement) => render(ui)
const store = createStore()
return render(<Provider store={store}>{ui}</Provider>)
}
describe('GotoAnything', () => { describe('GotoAnything', () => {
beforeEach(() => { beforeEach(() => {
routerPush.mockClear() routerPush.mockClear()
Object.keys(hotkeyHandlers).forEach((key) => delete hotkeyHandlers[key]) gotoAnythingDialogHandle.close()
mockQueryResult = { data: [], isLoading: false, isError: false, error: null } remoteQueryStates = {
app: emptyRemoteQueryState(),
knowledge: emptyRemoteQueryState(),
plugin: emptyRemoteQueryState(),
}
debouncedSearchQuery = undefined
enabledRemoteQueryKeys = []
matchActionMock.mockReset() matchActionMock.mockReset()
searchAnythingMock.mockClear()
mockFindCommand = null mockFindCommand = null
mockAvailableCommands = []
}) })
describe('modal behavior', () => { describe('modal behavior', () => {
it('should open modal via Ctrl+K shortcut', async () => { it('should open modal via Ctrl+K shortcut', async () => {
renderGotoAnything(<GotoAnything />) renderGotoAnything(<GotoAnything />)
triggerKeyPress('ctrl.k') triggerSearchShortcut()
await waitFor(() => { await waitFor(() => {
expect( expect(
screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder'), screen.getByRole('dialog', { name: 'app.gotoAnything.searchTitle' }),
).toBeInTheDocument() ).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 () => { it('should close modal via ESC key', async () => {
const user = userEvent.setup() const user = userEvent.setup()
renderGotoAnything(<GotoAnything />) renderGotoAnything(<GotoAnything />)
triggerKeyPress('ctrl.k') triggerSearchShortcut()
await waitFor(() => { await waitFor(() => {
expect( expect(
screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder'), 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 />) renderGotoAnything(<GotoAnything />)
triggerKeyPress('ctrl.k') triggerSearchShortcut()
await waitFor(() => { const input = await screen.findByPlaceholderText('app.gotoAnything.searchPlaceholder')
expect( await user.type(input, 'workflow')
screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder'),
).toBeInTheDocument()
})
triggerKeyPress('ctrl.k') triggerSearchShortcut()
await waitFor(() => {
expect(
screen.queryByPlaceholderText('app.gotoAnything.searchPlaceholder'),
).not.toBeInTheDocument()
})
})
it('should call onHide when modal closes', async () => { expect(input).toHaveValue('workflow')
const user = userEvent.setup() expect(input).toHaveFocus()
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()
})
}) })
it('should reset search query when modal opens', async () => { it('should reset search query when modal opens', async () => {
const user = userEvent.setup() const user = userEvent.setup()
renderGotoAnything(<GotoAnything />) renderGotoAnything(<GotoAnything />)
triggerKeyPress('ctrl.k') triggerSearchShortcut()
await waitFor(() => { await waitFor(() => {
expect( expect(
screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder'), screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder'),
@ -258,7 +313,7 @@ describe('GotoAnything', () => {
).not.toBeInTheDocument() ).not.toBeInTheDocument()
}) })
triggerKeyPress('ctrl.k') triggerSearchShortcut()
await waitFor(() => { await waitFor(() => {
const newInput = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder') const newInput = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')
expect(newInput).toHaveValue('') expect(newInput).toHaveValue('')
@ -269,25 +324,20 @@ describe('GotoAnything', () => {
describe('search functionality', () => { describe('search functionality', () => {
it('should navigate to selected result', async () => { it('should navigate to selected result', async () => {
const user = userEvent.setup() const user = userEvent.setup()
mockQueryResult = { setRemoteResults([
data: [ {
{ id: 'app-1',
id: 'app-1', type: 'app',
type: 'app', title: 'Sample App',
title: 'Sample App', description: 'desc',
description: 'desc', path: '/apps/1',
path: '/apps/1', icon: <div data-testid="icon">🧩</div>,
icon: <div data-testid="icon">🧩</div>, data: {},
data: {}, },
}, ])
],
isLoading: false,
isError: false,
error: null,
}
renderGotoAnything(<GotoAnything />) renderGotoAnything(<GotoAnything />)
triggerKeyPress('ctrl.k') triggerSearchShortcut()
await waitFor(() => { await waitFor(() => {
expect( expect(
@ -302,12 +352,84 @@ describe('GotoAnything', () => {
await user.click(result) await user.click(result)
expect(routerPush).toHaveBeenCalledWith('/apps/1') 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 () => { it('should clear selection when typing without prefix', async () => {
const user = userEvent.setup() const user = userEvent.setup()
renderGotoAnything(<GotoAnything />) renderGotoAnything(<GotoAnything />)
triggerKeyPress('ctrl.k') triggerSearchShortcut()
await waitFor(() => { await waitFor(() => {
expect( expect(
@ -320,20 +442,36 @@ describe('GotoAnything', () => {
expect(input).toHaveValue('test query') 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', () => { describe('empty states', () => {
it('should show loading state', async () => { it('should show loading state', async () => {
const user = userEvent.setup() const user = userEvent.setup()
mockQueryResult = { remoteQueryStates.app.isLoading = true
data: [],
isLoading: true,
isError: false,
error: null,
}
renderGotoAnything(<GotoAnything />) renderGotoAnything(<GotoAnything />)
triggerKeyPress('ctrl.k') triggerSearchShortcut()
await waitFor(() => { await waitFor(() => {
expect( expect(
@ -346,20 +484,21 @@ describe('GotoAnything', () => {
const searchingTexts = screen.getAllByText('app.gotoAnything.searching') const searchingTexts = screen.getAllByText('app.gotoAnything.searching')
expect(searchingTexts.length).toBeGreaterThanOrEqual(1) 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 () => { it('should show error state', async () => {
const user = userEvent.setup() const user = userEvent.setup()
const testError = new Error('Search failed') const testError = new Error('Search failed')
mockQueryResult = { remoteQueryStates = {
data: [], app: { data: [], isLoading: false, isError: true, error: testError },
isLoading: false, knowledge: { data: [], isLoading: false, isError: true, error: testError },
isError: true, plugin: { data: [], isLoading: false, isError: true, error: testError },
error: testError,
} }
renderGotoAnything(<GotoAnything />) renderGotoAnything(<GotoAnything />)
triggerKeyPress('ctrl.k') triggerSearchShortcut()
await waitFor(() => { await waitFor(() => {
expect( expect(
@ -370,12 +509,47 @@ describe('GotoAnything', () => {
const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder') const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')
await user.type(input, 'search') 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 () => { it('should show default state when no query', async () => {
renderGotoAnything(<GotoAnything />) renderGotoAnything(<GotoAnything />)
triggerKeyPress('ctrl.k') triggerSearchShortcut()
await waitFor(() => { await waitFor(() => {
expect( expect(
@ -383,20 +557,13 @@ describe('GotoAnything', () => {
).toBeInTheDocument() ).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 () => { it('should show no results state when search returns empty', async () => {
const user = userEvent.setup() const user = userEvent.setup()
mockQueryResult = {
data: [],
isLoading: false,
isError: false,
error: null,
}
renderGotoAnything(<GotoAnything />) renderGotoAnything(<GotoAnything />)
triggerKeyPress('ctrl.k') triggerSearchShortcut()
await waitFor(() => { await waitFor(() => {
expect( expect(
@ -407,35 +574,30 @@ describe('GotoAnything', () => {
const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder') const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')
await user.type(input, 'nonexistent') await user.type(input, 'nonexistent')
expect(screen.getByText('app.gotoAnything.noResults')).toBeInTheDocument() expect(await screen.findByText('app.gotoAnything.noResults')).toBeInTheDocument()
}) })
}) })
describe('plugin installation', () => { describe('plugin installation', () => {
it('should open plugin installer when selecting plugin result', async () => { it('should open plugin installer when selecting plugin result', async () => {
const user = userEvent.setup() const user = userEvent.setup()
mockQueryResult = { setRemoteResults([
data: [ {
{ id: 'plugin-1',
id: 'plugin-1', type: 'plugin',
type: 'plugin', title: 'Plugin Item',
title: 'Plugin Item', description: 'desc',
description: 'desc', path: '',
path: '', icon: <div />,
icon: <div />, data: {
data: { name: 'Plugin Item',
name: 'Plugin Item', latest_package_identifier: 'pkg',
latest_package_identifier: 'pkg',
},
}, },
], },
isLoading: false, ])
isError: false,
error: null,
}
renderGotoAnything(<GotoAnything />) renderGotoAnything(<GotoAnything />)
triggerKeyPress('ctrl.k') triggerSearchShortcut()
await waitFor(() => { await waitFor(() => {
expect( expect(
@ -454,28 +616,23 @@ describe('GotoAnything', () => {
it('should close plugin installer via close button', async () => { it('should close plugin installer via close button', async () => {
const user = userEvent.setup() const user = userEvent.setup()
mockQueryResult = { setRemoteResults([
data: [ {
{ id: 'plugin-1',
id: 'plugin-1', type: 'plugin',
type: 'plugin', title: 'Plugin Item',
title: 'Plugin Item', description: 'desc',
description: 'desc', path: '',
path: '', icon: <div />,
icon: <div />, data: {
data: { name: 'Plugin Item',
name: 'Plugin Item', latest_package_identifier: 'pkg',
latest_package_identifier: 'pkg',
},
}, },
], },
isLoading: false, ])
isError: false,
error: null,
}
renderGotoAnything(<GotoAnything />) renderGotoAnything(<GotoAnything />)
triggerKeyPress('ctrl.k') triggerSearchShortcut()
await waitFor(() => { await waitFor(() => {
expect( expect(
@ -499,28 +656,23 @@ describe('GotoAnything', () => {
it('should close plugin installer on success', async () => { it('should close plugin installer on success', async () => {
const user = userEvent.setup() const user = userEvent.setup()
mockQueryResult = { setRemoteResults([
data: [ {
{ id: 'plugin-1',
id: 'plugin-1', type: 'plugin',
type: 'plugin', title: 'Plugin Item',
title: 'Plugin Item', description: 'desc',
description: 'desc', path: '',
path: '', icon: <div />,
icon: <div />, data: {
data: { name: 'Plugin Item',
name: 'Plugin Item', latest_package_identifier: 'pkg',
latest_package_identifier: 'pkg',
},
}, },
], },
isLoading: false, ])
isError: false,
error: null,
}
renderGotoAnything(<GotoAnything />) renderGotoAnything(<GotoAnything />)
triggerKeyPress('ctrl.k') triggerSearchShortcut()
await waitFor(() => { await waitFor(() => {
expect( expect(
@ -552,9 +704,10 @@ describe('GotoAnything', () => {
execute: executeMock, execute: executeMock,
isAvailable: () => true, isAvailable: () => true,
} }
mockAvailableCommands = [{ name: 'theme', description: 'Change theme' }]
renderGotoAnything(<GotoAnything />) renderGotoAnything(<GotoAnything />)
triggerKeyPress('ctrl.k') triggerSearchShortcut()
await waitFor(() => { await waitFor(() => {
expect( expect(
@ -566,7 +719,7 @@ describe('GotoAnything', () => {
await user.type(input, '/theme') await user.type(input, '/theme')
await user.keyboard('{Enter}') await user.keyboard('{Enter}')
expect(executeMock).toHaveBeenCalled() expect(executeMock).toHaveBeenCalledTimes(1)
}) })
it('should NOT execute unavailable slash command', async () => { it('should NOT execute unavailable slash command', async () => {
@ -579,7 +732,7 @@ describe('GotoAnything', () => {
} }
renderGotoAnything(<GotoAnything />) renderGotoAnything(<GotoAnything />)
triggerKeyPress('ctrl.k') triggerSearchShortcut()
await waitFor(() => { await waitFor(() => {
expect( expect(
@ -601,9 +754,10 @@ describe('GotoAnything', () => {
mode: 'submenu', mode: 'submenu',
execute: executeMock, execute: executeMock,
} }
mockAvailableCommands = [{ name: 'language', description: 'Change language' }]
renderGotoAnything(<GotoAnything />) renderGotoAnything(<GotoAnything />)
triggerKeyPress('ctrl.k') triggerSearchShortcut()
await waitFor(() => { await waitFor(() => {
expect( expect(
@ -625,9 +779,10 @@ describe('GotoAnything', () => {
execute: vi.fn(), execute: vi.fn(),
isAvailable: () => true, isAvailable: () => true,
} }
mockAvailableCommands = [{ name: 'theme', description: 'Change theme' }]
renderGotoAnything(<GotoAnything />) renderGotoAnything(<GotoAnything />)
triggerKeyPress('ctrl.k') triggerSearchShortcut()
await waitFor(() => { await waitFor(() => {
expect( expect(
@ -650,25 +805,20 @@ describe('GotoAnything', () => {
describe('result navigation', () => { describe('result navigation', () => {
it('should handle knowledge result navigation', async () => { it('should handle knowledge result navigation', async () => {
const user = userEvent.setup() const user = userEvent.setup()
mockQueryResult = { setRemoteResults([
data: [ {
{ id: 'kb-1',
id: 'kb-1', type: 'knowledge',
type: 'knowledge', title: 'Knowledge Base',
title: 'Knowledge Base', description: 'desc',
description: 'desc', path: '/datasets/kb-1',
path: '/datasets/kb-1', icon: <div />,
icon: <div />, data: {},
data: {}, },
}, ])
],
isLoading: false,
isError: false,
error: null,
}
renderGotoAnything(<GotoAnything />) renderGotoAnything(<GotoAnything />)
triggerKeyPress('ctrl.k') triggerSearchShortcut()
await waitFor(() => { await waitFor(() => {
expect( expect(
@ -687,25 +837,20 @@ describe('GotoAnything', () => {
it('should NOT navigate when result has no path', async () => { it('should NOT navigate when result has no path', async () => {
const user = userEvent.setup() const user = userEvent.setup()
mockQueryResult = { setRemoteResults([
data: [ {
{ id: 'item-1',
id: 'item-1', type: 'app',
type: 'app', title: 'No Path Item',
title: 'No Path Item', description: 'desc',
description: 'desc', path: '',
path: '', icon: <div />,
icon: <div />, data: {},
data: {}, },
}, ])
],
isLoading: false,
isError: false,
error: null,
}
renderGotoAnything(<GotoAnything />) renderGotoAnything(<GotoAnything />)
triggerKeyPress('ctrl.k') triggerSearchShortcut()
await waitFor(() => { await waitFor(() => {
expect( expect(

View File

@ -1,159 +1,80 @@
import type { App } from '@/types/app' import { appAction, appSearchQueryOptions } from '../app'
import { appAction } from '../app'
vi.mock('@/service/apps', () => ({ const serviceMocks = vi.hoisted(() => ({ queryOptions: vi.fn((options) => options) }))
fetchAppList: vi.fn(),
vi.mock('@/service/client', () => ({
consoleQuery: { apps: { get: { queryOptions: serviceMocks.queryOptions } } },
})) }))
vi.mock('@/utils/app-redirection', () => ({ vi.mock('@/utils/app-redirection', () => ({
getRedirectionPath: vi.fn((app: { id: string }) => `/app/${app.id}`), getRedirectionPath: vi.fn((app: { id: string }) => `/app/${app.id}`),
})) }))
vi.mock('../../../app/type-selector', () => ({ vi.mock('../../../app/type-selector', () => ({ AppTypeIcon: () => null }))
AppTypeIcon: () => null,
}))
describe('appAction', () => { type AppQueryResponse = Parameters<
beforeEach(() => { NonNullable<ReturnType<typeof appSearchQueryOptions>['select']>
vi.clearAllMocks() >[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', () => { it('builds generated query options from the search term', () => {
expect(appAction.key).toBe('@app') appSearchQueryOptions('assistant', false)
expect(appAction.shortcut).toBe('@app')
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 () => { it('selects plain app results for general search', () => {
const { fetchAppList } = await import('@/service/apps') const options = appSearchQueryOptions('my app', false)
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,
})
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({ it('selects app sections only for scoped search', () => {
url: 'apps', const chatOptions = appSearchQueryOptions('', true)
params: { page: 1, name: 'test' }, const workflowOptions = appSearchQueryOptions('', true)
})
expect(results).toHaveLength(5) expect(chatOptions.select!(response([app()])).map((result) => result.id)).toEqual([
expect(results[0]).toMatchObject({ 'app-1',
id: 'app-1',
title: 'My App',
type: 'app',
})
expect(results.slice(1).map((r) => r.id)).toEqual([
'app-1:configuration', 'app-1:configuration',
'app-1:overview', 'app-1:overview',
'app-1:logs', 'app-1:logs',
'app-1:develop', 'app-1:develop',
]) ])
}) expect(
workflowOptions.select!(response([app({ id: 'wf-1', mode: 'workflow' })])).map(
it('returns workflow sub-sections for workflow-mode apps', async () => { (result) => result.id,
const { fetchAppList } = await import('@/service/apps') ),
vi.mocked(fetchAppList).mockResolvedValue({ ).toEqual(['wf-1', 'wf-1:workflow', 'wf-1:overview', 'wf-1:logs'])
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()
}) })
}) })

View File

@ -1,8 +1,6 @@
import type { ActionItem, SearchResult } from '../types' import type { ActionItem } from '../types'
import type { DataSet } from '@/models/datasets'
import type { App } from '@/types/app'
import { slashCommandRegistry } from '../commands/registry' import { slashCommandRegistry } from '../commands/registry'
import { createActions, matchAction, searchAnything } from '../index' import { createActions, getActionSearchTerm, matchAction } from '../index'
vi.mock('../app', () => ({ vi.mock('../app', () => ({
appAction: { appAction: {
@ -10,7 +8,7 @@ vi.mock('../app', () => ({
shortcut: '@app', shortcut: '@app',
title: 'Apps', title: 'Apps',
description: 'Search apps', description: 'Search apps',
search: vi.fn().mockResolvedValue([]), source: 'remote',
} satisfies ActionItem, } satisfies ActionItem,
})) }))
@ -20,7 +18,7 @@ vi.mock('../knowledge', () => ({
shortcut: '@kb', shortcut: '@kb',
title: 'Knowledge', title: 'Knowledge',
description: 'Search knowledge', description: 'Search knowledge',
search: vi.fn().mockResolvedValue([]), source: 'remote',
} satisfies ActionItem, } satisfies ActionItem,
})) }))
@ -30,7 +28,7 @@ vi.mock('../plugin', () => ({
shortcut: '@plugin', shortcut: '@plugin',
title: 'Plugins', title: 'Plugins',
description: 'Search plugins', description: 'Search plugins',
search: vi.fn().mockResolvedValue([]), source: 'remote',
} satisfies ActionItem, } satisfies ActionItem,
})) }))
@ -40,7 +38,8 @@ vi.mock('../commands/slash', () => ({
shortcut: '/', shortcut: '/',
title: 'Commands', title: 'Commands',
description: 'Slash commands', description: 'Slash commands',
search: vi.fn().mockResolvedValue([]), source: 'local',
search: vi.fn(() => []),
} satisfies ActionItem, } satisfies ActionItem,
})) }))
@ -50,7 +49,8 @@ vi.mock('../workflow-nodes', () => ({
shortcut: '@node', shortcut: '@node',
title: 'Workflow Nodes', title: 'Workflow Nodes',
description: 'Search workflow nodes', description: 'Search workflow nodes',
search: vi.fn().mockResolvedValue([]), source: 'local',
search: vi.fn(() => []),
} satisfies ActionItem, } satisfies ActionItem,
})) }))
@ -60,266 +60,66 @@ vi.mock('../rag-pipeline-nodes', () => ({
shortcut: '@node', shortcut: '@node',
title: 'RAG Pipeline Nodes', title: 'RAG Pipeline Nodes',
description: 'Search RAG nodes', description: 'Search RAG nodes',
search: vi.fn().mockResolvedValue([]), source: 'local',
search: vi.fn(() => []),
} satisfies ActionItem, } satisfies ActionItem,
})) }))
vi.mock('../commands/registry') vi.mock('../commands/registry')
describe('createActions', () => { describe('createActions', () => {
it('returns base actions when neither workflow nor rag-pipeline page', () => { it('returns only global actions outside graph pages', () => {
const actions = createActions(false, false) expect(createActions(false, false)).toEqual(
expect.objectContaining({ slash: expect.any(Object), app: expect.any(Object) }),
expect(actions).toHaveProperty('slash') )
expect(actions).toHaveProperty('app') expect(createActions(false, false)).not.toHaveProperty('node')
expect(actions).toHaveProperty('knowledge')
expect(actions).toHaveProperty('plugin')
expect(actions).not.toHaveProperty('node')
}) })
it('includes workflow nodes action on workflow pages', () => { it('uses the workflow-owned node action on workflow pages', () => {
const actions = createActions(true, false) as Record<string, ActionItem> expect((createActions(true, false) as Record<string, ActionItem>).node!.title).toBe(
'Workflow Nodes',
expect(actions).toHaveProperty('node') )
expect(actions.node!.title).toBe('Workflow Nodes')
}) })
it('includes rag-pipeline nodes action on rag-pipeline pages', () => { it('uses the RAG-owned node action when both graph flags are true', () => {
const actions = createActions(false, true) as Record<string, ActionItem> expect((createActions(true, true) as Record<string, ActionItem>).node!.title).toBe(
'RAG Pipeline Nodes',
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')
}) })
}) })
describe('searchAnything', () => { describe('getActionSearchTerm', () => {
beforeEach(() => { it('removes either the action key or shortcut', () => {
vi.clearAllMocks() const action = createActions(false, false).knowledge
})
it('delegates to specific action when actionItem is provided', async () => { expect(getActionSearchTerm('@knowledge vector store', action)).toBe('vector store')
const mockResults: SearchResult[] = [ expect(getActionSearchTerm('@kb vector store', action)).toBe('vector store')
{ 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()
}) })
}) })
describe('matchAction', () => { describe('matchAction', () => {
const actions: Record<string, ActionItem> = { const actions = createActions(false, false)
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() },
}
beforeEach(() => { 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([]) vi.mocked(slashCommandRegistry.getAllCommands).mockReturnValue([])
const result = matchAction('random query', actions)
expect(result).toBeUndefined()
}) })
describe('slash command matching', () => { it.each([
it('matches submenu command with full name', () => { ['@app query', '@app'],
vi.mocked(slashCommandRegistry.getAllCommands).mockReturnValue([ ['@kb query', '@knowledge'],
{ name: 'theme', mode: 'submenu', description: '', search: vi.fn() }, ['@plugin query', '@plugin'],
]) ])('matches %s', (query, key) => {
expect(matchAction(query, actions)?.key).toBe(key)
})
const result = matchAction('/theme', actions) it('matches complete submenu commands but leaves direct commands in the command picker', () => {
expect(result?.key).toBe('/') 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', () => { expect(matchAction('/theme dark', actions)?.key).toBe('/')
vi.mocked(slashCommandRegistry.getAllCommands).mockReturnValue([ expect(matchAction('/docs', actions)).toBeUndefined()
{ name: 'theme', mode: 'submenu', description: '', search: vi.fn() }, expect(matchAction('/the', actions)).toBeUndefined()
])
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()
})
}) })
}) })

View File

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

View File

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

View File

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

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 { ActionItem, AppSearchResult, SearchResult } from './types'
import type { App } from '@/types/app' import type { AppIconType, AppModeEnum as AppMode } from '@/types/app'
import { import { consoleQuery } from '@/service/client'
RiFileListLine,
RiLayoutLine,
RiLineChartLine,
RiNodeTree,
RiTerminalBoxLine,
} from '@remixicon/react'
import * as React from 'react'
import { fetchAppList } from '@/service/apps'
import { AppModeEnum } from '@/types/app' import { AppModeEnum } from '@/types/app'
import { getRedirectionPath } from '@/utils/app-redirection' import { getRedirectionPath } from '@/utils/app-redirection'
import { AppTypeIcon } from '../../app/type-selector' import { AppTypeIcon } from '../../app/type-selector'
import AppIcon from '../../base/app-icon' import AppIcon from '../../base/app-icon'
const WORKFLOW_MODES = new Set([AppModeEnum.WORKFLOW, AppModeEnum.ADVANCED_CHAT]) 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}` const base = `/app/${app.id}`
if (WORKFLOW_MODES.has(app.mode)) { if (WORKFLOW_MODES.has(getAppMode(app))) {
return [ return [
{ id: 'workflow', label: 'Workflow', path: `${base}/workflow`, icon: RiNodeTree }, {
{ id: 'overview', label: 'Overview', path: `${base}/overview`, icon: RiLineChartLine }, id: 'workflow',
{ id: 'logs', label: 'Logs', path: `${base}/logs`, icon: RiFileListLine }, 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 [ return [
@ -32,53 +58,68 @@ const getAppSections = (app: App): AppSection[] => {
id: 'configuration', id: 'configuration',
label: 'Configuration', label: 'Configuration',
path: `${base}/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"> <div className="relative shrink-0">
<AppIcon <AppIcon
size="large" size="large"
iconType={app.icon_type} iconType={getAppIconType(app)}
icon={app.icon} icon={app.icon ?? undefined}
background={app.icon_background} background={app.icon_background}
imageUrl={app.icon_url} imageUrl={app.icon_url}
/> />
<AppTypeIcon <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" 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" className="size-3"
type={app.mode} type={getAppMode(app)}
/> />
</div> </div>
) )
const parser = (apps: App[]): AppSearchResult[] => { function getAppResults(apps: AppPartial[]): AppSearchResult[] {
return apps.map((app) => ({ return apps.map((app) => ({
id: app.id, id: app.id,
title: app.name, title: app.name,
description: app.description, description: app.description ?? undefined,
type: 'app' as const, type: 'app' as const,
path: getRedirectionPath(app), path: getAppPath(app),
icon: appIcon(app), icon: appIcon(app),
data: app, data: app,
})) }))
} }
// Generate sub-section results for matched apps when in scoped @app search // 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[] = [] const results: SearchResult[] = []
for (const app of apps) { for (const app of apps) {
results.push({ results.push({
id: app.id, id: app.id,
title: app.name, title: app.name,
description: app.description, description: app.description ?? undefined,
type: 'app' as const, type: 'app' as const,
path: getRedirectionPath(app), path: getAppPath(app),
icon: appIcon(app), icon: appIcon(app),
data: app, data: app,
}) })
@ -91,7 +132,7 @@ const parserWithSections = (apps: App[]): SearchResult[] => {
path: section.path, path: section.path,
icon: ( icon: (
<div className="flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-panel-bg"> <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> </div>
), ),
data: app, data: app,
@ -106,21 +147,19 @@ export const appAction: ActionItem = {
shortcut: '@app', shortcut: '@app',
title: 'Search Applications', title: 'Search Applications',
description: 'Search and navigate to your applications', description: 'Search and navigate to your applications',
search: async (query, searchTerm = '', _locale) => { source: 'remote',
const isScoped = query.trimStart().startsWith('@app') || query.trimStart().startsWith('@App') }
try {
const response = await fetchAppList({ export function appSearchQueryOptions(searchTerm: string, scoped: boolean) {
url: 'apps', return consoleQuery.apps.get.queryOptions({
params: { input: {
page: 1, query: {
name: searchTerm, page: 1,
}, name: searchTerm,
}) },
const apps = response?.data || [] },
return isScoped ? parserWithSections(apps) : parser(apps) retry: false,
} catch (error) { select: (response) =>
console.warn('App search failed:', error) scoped ? getScopedAppResults(response.data) : getAppResults(response.data),
return [] })
}
},
} }

View File

@ -1,12 +1,5 @@
import { executeCommand } from '../command-bus' import { executeCommand } from '../command-bus'
import { createCommand } from '../create' 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 // 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. // register() pushes into the command bus can be observed by the tests.
const mockOpenGenerator = vi.fn() const mockOpenGenerator = vi.fn()

View File

@ -1,10 +1,5 @@
import { executeCommand } from '../command-bus' import { executeCommand } from '../command-bus'
import { refineCommand } from '../refine' 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. // Spy on the generator store so we can observe what /refine opens it with.
const mockOpenGenerator = vi.fn() const mockOpenGenerator = vi.fn()
vi.mock('@/app/components/workflow/workflow-generator/store', () => ({ vi.mock('@/app/components/workflow/workflow-generator/store', () => ({

View File

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

View File

@ -91,6 +91,8 @@ describe('slashAction', () => {
{ id: 'theme', title: '/theme', type: 'command', data: { command: 'theme' } }, { 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') const results = await slashAction.search('/theme dark', 'dark')
expect(mockSearch).toHaveBeenCalledWith('/theme dark', 'ja') expect(mockSearch).toHaveBeenCalledWith('/theme dark', 'ja')

View File

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

View File

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

View File

@ -1,7 +1,5 @@
import type { SlashCommandHandler } from './types' import type { SlashCommandHandler } from './types'
import type { WorkflowGeneratorMode } from '@/app/components/workflow/workflow-generator/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 { getI18n } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store' import { useStore as useAppStore } from '@/app/components/app/store'
import { useWorkflowGeneratorStore } from '@/app/components/workflow/workflow-generator/store' import { useWorkflowGeneratorStore } from '@/app/components/workflow/workflow-generator/store'
@ -18,7 +16,7 @@ type CreateOption = {
mode: WorkflowGeneratorMode mode: WorkflowGeneratorMode
/** When set, the modal opens in auto-mode and the planner picks the app type. */ /** When set, the modal opens in auto-mode and the planner picks the app type. */
auto?: boolean auto?: boolean
icon: React.ComponentType<{ className?: string }> iconClassName: string
} }
// `as const` keeps titleKey/descKey as literal types so the typed `i18n.t` // `as const` keeps titleKey/descKey as literal types so the typed `i18n.t`
@ -30,7 +28,7 @@ const OPTIONS = [
descKey: 'gotoAnything.actions.createAutoDesc', descKey: 'gotoAnything.actions.createAutoDesc',
mode: 'advanced-chat', mode: 'advanced-chat',
auto: true, auto: true,
icon: RiSparkling2Line, iconClassName: 'i-ri-sparkling-2-line',
}, },
{ {
id: 'workflow', id: 'workflow',
@ -38,7 +36,7 @@ const OPTIONS = [
descKey: 'gotoAnything.actions.createWorkflowDesc', descKey: 'gotoAnything.actions.createWorkflowDesc',
mode: 'workflow', mode: 'workflow',
auto: false, auto: false,
icon: RiNodeTree, iconClassName: 'i-ri-node-tree',
}, },
{ {
id: 'chatflow', id: 'chatflow',
@ -46,7 +44,7 @@ const OPTIONS = [
descKey: 'gotoAnything.actions.createChatflowDesc', descKey: 'gotoAnything.actions.createChatflowDesc',
mode: 'advanced-chat', mode: 'advanced-chat',
auto: false, auto: false,
icon: RiChat3Line, iconClassName: 'i-ri-chat-3-line',
}, },
] as const satisfies readonly CreateOption[] ] as const satisfies readonly CreateOption[]
@ -71,19 +69,17 @@ const OPTIONS = [
export const createCommand: SlashCommandHandler = { export const createCommand: SlashCommandHandler = {
name: 'create', name: 'create',
aliases: ['new', 'generate'], 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' }), description: getI18n().t(($) => $['gotoAnything.actions.createCategoryDesc'], { ns: 'app' }),
mode: 'submenu', mode: 'submenu',
async search(args: string, locale?: string) { search(args: string, locale?: string) {
const i18n = getI18n() const i18n = getI18n()
const tr = (key: (typeof OPTIONS)[number]['titleKey' | 'descKey']) => const tr = (key: (typeof OPTIONS)[number]['titleKey' | 'descKey']) =>
i18n.t(($) => $[key], { ns: 'app', lng: locale }) 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"> <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> </div>
) )
@ -94,7 +90,7 @@ export const createCommand: SlashCommandHandler = {
// back to the option's static description otherwise. // back to the option's static description otherwise.
description: instruction || tr(opt.descKey), description: instruction || tr(opt.descKey),
type: 'command' as const, type: 'command' as const,
icon: renderIcon(opt.icon), icon: renderIcon(opt.iconClassName),
data: { command: 'create.open', args: { mode: opt.mode, auto: !!opt.auto, instruction } }, data: { command: 'create.open', args: { mode: opt.mode, auto: !!opt.auto, instruction } },
}) })

View File

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

View File

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

View File

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

View File

@ -36,7 +36,7 @@ export const languageCommand: SlashCommandHandler<LanguageDeps> = {
description: 'Switch between different languages', description: 'Switch between different languages',
mode: 'submenu', // Explicitly set submenu mode 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 language options directly, regardless of parameters
return buildLanguageCommands(args) return buildLanguageCommands(args)
}, },

View File

@ -1,7 +1,5 @@
import type { SlashCommandHandler } from './types' import type { SlashCommandHandler } from './types'
import type { WorkflowGeneratorMode } from '@/app/components/workflow/workflow-generator/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 { getI18n } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store' import { useStore as useAppStore } from '@/app/components/app/store'
import { useWorkflowGeneratorStore } from '@/app/components/workflow/workflow-generator/store' import { useWorkflowGeneratorStore } from '@/app/components/workflow/workflow-generator/store'
@ -49,8 +47,6 @@ const openRefineGenerator = () => {
export const refineCommand: SlashCommandHandler = { export const refineCommand: SlashCommandHandler = {
name: 'refine', name: 'refine',
aliases: ['improve'], 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' }), description: getI18n().t(($) => $['gotoAnything.actions.refineCategoryDesc'], { ns: 'app' }),
mode: 'direct', mode: 'direct',
@ -60,7 +56,7 @@ export const refineCommand: SlashCommandHandler = {
execute: openRefineGenerator, execute: openRefineGenerator,
async search(_args: string, locale?: string) { search(_args: string, locale?: string) {
const i18n = getI18n() const i18n = getI18n()
return [ return [
{ {
@ -73,7 +69,7 @@ export const refineCommand: SlashCommandHandler = {
type: 'command' as const, type: 'command' as const,
icon: ( icon: (
<div className="flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-panel-bg"> <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> </div>
), ),
data: { command: 'refine.open', args: {} }, data: { command: 'refine.open', args: {} },

View File

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

View File

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

View File

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

View File

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

View File

@ -1,169 +1,4 @@
/** import type { ActionItem } from './types'
* 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 { appAction } from './app' import { appAction } from './app'
import { slashCommandRegistry } from './commands/registry' import { slashCommandRegistry } from './commands/registry'
import { slashAction } from './commands/slash' import { slashAction } from './commands/slash'
@ -172,120 +7,38 @@ import { pluginAction } from './plugin'
import { ragPipelineNodesAction } from './rag-pipeline-nodes' import { ragPipelineNodesAction } from './rag-pipeline-nodes'
import { workflowNodesAction } from './workflow-nodes' import { workflowNodesAction } from './workflow-nodes'
// Create dynamic Actions based on context const defaultActions = {
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 = {
slash: slashAction, slash: slashAction,
app: appAction, app: appAction,
knowledge: knowledgeAction, knowledge: knowledgeAction,
plugin: pluginAction, 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 ( export function getActionSearchTerm(query: string, action: ActionItem) {
locale: string, const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
query: string, const prefixPattern = new RegExp(
actionItem?: ActionItem, `^(${escapeRegExp(action.key)}|${escapeRegExp(action.shortcut)})\\s*`,
dynamicActions?: Record<string, ActionItem>, )
): Promise<SearchResult[]> => { return query.trim().replace(prefixPattern, '').trim()
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 const matchAction = (query: string, actions: Record<string, ActionItem>) => { export function matchAction(query: string, actions: Record<string, ActionItem>) {
return Object.values(actions).find((action) => { return Object.values(actions).find((action) => {
// Special handling for slash commands
if (action.key === '/') { if (action.key === '/') {
// Get all registered commands from the registry return slashCommandRegistry.getAllCommands().some((command) => {
const allCommands = slashCommandRegistry.getAllCommands() if (command.mode === 'direct') return false
// Check if query matches any registered command const commandPattern = `/${command.name}`
return allCommands.some((cmd) => { return query === commandPattern || query.startsWith(`${commandPattern} `)
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 new RegExp(`^(${action.key}|${action.shortcut})(?:\\s|$)`).test(query)
return reg.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 { ActionItem, KnowledgeSearchResult } from './types'
import type { DataSet } from '@/models/datasets'
import { cn } from '@langgenius/dify-ui/cn' 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' import { Folder } from '../../base/icons/src/vender/solid/files'
const EXTERNAL_PROVIDER = 'external' as const const EXTERNAL_PROVIDER = 'external' as const
const isExternalProvider = (provider: string): boolean => provider === EXTERNAL_PROVIDER const isExternalProvider = (provider: string): boolean => provider === EXTERNAL_PROVIDER
const parser = (datasets: DataSet[]): KnowledgeSearchResult[] => { function getKnowledgeResults(datasets: DatasetListItemResponse[]): KnowledgeSearchResult[] {
return datasets.map((dataset) => { return datasets.map((dataset) => {
const path = isExternalProvider(dataset.provider) const path = isExternalProvider(dataset.provider)
? `/datasets/${dataset.id}/hitTesting` ? `/datasets/${dataset.id}/hitTesting`
@ -15,7 +15,7 @@ const parser = (datasets: DataSet[]): KnowledgeSearchResult[] => {
return { return {
id: dataset.id, id: dataset.id,
title: dataset.name, title: dataset.name,
description: dataset.description, description: dataset.description ?? undefined,
type: 'knowledge' as const, type: 'knowledge' as const,
path, path,
icon: ( icon: (
@ -38,22 +38,19 @@ export const knowledgeAction: ActionItem = {
shortcut: '@kb', shortcut: '@kb',
title: 'Search Knowledge Bases', title: 'Search Knowledge Bases',
description: 'Search and navigate to your knowledge bases', description: 'Search and navigate to your knowledge bases',
// action, source: 'remote',
search: async (_, searchTerm = '', _locale) => { }
try {
const response = await fetchDatasets({ export function knowledgeSearchQueryOptions(searchTerm: string) {
url: '/datasets', return consoleQuery.datasets.get.queryOptions({
params: { input: {
page: 1, query: {
limit: 10, page: 1,
keyword: searchTerm, limit: 10,
}, keyword: searchTerm,
}) },
const datasets = response?.data || [] },
return parser(datasets) retry: false,
} catch (error) { select: (response) => getKnowledgeResults(response.data),
console.warn('Knowledge search failed:', error) })
return []
}
},
} }

View File

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

View File

@ -1,19 +1,15 @@
import type { ActionItem } from './types' 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 = { export const ragPipelineNodesAction: ActionItem = {
key: '@node', key: '@node',
shortcut: '@node', shortcut: '@node',
title: 'Search RAG Pipeline Nodes', title: 'Search RAG Pipeline Nodes',
description: 'Find and jump to nodes in the current RAG pipeline by name or type', description: 'Find and jump to nodes in the current RAG pipeline by name or type',
searchFn: undefined, // Will be set by useRagPipelineSearch hook source: 'local',
search: async (_, searchTerm = '', _locale) => { search: (_, searchTerm = '', _locale) => {
try { try {
// Use the searchFn if available (set by useRagPipelineSearch hook) return findRagPipelineNodes(searchTerm)
if (ragPipelineNodesAction.searchFn) return ragPipelineNodesAction.searchFn(searchTerm)
// If not in RAG pipeline context, return empty array
return []
} catch (error) { } catch (error) {
console.warn('RAG pipeline nodes search failed:', error) console.warn('RAG pipeline nodes search failed:', error)
return [] 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 { ReactNode } from 'react'
import type { TypeWithI18N } from '../../base/form/types' import type { TypeWithI18N } from '../../base/form/types'
import type { Plugin } from '../../plugins/types' import type { Plugin } from '../../plugins/types'
import type { CommonNodeType } from '../../workflow/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 SearchResultType = 'app' | 'knowledge' | 'plugin' | 'workflow-node' | 'command' | 'recent'
type BaseSearchResult<T = any> = { type BaseSearchResult<T> = {
id: string id: string
title: string title: string
description?: string description?: string
@ -19,7 +19,7 @@ type BaseSearchResult<T = any> = {
export type AppSearchResult = { export type AppSearchResult = {
type: 'app' type: 'app'
} & BaseSearchResult<App> } & BaseSearchResult<AppPartial>
export type PluginSearchResult = { export type PluginSearchResult = {
type: 'plugin' type: 'plugin'
@ -27,7 +27,7 @@ export type PluginSearchResult = {
export type KnowledgeSearchResult = { export type KnowledgeSearchResult = {
type: 'knowledge' type: 'knowledge'
} & BaseSearchResult<DataSet> } & BaseSearchResult<DatasetListItemResponse>
type WorkflowNodeSearchResult = { type WorkflowNodeSearchResult = {
type: 'workflow-node' type: 'workflow-node'
@ -39,7 +39,7 @@ type WorkflowNodeSearchResult = {
export type CommandSearchResult = { export type CommandSearchResult = {
type: 'command' type: 'command'
} & BaseSearchResult<{ command: string; args?: Record<string, any> }> } & BaseSearchResult<{ command: string; args?: Record<string, unknown> }>
export type RecentSearchResult = { export type RecentSearchResult = {
type: 'recent' type: 'recent'
@ -54,16 +54,21 @@ export type SearchResult =
| CommandSearchResult | CommandSearchResult
| RecentSearchResult | RecentSearchResult
export type ActionItem = { type ActionItemBase = {
key: '@app' | '@knowledge' | '@plugin' | '@node' | '/' key: '@app' | '@knowledge' | '@plugin' | '@node' | '/'
shortcut: string shortcut: string
title: string | TypeWithI18N title: string | TypeWithI18N
description: string description: string
action?: (data: SearchResult) => void 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 type { ActionItem } from './types'
import { findWorkflowNodes } from '@/app/components/workflow/goto-anything-search'
// Create the workflow nodes action
export const workflowNodesAction: ActionItem = { export const workflowNodesAction: ActionItem = {
key: '@node', key: '@node',
shortcut: '@node', shortcut: '@node',
title: 'Search Workflow Nodes', title: 'Search Workflow Nodes',
description: 'Find and jump to nodes in the current workflow by name or type', description: 'Find and jump to nodes in the current workflow by name or type',
searchFn: undefined, // Will be set by useWorkflowSearch hook source: 'local',
search: async (_, searchTerm = '', _locale) => { search: (_, searchTerm = '', _locale) => {
try { try {
// Use the searchFn if available (set by useWorkflowSearch hook) return findWorkflowNodes(searchTerm)
if (workflowNodesAction.searchFn) return workflowNodesAction.searchFn(searchTerm)
// If not in workflow context, return empty array
return []
} catch (error) { } catch (error) {
console.warn('Workflow nodes search failed:', error) console.warn('Workflow nodes search failed:', error)
return [] 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 { render, screen } from '@testing-library/react'
import EmptyState from '../empty-state' import { EmptyState } from '../empty-state'
describe('EmptyState', () => { describe('EmptyState', () => {
describe('loading variant', () => { describe('loading variant', () => {
@ -73,11 +73,11 @@ describe('EmptyState', () => {
}) })
it('should show specific search hint with shortcuts', () => { it('should show specific search hint with shortcuts', () => {
const Actions = { const actions = {
app: { key: '@app', shortcut: '@app' }, app: { key: '@app', shortcut: '@app' },
plugin: { key: '@plugin', shortcut: '@plugin' }, plugin: { key: '@plugin', shortcut: '@plugin' },
} as unknown as Record<string, import('../../actions/types').ActionItem> } 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( expect(
screen.getByText( screen.getByText(
@ -145,7 +145,7 @@ describe('EmptyState', () => {
expect(screen.getByText('app.gotoAnything.noResults')).toBeInTheDocument() 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" />) render(<EmptyState variant="no-results" searchMode="general" />)
expect( expect(

View File

@ -1,261 +1,53 @@
import { render, screen } from '@testing-library/react' 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('Footer', () => {
describe('left content', () => { it('shows the result count and active scope', () => {
describe('when there are results', () => { render(<Footer {...defaultProps} resultCount={3} searchMode="@app" hasQuery />)
it('should show result count', () => {
render(
<Footer
resultCount={5}
searchMode="general"
isError={false}
isCommandsMode={false}
hasQuery={true}
/>,
)
expect(screen.getByText('app.gotoAnything.resultCount:{"count":5}')).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()
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()
})
})
}) })
describe('right content', () => { it('reports partial provider failure even when results remain available', () => {
describe('when there are results or error', () => { render(<Footer {...defaultProps} resultCount={2} hasUnavailableServices hasQuery />)
it('should show clear to search all when in specific mode', () => {
render(
<Footer
resultCount={5}
searchMode="@app"
isError={false}
isCommandsMode={false}
hasQuery={true}
/>,
)
expect(screen.getByText('app.gotoAnything.clearToSearchAll')).toBeInTheDocument() expect(screen.getByText('app.gotoAnything.someServicesUnavailable')).toHaveClass('text-red-500')
}) expect(screen.getByText('app.gotoAnything.useAtForSpecific')).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()
})
})
}) })
describe('styling', () => { it('reports pending remote search', () => {
it('should have border and background classes', () => { render(<Footer {...defaultProps} isLoading hasQuery />)
const { container } = render(
<Footer
resultCount={0}
searchMode="general"
isError={false}
isCommandsMode={false}
hasQuery={false}
/>,
)
const footer = container.firstChild expect(screen.getByText('app.gotoAnything.searching')).toBeInTheDocument()
expect(footer).toHaveClass('border-t', 'border-divider-subtle', 'bg-components-panel-bg-blur') expect(screen.getByText('app.gotoAnything.tips')).toBeInTheDocument()
}) })
it('should have flex layout for content', () => { it('reports command selection mode', () => {
const { container } = render( render(<Footer {...defaultProps} isCommandsMode />)
<Footer
resultCount={0}
searchMode="general"
isError={false}
isCommandsMode={false}
hasQuery={false}
/>,
)
const flexContainer = container.querySelector('.flex.items-center.justify-between') expect(screen.getByText('app.gotoAnything.selectToNavigate')).toBeInTheDocument()
expect(flexContainer).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' 'use client'
import type { FC } from 'react'
import type { ActionItem } from '../actions/types' import type { ActionItem } from '../actions/types'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -10,15 +9,15 @@ type EmptyStateProps = {
variant: EmptyStateVariant variant: EmptyStateVariant
searchMode?: string searchMode?: string
error?: Error | null error?: Error | null
Actions?: Record<string, ActionItem> actions?: Record<string, ActionItem>
} }
const EmptyState: FC<EmptyStateProps> = ({ export function EmptyState({
variant, variant,
searchMode = 'general', searchMode = 'general',
error, error,
Actions = {}, actions = {},
}) => { }: EmptyStateProps) {
const { t } = useTranslation() const { t } = useTranslation()
if (variant === 'loading') { if (variant === 'loading') {
@ -67,7 +66,6 @@ const EmptyState: FC<EmptyStateProps> = ({
) )
} }
// variant === 'no-results'
const isCommandSearch = searchMode !== 'general' const isCommandSearch = searchMode !== 'general'
const commandType = isCommandSearch ? searchMode.replace('@', '') : '' const commandType = isCommandSearch ? searchMode.replace('@', '') : ''
@ -93,7 +91,7 @@ const EmptyState: FC<EmptyStateProps> = ({
return t(($) => $['gotoAnything.emptyState.tryDifferentTerm'], { ns: 'app' }) return t(($) => $['gotoAnything.emptyState.tryDifferentTerm'], { ns: 'app' })
} }
const shortcuts = Object.values(Actions) const shortcuts = Object.values(actions)
.map((action) => action.shortcut) .map((action) => action.shortcut)
.join(', ') .join(', ')
return t(($) => $['gotoAnything.emptyState.trySpecificSearch'], { ns: 'app', shortcuts }) return t(($) => $['gotoAnything.emptyState.trySpecificSearch'], { ns: 'app', shortcuts })
@ -108,5 +106,3 @@ const EmptyState: FC<EmptyStateProps> = ({
</div> </div>
) )
} }
export default EmptyState

View File

@ -1,35 +1,36 @@
'use client' 'use client'
import type { FC } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
type FooterProps = { type FooterProps = {
resultCount: number resultCount: number
searchMode: string searchMode: string
isError: boolean isLoading: boolean
hasUnavailableServices: boolean
isCommandsMode: boolean isCommandsMode: boolean
hasQuery: boolean hasQuery: boolean
} }
const Footer: FC<FooterProps> = ({ export function Footer({
resultCount, resultCount,
searchMode, searchMode,
isError, isLoading,
hasUnavailableServices,
isCommandsMode, isCommandsMode,
hasQuery, hasQuery,
}) => { }: FooterProps) {
const { t } = useTranslation() const { t } = useTranslation()
const renderLeftContent = () => { const renderLeftContent = () => {
if (resultCount > 0 || isError) { if (hasUnavailableServices) {
if (isError) { return (
return ( <span className="text-red-500">
<span className="text-red-500"> {t(($) => $['gotoAnything.someServicesUnavailable'], { ns: 'app' })}
{t(($) => $['gotoAnything.someServicesUnavailable'], { ns: 'app' })} </span>
</span> )
) }
}
if (resultCount > 0) {
return ( return (
<> <>
{t(($) => $['gotoAnything.resultCount'], { ns: 'app', count: resultCount })} {t(($) => $['gotoAnything.resultCount'], { ns: 'app', count: resultCount })}
@ -50,7 +51,7 @@ const Footer: FC<FooterProps> = ({
{(() => { {(() => {
if (isCommandsMode) return t(($) => $['gotoAnything.selectToNavigate'], { ns: 'app' }) 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' }) return t(($) => $['gotoAnything.startTyping'], { ns: 'app' })
})()} })()}
@ -59,7 +60,7 @@ const Footer: FC<FooterProps> = ({
} }
const renderRightContent = () => { const renderRightContent = () => {
if (resultCount > 0 || isError) { if (resultCount > 0 || hasUnavailableServices) {
return ( return (
<span className="opacity-60"> <span className="opacity-60">
{searchMode !== 'general' {searchMode !== 'general'
@ -87,5 +88,3 @@ const Footer: FC<FooterProps> = ({
</div> </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' 'use client'
import type { FC, KeyboardEvent } from 'react' import type { AutocompleteChangeEventDetails } from '@langgenius/dify-ui/autocomplete'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' import type { Plugin } from '../plugins/types'
import { Command } from 'cmdk' import type { ActionItem, RecentSearchResult, SearchResult } from './actions/types'
import { useCallback, useEffect, useMemo, useRef } from 'react' 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 { 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 { PluginInstallPermissionProvider } from '../plugins/install-plugin/components/plugin-install-permission-provider'
import useWorkspacePluginInstallPermission from '../plugins/install-plugin/hooks/use-workspace-plugin-install-permission' import useWorkspacePluginInstallPermission from '../plugins/install-plugin/hooks/use-workspace-plugin-install-permission'
import InstallFromMarketplace from '../plugins/install-plugin/install-from-marketplace' 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 { slashCommandRegistry } from './actions/commands/registry'
import { SlashCommandProvider } from './actions/commands/slash-provider' import { SlashCommandProvider } from './actions/commands/slash-provider'
import CommandSelector from './command-selector' import { knowledgeSearchQueryOptions } from './actions/knowledge'
import { EmptyState, Footer, ResultList, SearchInput } from './components' import { pluginSearchQueryOptions } from './actions/plugin'
import { GotoAnythingProvider, useGotoAnythingContext } from './context' import { addRecentItem, getRecentItems } from './actions/recent-store'
import { useGotoAnythingModal } from './hooks/use-goto-anything-modal' import { EmptyState } from './components/empty-state'
import { useGotoAnythingNavigation } from './hooks/use-goto-anything-navigation' import { Footer } from './components/footer'
import { useGotoAnythingResults } from './hooks/use-goto-anything-results' import { gotoAnythingDialogHandle } from './dialog-handle'
import { useGotoAnythingSearch } from './hooks/use-goto-anything-search'
type Props = Readonly<{ const appWorkflowPathPattern = /^\/app\/[^/]+\/workflow$/
onHide?: () => void const sharedWorkflowPathPattern = /^\/workflow\/[^/]+$/
}> const ragPipelinePathPattern = /^\/datasets\/[^/]+\/pipeline$/
const searchHotkey = 'Mod+K'
const searchShortcut = searchHotkey.split('+')
const GotoAnythingDialog: FC<Props> = ({ onHide }) => { type CommandOption = {
const { t } = useTranslation() kind: 'command-option'
const { isWorkflowPage, isRagPipelinePage } = useGotoAnythingContext() shortcut: string
const { canInstallPlugin, currentDifyVersion } = useWorkspacePluginInstallPermission() description: string
const prevShowRef = useRef(false) }
// Search state management (called first so setSearchQuery is available) type GotoAnythingOption = CommandOption | SearchResult
const {
searchQuery,
setSearchQuery,
searchQueryDebouncedValue,
searchMode,
isCommandsMode,
cmdVal,
setCmdVal,
clearSelection,
Actions,
} = useGotoAnythingSearch()
// Modal state management const slashCommandDescriptionKeys = {
const { open, onOpenChange, inputRef } = useGotoAnythingModal() '/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 const actionDescriptionKeys = {
useEffect(() => { '@app': 'gotoAnything.actions.searchApplicationsDesc',
if (open && !prevShowRef.current) { '@plugin': 'gotoAnything.actions.searchPluginsDesc',
// Modal just opened - reset search '@knowledge': 'gotoAnything.actions.searchKnowledgeBasesDesc',
setSearchQuery('') '@node': 'gotoAnything.actions.searchWorkflowNodesDesc',
} else if (!open && prevShowRef.current) { } as const
// Modal just closed
setSearchQuery('')
clearSelection()
onHide?.()
}
prevShowRef.current = open
}, [open, setSearchQuery, clearSelection, onHide])
// Results fetching and processing const groupLabelKeys = {
const { dedupedResults, groupedResults, isLoading, isError, error } = useGotoAnythingResults({ app: 'gotoAnything.groups.apps',
searchQueryDebouncedValue, plugin: 'gotoAnything.groups.plugins',
searchMode, knowledge: 'gotoAnything.groups.knowledgeBases',
isCommandsMode, 'workflow-node': 'gotoAnything.groups.workflowNodes',
Actions, command: 'gotoAnything.groups.commands',
isWorkflowPage, recent: 'gotoAnything.groups.recent',
isRagPipelinePage, } as const
cmdVal,
setCmdVal, 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 function groupSearchResults(results: SearchResult[]) {
const { handleCommandSelect, handleNavigate, activePlugin, setActivePlugin } = return results.reduce<Record<string, SearchResult[]>>((groups, result) => {
useGotoAnythingNavigation({ const group = groups[result.type] ?? []
Actions, group.push(result)
setSearchQuery, groups[result.type] = group
clearSelection, return groups
inputRef, }, {})
onClose: () => onOpenChange(false), }
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 let emptyStateVariant: 'loading' | 'error' | 'default' | 'no-results' | null = null
const handleSearchChange = useCallback( if (isLoading) emptyStateVariant = 'loading'
(value: string) => { else if (isError) emptyStateVariant = 'error'
setSearchQuery(value) else if (!trimmedSearchQuery && autocompleteResultCount === 0) emptyStateVariant = 'default'
if (!value.startsWith('@') && !value.startsWith('/')) clearSelection() else if (autocompleteResultCount === 0 && !isCommandsMode) emptyStateVariant = 'no-results'
},
[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])
return ( return (
<> <>
<SlashCommandProvider /> <SlashCommandProvider />
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog handle={gotoAnythingDialogHandle} onOpenChange={resetSearch}>
<DialogContent className="w-[480px]! overflow-hidden p-0!"> <DialogPortal>
<Command <DialogBackdrop />
className="outline-hidden" <DialogPopup
value={cmdVal} initialFocus={inputRef}
onValueChange={setCmdVal} 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!"
disablePointerSelection
loop
> >
<SearchInput <DialogTitle className="sr-only">
inputRef={inputRef} {t(($) => $['gotoAnything.searchTitle'], { ns: 'app' })}
</DialogTitle>
<Autocomplete<GotoAnythingOption>
items={visibleOptions}
value={searchQuery} value={searchQuery}
onChange={handleSearchChange} onValueChange={handleAutocompleteValueChange}
onKeyDown={handleSearchKeyDown} onOpenChange={handleAutocompleteOpenChange}
searchMode={searchMode} itemToStringValue={optionToInputValue}
placeholder={t(($) => $['gotoAnything.searchPlaceholder'], { ns: 'app' })} 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' })}
/> />
</DialogPopup>
<Command.List className="h-[240px] overflow-y-auto"> </DialogPortal>
{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>
</Dialog> </Dialog>
{activePlugin && canInstallPlugin && ( {activePlugin && canInstallPlugin && (
@ -205,10 +613,6 @@ const GotoAnythingDialog: FC<Props> = ({ onHide }) => {
) )
} }
export const GotoAnything: FC<Props> = (props) => { export function GotoAnything() {
return ( return <GotoAnythingDialog />
<GotoAnythingProvider>
<GotoAnythingDialog {...props} />
</GotoAnythingProvider>
)
} }

View File

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

View File

@ -1,22 +1,26 @@
'use client' 'use client'
import { DialogTrigger } from '@langgenius/dify-ui/dialog'
import { Kbd } from '@langgenius/dify-ui/kbd' import { Kbd } from '@langgenius/dify-ui/kbd'
import { formatForDisplay } from '@tanstack/react-hotkeys' import { formatForDisplay } from '@tanstack/react-hotkeys'
import { useTranslation } from 'react-i18next' 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'] const searchShortcut = ['Mod', 'K']
export function MainNavSearchButton() { export function MainNavSearchButton() {
const { t } = useTranslation() const { t } = useTranslation()
const setGotoAnythingOpen = useSetGotoAnythingOpen()
return ( return (
<button <DialogTrigger
type="button" handle={gotoAnythingDialogHandle}
aria-label={t(($) => $['gotoAnything.searchTitle'], { ns: 'app' })} render={
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" <button
onClick={() => setGotoAnythingOpen(true)} 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" /> <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"> <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> <span key={key}>{formatForDisplay(key)}</span>
))} ))}
</Kbd> </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 { renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { BlockEnum } from '@/app/components/workflow/types' import { BlockEnum } from '@/app/components/workflow/types'
import { findRagPipelineNodes } from '../../goto-anything-search'
import { useRagPipelineSearch } from '../use-rag-pipeline-search' import { useRagPipelineSearch } from '../use-rag-pipeline-search'
const mockNodes: Array<{ id: string; data: Record<string, unknown> }> = [] const mockNodes: Array<{ id: string; data: Record<string, unknown> }> = []
@ -23,20 +24,6 @@ vi.mock('@/app/components/workflow/block-icon', () => ({
default: () => null, 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() const mockCleanupListener = vi.fn()
vi.mock('@/app/components/workflow/utils/node-navigation', () => ({ vi.mock('@/app/components/workflow/utils/node-navigation', () => ({
setupNodeSelectionListener: () => mockCleanupListener, setupNodeSelectionListener: () => mockCleanupListener,
@ -46,7 +33,6 @@ describe('useRagPipelineSearch', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
mockNodes.length = 0 mockNodes.length = 0
mockRagPipelineNodesAction.searchFn = undefined
}) })
afterEach(() => { afterEach(() => {
@ -65,15 +51,21 @@ describe('useRagPipelineSearch', () => {
data: { type: BlockEnum.LLM, title: 'LLM Node', desc: '' }, 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', () => { it('should expose empty search results when no nodes exist', () => {
renderHook(() => useRagPipelineSearch()) const { unmount } = renderHook(() => useRagPipelineSearch())
expect(mockRagPipelineNodesAction.searchFn).toBeUndefined() expect(findRagPipelineNodes('LLM')).toEqual([])
unmount()
}) })
it('should cleanup search function on unmount', () => { it('should cleanup search function on unmount', () => {
@ -84,11 +76,11 @@ describe('useRagPipelineSearch', () => {
const { unmount } = renderHook(() => useRagPipelineSearch()) const { unmount } = renderHook(() => useRagPipelineSearch())
expect(mockRagPipelineNodesAction.searchFn).toBeDefined() expect(findRagPipelineNodes('Start')).not.toEqual([])
unmount() unmount()
expect(mockRagPipelineNodesAction.searchFn).toBeUndefined() expect(findRagPipelineNodes('Start')).toEqual([])
}) })
it('should setup node selection listener', () => { it('should setup node selection listener', () => {
@ -136,8 +128,7 @@ describe('useRagPipelineSearch', () => {
it('should find nodes by title', () => { it('should find nodes by title', () => {
renderHook(() => useRagPipelineSearch()) renderHook(() => useRagPipelineSearch())
const searchFn = mockRagPipelineNodesAction.searchFn! const results = findRagPipelineNodes('GPT')
const results = searchFn('GPT')
expect(results.length).toBeGreaterThan(0) expect(results.length).toBeGreaterThan(0)
expect(results[0]!.title).toBe('GPT Model') expect(results[0]!.title).toBe('GPT Model')
@ -146,8 +137,7 @@ describe('useRagPipelineSearch', () => {
it('should find nodes by type', () => { it('should find nodes by type', () => {
renderHook(() => useRagPipelineSearch()) renderHook(() => useRagPipelineSearch())
const searchFn = mockRagPipelineNodesAction.searchFn! const results = findRagPipelineNodes(BlockEnum.LLM)
const results = searchFn(BlockEnum.LLM)
expect(results.some((r) => r.title === 'GPT Model')).toBe(true) expect(results.some((r) => r.title === 'GPT Model')).toBe(true)
}) })
@ -155,8 +145,7 @@ describe('useRagPipelineSearch', () => {
it('should find nodes by description', () => { it('should find nodes by description', () => {
renderHook(() => useRagPipelineSearch()) renderHook(() => useRagPipelineSearch())
const searchFn = mockRagPipelineNodesAction.searchFn! const results = findRagPipelineNodes('knowledge')
const results = searchFn('knowledge')
expect(results.some((r) => r.title === 'Knowledge Base')).toBe(true) 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', () => { it('should return all nodes when search term is empty', () => {
renderHook(() => useRagPipelineSearch()) renderHook(() => useRagPipelineSearch())
const searchFn = mockRagPipelineNodesAction.searchFn! const results = findRagPipelineNodes('')
const results = searchFn('')
expect(results.length).toBe(4) expect(results.length).toBe(4)
}) })
@ -173,8 +161,7 @@ describe('useRagPipelineSearch', () => {
it('should sort by alphabetical order when no search term', () => { it('should sort by alphabetical order when no search term', () => {
renderHook(() => useRagPipelineSearch()) renderHook(() => useRagPipelineSearch())
const searchFn = mockRagPipelineNodesAction.searchFn! const results = findRagPipelineNodes('')
const results = searchFn('')
const titles = results.map((r) => r.title) const titles = results.map((r) => r.title)
const sortedTitles = [...titles].sort((a, b) => a.localeCompare(b)) 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', () => { it('should sort by relevance score when search term provided', () => {
renderHook(() => useRagPipelineSearch()) renderHook(() => useRagPipelineSearch())
const searchFn = mockRagPipelineNodesAction.searchFn! const results = findRagPipelineNodes('Search')
const results = searchFn('Search')
expect(results[0]!.title).toBe('Web Search') expect(results[0]!.title).toBe('Web Search')
}) })
@ -193,8 +179,7 @@ describe('useRagPipelineSearch', () => {
it('should return empty array when no nodes match', () => { it('should return empty array when no nodes match', () => {
renderHook(() => useRagPipelineSearch()) renderHook(() => useRagPipelineSearch())
const searchFn = mockRagPipelineNodesAction.searchFn! const results = findRagPipelineNodes('nonexistent-xyz-12345')
const results = searchFn('nonexistent-xyz-12345')
expect(results).toEqual([]) expect(results).toEqual([])
}) })
@ -202,8 +187,7 @@ describe('useRagPipelineSearch', () => {
it('should enhance Tool node description from tool_description', () => { it('should enhance Tool node description from tool_description', () => {
renderHook(() => useRagPipelineSearch()) renderHook(() => useRagPipelineSearch())
const searchFn = mockRagPipelineNodesAction.searchFn! const results = findRagPipelineNodes('web')
const results = searchFn('web')
const toolResult = results.find((r) => r.title === 'Web Search') const toolResult = results.find((r) => r.title === 'Web Search')
expect(toolResult).toBeDefined() expect(toolResult).toBeDefined()
@ -213,18 +197,18 @@ describe('useRagPipelineSearch', () => {
it('should include metadata with nodeId', () => { it('should include metadata with nodeId', () => {
renderHook(() => useRagPipelineSearch()) renderHook(() => useRagPipelineSearch())
const searchFn = mockRagPipelineNodesAction.searchFn! const results = findRagPipelineNodes('Start')
const results = searchFn('Start')
const startResult = results.find((r) => r.title === 'Start Node') 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') expect(startResult?.metadata?.nodeId).toBe('node-4')
}) })
it('should set result type as workflow-node', () => { it('should set result type as workflow-node', () => {
renderHook(() => useRagPipelineSearch()) renderHook(() => useRagPipelineSearch())
const searchFn = mockRagPipelineNodesAction.searchFn! const results = findRagPipelineNodes('Start')
const results = searchFn('Start')
expect(results[0]!.type).toBe('workflow-node') 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 { ToolNodeType } from '@/app/components/workflow/nodes/tool/types'
import type { CommonNodeType } from '@/app/components/workflow/types' import type { CommonNodeType } from '@/app/components/workflow/types'
import { useCallback, useEffect, useMemo } from 'react' 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 BlockIcon from '@/app/components/workflow/block-icon'
import { useNodesInteractions } from '@/app/components/workflow/hooks/use-nodes-interactions' import { useNodesInteractions } from '@/app/components/workflow/hooks/use-nodes-interactions'
import { useGetToolIcon } from '@/app/components/workflow/hooks/use-tool-icon' import { useGetToolIcon } from '@/app/components/workflow/hooks/use-tool-icon'
@ -107,7 +107,7 @@ export const useRagPipelineSearch = () => {
) )
// Create search function for RAG pipeline nodes // Create search function for RAG pipeline nodes
const searchRagPipelineNodes = useCallback( const findRagPipelineNodes = useCallback(
(query: string) => { (query: string) => {
if (!searchableNodes.length) return [] if (!searchableNodes.length) return []
@ -154,18 +154,9 @@ export const useRagPipelineSearch = () => {
[searchableNodes, calculateScore], [searchableNodes, calculateScore],
) )
// Directly set the search function on the action object
useEffect(() => { useEffect(() => {
if (searchableNodes.length > 0) { return registerRagPipelineNodeSearch(findRagPipelineNodes)
// Set the search function directly on the action }, [findRagPipelineNodes])
ragPipelineNodesAction.searchFn = searchRagPipelineNodes
}
return () => {
// Clean up when component unmounts
ragPipelineNodesAction.searchFn = undefined
}
}, [searchableNodes, searchRagPipelineNodes])
// Set up node selection event listener using the utility function // Set up node selection event listener using the utility function
useEffect(() => { useEffect(() => {

View File

@ -1,11 +1,12 @@
'use client' 'use client'
import { DialogTrigger } from '@langgenius/dify-ui/dialog'
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd' import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { formatForDisplay } from '@tanstack/react-hotkeys' import { formatForDisplay } from '@tanstack/react-hotkeys'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import SidebarLeftArrowIcon from '@/app/components/base/icons/src/vender/SidebarLeftArrowIcon' 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 Link from '@/next/link'
import { useRouter } from '@/next/navigation' import { useRouter } from '@/next/navigation'
import ToggleButton from '../../app-sidebar/toggle-button' import ToggleButton from '../../app-sidebar/toggle-button'
@ -20,7 +21,6 @@ const SEARCH_SHORTCUT = ['Mod', 'K']
const SnippetDetailTop = ({ expand = true, onToggle }: SnippetDetailTopProps) => { const SnippetDetailTop = ({ expand = true, onToggle }: SnippetDetailTopProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const router = useRouter() const router = useRouter()
const setGotoAnythingOpen = useSetGotoAnythingOpen()
if (!expand) { if (!expand) {
return ( return (
@ -68,14 +68,18 @@ const SnippetDetailTop = ({ expand = true, onToggle }: SnippetDetailTopProps) =>
<Tooltip> <Tooltip>
<TooltipTrigger <TooltipTrigger
render={ render={
<button <DialogTrigger
type="button" handle={gotoAnythingDialogHandle}
aria-label={t(($) => $['gotoAnything.searchTitle'], { ns: 'app' })} render={
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" <button
onClick={() => setGotoAnythingOpen(true)} type="button"
> aria-label={t(($) => $['gotoAnything.searchTitle'], { ns: 'app' })}
<span aria-hidden className="i-custom-vender-main-nav-quick-search size-4" /> 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"
</button> >
<span aria-hidden className="i-custom-vender-main-nav-quick-search size-4" />
</button>
}
/>
} }
/> />
<TooltipContent <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 type { CommonNodeType, Node, ToolWithProvider } from '../../types'
import { act, renderHook } from '@testing-library/react' 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 { CollectionType } from '@/app/components/tools/types'
import { findWorkflowNodes } from '../../goto-anything-search'
import { BlockEnum } from '../../types' import { BlockEnum } from '../../types'
import { useWorkflowSearch } from '../use-workflow-search' import { useWorkflowSearch } from '../use-workflow-search'
@ -49,7 +49,6 @@ describe('useWorkflowSearch', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
runtimeNodes.length = 0 runtimeNodes.length = 0
workflowNodesAction.searchFn = undefined
}) })
it('registers workflow node search results with tool icons and llm metadata scoring', async () => { 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 { unmount } = renderHook(() => useWorkflowSearch())
const llmResults = await workflowNodesAction.search('', 'gpt') const llmResults = findWorkflowNodes('gpt')
expect(llmResults.map((item) => item.id)).toEqual(['llm-1']) expect(llmResults.map((item) => item.id)).toEqual(['llm-1'])
expect(llmResults[0]?.title).toBe('Writer') 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.map((item) => item.id)).toEqual(['tool-1'])
expect(toolResults[0]?.description).toBe('Search the web') expect(toolResults[0]?.description).toBe('Search the web')
unmount() unmount()
expect(workflowNodesAction.searchFn).toBeUndefined() expect(findWorkflowNodes('gpt')).toEqual([])
}) })
it('binds the node selection listener to handleNodeSelect', () => { 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 type { Emoji } from '@/app/components/tools/types'
import { useCallback, useEffect, useMemo } from 'react' import { useCallback, useEffect, useMemo } from 'react'
import { useNodes } from 'reactflow' import { useNodes } from 'reactflow'
import { workflowNodesAction } from '@/app/components/goto-anything/actions/workflow-nodes'
import { CollectionType } from '@/app/components/tools/types' import { CollectionType } from '@/app/components/tools/types'
import BlockIcon from '@/app/components/workflow/block-icon' import BlockIcon from '@/app/components/workflow/block-icon'
import { registerWorkflowNodeSearch } from '@/app/components/workflow/goto-anything-search'
import { import {
useAllBuiltInTools, useAllBuiltInTools,
useAllCustomTools, useAllCustomTools,
@ -135,7 +135,7 @@ export const useWorkflowSearch = () => {
) )
// Create search function for workflow nodes // Create search function for workflow nodes
const searchWorkflowNodes = useCallback( const findWorkflowNodes = useCallback(
(query: string) => { (query: string) => {
if (!searchableNodes.length) return [] if (!searchableNodes.length) return []
@ -182,18 +182,9 @@ export const useWorkflowSearch = () => {
[searchableNodes, calculateScore], [searchableNodes, calculateScore],
) )
// Directly set the search function on the action object
useEffect(() => { useEffect(() => {
if (searchableNodes.length > 0) { return registerWorkflowNodeSearch(findWorkflowNodes)
// Set the search function directly on the action }, [findWorkflowNodes])
workflowNodesAction.searchFn = searchWorkflowNodes
}
return () => {
// Clean up when component unmounts
workflowNodesAction.searchFn = undefined
}
}, [searchableNodes, searchWorkflowNodes])
// Set up node selection event listener using the utility function // Set up node selection event listener using the utility function
useEffect(() => { useEffect(() => {

View File

@ -5,6 +5,7 @@ import type { ComponentProps } from 'react'
import type { AgentDetailSectionKey } from './section' import type { AgentDetailSectionKey } from './section'
import type { NavIcon } from '@/app/components/app-sidebar/nav-link' import type { NavIcon } from '@/app/components/app-sidebar/nav-link'
import { cn } from '@langgenius/dify-ui/cn' import { cn } from '@langgenius/dify-ui/cn'
import { DialogTrigger } from '@langgenius/dify-ui/dialog'
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd' import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { formatForDisplay } from '@tanstack/react-hotkeys' 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 AppIcon from '@/app/components/base/app-icon'
import Divider from '@/app/components/base/divider' import Divider from '@/app/components/base/divider'
import SidebarLeftArrowIcon from '@/app/components/base/icons/src/vender/SidebarLeftArrowIcon' 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 Link from '@/next/link'
import { usePathname } from '@/next/navigation' import { usePathname } from '@/next/navigation'
import { consoleQuery } from '@/service/client' import { consoleQuery } from '@/service/client'
@ -86,7 +87,6 @@ const getAgentDetailNavigation = (agentId: string): AgentDetailNavItem[] => [
export function AgentDetailTop({ expand = true, onToggle }: AgentDetailTopProps) { export function AgentDetailTop({ expand = true, onToggle }: AgentDetailTopProps) {
const { t: tApp } = useTranslation('app') const { t: tApp } = useTranslation('app')
const { t: tCommon } = useTranslation('common') const { t: tCommon } = useTranslation('common')
const setGotoAnythingOpen = useSetGotoAnythingOpen()
if (!expand) { if (!expand) {
return ( return (
@ -125,14 +125,18 @@ export function AgentDetailTop({ expand = true, onToggle }: AgentDetailTopProps)
<Tooltip> <Tooltip>
<TooltipTrigger <TooltipTrigger
render={ render={
<button <DialogTrigger
type="button" handle={gotoAnythingDialogHandle}
aria-label={tApp(($) => $['gotoAnything.searchTitle'])} render={
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" <button
onClick={() => setGotoAnythingOpen(true)} type="button"
> aria-label={tApp(($) => $['gotoAnything.searchTitle'])}
<span aria-hidden className="i-custom-vender-main-nav-quick-search size-4" /> 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"
</button> >
<span aria-hidden className="i-custom-vender-main-nav-quick-search size-4" />
</button>
}
/>
} }
/> />
<TooltipContent <TooltipContent

View File

@ -4,6 +4,7 @@ import type { ComponentProps, PropsWithoutRef } from 'react'
import type { InstanceDetailTabKey } from './tabs' import type { InstanceDetailTabKey } from './tabs'
import type { NavIcon } from '@/app/components/app-sidebar/nav-link' import type { NavIcon } from '@/app/components/app-sidebar/nav-link'
import { cn } from '@langgenius/dify-ui/cn' import { cn } from '@langgenius/dify-ui/cn'
import { DialogTrigger } from '@langgenius/dify-ui/dialog'
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd' import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { formatForDisplay } from '@tanstack/react-hotkeys' 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 Divider from '@/app/components/base/divider'
import SidebarLeftArrowIcon from '@/app/components/base/icons/src/vender/SidebarLeftArrowIcon' import SidebarLeftArrowIcon from '@/app/components/base/icons/src/vender/SidebarLeftArrowIcon'
import { SkeletonContainer, SkeletonRectangle } from '@/app/components/base/skeleton' 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 Link from '@/next/link'
import { usePathname } from '@/next/navigation' import { usePathname } from '@/next/navigation'
import { DeploymentActionsMenu } from '../deployment-actions' import { DeploymentActionsMenu } from '../deployment-actions'
@ -192,7 +193,6 @@ export function DeploymentDetailTop({
onToggle?: () => void onToggle?: () => void
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const setGotoAnythingOpen = useSetGotoAnythingOpen()
if (!expand) { if (!expand) {
return ( return (
@ -231,14 +231,18 @@ export function DeploymentDetailTop({
<Tooltip> <Tooltip>
<TooltipTrigger <TooltipTrigger
render={ render={
<button <DialogTrigger
type="button" handle={gotoAnythingDialogHandle}
aria-label={t(($) => $['gotoAnything.searchTitle'], { ns: 'app' })} render={
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" <button
onClick={() => setGotoAnythingOpen(true)} type="button"
> aria-label={t(($) => $['gotoAnything.searchTitle'], { ns: 'app' })}
<span aria-hidden className="i-custom-vender-main-nav-quick-search size-4" /> 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"
</button> >
<span aria-hidden className="i-custom-vender-main-nav-quick-search size-4" />
</button>
}
/>
} }
/> />
<TooltipContent <TooltipContent

View File

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

View File

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