mirror of
https://github.com/langgenius/dify.git
synced 2026-07-21 02:28:30 +08:00
refactor(web): rebuild goto anything dialog (#38914)
This commit is contained in:
parent
c278148c8f
commit
e2fccb604a
@ -122,22 +122,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/__tests__/goto-anything/command-selector.test.tsx": {
|
||||
"jsx_a11y/click-events-have-key-events": {
|
||||
"count": 1
|
||||
},
|
||||
"jsx_a11y/no-static-element-interactions": {
|
||||
"count": 1
|
||||
},
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/__tests__/goto-anything/search-error-handling.test.ts": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/__tests__/i18n-upload-features.test.ts": {
|
||||
"no-console": {
|
||||
"count": 3
|
||||
@ -3220,62 +3204,11 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/goto-anything/actions/commands/registry.ts": {
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"web/app/components/goto-anything/actions/commands/types.ts": {
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/goto-anything/actions/plugin.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/goto-anything/actions/recent-store.ts": {
|
||||
"no-restricted-globals": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/goto-anything/actions/types.ts": {
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/goto-anything/components/__tests__/search-input.spec.tsx": {
|
||||
"jsx_a11y/no-autofocus": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/goto-anything/components/index.ts": {
|
||||
"no-barrel-files/no-barrel-files": {
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"web/app/components/goto-anything/components/search-input.tsx": {
|
||||
"jsx_a11y/no-autofocus": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/goto-anything/context.tsx": {
|
||||
"eslint-react/set-state-in-effect": {
|
||||
"count": 4
|
||||
},
|
||||
"react/only-export-components": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/goto-anything/hooks/use-goto-anything-results.ts": {
|
||||
"@tanstack/query/exhaustive-deps": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/header/account-setting/data-source-page-new/__tests__/item.spec.tsx": {
|
||||
"jsx_a11y/click-events-have-key-events": {
|
||||
"count": 1
|
||||
@ -6846,7 +6779,7 @@
|
||||
"count": 1
|
||||
},
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 7
|
||||
"count": 6
|
||||
}
|
||||
},
|
||||
"web/service/base.ts": {
|
||||
|
||||
@ -1,5 +1,13 @@
|
||||
import { render } from 'vitest-browser-react'
|
||||
import { Dialog, DialogCloseButton, DialogContent, DialogDescription, DialogTitle } from '../index'
|
||||
import {
|
||||
createDialogHandle,
|
||||
Dialog,
|
||||
DialogCloseButton,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '../index'
|
||||
|
||||
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
|
||||
|
||||
@ -18,6 +26,26 @@ describe('Dialog wrapper', () => {
|
||||
await expect.element(screen.getByRole('dialog')).toHaveTextContent('Dialog Title')
|
||||
await expect.element(screen.getByRole('dialog')).toHaveTextContent('Dialog Description')
|
||||
})
|
||||
|
||||
it('should connect a detached trigger to the dialog', async () => {
|
||||
const handle = createDialogHandle()
|
||||
const screen = await render(
|
||||
<>
|
||||
<DialogTrigger handle={handle}>Open dialog</DialogTrigger>
|
||||
<Dialog handle={handle}>
|
||||
<DialogContent>
|
||||
<DialogTitle>Detached dialog</DialogTitle>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>,
|
||||
)
|
||||
|
||||
await screen.getByRole('button', { name: 'Open dialog' }).click()
|
||||
|
||||
await expect
|
||||
.element(screen.getByRole('dialog', { name: 'Detached dialog' }))
|
||||
.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
|
||||
@ -9,6 +9,7 @@ export const DialogTrigger = BaseDialog.Trigger
|
||||
export const DialogTitle = BaseDialog.Title
|
||||
export const DialogDescription = BaseDialog.Description
|
||||
export const DialogPortal = BaseDialog.Portal
|
||||
export const createDialogHandle = BaseDialog.createHandle
|
||||
|
||||
type DialogBackdropProps = Omit<BaseDialog.Backdrop.Props, 'className'> & {
|
||||
className?: string
|
||||
|
||||
1687
pnpm-lock.yaml
generated
1687
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -142,7 +142,6 @@ catalog:
|
||||
class-variance-authority: 0.7.1
|
||||
cli-table3: 0.6.5
|
||||
clsx: 2.1.1
|
||||
cmdk: 1.1.1
|
||||
code-inspector-plugin: 1.6.6
|
||||
concurrently: ^10.0.3
|
||||
copy-to-clipboard: 4.0.2
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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([])
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,7 +1,7 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Dialog, DialogPopup, DialogPortal, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { createStore, Provider as JotaiProvider } from 'jotai'
|
||||
import { useGotoAnythingOpen } from '@/app/components/goto-anything/atoms'
|
||||
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
|
||||
import AppDetailTop from '../app-detail-top'
|
||||
|
||||
vi.mock('../toggle-button', () => ({
|
||||
@ -26,54 +26,55 @@ vi.mock('../toggle-button', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
function GotoAnythingOpenProbe() {
|
||||
const open = useGotoAnythingOpen()
|
||||
|
||||
return <div data-testid="goto-anything-open">{String(open)}</div>
|
||||
}
|
||||
|
||||
const renderWithGotoAnythingStore = (ui: ReactNode) => {
|
||||
const store = createStore()
|
||||
|
||||
return render(<JotaiProvider store={store}>{ui}</JotaiProvider>)
|
||||
function TestGotoAnythingDialog() {
|
||||
return (
|
||||
<Dialog handle={gotoAnythingDialogHandle}>
|
||||
<DialogPortal>
|
||||
<DialogPopup>
|
||||
<DialogTitle>Goto Anything</DialogTitle>
|
||||
</DialogPopup>
|
||||
</DialogPortal>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
describe('AppDetailTop', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
gotoAnythingDialogHandle.close()
|
||||
})
|
||||
|
||||
it('links the combined home control to home', () => {
|
||||
renderWithGotoAnythingStore(<AppDetailTop />)
|
||||
render(<AppDetailTop />)
|
||||
|
||||
expect(screen.getByRole('link', { name: 'common.mainNav.home' })).toHaveAttribute('href', '/')
|
||||
expect(screen.queryByRole('button', { name: 'common.operation.back' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('links the Studio breadcrumb to the Studio page', () => {
|
||||
renderWithGotoAnythingStore(<AppDetailTop />)
|
||||
render(<AppDetailTop />)
|
||||
|
||||
expect(screen.getByRole('link', { name: 'common.menus.apps' })).toHaveAttribute('href', '/apps')
|
||||
})
|
||||
|
||||
it('keeps the quick search action', () => {
|
||||
renderWithGotoAnythingStore(
|
||||
render(
|
||||
<>
|
||||
<AppDetailTop />
|
||||
<GotoAnythingOpenProbe />
|
||||
<TestGotoAnythingDialog />
|
||||
</>,
|
||||
)
|
||||
expect(screen.getByTestId('goto-anything-open')).toHaveTextContent('false')
|
||||
expect(screen.queryByRole('dialog', { name: 'Goto Anything' })).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'app.gotoAnything.searchTitle' }))
|
||||
|
||||
expect(screen.getByTestId('goto-anything-open')).toHaveTextContent('true')
|
||||
expect(screen.getByRole('dialog', { name: 'Goto Anything' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the sidebar toggle action in the top right', () => {
|
||||
const onToggle = vi.fn()
|
||||
|
||||
renderWithGotoAnythingStore(<AppDetailTop onToggle={onToggle} />)
|
||||
render(<AppDetailTop onToggle={onToggle} />)
|
||||
fireEvent.click(screen.getByTestId('toggle-button'))
|
||||
|
||||
expect(screen.getByTestId('toggle-button')).toHaveAttribute('data-expand', 'true')
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Dialog, DialogPopup, DialogPortal, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { createStore, Provider as JotaiProvider } from 'jotai'
|
||||
import { useGotoAnythingOpen } from '@/app/components/goto-anything/atoms'
|
||||
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
|
||||
import DatasetDetailTop from '../dataset-detail-top'
|
||||
|
||||
vi.mock('../toggle-button', () => ({
|
||||
@ -26,25 +26,26 @@ vi.mock('../toggle-button', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
function GotoAnythingOpenProbe() {
|
||||
const open = useGotoAnythingOpen()
|
||||
|
||||
return <div data-testid="goto-anything-open">{String(open)}</div>
|
||||
}
|
||||
|
||||
const renderWithGotoAnythingStore = (ui: ReactNode) => {
|
||||
const store = createStore()
|
||||
|
||||
return render(<JotaiProvider store={store}>{ui}</JotaiProvider>)
|
||||
function TestGotoAnythingDialog() {
|
||||
return (
|
||||
<Dialog handle={gotoAnythingDialogHandle}>
|
||||
<DialogPortal>
|
||||
<DialogPopup>
|
||||
<DialogTitle>Goto Anything</DialogTitle>
|
||||
</DialogPopup>
|
||||
</DialogPortal>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
describe('DatasetDetailTop', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
gotoAnythingDialogHandle.close()
|
||||
})
|
||||
|
||||
it('links the combined home control to home and labels the breadcrumb as datasets', () => {
|
||||
renderWithGotoAnythingStore(<DatasetDetailTop />)
|
||||
render(<DatasetDetailTop />)
|
||||
|
||||
expect(screen.getByRole('link', { name: 'common.mainNav.home' })).toHaveAttribute('href', '/')
|
||||
expect(screen.getByRole('link', { name: 'common.menus.datasets' })).toHaveAttribute(
|
||||
@ -55,23 +56,23 @@ describe('DatasetDetailTop', () => {
|
||||
})
|
||||
|
||||
it('keeps the quick search action', () => {
|
||||
renderWithGotoAnythingStore(
|
||||
render(
|
||||
<>
|
||||
<DatasetDetailTop />
|
||||
<GotoAnythingOpenProbe />
|
||||
<TestGotoAnythingDialog />
|
||||
</>,
|
||||
)
|
||||
expect(screen.getByTestId('goto-anything-open')).toHaveTextContent('false')
|
||||
expect(screen.queryByRole('dialog', { name: 'Goto Anything' })).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'app.gotoAnything.searchTitle' }))
|
||||
|
||||
expect(screen.getByTestId('goto-anything-open')).toHaveTextContent('true')
|
||||
expect(screen.getByRole('dialog', { name: 'Goto Anything' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the sidebar toggle action in the top right', () => {
|
||||
const onToggle = vi.fn()
|
||||
|
||||
renderWithGotoAnythingStore(<DatasetDetailTop expand={false} onToggle={onToggle} />)
|
||||
render(<DatasetDetailTop expand={false} onToggle={onToggle} />)
|
||||
fireEvent.click(screen.getByTestId('toggle-button'))
|
||||
|
||||
expect(screen.getByTestId('toggle-button')).toHaveAttribute('data-expand', 'false')
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { DialogTrigger } from '@langgenius/dify-ui/dialog'
|
||||
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { formatForDisplay } from '@tanstack/react-hotkeys'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import SidebarLeftArrowIcon from '@/app/components/base/icons/src/vender/SidebarLeftArrowIcon'
|
||||
import { useSetGotoAnythingOpen } from '@/app/components/goto-anything/atoms'
|
||||
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
|
||||
import Link from '@/next/link'
|
||||
import ToggleButton from './toggle-button'
|
||||
|
||||
@ -18,7 +19,6 @@ const SEARCH_SHORTCUT = ['Mod', 'K']
|
||||
|
||||
const AppDetailTop = ({ expand = true, onToggle }: AppDetailTopProps) => {
|
||||
const { t } = useTranslation()
|
||||
const setGotoAnythingOpen = useSetGotoAnythingOpen()
|
||||
|
||||
if (!expand) {
|
||||
return (
|
||||
@ -62,14 +62,18 @@ const AppDetailTop = ({ expand = true, onToggle }: AppDetailTopProps) => {
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t(($) => $['gotoAnything.searchTitle'], { ns: 'app' })}
|
||||
className="flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-[10px] text-text-tertiary transition-colors hover:bg-state-base-hover hover:text-text-secondary"
|
||||
onClick={() => setGotoAnythingOpen(true)}
|
||||
>
|
||||
<span aria-hidden className="i-custom-vender-main-nav-quick-search size-4" />
|
||||
</button>
|
||||
<DialogTrigger
|
||||
handle={gotoAnythingDialogHandle}
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t(($) => $['gotoAnything.searchTitle'], { ns: 'app' })}
|
||||
className="flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-[10px] text-text-tertiary transition-colors hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
|
||||
>
|
||||
<span aria-hidden className="i-custom-vender-main-nav-quick-search size-4" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<TooltipContent
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { DialogTrigger } from '@langgenius/dify-ui/dialog'
|
||||
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { formatForDisplay } from '@tanstack/react-hotkeys'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import SidebarLeftArrowIcon from '@/app/components/base/icons/src/vender/SidebarLeftArrowIcon'
|
||||
import { useSetGotoAnythingOpen } from '@/app/components/goto-anything/atoms'
|
||||
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
|
||||
import Link from '@/next/link'
|
||||
import ToggleButton from './toggle-button'
|
||||
|
||||
@ -18,7 +19,6 @@ const SEARCH_SHORTCUT = ['Mod', 'K']
|
||||
|
||||
const DatasetDetailTop = ({ expand = true, onToggle }: DatasetDetailTopProps) => {
|
||||
const { t } = useTranslation()
|
||||
const setGotoAnythingOpen = useSetGotoAnythingOpen()
|
||||
|
||||
if (!expand) {
|
||||
return (
|
||||
@ -62,14 +62,18 @@ const DatasetDetailTop = ({ expand = true, onToggle }: DatasetDetailTopProps) =>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t(($) => $['gotoAnything.searchTitle'], { ns: 'app' })}
|
||||
className="flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-[10px] text-text-tertiary transition-colors hover:bg-state-base-hover hover:text-text-secondary"
|
||||
onClick={() => setGotoAnythingOpen(true)}
|
||||
>
|
||||
<span aria-hidden className="i-custom-vender-main-nav-quick-search size-4" />
|
||||
</button>
|
||||
<DialogTrigger
|
||||
handle={gotoAnythingDialogHandle}
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t(($) => $['gotoAnything.searchTitle'], { ns: 'app' })}
|
||||
className="flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-[10px] text-text-tertiary transition-colors hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
|
||||
>
|
||||
<span aria-hidden className="i-custom-vender-main-nav-quick-search size-4" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<TooltipContent
|
||||
|
||||
33
web/app/components/goto-anything/README.md
Normal file
33
web/app/components/goto-anything/README.md
Normal 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.
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -1,9 +1,11 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { ActionItem, SearchResult } from '../actions/types'
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import { DialogTrigger } from '@langgenius/dify-ui/dialog'
|
||||
import { detectPlatform } from '@tanstack/react-hotkeys'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createStore, Provider } from 'jotai'
|
||||
import * as React from 'react'
|
||||
import { gotoAnythingDialogHandle } from '../dialog-handle'
|
||||
import { GotoAnything } from '../index'
|
||||
|
||||
type TestSearchResult = Omit<SearchResult, 'icon' | 'data'> & {
|
||||
@ -19,57 +21,66 @@ vi.mock('@/next/navigation', () => ({
|
||||
usePathname: () => '/',
|
||||
}))
|
||||
|
||||
type KeyPressEvent = {
|
||||
preventDefault: () => void
|
||||
target?: EventTarget
|
||||
}
|
||||
|
||||
type HotkeyRegistration = {
|
||||
handler: (event: KeyPressEvent) => void
|
||||
options?: { enabled?: boolean }
|
||||
}
|
||||
|
||||
const hotkeyHandlers: Record<string, HotkeyRegistration> = {}
|
||||
let debouncedSearchQuery: string | undefined
|
||||
vi.mock('ahooks', () => ({
|
||||
useDebounce: <T,>(value: T) => value,
|
||||
useDebounce: <T,>(value: T) => (debouncedSearchQuery ?? value) as T,
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-hotkeys', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@tanstack/react-hotkeys')>()
|
||||
return {
|
||||
...actual,
|
||||
useHotkey: (
|
||||
hotkey: string,
|
||||
handler: (event: KeyPressEvent) => void,
|
||||
options?: HotkeyRegistration['options'],
|
||||
) => {
|
||||
hotkeyHandlers[hotkey] = { handler, options }
|
||||
},
|
||||
}
|
||||
})
|
||||
const isMac = detectPlatform() === 'mac'
|
||||
|
||||
const HOTKEY_ALIAS: Record<string, string> = {
|
||||
'ctrl.k': 'Mod+K',
|
||||
function triggerSearchShortcut(target: Document | HTMLElement = document) {
|
||||
fireEvent.keyDown(target, {
|
||||
key: 'k',
|
||||
ctrlKey: !isMac,
|
||||
metaKey: isMac,
|
||||
})
|
||||
}
|
||||
|
||||
const triggerKeyPress = (combo: string) => {
|
||||
const hotkey = HOTKEY_ALIAS[combo] ?? combo
|
||||
const registration = hotkeyHandlers[hotkey]
|
||||
if (registration && registration.options?.enabled !== false) {
|
||||
act(() => {
|
||||
registration.handler({ preventDefault: vi.fn(), target: document.body })
|
||||
})
|
||||
}
|
||||
type RemoteQueryState = {
|
||||
data: TestSearchResult[]
|
||||
isLoading: boolean
|
||||
isError: boolean
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
let mockQueryResult = {
|
||||
data: [] as TestSearchResult[],
|
||||
const emptyRemoteQueryState = (): RemoteQueryState => ({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null as Error | null,
|
||||
error: null,
|
||||
})
|
||||
|
||||
let remoteQueryStates: Record<'app' | 'knowledge' | 'plugin', RemoteQueryState> = {
|
||||
app: emptyRemoteQueryState(),
|
||||
knowledge: emptyRemoteQueryState(),
|
||||
plugin: emptyRemoteQueryState(),
|
||||
}
|
||||
let enabledRemoteQueryKeys: string[] = []
|
||||
|
||||
function setRemoteResults(results: TestSearchResult[]) {
|
||||
results.forEach((result) => {
|
||||
if (result.type === 'app' || result.type === 'knowledge' || result.type === 'plugin')
|
||||
remoteQueryStates[result.type].data.push(result)
|
||||
})
|
||||
}
|
||||
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
useQuery: () => mockQueryResult,
|
||||
useQuery: (options: { queryKey: [key: keyof typeof remoteQueryStates]; enabled?: boolean }) => {
|
||||
if (options.enabled) enabledRemoteQueryKeys.push(options.queryKey[0])
|
||||
return options.enabled ? remoteQueryStates[options.queryKey[0]] : emptyRemoteQueryState()
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../actions/app', () => ({
|
||||
appSearchQueryOptions: () => ({ queryKey: ['app'] }),
|
||||
}))
|
||||
|
||||
vi.mock('../actions/knowledge', () => ({
|
||||
knowledgeSearchQueryOptions: () => ({ queryKey: ['knowledge'] }),
|
||||
}))
|
||||
|
||||
vi.mock('../actions/plugin', () => ({
|
||||
pluginSearchQueryOptions: () => ({ queryKey: ['plugin'] }),
|
||||
}))
|
||||
vi.mock(
|
||||
'@/app/components/plugins/install-plugin/hooks/use-workspace-plugin-install-permission',
|
||||
@ -81,35 +92,38 @@ vi.mock(
|
||||
}),
|
||||
)
|
||||
|
||||
const contextValue = { isWorkflowPage: false, isRagPipelinePage: false }
|
||||
vi.mock('../context', () => ({
|
||||
useGotoAnythingContext: () => contextValue,
|
||||
GotoAnythingProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}))
|
||||
|
||||
const createActionItem = (key: ActionItem['key'], shortcut: string): ActionItem => ({
|
||||
const createRemoteAction = (key: ActionItem['key'], shortcut: string): ActionItem => ({
|
||||
key,
|
||||
shortcut,
|
||||
title: `${key} title`,
|
||||
description: `${key} desc`,
|
||||
action: vi.fn(),
|
||||
search: vi.fn(),
|
||||
source: 'remote',
|
||||
})
|
||||
|
||||
const actionsMock = {
|
||||
slash: createActionItem('/', '/'),
|
||||
app: createActionItem('@app', '@app'),
|
||||
plugin: createActionItem('@plugin', '@plugin'),
|
||||
slash: {
|
||||
key: '/',
|
||||
shortcut: '/',
|
||||
title: '/ title',
|
||||
description: '/ desc',
|
||||
source: 'local',
|
||||
action: vi.fn(),
|
||||
search: vi.fn(() => []),
|
||||
} satisfies ActionItem,
|
||||
app: createRemoteAction('@app', '@app'),
|
||||
knowledge: createRemoteAction('@knowledge', '@kb'),
|
||||
plugin: createRemoteAction('@plugin', '@plugin'),
|
||||
}
|
||||
|
||||
const createActionsMock = vi.fn(() => actionsMock)
|
||||
const matchActionMock = vi.fn(() => undefined)
|
||||
const searchAnythingMock = vi.fn(async () => mockQueryResult.data)
|
||||
|
||||
const matchActionMock = vi.fn<
|
||||
(query: string, actions: Record<string, ActionItem>) => ActionItem | undefined
|
||||
>(() => undefined)
|
||||
vi.mock('../actions', () => ({
|
||||
createActions: () => createActionsMock(),
|
||||
matchAction: () => matchActionMock(),
|
||||
searchAnything: () => searchAnythingMock(),
|
||||
getActionSearchTerm: (_query: string, action: ActionItem) => action.key,
|
||||
matchAction: (query: string, actions: Record<string, ActionItem>) =>
|
||||
matchActionMock(query, actions),
|
||||
}))
|
||||
|
||||
vi.mock('../actions/commands/slash-provider', () => ({
|
||||
@ -123,10 +137,11 @@ type MockSlashCommand = {
|
||||
} | null
|
||||
|
||||
let mockFindCommand: MockSlashCommand = null
|
||||
let mockAvailableCommands: Array<{ name: string; description: string }> = []
|
||||
vi.mock('../actions/commands/registry', () => ({
|
||||
slashCommandRegistry: {
|
||||
findCommand: () => mockFindCommand,
|
||||
getAvailableCommands: () => [],
|
||||
getAvailableCommands: () => mockAvailableCommands,
|
||||
getAllCommands: () => [],
|
||||
},
|
||||
}))
|
||||
@ -153,40 +168,102 @@ vi.mock('../../plugins/install-plugin/install-from-marketplace', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
const renderGotoAnything = (ui: React.ReactElement) => {
|
||||
const store = createStore()
|
||||
|
||||
return render(<Provider store={store}>{ui}</Provider>)
|
||||
}
|
||||
const renderGotoAnything = (ui: React.ReactElement) => render(ui)
|
||||
|
||||
describe('GotoAnything', () => {
|
||||
beforeEach(() => {
|
||||
routerPush.mockClear()
|
||||
Object.keys(hotkeyHandlers).forEach((key) => delete hotkeyHandlers[key])
|
||||
mockQueryResult = { data: [], isLoading: false, isError: false, error: null }
|
||||
gotoAnythingDialogHandle.close()
|
||||
remoteQueryStates = {
|
||||
app: emptyRemoteQueryState(),
|
||||
knowledge: emptyRemoteQueryState(),
|
||||
plugin: emptyRemoteQueryState(),
|
||||
}
|
||||
debouncedSearchQuery = undefined
|
||||
enabledRemoteQueryKeys = []
|
||||
matchActionMock.mockReset()
|
||||
searchAnythingMock.mockClear()
|
||||
mockFindCommand = null
|
||||
mockAvailableCommands = []
|
||||
})
|
||||
|
||||
describe('modal behavior', () => {
|
||||
it('should open modal via Ctrl+K shortcut', async () => {
|
||||
renderGotoAnything(<GotoAnything />)
|
||||
|
||||
triggerKeyPress('ctrl.k')
|
||||
triggerSearchShortcut()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder'),
|
||||
screen.getByRole('dialog', { name: 'app.gotoAnything.searchTitle' }),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toHaveFocus()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not open from an unrelated editable field', () => {
|
||||
renderGotoAnything(
|
||||
<>
|
||||
<input aria-label="Unrelated field" />
|
||||
<GotoAnything />
|
||||
</>,
|
||||
)
|
||||
|
||||
triggerSearchShortcut(screen.getByRole('textbox', { name: 'Unrelated field' }))
|
||||
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each(['shiftKey', 'altKey'] as const)('should ignore Mod+K with %s', (extraModifier) => {
|
||||
renderGotoAnything(<GotoAnything />)
|
||||
|
||||
fireEvent.keyDown(document, {
|
||||
key: 'k',
|
||||
ctrlKey: !isMac,
|
||||
metaKey: isMac,
|
||||
[extraModifier]: true,
|
||||
})
|
||||
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should ignore K with the non-primary platform modifier', () => {
|
||||
renderGotoAnything(<GotoAnything />)
|
||||
|
||||
fireEvent.keyDown(document, {
|
||||
key: 'k',
|
||||
ctrlKey: isMac,
|
||||
metaKey: !isMac,
|
||||
})
|
||||
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should restore focus to the detached trigger after Escape', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderGotoAnything(
|
||||
<>
|
||||
<DialogTrigger
|
||||
handle={gotoAnythingDialogHandle}
|
||||
render={<button type="button">Search</button>}
|
||||
/>
|
||||
<GotoAnything />
|
||||
</>,
|
||||
)
|
||||
const trigger = screen.getByRole('button', { name: 'Search' })
|
||||
|
||||
await user.click(trigger)
|
||||
expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toHaveFocus()
|
||||
|
||||
await user.keyboard('{Escape}')
|
||||
|
||||
await waitFor(() => expect(trigger).toHaveFocus())
|
||||
})
|
||||
|
||||
it('should close modal via ESC key', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderGotoAnything(<GotoAnything />)
|
||||
|
||||
triggerKeyPress('ctrl.k')
|
||||
triggerSearchShortcut()
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder'),
|
||||
@ -201,47 +278,25 @@ describe('GotoAnything', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should toggle modal when pressing Ctrl+K twice', async () => {
|
||||
it('should keep the modal open when pressing Ctrl+K again', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderGotoAnything(<GotoAnything />)
|
||||
|
||||
triggerKeyPress('ctrl.k')
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder'),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
triggerSearchShortcut()
|
||||
const input = await screen.findByPlaceholderText('app.gotoAnything.searchPlaceholder')
|
||||
await user.type(input, 'workflow')
|
||||
|
||||
triggerKeyPress('ctrl.k')
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByPlaceholderText('app.gotoAnything.searchPlaceholder'),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
triggerSearchShortcut()
|
||||
|
||||
it('should call onHide when modal closes', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onHide = vi.fn()
|
||||
renderGotoAnything(<GotoAnything onHide={onHide} />)
|
||||
|
||||
triggerKeyPress('ctrl.k')
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder'),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await user.keyboard('{Escape}')
|
||||
await waitFor(() => {
|
||||
expect(onHide).toHaveBeenCalled()
|
||||
})
|
||||
expect(input).toHaveValue('workflow')
|
||||
expect(input).toHaveFocus()
|
||||
})
|
||||
|
||||
it('should reset search query when modal opens', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderGotoAnything(<GotoAnything />)
|
||||
|
||||
triggerKeyPress('ctrl.k')
|
||||
triggerSearchShortcut()
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder'),
|
||||
@ -258,7 +313,7 @@ describe('GotoAnything', () => {
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
triggerKeyPress('ctrl.k')
|
||||
triggerSearchShortcut()
|
||||
await waitFor(() => {
|
||||
const newInput = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')
|
||||
expect(newInput).toHaveValue('')
|
||||
@ -269,25 +324,20 @@ describe('GotoAnything', () => {
|
||||
describe('search functionality', () => {
|
||||
it('should navigate to selected result', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockQueryResult = {
|
||||
data: [
|
||||
{
|
||||
id: 'app-1',
|
||||
type: 'app',
|
||||
title: 'Sample App',
|
||||
description: 'desc',
|
||||
path: '/apps/1',
|
||||
icon: <div data-testid="icon">🧩</div>,
|
||||
data: {},
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
}
|
||||
setRemoteResults([
|
||||
{
|
||||
id: 'app-1',
|
||||
type: 'app',
|
||||
title: 'Sample App',
|
||||
description: 'desc',
|
||||
path: '/apps/1',
|
||||
icon: <div data-testid="icon">🧩</div>,
|
||||
data: {},
|
||||
},
|
||||
])
|
||||
|
||||
renderGotoAnything(<GotoAnything />)
|
||||
triggerKeyPress('ctrl.k')
|
||||
triggerSearchShortcut()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
@ -302,12 +352,84 @@ describe('GotoAnything', () => {
|
||||
await user.click(result)
|
||||
|
||||
expect(routerPush).toHaveBeenCalledWith('/apps/1')
|
||||
expect(routerPush).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should navigate the highlighted result with ArrowDown and Enter', async () => {
|
||||
const user = userEvent.setup()
|
||||
setRemoteResults([
|
||||
{
|
||||
id: 'app-1',
|
||||
type: 'app',
|
||||
title: 'Keyboard App',
|
||||
path: '/apps/keyboard',
|
||||
data: {},
|
||||
},
|
||||
])
|
||||
|
||||
renderGotoAnything(<GotoAnything />)
|
||||
triggerSearchShortcut()
|
||||
const input = await screen.findByRole('combobox', {
|
||||
name: 'app.gotoAnything.searchTitle',
|
||||
})
|
||||
|
||||
await user.type(input, 'keyboard')
|
||||
await user.keyboard('{ArrowDown}{Enter}')
|
||||
|
||||
expect(routerPush).toHaveBeenCalledWith('/apps/keyboard')
|
||||
expect(routerPush).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should loop from the last command to the first with ArrowDown', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockAvailableCommands = [
|
||||
{ name: 'theme', description: 'Change theme' },
|
||||
{ name: 'language', description: 'Change language' },
|
||||
]
|
||||
|
||||
renderGotoAnything(<GotoAnything />)
|
||||
triggerSearchShortcut()
|
||||
const input = await screen.findByRole('combobox', {
|
||||
name: 'app.gotoAnything.searchTitle',
|
||||
})
|
||||
|
||||
await user.type(input, '/')
|
||||
const options = screen.getAllByRole('option')
|
||||
expect(options).toHaveLength(2)
|
||||
const [firstOption, secondOption] = options
|
||||
if (!firstOption || !secondOption) throw new Error('Expected two command options')
|
||||
|
||||
await user.keyboard('{ArrowDown}')
|
||||
expect(input).toHaveAttribute('aria-activedescendant', secondOption.id)
|
||||
|
||||
await user.keyboard('{ArrowDown}')
|
||||
expect(input).toHaveAttribute('aria-activedescendant', firstOption.id)
|
||||
})
|
||||
|
||||
it('should announce the displayed command count', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockAvailableCommands = [
|
||||
{ name: 'theme', description: 'Change theme' },
|
||||
{ name: 'language', description: 'Change language' },
|
||||
]
|
||||
|
||||
renderGotoAnything(<GotoAnything />)
|
||||
triggerSearchShortcut()
|
||||
const input = await screen.findByRole('combobox', {
|
||||
name: 'app.gotoAnything.searchTitle',
|
||||
})
|
||||
|
||||
await user.type(input, '/')
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent(
|
||||
'app.gotoAnything.resultCount:{"count":2}',
|
||||
)
|
||||
})
|
||||
|
||||
it('should clear selection when typing without prefix', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderGotoAnything(<GotoAnything />)
|
||||
triggerKeyPress('ctrl.k')
|
||||
triggerSearchShortcut()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
@ -320,20 +442,36 @@ describe('GotoAnything', () => {
|
||||
|
||||
expect(input).toHaveValue('test query')
|
||||
})
|
||||
|
||||
it('should not search providers with a stale scope prefix', async () => {
|
||||
const user = userEvent.setup()
|
||||
matchActionMock.mockImplementation((query: string) =>
|
||||
query.startsWith('@app') ? actionsMock.app : undefined,
|
||||
)
|
||||
|
||||
renderGotoAnything(<GotoAnything />)
|
||||
triggerSearchShortcut()
|
||||
const input = await screen.findByRole('combobox', {
|
||||
name: 'app.gotoAnything.searchTitle',
|
||||
})
|
||||
|
||||
await user.type(input, '@')
|
||||
debouncedSearchQuery = '@'
|
||||
enabledRemoteQueryKeys = []
|
||||
await user.click(screen.getByText('@app'))
|
||||
|
||||
expect(input).toHaveValue('@app ')
|
||||
expect(enabledRemoteQueryKeys).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('empty states', () => {
|
||||
it('should show loading state', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockQueryResult = {
|
||||
data: [],
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
error: null,
|
||||
}
|
||||
remoteQueryStates.app.isLoading = true
|
||||
|
||||
renderGotoAnything(<GotoAnything />)
|
||||
triggerKeyPress('ctrl.k')
|
||||
triggerSearchShortcut()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
@ -346,20 +484,21 @@ describe('GotoAnything', () => {
|
||||
|
||||
const searchingTexts = screen.getAllByText('app.gotoAnything.searching')
|
||||
expect(searchingTexts.length).toBeGreaterThanOrEqual(1)
|
||||
expect(screen.getByRole('status')).toHaveTextContent('app.gotoAnything.searching')
|
||||
expect(document.querySelector('[aria-busy="true"]')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show error state', async () => {
|
||||
const user = userEvent.setup()
|
||||
const testError = new Error('Search failed')
|
||||
mockQueryResult = {
|
||||
data: [],
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
error: testError,
|
||||
remoteQueryStates = {
|
||||
app: { data: [], isLoading: false, isError: true, error: testError },
|
||||
knowledge: { data: [], isLoading: false, isError: true, error: testError },
|
||||
plugin: { data: [], isLoading: false, isError: true, error: testError },
|
||||
}
|
||||
|
||||
renderGotoAnything(<GotoAnything />)
|
||||
triggerKeyPress('ctrl.k')
|
||||
triggerSearchShortcut()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
@ -370,12 +509,47 @@ describe('GotoAnything', () => {
|
||||
const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')
|
||||
await user.type(input, 'search')
|
||||
|
||||
expect(screen.getByText('app.gotoAnything.searchFailed')).toBeInTheDocument()
|
||||
expect(screen.getByRole('status')).toHaveTextContent('app.gotoAnything.searchFailed')
|
||||
expect(screen.getAllByText('app.gotoAnything.searchFailed')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should preserve successful results when one provider fails', async () => {
|
||||
const user = userEvent.setup()
|
||||
setRemoteResults([
|
||||
{
|
||||
id: 'app-1',
|
||||
type: 'app',
|
||||
title: 'Available App',
|
||||
path: '/apps/available',
|
||||
data: {},
|
||||
},
|
||||
])
|
||||
remoteQueryStates.plugin = {
|
||||
data: [],
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
error: new Error('Marketplace unavailable'),
|
||||
}
|
||||
|
||||
renderGotoAnything(<GotoAnything />)
|
||||
triggerSearchShortcut()
|
||||
const input = await screen.findByRole('combobox', {
|
||||
name: 'app.gotoAnything.searchTitle',
|
||||
})
|
||||
|
||||
await user.type(input, 'available')
|
||||
|
||||
expect(await screen.findByText('Available App')).toBeInTheDocument()
|
||||
expect(screen.getByRole('status')).toHaveTextContent(
|
||||
'app.gotoAnything.someServicesUnavailable',
|
||||
)
|
||||
expect(screen.getAllByText('app.gotoAnything.someServicesUnavailable')).toHaveLength(2)
|
||||
expect(screen.queryByText('app.gotoAnything.searchFailed')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show default state when no query', async () => {
|
||||
renderGotoAnything(<GotoAnything />)
|
||||
triggerKeyPress('ctrl.k')
|
||||
triggerSearchShortcut()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
@ -383,20 +557,13 @@ describe('GotoAnything', () => {
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.getByText('app.gotoAnything.searchTitle')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('app.gotoAnything.searchTitle')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should show no results state when search returns empty', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockQueryResult = {
|
||||
data: [],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
}
|
||||
|
||||
renderGotoAnything(<GotoAnything />)
|
||||
triggerKeyPress('ctrl.k')
|
||||
triggerSearchShortcut()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
@ -407,35 +574,30 @@ describe('GotoAnything', () => {
|
||||
const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')
|
||||
await user.type(input, 'nonexistent')
|
||||
|
||||
expect(screen.getByText('app.gotoAnything.noResults')).toBeInTheDocument()
|
||||
expect(await screen.findByText('app.gotoAnything.noResults')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('plugin installation', () => {
|
||||
it('should open plugin installer when selecting plugin result', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockQueryResult = {
|
||||
data: [
|
||||
{
|
||||
id: 'plugin-1',
|
||||
type: 'plugin',
|
||||
title: 'Plugin Item',
|
||||
description: 'desc',
|
||||
path: '',
|
||||
icon: <div />,
|
||||
data: {
|
||||
name: 'Plugin Item',
|
||||
latest_package_identifier: 'pkg',
|
||||
},
|
||||
setRemoteResults([
|
||||
{
|
||||
id: 'plugin-1',
|
||||
type: 'plugin',
|
||||
title: 'Plugin Item',
|
||||
description: 'desc',
|
||||
path: '',
|
||||
icon: <div />,
|
||||
data: {
|
||||
name: 'Plugin Item',
|
||||
latest_package_identifier: 'pkg',
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
}
|
||||
},
|
||||
])
|
||||
|
||||
renderGotoAnything(<GotoAnything />)
|
||||
triggerKeyPress('ctrl.k')
|
||||
triggerSearchShortcut()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
@ -454,28 +616,23 @@ describe('GotoAnything', () => {
|
||||
|
||||
it('should close plugin installer via close button', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockQueryResult = {
|
||||
data: [
|
||||
{
|
||||
id: 'plugin-1',
|
||||
type: 'plugin',
|
||||
title: 'Plugin Item',
|
||||
description: 'desc',
|
||||
path: '',
|
||||
icon: <div />,
|
||||
data: {
|
||||
name: 'Plugin Item',
|
||||
latest_package_identifier: 'pkg',
|
||||
},
|
||||
setRemoteResults([
|
||||
{
|
||||
id: 'plugin-1',
|
||||
type: 'plugin',
|
||||
title: 'Plugin Item',
|
||||
description: 'desc',
|
||||
path: '',
|
||||
icon: <div />,
|
||||
data: {
|
||||
name: 'Plugin Item',
|
||||
latest_package_identifier: 'pkg',
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
}
|
||||
},
|
||||
])
|
||||
|
||||
renderGotoAnything(<GotoAnything />)
|
||||
triggerKeyPress('ctrl.k')
|
||||
triggerSearchShortcut()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
@ -499,28 +656,23 @@ describe('GotoAnything', () => {
|
||||
|
||||
it('should close plugin installer on success', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockQueryResult = {
|
||||
data: [
|
||||
{
|
||||
id: 'plugin-1',
|
||||
type: 'plugin',
|
||||
title: 'Plugin Item',
|
||||
description: 'desc',
|
||||
path: '',
|
||||
icon: <div />,
|
||||
data: {
|
||||
name: 'Plugin Item',
|
||||
latest_package_identifier: 'pkg',
|
||||
},
|
||||
setRemoteResults([
|
||||
{
|
||||
id: 'plugin-1',
|
||||
type: 'plugin',
|
||||
title: 'Plugin Item',
|
||||
description: 'desc',
|
||||
path: '',
|
||||
icon: <div />,
|
||||
data: {
|
||||
name: 'Plugin Item',
|
||||
latest_package_identifier: 'pkg',
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
}
|
||||
},
|
||||
])
|
||||
|
||||
renderGotoAnything(<GotoAnything />)
|
||||
triggerKeyPress('ctrl.k')
|
||||
triggerSearchShortcut()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
@ -552,9 +704,10 @@ describe('GotoAnything', () => {
|
||||
execute: executeMock,
|
||||
isAvailable: () => true,
|
||||
}
|
||||
mockAvailableCommands = [{ name: 'theme', description: 'Change theme' }]
|
||||
|
||||
renderGotoAnything(<GotoAnything />)
|
||||
triggerKeyPress('ctrl.k')
|
||||
triggerSearchShortcut()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
@ -566,7 +719,7 @@ describe('GotoAnything', () => {
|
||||
await user.type(input, '/theme')
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
expect(executeMock).toHaveBeenCalled()
|
||||
expect(executeMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should NOT execute unavailable slash command', async () => {
|
||||
@ -579,7 +732,7 @@ describe('GotoAnything', () => {
|
||||
}
|
||||
|
||||
renderGotoAnything(<GotoAnything />)
|
||||
triggerKeyPress('ctrl.k')
|
||||
triggerSearchShortcut()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
@ -601,9 +754,10 @@ describe('GotoAnything', () => {
|
||||
mode: 'submenu',
|
||||
execute: executeMock,
|
||||
}
|
||||
mockAvailableCommands = [{ name: 'language', description: 'Change language' }]
|
||||
|
||||
renderGotoAnything(<GotoAnything />)
|
||||
triggerKeyPress('ctrl.k')
|
||||
triggerSearchShortcut()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
@ -625,9 +779,10 @@ describe('GotoAnything', () => {
|
||||
execute: vi.fn(),
|
||||
isAvailable: () => true,
|
||||
}
|
||||
mockAvailableCommands = [{ name: 'theme', description: 'Change theme' }]
|
||||
|
||||
renderGotoAnything(<GotoAnything />)
|
||||
triggerKeyPress('ctrl.k')
|
||||
triggerSearchShortcut()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
@ -650,25 +805,20 @@ describe('GotoAnything', () => {
|
||||
describe('result navigation', () => {
|
||||
it('should handle knowledge result navigation', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockQueryResult = {
|
||||
data: [
|
||||
{
|
||||
id: 'kb-1',
|
||||
type: 'knowledge',
|
||||
title: 'Knowledge Base',
|
||||
description: 'desc',
|
||||
path: '/datasets/kb-1',
|
||||
icon: <div />,
|
||||
data: {},
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
}
|
||||
setRemoteResults([
|
||||
{
|
||||
id: 'kb-1',
|
||||
type: 'knowledge',
|
||||
title: 'Knowledge Base',
|
||||
description: 'desc',
|
||||
path: '/datasets/kb-1',
|
||||
icon: <div />,
|
||||
data: {},
|
||||
},
|
||||
])
|
||||
|
||||
renderGotoAnything(<GotoAnything />)
|
||||
triggerKeyPress('ctrl.k')
|
||||
triggerSearchShortcut()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
@ -687,25 +837,20 @@ describe('GotoAnything', () => {
|
||||
|
||||
it('should NOT navigate when result has no path', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockQueryResult = {
|
||||
data: [
|
||||
{
|
||||
id: 'item-1',
|
||||
type: 'app',
|
||||
title: 'No Path Item',
|
||||
description: 'desc',
|
||||
path: '',
|
||||
icon: <div />,
|
||||
data: {},
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
}
|
||||
setRemoteResults([
|
||||
{
|
||||
id: 'item-1',
|
||||
type: 'app',
|
||||
title: 'No Path Item',
|
||||
description: 'desc',
|
||||
path: '',
|
||||
icon: <div />,
|
||||
data: {},
|
||||
},
|
||||
])
|
||||
|
||||
renderGotoAnything(<GotoAnything />)
|
||||
triggerKeyPress('ctrl.k')
|
||||
triggerSearchShortcut()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
|
||||
@ -1,159 +1,80 @@
|
||||
import type { App } from '@/types/app'
|
||||
import { appAction } from '../app'
|
||||
import { appAction, appSearchQueryOptions } from '../app'
|
||||
|
||||
vi.mock('@/service/apps', () => ({
|
||||
fetchAppList: vi.fn(),
|
||||
const serviceMocks = vi.hoisted(() => ({ queryOptions: vi.fn((options) => options) }))
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleQuery: { apps: { get: { queryOptions: serviceMocks.queryOptions } } },
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/app-redirection', () => ({
|
||||
getRedirectionPath: vi.fn((app: { id: string }) => `/app/${app.id}`),
|
||||
}))
|
||||
|
||||
vi.mock('../../../app/type-selector', () => ({
|
||||
AppTypeIcon: () => null,
|
||||
}))
|
||||
vi.mock('../../../app/type-selector', () => ({ AppTypeIcon: () => null }))
|
||||
|
||||
describe('appAction', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
type AppQueryResponse = Parameters<
|
||||
NonNullable<ReturnType<typeof appSearchQueryOptions>['select']>
|
||||
>[0]
|
||||
type AppQueryItem = AppQueryResponse['data'][number]
|
||||
|
||||
function app(overrides: Partial<AppQueryItem> = {}): AppQueryItem {
|
||||
return {
|
||||
id: 'app-1',
|
||||
name: 'My App',
|
||||
description: 'A great app',
|
||||
mode: 'chat',
|
||||
icon: '',
|
||||
icon_type: 'emoji',
|
||||
icon_background: '',
|
||||
icon_url: '',
|
||||
...overrides,
|
||||
} as AppQueryItem
|
||||
}
|
||||
|
||||
function response(data: AppQueryItem[]): AppQueryResponse {
|
||||
return { data, has_more: false, limit: 10, page: 1, total: data.length }
|
||||
}
|
||||
|
||||
describe('app search query', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('exposes remote action metadata', () => {
|
||||
expect(appAction).toMatchObject({ key: '@app', shortcut: '@app', source: 'remote' })
|
||||
})
|
||||
|
||||
it('has correct metadata', () => {
|
||||
expect(appAction.key).toBe('@app')
|
||||
expect(appAction.shortcut).toBe('@app')
|
||||
it('builds generated query options from the search term', () => {
|
||||
appSearchQueryOptions('assistant', false)
|
||||
|
||||
expect(serviceMocks.queryOptions).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
input: { query: { page: 1, name: 'assistant' } },
|
||||
retry: false,
|
||||
select: expect.any(Function),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('returns parsed app results on success', async () => {
|
||||
const { fetchAppList } = await import('@/service/apps')
|
||||
vi.mocked(fetchAppList).mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: 'app-1',
|
||||
name: 'My App',
|
||||
description: 'A great app',
|
||||
mode: 'chat',
|
||||
icon: '',
|
||||
icon_type: 'emoji',
|
||||
icon_background: '',
|
||||
icon_url: '',
|
||||
} as unknown as App,
|
||||
],
|
||||
has_more: false,
|
||||
limit: 10,
|
||||
page: 1,
|
||||
total: 1,
|
||||
})
|
||||
it('selects plain app results for general search', () => {
|
||||
const options = appSearchQueryOptions('my app', false)
|
||||
|
||||
const results = await appAction.search('@app test', 'test', 'en')
|
||||
expect(options.select!(response([app(), app({ id: 'app-2', name: 'Other' })]))).toHaveLength(2)
|
||||
})
|
||||
|
||||
expect(fetchAppList).toHaveBeenCalledWith({
|
||||
url: 'apps',
|
||||
params: { page: 1, name: 'test' },
|
||||
})
|
||||
expect(results).toHaveLength(5)
|
||||
expect(results[0]).toMatchObject({
|
||||
id: 'app-1',
|
||||
title: 'My App',
|
||||
type: 'app',
|
||||
})
|
||||
expect(results.slice(1).map((r) => r.id)).toEqual([
|
||||
it('selects app sections only for scoped search', () => {
|
||||
const chatOptions = appSearchQueryOptions('', true)
|
||||
const workflowOptions = appSearchQueryOptions('', true)
|
||||
|
||||
expect(chatOptions.select!(response([app()])).map((result) => result.id)).toEqual([
|
||||
'app-1',
|
||||
'app-1:configuration',
|
||||
'app-1:overview',
|
||||
'app-1:logs',
|
||||
'app-1:develop',
|
||||
])
|
||||
})
|
||||
|
||||
it('returns workflow sub-sections for workflow-mode apps', async () => {
|
||||
const { fetchAppList } = await import('@/service/apps')
|
||||
vi.mocked(fetchAppList).mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: 'wf-1',
|
||||
name: 'Flow',
|
||||
description: '',
|
||||
mode: 'workflow',
|
||||
icon: '',
|
||||
icon_type: 'emoji',
|
||||
icon_background: '',
|
||||
icon_url: '',
|
||||
} as unknown as App,
|
||||
],
|
||||
has_more: false,
|
||||
limit: 10,
|
||||
page: 1,
|
||||
total: 1,
|
||||
})
|
||||
|
||||
const results = await appAction.search('@app', '', 'en')
|
||||
|
||||
expect(results).toHaveLength(4)
|
||||
expect(results.slice(1).map((r) => r.id)).toEqual([
|
||||
'wf-1:workflow',
|
||||
'wf-1:overview',
|
||||
'wf-1:logs',
|
||||
])
|
||||
})
|
||||
|
||||
it('returns apps without sub-sections for unscoped queries', async () => {
|
||||
const { fetchAppList } = await import('@/service/apps')
|
||||
vi.mocked(fetchAppList).mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: 'app-1',
|
||||
name: 'My App',
|
||||
description: '',
|
||||
mode: 'chat',
|
||||
icon: '',
|
||||
icon_type: 'emoji',
|
||||
icon_background: '',
|
||||
icon_url: '',
|
||||
} as unknown as App,
|
||||
{
|
||||
id: 'app-2',
|
||||
name: 'Other',
|
||||
description: '',
|
||||
mode: 'chat',
|
||||
icon: '',
|
||||
icon_type: 'emoji',
|
||||
icon_background: '',
|
||||
icon_url: '',
|
||||
} as unknown as App,
|
||||
],
|
||||
has_more: false,
|
||||
limit: 10,
|
||||
page: 1,
|
||||
total: 2,
|
||||
})
|
||||
|
||||
const results = await appAction.search('my app', 'my app', 'en')
|
||||
|
||||
expect(results).toHaveLength(2)
|
||||
expect(results.map((r) => r.id)).toEqual(['app-1', 'app-2'])
|
||||
})
|
||||
|
||||
it('returns empty array when response has no data', async () => {
|
||||
const { fetchAppList } = await import('@/service/apps')
|
||||
vi.mocked(fetchAppList).mockResolvedValue({
|
||||
data: [],
|
||||
has_more: false,
|
||||
limit: 10,
|
||||
page: 1,
|
||||
total: 0,
|
||||
})
|
||||
|
||||
const results = await appAction.search('@app', '', 'en')
|
||||
expect(results).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array on API failure', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const { fetchAppList } = await import('@/service/apps')
|
||||
vi.mocked(fetchAppList).mockRejectedValue(new Error('network error'))
|
||||
|
||||
const results = await appAction.search('@app fail', 'fail', 'en')
|
||||
expect(results).toEqual([])
|
||||
expect(warnSpy).toHaveBeenCalledWith('App search failed:', expect.any(Error))
|
||||
warnSpy.mockRestore()
|
||||
expect(
|
||||
workflowOptions.select!(response([app({ id: 'wf-1', mode: 'workflow' })])).map(
|
||||
(result) => result.id,
|
||||
),
|
||||
).toEqual(['wf-1', 'wf-1:workflow', 'wf-1:overview', 'wf-1:logs'])
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,8 +1,6 @@
|
||||
import type { ActionItem, SearchResult } from '../types'
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import type { App } from '@/types/app'
|
||||
import type { ActionItem } from '../types'
|
||||
import { slashCommandRegistry } from '../commands/registry'
|
||||
import { createActions, matchAction, searchAnything } from '../index'
|
||||
import { createActions, getActionSearchTerm, matchAction } from '../index'
|
||||
|
||||
vi.mock('../app', () => ({
|
||||
appAction: {
|
||||
@ -10,7 +8,7 @@ vi.mock('../app', () => ({
|
||||
shortcut: '@app',
|
||||
title: 'Apps',
|
||||
description: 'Search apps',
|
||||
search: vi.fn().mockResolvedValue([]),
|
||||
source: 'remote',
|
||||
} satisfies ActionItem,
|
||||
}))
|
||||
|
||||
@ -20,7 +18,7 @@ vi.mock('../knowledge', () => ({
|
||||
shortcut: '@kb',
|
||||
title: 'Knowledge',
|
||||
description: 'Search knowledge',
|
||||
search: vi.fn().mockResolvedValue([]),
|
||||
source: 'remote',
|
||||
} satisfies ActionItem,
|
||||
}))
|
||||
|
||||
@ -30,7 +28,7 @@ vi.mock('../plugin', () => ({
|
||||
shortcut: '@plugin',
|
||||
title: 'Plugins',
|
||||
description: 'Search plugins',
|
||||
search: vi.fn().mockResolvedValue([]),
|
||||
source: 'remote',
|
||||
} satisfies ActionItem,
|
||||
}))
|
||||
|
||||
@ -40,7 +38,8 @@ vi.mock('../commands/slash', () => ({
|
||||
shortcut: '/',
|
||||
title: 'Commands',
|
||||
description: 'Slash commands',
|
||||
search: vi.fn().mockResolvedValue([]),
|
||||
source: 'local',
|
||||
search: vi.fn(() => []),
|
||||
} satisfies ActionItem,
|
||||
}))
|
||||
|
||||
@ -50,7 +49,8 @@ vi.mock('../workflow-nodes', () => ({
|
||||
shortcut: '@node',
|
||||
title: 'Workflow Nodes',
|
||||
description: 'Search workflow nodes',
|
||||
search: vi.fn().mockResolvedValue([]),
|
||||
source: 'local',
|
||||
search: vi.fn(() => []),
|
||||
} satisfies ActionItem,
|
||||
}))
|
||||
|
||||
@ -60,266 +60,66 @@ vi.mock('../rag-pipeline-nodes', () => ({
|
||||
shortcut: '@node',
|
||||
title: 'RAG Pipeline Nodes',
|
||||
description: 'Search RAG nodes',
|
||||
search: vi.fn().mockResolvedValue([]),
|
||||
source: 'local',
|
||||
search: vi.fn(() => []),
|
||||
} satisfies ActionItem,
|
||||
}))
|
||||
|
||||
vi.mock('../commands/registry')
|
||||
|
||||
describe('createActions', () => {
|
||||
it('returns base actions when neither workflow nor rag-pipeline page', () => {
|
||||
const actions = createActions(false, false)
|
||||
|
||||
expect(actions).toHaveProperty('slash')
|
||||
expect(actions).toHaveProperty('app')
|
||||
expect(actions).toHaveProperty('knowledge')
|
||||
expect(actions).toHaveProperty('plugin')
|
||||
expect(actions).not.toHaveProperty('node')
|
||||
it('returns only global actions outside graph pages', () => {
|
||||
expect(createActions(false, false)).toEqual(
|
||||
expect.objectContaining({ slash: expect.any(Object), app: expect.any(Object) }),
|
||||
)
|
||||
expect(createActions(false, false)).not.toHaveProperty('node')
|
||||
})
|
||||
|
||||
it('includes workflow nodes action on workflow pages', () => {
|
||||
const actions = createActions(true, false) as Record<string, ActionItem>
|
||||
|
||||
expect(actions).toHaveProperty('node')
|
||||
expect(actions.node!.title).toBe('Workflow Nodes')
|
||||
it('uses the workflow-owned node action on workflow pages', () => {
|
||||
expect((createActions(true, false) as Record<string, ActionItem>).node!.title).toBe(
|
||||
'Workflow Nodes',
|
||||
)
|
||||
})
|
||||
|
||||
it('includes rag-pipeline nodes action on rag-pipeline pages', () => {
|
||||
const actions = createActions(false, true) as Record<string, ActionItem>
|
||||
|
||||
expect(actions).toHaveProperty('node')
|
||||
expect(actions.node!.title).toBe('RAG Pipeline Nodes')
|
||||
})
|
||||
|
||||
it('rag-pipeline page takes priority over workflow page', () => {
|
||||
const actions = createActions(true, true) as Record<string, ActionItem>
|
||||
|
||||
expect(actions.node!.title).toBe('RAG Pipeline Nodes')
|
||||
it('uses the RAG-owned node action when both graph flags are true', () => {
|
||||
expect((createActions(true, true) as Record<string, ActionItem>).node!.title).toBe(
|
||||
'RAG Pipeline Nodes',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('searchAnything', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
describe('getActionSearchTerm', () => {
|
||||
it('removes either the action key or shortcut', () => {
|
||||
const action = createActions(false, false).knowledge
|
||||
|
||||
it('delegates to specific action when actionItem is provided', async () => {
|
||||
const mockResults: SearchResult[] = [
|
||||
{ id: '1', title: 'App1', type: 'app', data: {} as unknown as App },
|
||||
]
|
||||
const action: ActionItem = {
|
||||
key: '@app',
|
||||
shortcut: '@app',
|
||||
title: 'Apps',
|
||||
description: 'Search apps',
|
||||
search: vi.fn().mockResolvedValue(mockResults),
|
||||
}
|
||||
|
||||
const results = await searchAnything('en', '@app myquery', action)
|
||||
|
||||
expect(action.search).toHaveBeenCalledWith('@app myquery', 'myquery', 'en')
|
||||
expect(results).toEqual(mockResults)
|
||||
})
|
||||
|
||||
it('strips action prefix from search term', async () => {
|
||||
const action: ActionItem = {
|
||||
key: '@knowledge',
|
||||
shortcut: '@kb',
|
||||
title: 'KB',
|
||||
description: 'Search KB',
|
||||
search: vi.fn().mockResolvedValue([]),
|
||||
}
|
||||
|
||||
await searchAnything('en', '@kb hello', action)
|
||||
|
||||
expect(action.search).toHaveBeenCalledWith('@kb hello', 'hello', 'en')
|
||||
})
|
||||
|
||||
it('returns empty for queries starting with @ without actionItem', async () => {
|
||||
const results = await searchAnything('en', '@unknown')
|
||||
expect(results).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty for queries starting with / without actionItem', async () => {
|
||||
const results = await searchAnything('en', '/theme')
|
||||
expect(results).toEqual([])
|
||||
})
|
||||
|
||||
it('handles action search failure gracefully', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const action: ActionItem = {
|
||||
key: '@app',
|
||||
shortcut: '@app',
|
||||
title: 'Apps',
|
||||
description: 'Search apps',
|
||||
search: vi.fn().mockRejectedValue(new Error('network error')),
|
||||
}
|
||||
|
||||
const results = await searchAnything('en', '@app test', action)
|
||||
expect(results).toEqual([])
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Search failed for @app'),
|
||||
expect.any(Error),
|
||||
)
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('runs global search across all non-slash actions for plain queries', async () => {
|
||||
const appResults: SearchResult[] = [
|
||||
{ id: 'a1', title: 'My App', type: 'app', data: {} as unknown as App },
|
||||
]
|
||||
const kbResults: SearchResult[] = [
|
||||
{ id: 'k1', title: 'My KB', type: 'knowledge', data: {} as unknown as DataSet },
|
||||
]
|
||||
|
||||
const dynamicActions: Record<string, ActionItem> = {
|
||||
slash: {
|
||||
key: '/',
|
||||
shortcut: '/',
|
||||
title: 'Slash',
|
||||
description: '',
|
||||
search: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
app: {
|
||||
key: '@app',
|
||||
shortcut: '@app',
|
||||
title: 'App',
|
||||
description: '',
|
||||
search: vi.fn().mockResolvedValue(appResults),
|
||||
},
|
||||
knowledge: {
|
||||
key: '@knowledge',
|
||||
shortcut: '@kb',
|
||||
title: 'KB',
|
||||
description: '',
|
||||
search: vi.fn().mockResolvedValue(kbResults),
|
||||
},
|
||||
}
|
||||
|
||||
const results = await searchAnything('en', 'my query', undefined, dynamicActions)
|
||||
|
||||
expect(dynamicActions.slash!.search).not.toHaveBeenCalled()
|
||||
expect(results).toHaveLength(2)
|
||||
expect(results).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: 'a1' }),
|
||||
expect.objectContaining({ id: 'k1' }),
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
it('handles partial search failures in global search gracefully', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const dynamicActions: Record<string, ActionItem> = {
|
||||
app: {
|
||||
key: '@app',
|
||||
shortcut: '@app',
|
||||
title: 'App',
|
||||
description: '',
|
||||
search: vi.fn().mockRejectedValue(new Error('fail')),
|
||||
},
|
||||
knowledge: {
|
||||
key: '@knowledge',
|
||||
shortcut: '@kb',
|
||||
title: 'KB',
|
||||
description: '',
|
||||
search: vi
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
{ id: 'k1', title: 'KB1', type: 'knowledge', data: {} as unknown as DataSet },
|
||||
]),
|
||||
},
|
||||
}
|
||||
|
||||
const results = await searchAnything('en', 'query', undefined, dynamicActions)
|
||||
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0]!.id).toBe('k1')
|
||||
expect(warnSpy).toHaveBeenCalled()
|
||||
warnSpy.mockRestore()
|
||||
expect(getActionSearchTerm('@knowledge vector store', action)).toBe('vector store')
|
||||
expect(getActionSearchTerm('@kb vector store', action)).toBe('vector store')
|
||||
})
|
||||
})
|
||||
|
||||
describe('matchAction', () => {
|
||||
const actions: Record<string, ActionItem> = {
|
||||
app: { key: '@app', shortcut: '@app', title: 'App', description: '', search: vi.fn() },
|
||||
knowledge: {
|
||||
key: '@knowledge',
|
||||
shortcut: '@kb',
|
||||
title: 'KB',
|
||||
description: '',
|
||||
search: vi.fn(),
|
||||
},
|
||||
plugin: {
|
||||
key: '@plugin',
|
||||
shortcut: '@plugin',
|
||||
title: 'Plugin',
|
||||
description: '',
|
||||
search: vi.fn(),
|
||||
},
|
||||
slash: { key: '/', shortcut: '/', title: 'Slash', description: '', search: vi.fn() },
|
||||
}
|
||||
const actions = createActions(false, false)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('matches @app query', () => {
|
||||
const result = matchAction('@app test', actions)
|
||||
expect(result?.key).toBe('@app')
|
||||
})
|
||||
|
||||
it('matches @kb shortcut', () => {
|
||||
const result = matchAction('@kb test', actions)
|
||||
expect(result?.key).toBe('@knowledge')
|
||||
})
|
||||
|
||||
it('matches @plugin query', () => {
|
||||
const result = matchAction('@plugin test', actions)
|
||||
expect(result?.key).toBe('@plugin')
|
||||
})
|
||||
|
||||
it('returns undefined for unmatched query', () => {
|
||||
vi.mocked(slashCommandRegistry.getAllCommands).mockReturnValue([])
|
||||
const result = matchAction('random query', actions)
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
describe('slash command matching', () => {
|
||||
it('matches submenu command with full name', () => {
|
||||
vi.mocked(slashCommandRegistry.getAllCommands).mockReturnValue([
|
||||
{ name: 'theme', mode: 'submenu', description: '', search: vi.fn() },
|
||||
])
|
||||
it.each([
|
||||
['@app query', '@app'],
|
||||
['@kb query', '@knowledge'],
|
||||
['@plugin query', '@plugin'],
|
||||
])('matches %s', (query, key) => {
|
||||
expect(matchAction(query, actions)?.key).toBe(key)
|
||||
})
|
||||
|
||||
const result = matchAction('/theme', actions)
|
||||
expect(result?.key).toBe('/')
|
||||
})
|
||||
it('matches complete submenu commands but leaves direct commands in the command picker', () => {
|
||||
vi.mocked(slashCommandRegistry.getAllCommands).mockReturnValue([
|
||||
{ name: 'theme', mode: 'submenu', description: '', search: vi.fn(() => []) },
|
||||
{ name: 'docs', mode: 'direct', description: '', search: vi.fn(() => []) },
|
||||
])
|
||||
|
||||
it('matches submenu command with args', () => {
|
||||
vi.mocked(slashCommandRegistry.getAllCommands).mockReturnValue([
|
||||
{ name: 'theme', mode: 'submenu', description: '', search: vi.fn() },
|
||||
])
|
||||
|
||||
const result = matchAction('/theme dark', actions)
|
||||
expect(result?.key).toBe('/')
|
||||
})
|
||||
|
||||
it('does not match direct-mode commands', () => {
|
||||
vi.mocked(slashCommandRegistry.getAllCommands).mockReturnValue([
|
||||
{ name: 'docs', mode: 'direct', description: '', search: vi.fn() },
|
||||
])
|
||||
|
||||
const result = matchAction('/docs', actions)
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not match partial slash command name', () => {
|
||||
vi.mocked(slashCommandRegistry.getAllCommands).mockReturnValue([
|
||||
{ name: 'theme', mode: 'submenu', description: '', search: vi.fn() },
|
||||
])
|
||||
|
||||
const result = matchAction('/the', actions)
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
expect(matchAction('/theme dark', actions)?.key).toBe('/')
|
||||
expect(matchAction('/docs', actions)).toBeUndefined()
|
||||
expect(matchAction('/the', actions)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,110 +1,62 @@
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import { knowledgeAction } from '../knowledge'
|
||||
import type {
|
||||
DatasetListItemResponse,
|
||||
DatasetListResponse,
|
||||
} from '@dify/contracts/api/console/datasets/types.gen'
|
||||
import { knowledgeAction, knowledgeSearchQueryOptions } from '../knowledge'
|
||||
|
||||
vi.mock('@/service/datasets', () => ({
|
||||
fetchDatasets: vi.fn(),
|
||||
const serviceMocks = vi.hoisted(() => ({ queryOptions: vi.fn((options) => options) }))
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleQuery: { datasets: { get: { queryOptions: serviceMocks.queryOptions } } },
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/cn', () => ({
|
||||
cn: (...args: string[]) => args.filter(Boolean).join(' '),
|
||||
}))
|
||||
function dataset(overrides: Partial<DatasetListItemResponse> = {}): DatasetListItemResponse {
|
||||
return {
|
||||
id: 'dataset-1',
|
||||
name: 'Knowledge',
|
||||
description: 'Description',
|
||||
provider: 'vendor',
|
||||
embedding_available: true,
|
||||
...overrides,
|
||||
} as DatasetListItemResponse
|
||||
}
|
||||
|
||||
describe('knowledgeAction', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
function response(data: DatasetListItemResponse[]): DatasetListResponse {
|
||||
return { data, has_more: false, limit: 10, page: 1, total: data.length }
|
||||
}
|
||||
|
||||
it('has correct metadata', () => {
|
||||
expect(knowledgeAction.key).toBe('@knowledge')
|
||||
expect(knowledgeAction.shortcut).toBe('@kb')
|
||||
})
|
||||
describe('knowledge search query', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('returns parsed dataset results on success', async () => {
|
||||
const { fetchDatasets } = await import('@/service/datasets')
|
||||
vi.mocked(fetchDatasets).mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: 'ds-1',
|
||||
name: 'My Knowledge',
|
||||
description: 'A KB',
|
||||
provider: 'vendor',
|
||||
embedding_available: true,
|
||||
} as unknown as DataSet,
|
||||
],
|
||||
has_more: false,
|
||||
limit: 10,
|
||||
page: 1,
|
||||
total: 1,
|
||||
})
|
||||
|
||||
const results = await knowledgeAction.search('@knowledge query', 'query', 'en')
|
||||
|
||||
expect(fetchDatasets).toHaveBeenCalledWith({
|
||||
url: '/datasets',
|
||||
params: { page: 1, limit: 10, keyword: 'query' },
|
||||
})
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0]).toMatchObject({
|
||||
id: 'ds-1',
|
||||
title: 'My Knowledge',
|
||||
type: 'knowledge',
|
||||
it('exposes remote action metadata', () => {
|
||||
expect(knowledgeAction).toMatchObject({
|
||||
key: '@knowledge',
|
||||
shortcut: '@kb',
|
||||
source: 'remote',
|
||||
})
|
||||
})
|
||||
|
||||
it('generates correct path for external provider', async () => {
|
||||
const { fetchDatasets } = await import('@/service/datasets')
|
||||
vi.mocked(fetchDatasets).mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: 'ds-ext',
|
||||
name: 'External',
|
||||
description: '',
|
||||
provider: 'external',
|
||||
embedding_available: true,
|
||||
} as unknown as DataSet,
|
||||
],
|
||||
has_more: false,
|
||||
limit: 10,
|
||||
page: 1,
|
||||
total: 1,
|
||||
})
|
||||
it('builds generated query options from the search term', () => {
|
||||
knowledgeSearchQueryOptions('vector')
|
||||
|
||||
const results = await knowledgeAction.search('@knowledge', '', 'en')
|
||||
|
||||
expect(results[0]!.path).toBe('/datasets/ds-ext/hitTesting')
|
||||
expect(serviceMocks.queryOptions).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
input: { query: { page: 1, limit: 10, keyword: 'vector' } },
|
||||
retry: false,
|
||||
select: expect.any(Function),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('generates correct path for non-external provider', async () => {
|
||||
const { fetchDatasets } = await import('@/service/datasets')
|
||||
vi.mocked(fetchDatasets).mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: 'ds-2',
|
||||
name: 'Internal',
|
||||
description: '',
|
||||
provider: 'vendor',
|
||||
embedding_available: true,
|
||||
} as unknown as DataSet,
|
||||
],
|
||||
has_more: false,
|
||||
limit: 10,
|
||||
page: 1,
|
||||
total: 1,
|
||||
})
|
||||
it('selects paths from the dataset provider contract', () => {
|
||||
const options = knowledgeSearchQueryOptions('')
|
||||
const results = options.select!(
|
||||
response([dataset(), dataset({ id: 'external', provider: 'external' })]),
|
||||
)
|
||||
|
||||
const results = await knowledgeAction.search('@knowledge', '', 'en')
|
||||
|
||||
expect(results[0]!.path).toBe('/datasets/ds-2/documents')
|
||||
})
|
||||
|
||||
it('returns empty array on API failure', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const { fetchDatasets } = await import('@/service/datasets')
|
||||
vi.mocked(fetchDatasets).mockRejectedValue(new Error('fail'))
|
||||
|
||||
const results = await knowledgeAction.search('@knowledge', 'fail', 'en')
|
||||
expect(results).toEqual([])
|
||||
expect(warnSpy).toHaveBeenCalledWith('Knowledge search failed:', expect.any(Error))
|
||||
warnSpy.mockRestore()
|
||||
expect(results.map((result) => result.path)).toEqual([
|
||||
'/datasets/dataset-1/documents',
|
||||
'/datasets/external/hitTesting',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,73 +1,43 @@
|
||||
import type { SearchResult } from '../types'
|
||||
import { ragPipelineNodesAction } from '../rag-pipeline-nodes'
|
||||
import { workflowNodesAction } from '../workflow-nodes'
|
||||
import {
|
||||
findRagPipelineNodes,
|
||||
registerRagPipelineNodeSearch,
|
||||
} from '@/app/components/rag-pipeline/goto-anything-search'
|
||||
import {
|
||||
findWorkflowNodes,
|
||||
registerWorkflowNodeSearch,
|
||||
} from '@/app/components/workflow/goto-anything-search'
|
||||
|
||||
describe('workflowNodesAction', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
workflowNodesAction.searchFn = undefined
|
||||
})
|
||||
|
||||
it('should return an empty result when no workflow search function is registered', async () => {
|
||||
await expect(workflowNodesAction.search('@node llm', 'llm', 'en')).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it('should delegate to the injected workflow search function', async () => {
|
||||
describe('workflow node search registry', () => {
|
||||
it('registers, searches, and unregisters workflow nodes', () => {
|
||||
const results: SearchResult[] = [
|
||||
{ id: 'workflow-node-1', title: 'LLM', type: 'workflow-node', data: {} as never },
|
||||
]
|
||||
workflowNodesAction.searchFn = vi.fn().mockReturnValue(results)
|
||||
const search = vi.fn().mockReturnValue(results)
|
||||
const unregister = registerWorkflowNodeSearch(search)
|
||||
|
||||
await expect(workflowNodesAction.search('@node llm', 'llm', 'en')).resolves.toEqual(results)
|
||||
expect(workflowNodesAction.searchFn).toHaveBeenCalledWith('llm')
|
||||
})
|
||||
expect(findWorkflowNodes('llm')).toEqual(results)
|
||||
expect(search).toHaveBeenCalledWith('llm')
|
||||
|
||||
it('should warn and return an empty list when workflow node search throws', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
workflowNodesAction.searchFn = vi.fn(() => {
|
||||
throw new Error('failed')
|
||||
})
|
||||
unregister()
|
||||
|
||||
await expect(workflowNodesAction.search('@node llm', 'llm', 'en')).resolves.toEqual([])
|
||||
expect(warnSpy).toHaveBeenCalledWith('Workflow nodes search failed:', expect.any(Error))
|
||||
|
||||
warnSpy.mockRestore()
|
||||
expect(findWorkflowNodes('llm')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('ragPipelineNodesAction', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
ragPipelineNodesAction.searchFn = undefined
|
||||
})
|
||||
|
||||
it('should return an empty result when no rag pipeline search function is registered', async () => {
|
||||
await expect(ragPipelineNodesAction.search('@node embed', 'embed', 'en')).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it('should delegate to the injected rag pipeline search function', async () => {
|
||||
describe('RAG pipeline node search registry', () => {
|
||||
it('registers, searches, and unregisters RAG pipeline nodes', () => {
|
||||
const results: SearchResult[] = [
|
||||
{ id: 'rag-node-1', title: 'Retriever', type: 'workflow-node', data: {} as never },
|
||||
]
|
||||
ragPipelineNodesAction.searchFn = vi.fn().mockReturnValue(results)
|
||||
const search = vi.fn().mockReturnValue(results)
|
||||
const unregister = registerRagPipelineNodeSearch(search)
|
||||
|
||||
await expect(
|
||||
ragPipelineNodesAction.search('@node retrieve', 'retrieve', 'en'),
|
||||
).resolves.toEqual(results)
|
||||
expect(ragPipelineNodesAction.searchFn).toHaveBeenCalledWith('retrieve')
|
||||
})
|
||||
expect(findRagPipelineNodes('retrieve')).toEqual(results)
|
||||
expect(search).toHaveBeenCalledWith('retrieve')
|
||||
|
||||
it('should warn and return an empty list when rag pipeline node search throws', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
ragPipelineNodesAction.searchFn = vi.fn(() => {
|
||||
throw new Error('failed')
|
||||
})
|
||||
unregister()
|
||||
|
||||
await expect(
|
||||
ragPipelineNodesAction.search('@node retrieve', 'retrieve', 'en'),
|
||||
).resolves.toEqual([])
|
||||
expect(warnSpy).toHaveBeenCalledWith('RAG pipeline nodes search failed:', expect.any(Error))
|
||||
|
||||
warnSpy.mockRestore()
|
||||
expect(findRagPipelineNodes('retrieve')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,37 +1,47 @@
|
||||
import { pluginAction } from '../plugin'
|
||||
import { pluginAction, pluginSearchQueryOptions } from '../plugin'
|
||||
|
||||
vi.mock('@/service/base', () => ({
|
||||
postMarketplace: vi.fn(),
|
||||
const serviceMocks = vi.hoisted(() => ({ queryOptions: vi.fn((options) => options) }))
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
marketplaceQuery: { searchAdvanced: { queryOptions: serviceMocks.queryOptions } },
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n-config', () => ({
|
||||
renderI18nObject: vi.fn((obj: Record<string, string> | string, locale: string) => {
|
||||
if (typeof obj === 'string') return obj
|
||||
return obj[locale] || obj.en_US || ''
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../../plugins/card/base/card-icon', () => ({
|
||||
default: () => null,
|
||||
renderI18nObject: vi.fn((value: Record<string, string> | string, locale: string) =>
|
||||
typeof value === 'string' ? value : value[locale] || value.en_US || '',
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../../../plugins/card/base/card-icon', () => ({ default: () => null }))
|
||||
vi.mock('../../../plugins/marketplace/utils', () => ({
|
||||
getFormattedPlugin: vi.fn((plugin) => ({ ...plugin, icon: 'icon-url' })),
|
||||
}))
|
||||
|
||||
describe('pluginAction', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
describe('plugin search query', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('exposes remote action metadata', () => {
|
||||
expect(pluginAction).toMatchObject({ key: '@plugin', shortcut: '@plugin', source: 'remote' })
|
||||
})
|
||||
|
||||
it('has correct metadata', () => {
|
||||
expect(pluginAction.key).toBe('@plugin')
|
||||
expect(pluginAction.shortcut).toBe('@plugin')
|
||||
it('builds generated marketplace query options', () => {
|
||||
pluginSearchQueryOptions('agent', 'en_US')
|
||||
|
||||
expect(serviceMocks.queryOptions).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
input: {
|
||||
params: { kind: 'plugins' },
|
||||
body: { page: 1, page_size: 10, query: 'agent' },
|
||||
},
|
||||
retry: false,
|
||||
select: expect.any(Function),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('returns parsed plugin results on success', async () => {
|
||||
const { postMarketplace } = await import('@/service/base')
|
||||
vi.mocked(postMarketplace).mockResolvedValue({
|
||||
it('selects formatted plugin results using the active locale', () => {
|
||||
const options = pluginSearchQueryOptions('', 'en_US')
|
||||
const results = options.select!({
|
||||
data: {
|
||||
plugins: [
|
||||
{
|
||||
@ -41,45 +51,15 @@ describe('pluginAction', () => {
|
||||
icon: 'icon.png',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
},
|
||||
})
|
||||
} as never)
|
||||
|
||||
const results = await pluginAction.search('@plugin', 'test', 'en_US')
|
||||
|
||||
expect(postMarketplace).toHaveBeenCalledWith('/plugins/search/advanced', {
|
||||
body: { page: 1, page_size: 10, query: 'test', type: 'plugin' },
|
||||
})
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0]).toMatchObject({
|
||||
id: 'plugin-1',
|
||||
title: 'My Plugin',
|
||||
type: 'plugin',
|
||||
})
|
||||
expect(results[0]).toMatchObject({ id: 'plugin-1', title: 'My Plugin', type: 'plugin' })
|
||||
})
|
||||
|
||||
it('returns empty array when response has unexpected structure', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const { postMarketplace } = await import('@/service/base')
|
||||
vi.mocked(postMarketplace).mockResolvedValue({ data: {} })
|
||||
it('selects an empty list when marketplace returns no plugins', () => {
|
||||
const options = pluginSearchQueryOptions('', 'en_US')
|
||||
|
||||
const results = await pluginAction.search('@plugin', 'test', 'en')
|
||||
expect(results).toEqual([])
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'Plugin search: Unexpected response structure',
|
||||
expect.anything(),
|
||||
)
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('returns empty array on API failure', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const { postMarketplace } = await import('@/service/base')
|
||||
vi.mocked(postMarketplace).mockRejectedValue(new Error('fail'))
|
||||
|
||||
const results = await pluginAction.search('@plugin', 'fail', 'en')
|
||||
expect(results).toEqual([])
|
||||
expect(warnSpy).toHaveBeenCalledWith('Plugin search failed:', expect.any(Error))
|
||||
warnSpy.mockRestore()
|
||||
expect(options.select!({ data: {} } as never)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,30 +1,56 @@
|
||||
import type { AppPartial } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import type { ActionItem, AppSearchResult, SearchResult } from './types'
|
||||
import type { App } from '@/types/app'
|
||||
import {
|
||||
RiFileListLine,
|
||||
RiLayoutLine,
|
||||
RiLineChartLine,
|
||||
RiNodeTree,
|
||||
RiTerminalBoxLine,
|
||||
} from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { fetchAppList } from '@/service/apps'
|
||||
import type { AppIconType, AppModeEnum as AppMode } from '@/types/app'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { getRedirectionPath } from '@/utils/app-redirection'
|
||||
import { AppTypeIcon } from '../../app/type-selector'
|
||||
import AppIcon from '../../base/app-icon'
|
||||
|
||||
const WORKFLOW_MODES = new Set([AppModeEnum.WORKFLOW, AppModeEnum.ADVANCED_CHAT])
|
||||
const APP_MODES = new Set<string>(Object.values(AppModeEnum))
|
||||
const APP_ICON_TYPES = new Set<string>(['emoji', 'image', 'link'])
|
||||
|
||||
type AppSection = { id: string; label: string; path: string; icon: React.ElementType }
|
||||
type AppSection = { id: string; label: string; path: string; iconClassName: string }
|
||||
|
||||
const getAppSections = (app: App): AppSection[] => {
|
||||
function getAppMode(app: AppPartial): AppMode {
|
||||
return APP_MODES.has(app.mode) ? (app.mode as AppMode) : AppModeEnum.CHAT
|
||||
}
|
||||
|
||||
function getAppIconType(app: AppPartial): AppIconType | null {
|
||||
return app.icon_type && APP_ICON_TYPES.has(app.icon_type) ? (app.icon_type as AppIconType) : null
|
||||
}
|
||||
|
||||
function getAppPath(app: AppPartial) {
|
||||
return getRedirectionPath({
|
||||
id: app.id,
|
||||
mode: getAppMode(app),
|
||||
permission_keys: app.permission_keys,
|
||||
})
|
||||
}
|
||||
|
||||
const getAppSections = (app: AppPartial): AppSection[] => {
|
||||
const base = `/app/${app.id}`
|
||||
if (WORKFLOW_MODES.has(app.mode)) {
|
||||
if (WORKFLOW_MODES.has(getAppMode(app))) {
|
||||
return [
|
||||
{ id: 'workflow', label: 'Workflow', path: `${base}/workflow`, icon: RiNodeTree },
|
||||
{ id: 'overview', label: 'Overview', path: `${base}/overview`, icon: RiLineChartLine },
|
||||
{ id: 'logs', label: 'Logs', path: `${base}/logs`, icon: RiFileListLine },
|
||||
{
|
||||
id: 'workflow',
|
||||
label: 'Workflow',
|
||||
path: `${base}/workflow`,
|
||||
iconClassName: 'i-ri-node-tree size-4 text-text-tertiary',
|
||||
},
|
||||
{
|
||||
id: 'overview',
|
||||
label: 'Overview',
|
||||
path: `${base}/overview`,
|
||||
iconClassName: 'i-ri-line-chart-line size-4 text-text-tertiary',
|
||||
},
|
||||
{
|
||||
id: 'logs',
|
||||
label: 'Logs',
|
||||
path: `${base}/logs`,
|
||||
iconClassName: 'i-ri-file-list-line size-4 text-text-tertiary',
|
||||
},
|
||||
]
|
||||
}
|
||||
return [
|
||||
@ -32,53 +58,68 @@ const getAppSections = (app: App): AppSection[] => {
|
||||
id: 'configuration',
|
||||
label: 'Configuration',
|
||||
path: `${base}/configuration`,
|
||||
icon: RiLayoutLine,
|
||||
iconClassName: 'i-ri-layout-line size-4 text-text-tertiary',
|
||||
},
|
||||
{
|
||||
id: 'overview',
|
||||
label: 'Overview',
|
||||
path: `${base}/overview`,
|
||||
iconClassName: 'i-ri-line-chart-line size-4 text-text-tertiary',
|
||||
},
|
||||
{
|
||||
id: 'logs',
|
||||
label: 'Logs',
|
||||
path: `${base}/logs`,
|
||||
iconClassName: 'i-ri-file-list-line size-4 text-text-tertiary',
|
||||
},
|
||||
{
|
||||
id: 'develop',
|
||||
label: 'Develop',
|
||||
path: `${base}/develop`,
|
||||
iconClassName: 'i-ri-terminal-box-line size-4 text-text-tertiary',
|
||||
},
|
||||
{ id: 'overview', label: 'Overview', path: `${base}/overview`, icon: RiLineChartLine },
|
||||
{ id: 'logs', label: 'Logs', path: `${base}/logs`, icon: RiFileListLine },
|
||||
{ id: 'develop', label: 'Develop', path: `${base}/develop`, icon: RiTerminalBoxLine },
|
||||
]
|
||||
}
|
||||
|
||||
const appIcon = (app: App) => (
|
||||
const appIcon = (app: AppPartial) => (
|
||||
<div className="relative shrink-0">
|
||||
<AppIcon
|
||||
size="large"
|
||||
iconType={app.icon_type}
|
||||
icon={app.icon}
|
||||
iconType={getAppIconType(app)}
|
||||
icon={app.icon ?? undefined}
|
||||
background={app.icon_background}
|
||||
imageUrl={app.icon_url}
|
||||
/>
|
||||
<AppTypeIcon
|
||||
wrapperClassName="absolute -bottom-0.5 -right-0.5 w-4 h-4 rounded-sm border border-divider-regular outline-solid outline-components-panel-on-panel-item-bg"
|
||||
className="size-3"
|
||||
type={app.mode}
|
||||
type={getAppMode(app)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
const parser = (apps: App[]): AppSearchResult[] => {
|
||||
function getAppResults(apps: AppPartial[]): AppSearchResult[] {
|
||||
return apps.map((app) => ({
|
||||
id: app.id,
|
||||
title: app.name,
|
||||
description: app.description,
|
||||
description: app.description ?? undefined,
|
||||
type: 'app' as const,
|
||||
path: getRedirectionPath(app),
|
||||
path: getAppPath(app),
|
||||
icon: appIcon(app),
|
||||
data: app,
|
||||
}))
|
||||
}
|
||||
|
||||
// Generate sub-section results for matched apps when in scoped @app search
|
||||
const parserWithSections = (apps: App[]): SearchResult[] => {
|
||||
function getScopedAppResults(apps: AppPartial[]): SearchResult[] {
|
||||
const results: SearchResult[] = []
|
||||
for (const app of apps) {
|
||||
results.push({
|
||||
id: app.id,
|
||||
title: app.name,
|
||||
description: app.description,
|
||||
description: app.description ?? undefined,
|
||||
type: 'app' as const,
|
||||
path: getRedirectionPath(app),
|
||||
path: getAppPath(app),
|
||||
icon: appIcon(app),
|
||||
data: app,
|
||||
})
|
||||
@ -91,7 +132,7 @@ const parserWithSections = (apps: App[]): SearchResult[] => {
|
||||
path: section.path,
|
||||
icon: (
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-panel-bg">
|
||||
<section.icon className="size-4 text-text-tertiary" />
|
||||
<span aria-hidden className={section.iconClassName} />
|
||||
</div>
|
||||
),
|
||||
data: app,
|
||||
@ -106,21 +147,19 @@ export const appAction: ActionItem = {
|
||||
shortcut: '@app',
|
||||
title: 'Search Applications',
|
||||
description: 'Search and navigate to your applications',
|
||||
search: async (query, searchTerm = '', _locale) => {
|
||||
const isScoped = query.trimStart().startsWith('@app') || query.trimStart().startsWith('@App')
|
||||
try {
|
||||
const response = await fetchAppList({
|
||||
url: 'apps',
|
||||
params: {
|
||||
page: 1,
|
||||
name: searchTerm,
|
||||
},
|
||||
})
|
||||
const apps = response?.data || []
|
||||
return isScoped ? parserWithSections(apps) : parser(apps)
|
||||
} catch (error) {
|
||||
console.warn('App search failed:', error)
|
||||
return []
|
||||
}
|
||||
},
|
||||
source: 'remote',
|
||||
}
|
||||
|
||||
export function appSearchQueryOptions(searchTerm: string, scoped: boolean) {
|
||||
return consoleQuery.apps.get.queryOptions({
|
||||
input: {
|
||||
query: {
|
||||
page: 1,
|
||||
name: searchTerm,
|
||||
},
|
||||
},
|
||||
retry: false,
|
||||
select: (response) =>
|
||||
scoped ? getScopedAppResults(response.data) : getAppResults(response.data),
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,12 +1,5 @@
|
||||
import { executeCommand } from '../command-bus'
|
||||
import { createCommand } from '../create'
|
||||
|
||||
// Stub the icon imports — these are React components we don't render here.
|
||||
vi.mock('@remixicon/react', () => ({
|
||||
RiChat3Line: () => null,
|
||||
RiNodeTree: () => null,
|
||||
RiSparkling2Line: () => null,
|
||||
}))
|
||||
// We spy on the store at module scope so the `create.open` handler that
|
||||
// register() pushes into the command bus can be observed by the tests.
|
||||
const mockOpenGenerator = vi.fn()
|
||||
|
||||
@ -1,10 +1,5 @@
|
||||
import { executeCommand } from '../command-bus'
|
||||
import { refineCommand } from '../refine'
|
||||
|
||||
// Stub the icon import — it's a React component we don't render here.
|
||||
vi.mock('@remixicon/react', () => ({
|
||||
RiSparkling2Line: () => null,
|
||||
}))
|
||||
// Spy on the generator store so we can observe what /refine opens it with.
|
||||
const mockOpenGenerator = vi.fn()
|
||||
vi.mock('@/app/components/workflow/workflow-generator/store', () => ({
|
||||
|
||||
@ -225,7 +225,9 @@ describe('SlashCommandRegistry', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const handler = createHandler({
|
||||
name: 'broken',
|
||||
search: vi.fn().mockRejectedValue(new Error('fail')),
|
||||
search: vi.fn(() => {
|
||||
throw new Error('fail')
|
||||
}),
|
||||
})
|
||||
registry.register(handler)
|
||||
|
||||
|
||||
@ -91,6 +91,8 @@ describe('slashAction', () => {
|
||||
{ id: 'theme', title: '/theme', type: 'command', data: { command: 'theme' } },
|
||||
])
|
||||
|
||||
expect(slashAction.source).toBe('local')
|
||||
if (slashAction.source !== 'local') throw new Error('Expected a local slash action')
|
||||
const results = await slashAction.search('/theme dark', 'dark')
|
||||
|
||||
expect(mockSearch).toHaveBeenCalledWith('/theme dark', 'ja')
|
||||
|
||||
@ -1,6 +1,4 @@
|
||||
import type { SlashCommandHandler } from './types'
|
||||
import { RiUser3Line } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { getI18n } from 'react-i18next'
|
||||
import { registerCommands, unregisterCommands } from './command-bus'
|
||||
|
||||
@ -20,7 +18,7 @@ export const accountCommand: SlashCommandHandler<AccountDeps> = {
|
||||
window.location.href = '/account'
|
||||
},
|
||||
|
||||
async search(args: string, locale: string = 'en') {
|
||||
search(args: string, locale: string = 'en') {
|
||||
const i18n = getI18n()
|
||||
return [
|
||||
{
|
||||
@ -33,7 +31,7 @@ export const accountCommand: SlashCommandHandler<AccountDeps> = {
|
||||
type: 'command' as const,
|
||||
icon: (
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-panel-bg">
|
||||
<RiUser3Line className="size-4 text-text-tertiary" />
|
||||
<span aria-hidden className="i-ri-user-3-line size-4 text-text-tertiary" />
|
||||
</div>
|
||||
),
|
||||
data: { command: 'navigation.account', args: {} },
|
||||
|
||||
@ -1,6 +1,4 @@
|
||||
import type { SlashCommandHandler } from './types'
|
||||
import { RiDiscordLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { getI18n } from 'react-i18next'
|
||||
import { registerCommands, unregisterCommands } from './command-bus'
|
||||
|
||||
@ -21,7 +19,7 @@ export const communityCommand: SlashCommandHandler<CommunityDeps> = {
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
},
|
||||
|
||||
async search(args: string, locale: string = 'en') {
|
||||
search(args: string, locale: string = 'en') {
|
||||
const i18n = getI18n()
|
||||
return [
|
||||
{
|
||||
@ -33,7 +31,7 @@ export const communityCommand: SlashCommandHandler<CommunityDeps> = {
|
||||
type: 'command' as const,
|
||||
icon: (
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-panel-bg">
|
||||
<RiDiscordLine className="size-4 text-text-tertiary" />
|
||||
<span aria-hidden className="i-ri-discord-line size-4 text-text-tertiary" />
|
||||
</div>
|
||||
),
|
||||
data: { command: 'navigation.community', args: { url: 'https://discord.gg/5AEfbxcd9k' } },
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
import type { SlashCommandHandler } from './types'
|
||||
import type { WorkflowGeneratorMode } from '@/app/components/workflow/workflow-generator/types'
|
||||
import { RiChat3Line, RiNodeTree, RiSparkling2Line } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { getI18n } from 'react-i18next'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { useWorkflowGeneratorStore } from '@/app/components/workflow/workflow-generator/store'
|
||||
@ -18,7 +16,7 @@ type CreateOption = {
|
||||
mode: WorkflowGeneratorMode
|
||||
/** When set, the modal opens in auto-mode and the planner picks the app type. */
|
||||
auto?: boolean
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
iconClassName: string
|
||||
}
|
||||
|
||||
// `as const` keeps titleKey/descKey as literal types so the typed `i18n.t`
|
||||
@ -30,7 +28,7 @@ const OPTIONS = [
|
||||
descKey: 'gotoAnything.actions.createAutoDesc',
|
||||
mode: 'advanced-chat',
|
||||
auto: true,
|
||||
icon: RiSparkling2Line,
|
||||
iconClassName: 'i-ri-sparkling-2-line',
|
||||
},
|
||||
{
|
||||
id: 'workflow',
|
||||
@ -38,7 +36,7 @@ const OPTIONS = [
|
||||
descKey: 'gotoAnything.actions.createWorkflowDesc',
|
||||
mode: 'workflow',
|
||||
auto: false,
|
||||
icon: RiNodeTree,
|
||||
iconClassName: 'i-ri-node-tree',
|
||||
},
|
||||
{
|
||||
id: 'chatflow',
|
||||
@ -46,7 +44,7 @@ const OPTIONS = [
|
||||
descKey: 'gotoAnything.actions.createChatflowDesc',
|
||||
mode: 'advanced-chat',
|
||||
auto: false,
|
||||
icon: RiChat3Line,
|
||||
iconClassName: 'i-ri-chat-3-line',
|
||||
},
|
||||
] as const satisfies readonly CreateOption[]
|
||||
|
||||
@ -71,19 +69,17 @@ const OPTIONS = [
|
||||
export const createCommand: SlashCommandHandler = {
|
||||
name: 'create',
|
||||
aliases: ['new', 'generate'],
|
||||
// Fallback only — the palette localises the root row via the slashKeyMap in
|
||||
// command-selector.tsx (gotoAnything.actions.createCategoryDesc).
|
||||
description: getI18n().t(($) => $['gotoAnything.actions.createCategoryDesc'], { ns: 'app' }),
|
||||
mode: 'submenu',
|
||||
|
||||
async search(args: string, locale?: string) {
|
||||
search(args: string, locale?: string) {
|
||||
const i18n = getI18n()
|
||||
const tr = (key: (typeof OPTIONS)[number]['titleKey' | 'descKey']) =>
|
||||
i18n.t(($) => $[key], { ns: 'app', lng: locale })
|
||||
|
||||
const renderIcon = (Icon: CreateOption['icon']) => (
|
||||
const renderIcon = (iconClassName: string) => (
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-panel-bg">
|
||||
<Icon className="size-4 text-text-tertiary" />
|
||||
<span aria-hidden className={`${iconClassName} size-4 text-text-tertiary`} />
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -94,7 +90,7 @@ export const createCommand: SlashCommandHandler = {
|
||||
// back to the option's static description otherwise.
|
||||
description: instruction || tr(opt.descKey),
|
||||
type: 'command' as const,
|
||||
icon: renderIcon(opt.icon),
|
||||
icon: renderIcon(opt.iconClassName),
|
||||
data: { command: 'create.open', args: { mode: opt.mode, auto: !!opt.auto, instruction } },
|
||||
})
|
||||
|
||||
|
||||
@ -1,6 +1,4 @@
|
||||
import type { SlashCommandHandler } from './types'
|
||||
import { RiBookOpenLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { getI18n } from 'react-i18next'
|
||||
import { defaultDocBaseUrl, getDocHomePath } from '@/context/i18n'
|
||||
import { getDocLanguage } from '@/i18n-config/language'
|
||||
@ -29,7 +27,7 @@ export const docsCommand: SlashCommandHandler<DocDeps> = {
|
||||
window.open(getDocsHomeUrl(), '_blank', 'noopener,noreferrer')
|
||||
},
|
||||
|
||||
async search(args: string, locale: string = 'en') {
|
||||
search(args: string, locale: string = 'en') {
|
||||
const i18n = getI18n()
|
||||
return [
|
||||
{
|
||||
@ -41,7 +39,7 @@ export const docsCommand: SlashCommandHandler<DocDeps> = {
|
||||
type: 'command' as const,
|
||||
icon: (
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-panel-bg">
|
||||
<RiBookOpenLine className="size-4 text-text-tertiary" />
|
||||
<span aria-hidden className="i-ri-book-open-line size-4 text-text-tertiary" />
|
||||
</div>
|
||||
),
|
||||
data: { command: 'navigation.doc', args: {} },
|
||||
|
||||
@ -1,6 +1,4 @@
|
||||
import type { SlashCommandHandler } from './types'
|
||||
import { RiFeedbackLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { getI18n } from 'react-i18next'
|
||||
import { registerCommands, unregisterCommands } from './command-bus'
|
||||
|
||||
@ -21,7 +19,7 @@ export const forumCommand: SlashCommandHandler<ForumDeps> = {
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
},
|
||||
|
||||
async search(args: string, locale: string = 'en') {
|
||||
search(args: string, locale: string = 'en') {
|
||||
const i18n = getI18n()
|
||||
return [
|
||||
{
|
||||
@ -33,7 +31,7 @@ export const forumCommand: SlashCommandHandler<ForumDeps> = {
|
||||
type: 'command' as const,
|
||||
icon: (
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-panel-bg">
|
||||
<RiFeedbackLine className="size-4 text-text-tertiary" />
|
||||
<span aria-hidden className="i-ri-feedback-line size-4 text-text-tertiary" />
|
||||
</div>
|
||||
),
|
||||
data: { command: 'navigation.forum', args: { url: 'https://forum.dify.ai' } },
|
||||
|
||||
@ -1,22 +1,13 @@
|
||||
import type { SlashCommandHandler } from './types'
|
||||
import {
|
||||
RiApps2Line,
|
||||
RiBookOpenLine,
|
||||
RiCompassLine,
|
||||
RiPlugLine,
|
||||
RiToolsLine,
|
||||
RiUserLine,
|
||||
} from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { registerCommands, unregisterCommands } from './command-bus'
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ id: 'apps', label: 'Apps', path: '/apps', icon: RiApps2Line },
|
||||
{ id: 'datasets', label: 'Knowledge', path: '/datasets', icon: RiBookOpenLine },
|
||||
{ id: 'plugins', label: 'Plugins', path: '/plugins', icon: RiPlugLine },
|
||||
{ id: 'tools', label: 'Tools', path: '/tools', icon: RiToolsLine },
|
||||
{ id: 'explore', label: 'Explore', path: '/explore', icon: RiCompassLine },
|
||||
{ id: 'account', label: 'Account', path: '/account', icon: RiUserLine },
|
||||
{ id: 'apps', label: 'Apps', path: '/apps', iconClassName: 'i-ri-apps-2-line' },
|
||||
{ id: 'datasets', label: 'Knowledge', path: '/datasets', iconClassName: 'i-ri-book-open-line' },
|
||||
{ id: 'plugins', label: 'Plugins', path: '/plugins', iconClassName: 'i-ri-plug-line' },
|
||||
{ id: 'tools', label: 'Tools', path: '/tools', iconClassName: 'i-ri-tools-line' },
|
||||
{ id: 'explore', label: 'Explore', path: '/explore', iconClassName: 'i-ri-compass-line' },
|
||||
{ id: 'account', label: 'Account', path: '/account', iconClassName: 'i-ri-user-line' },
|
||||
]
|
||||
|
||||
/**
|
||||
@ -28,7 +19,7 @@ export const goCommand: SlashCommandHandler = {
|
||||
description: 'Navigate to a section',
|
||||
mode: 'submenu',
|
||||
|
||||
async search(args: string, _locale: string = 'en') {
|
||||
search(args: string, _locale: string = 'en') {
|
||||
const query = args.trim().toLowerCase()
|
||||
const items = NAV_ITEMS.filter(
|
||||
(item) => !query || item.id.includes(query) || item.label.toLowerCase().includes(query),
|
||||
@ -40,7 +31,7 @@ export const goCommand: SlashCommandHandler = {
|
||||
type: 'command' as const,
|
||||
icon: (
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-panel-bg">
|
||||
<item.icon className="size-4 text-text-tertiary" />
|
||||
<span aria-hidden className={`${item.iconClassName} size-4 text-text-tertiary`} />
|
||||
</div>
|
||||
),
|
||||
data: { command: 'navigation.go', args: { path: item.path } },
|
||||
|
||||
@ -36,7 +36,7 @@ export const languageCommand: SlashCommandHandler<LanguageDeps> = {
|
||||
description: 'Switch between different languages',
|
||||
mode: 'submenu', // Explicitly set submenu mode
|
||||
|
||||
async search(args: string, _locale: string = 'en') {
|
||||
search(args: string, _locale: string = 'en') {
|
||||
// Return language options directly, regardless of parameters
|
||||
return buildLanguageCommands(args)
|
||||
},
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
import type { SlashCommandHandler } from './types'
|
||||
import type { WorkflowGeneratorMode } from '@/app/components/workflow/workflow-generator/types'
|
||||
import { RiSparkling2Line } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { getI18n } from 'react-i18next'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { useWorkflowGeneratorStore } from '@/app/components/workflow/workflow-generator/store'
|
||||
@ -49,8 +47,6 @@ const openRefineGenerator = () => {
|
||||
export const refineCommand: SlashCommandHandler = {
|
||||
name: 'refine',
|
||||
aliases: ['improve'],
|
||||
// Fallback only — the palette localises the root row via the slashKeyMap in
|
||||
// command-selector.tsx (gotoAnything.actions.refineCategoryDesc).
|
||||
description: getI18n().t(($) => $['gotoAnything.actions.refineCategoryDesc'], { ns: 'app' }),
|
||||
mode: 'direct',
|
||||
|
||||
@ -60,7 +56,7 @@ export const refineCommand: SlashCommandHandler = {
|
||||
|
||||
execute: openRefineGenerator,
|
||||
|
||||
async search(_args: string, locale?: string) {
|
||||
search(_args: string, locale?: string) {
|
||||
const i18n = getI18n()
|
||||
return [
|
||||
{
|
||||
@ -73,7 +69,7 @@ export const refineCommand: SlashCommandHandler = {
|
||||
type: 'command' as const,
|
||||
icon: (
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-panel-bg">
|
||||
<RiSparkling2Line className="size-4 text-text-tertiary" />
|
||||
<span aria-hidden className="i-ri-sparkling-2-line size-4 text-text-tertiary" />
|
||||
</div>
|
||||
),
|
||||
data: { command: 'refine.open', args: {} },
|
||||
|
||||
@ -1,18 +1,18 @@
|
||||
import type { CommandSearchResult } from '../types'
|
||||
import type { SlashCommandHandler } from './types'
|
||||
import type { SlashCommand, SlashCommandHandler } from './types'
|
||||
|
||||
/**
|
||||
* Slash Command Registry System
|
||||
* Responsible for managing registration, lookup, and search of all slash commands
|
||||
*/
|
||||
export class SlashCommandRegistry {
|
||||
private commands = new Map<string, SlashCommandHandler>()
|
||||
private commandDeps = new Map<string, any>()
|
||||
private commands = new Map<string, SlashCommand>()
|
||||
private commandDeps = new Map<string, unknown>()
|
||||
|
||||
/**
|
||||
* Register command handler
|
||||
*/
|
||||
register<TDeps = any>(handler: SlashCommandHandler<TDeps>, deps?: TDeps) {
|
||||
register<TDeps>(handler: SlashCommandHandler<TDeps>, deps?: TDeps) {
|
||||
// Register main command name
|
||||
this.commands.set(handler.name, handler)
|
||||
|
||||
@ -57,7 +57,7 @@ export class SlashCommandRegistry {
|
||||
/**
|
||||
* Find command handler
|
||||
*/
|
||||
findCommand(commandName: string): SlashCommandHandler | undefined {
|
||||
findCommand(commandName: string): SlashCommand | undefined {
|
||||
return this.commands.get(commandName)
|
||||
}
|
||||
|
||||
@ -65,7 +65,7 @@ export class SlashCommandRegistry {
|
||||
* Smart partial command matching
|
||||
* Prioritize alias matching, then match command name prefix
|
||||
*/
|
||||
private findBestPartialMatch(partialName: string): SlashCommandHandler | undefined {
|
||||
private findBestPartialMatch(partialName: string): SlashCommand | undefined {
|
||||
const lowerPartial = partialName.toLowerCase()
|
||||
|
||||
// First check if any alias starts with this
|
||||
@ -80,7 +80,7 @@ export class SlashCommandRegistry {
|
||||
/**
|
||||
* Find handler by alias prefix
|
||||
*/
|
||||
private findHandlerByAliasPrefix(prefix: string): SlashCommandHandler | undefined {
|
||||
private findHandlerByAliasPrefix(prefix: string): SlashCommand | undefined {
|
||||
for (const handler of this.getAllCommands()) {
|
||||
if (handler.aliases?.some((alias) => alias.toLowerCase().startsWith(prefix))) return handler
|
||||
}
|
||||
@ -90,15 +90,15 @@ export class SlashCommandRegistry {
|
||||
/**
|
||||
* Find handler by name prefix
|
||||
*/
|
||||
private findHandlerByNamePrefix(prefix: string): SlashCommandHandler | undefined {
|
||||
private findHandlerByNamePrefix(prefix: string): SlashCommand | undefined {
|
||||
return this.getAllCommands().find((handler) => handler.name.toLowerCase().startsWith(prefix))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered commands (deduplicated)
|
||||
*/
|
||||
getAllCommands(): SlashCommandHandler[] {
|
||||
const uniqueCommands = new Map<string, SlashCommandHandler>()
|
||||
getAllCommands(): SlashCommand[] {
|
||||
const uniqueCommands = new Map<string, SlashCommand>()
|
||||
this.commands.forEach((handler) => {
|
||||
uniqueCommands.set(handler.name, handler)
|
||||
})
|
||||
@ -109,7 +109,7 @@ export class SlashCommandRegistry {
|
||||
* Get all available commands in current context (deduplicated and filtered)
|
||||
* Commands without isAvailable method are considered always available
|
||||
*/
|
||||
getAvailableCommands(): SlashCommandHandler[] {
|
||||
getAvailableCommands(): SlashCommand[] {
|
||||
return this.getAllCommands().filter((handler) => this.isCommandAvailable(handler))
|
||||
}
|
||||
|
||||
@ -118,11 +118,11 @@ export class SlashCommandRegistry {
|
||||
* @param query Full query (e.g., "/theme dark" or "/lang en")
|
||||
* @param locale Current language
|
||||
*/
|
||||
async search(query: string, locale: string = 'en'): Promise<CommandSearchResult[]> {
|
||||
search(query: string, locale: string = 'en'): CommandSearchResult[] {
|
||||
const trimmed = query.trim()
|
||||
|
||||
// Handle root level search "/"
|
||||
if (trimmed === '/' || !trimmed.replace('/', '').trim()) return await this.getRootCommands()
|
||||
if (trimmed === '/' || !trimmed.replace('/', '').trim()) return this.getRootCommands()
|
||||
|
||||
// Parse command and arguments
|
||||
const afterSlash = trimmed.substring(1).trim()
|
||||
@ -134,7 +134,7 @@ export class SlashCommandRegistry {
|
||||
let handler = this.findCommand(commandName)
|
||||
if (handler && this.isCommandAvailable(handler)) {
|
||||
try {
|
||||
return await handler.search(args, locale)
|
||||
return handler.search(args, locale)
|
||||
} catch (error) {
|
||||
console.warn(`Command search failed for ${commandName}:`, error)
|
||||
return []
|
||||
@ -145,7 +145,7 @@ export class SlashCommandRegistry {
|
||||
handler = this.findBestPartialMatch(commandName)
|
||||
if (handler && this.isCommandAvailable(handler)) {
|
||||
try {
|
||||
return await handler.search(args, locale)
|
||||
return handler.search(args, locale)
|
||||
} catch (error) {
|
||||
console.warn(`Command search failed for ${handler.name}:`, error)
|
||||
return []
|
||||
@ -160,7 +160,7 @@ export class SlashCommandRegistry {
|
||||
* Get root level command list
|
||||
* Only shows commands that are available in current context
|
||||
*/
|
||||
private async getRootCommands(): Promise<CommandSearchResult[]> {
|
||||
private getRootCommands(): CommandSearchResult[] {
|
||||
return this.getAvailableCommands().map((handler) => ({
|
||||
id: `root-${handler.name}`,
|
||||
title: `/${handler.name}`,
|
||||
@ -221,7 +221,7 @@ export class SlashCommandRegistry {
|
||||
/**
|
||||
* Get command dependencies
|
||||
*/
|
||||
getCommandDependencies(commandName: string): any {
|
||||
getCommandDependencies(commandName: string): unknown {
|
||||
return this.commandDeps.get(commandName)
|
||||
}
|
||||
|
||||
@ -229,7 +229,7 @@ export class SlashCommandRegistry {
|
||||
* Determine if a command is available in the current context.
|
||||
* Defaults to true when a handler does not implement the guard.
|
||||
*/
|
||||
private isCommandAvailable(handler: SlashCommandHandler) {
|
||||
private isCommandAvailable(handler: SlashCommand) {
|
||||
return handler.isAvailable?.() ?? true
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,12 +14,13 @@ export const slashAction: ActionItem = {
|
||||
const i18n = getI18n()
|
||||
return i18n.t(($) => $['gotoAnything.actions.slashDesc'], { ns: 'app' })
|
||||
},
|
||||
source: 'local',
|
||||
action: (result) => {
|
||||
if (result.type !== 'command') return
|
||||
const { command, args } = result.data
|
||||
executeCommand(command, args)
|
||||
},
|
||||
search: async (query, _searchTerm = '') => {
|
||||
search: (query, _searchTerm = '') => {
|
||||
const i18n = getI18n()
|
||||
// Delegate all search logic to the command registry system
|
||||
return slashCommandRegistry.search(query, i18n.language)
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
import type { CommandSearchResult } from '../types'
|
||||
import type { SlashCommandHandler } from './types'
|
||||
import { RiComputerLine, RiMoonLine, RiSunLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { getI18n } from 'react-i18next'
|
||||
import { registerCommands, unregisterCommands } from './command-bus'
|
||||
|
||||
@ -15,19 +13,19 @@ const THEME_ITEMS = [
|
||||
id: 'system',
|
||||
titleKey: 'gotoAnything.actions.themeSystem',
|
||||
descKey: 'gotoAnything.actions.themeSystemDesc',
|
||||
icon: <RiComputerLine className="size-4 text-text-tertiary" />,
|
||||
iconClassName: 'i-ri-computer-line',
|
||||
},
|
||||
{
|
||||
id: 'light',
|
||||
titleKey: 'gotoAnything.actions.themeLight',
|
||||
descKey: 'gotoAnything.actions.themeLightDesc',
|
||||
icon: <RiSunLine className="size-4 text-text-tertiary" />,
|
||||
iconClassName: 'i-ri-sun-line',
|
||||
},
|
||||
{
|
||||
id: 'dark',
|
||||
titleKey: 'gotoAnything.actions.themeDark',
|
||||
descKey: 'gotoAnything.actions.themeDarkDesc',
|
||||
icon: <RiMoonLine className="size-4 text-text-tertiary" />,
|
||||
iconClassName: 'i-ri-moon-line',
|
||||
},
|
||||
] as const
|
||||
|
||||
@ -50,7 +48,7 @@ const buildThemeCommands = (query: string, locale?: string): CommandSearchResult
|
||||
type: 'command' as const,
|
||||
icon: (
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-panel-bg">
|
||||
{item.icon}
|
||||
<span aria-hidden className={`${item.iconClassName} size-4 text-text-tertiary`} />
|
||||
</div>
|
||||
),
|
||||
data: { command: 'theme.set', args: { value: item.id } },
|
||||
@ -66,7 +64,7 @@ export const themeCommand: SlashCommandHandler<ThemeDeps> = {
|
||||
description: 'Switch between light and dark themes',
|
||||
mode: 'submenu', // Explicitly set submenu mode
|
||||
|
||||
async search(args: string, locale: string = 'en') {
|
||||
search(args: string, locale: string = 'en') {
|
||||
// Return theme options directly, regardless of parameters
|
||||
return buildThemeCommands(args, locale)
|
||||
},
|
||||
|
||||
@ -4,7 +4,7 @@ import type { CommandSearchResult } from '../types'
|
||||
* Slash command handler interface
|
||||
* Each slash command should implement this interface
|
||||
*/
|
||||
export type SlashCommandHandler<TDeps = any> = {
|
||||
export type SlashCommandHandler<TDeps = unknown> = {
|
||||
/** Command name (e.g., 'theme', 'language') */
|
||||
name: string
|
||||
|
||||
@ -39,7 +39,7 @@ export type SlashCommandHandler<TDeps = any> = {
|
||||
* @param args Command arguments (part after removing command name)
|
||||
* @param locale Current language
|
||||
*/
|
||||
search: (args: string, locale?: string) => Promise<CommandSearchResult[]>
|
||||
search: (args: string, locale?: string) => CommandSearchResult[]
|
||||
|
||||
/**
|
||||
* Called when registering command, passing external dependencies
|
||||
@ -51,3 +51,5 @@ export type SlashCommandHandler<TDeps = any> = {
|
||||
*/
|
||||
unregister?: () => void
|
||||
}
|
||||
|
||||
export type SlashCommand = Omit<SlashCommandHandler, 'register'>
|
||||
|
||||
@ -1,169 +1,4 @@
|
||||
/**
|
||||
* Goto Anything - Action System
|
||||
*
|
||||
* This file defines the action registry for the goto-anything search system.
|
||||
* Actions handle different types of searches: apps, knowledge bases, plugins, workflow nodes, and commands.
|
||||
*
|
||||
* ## How to Add a New Slash Command
|
||||
*
|
||||
* 1. **Create Command Handler File** (in `./commands/` directory):
|
||||
* ```typescript
|
||||
* // commands/my-command.ts
|
||||
* import type { SlashCommandHandler } from './types'
|
||||
* import type { CommandSearchResult } from '../types'
|
||||
* import { registerCommands, unregisterCommands } from './command-bus'
|
||||
*
|
||||
* interface MyCommandDeps {
|
||||
* myService?: (data: any) => Promise<void>
|
||||
* }
|
||||
*
|
||||
* export const myCommand: SlashCommandHandler<MyCommandDeps> = {
|
||||
* name: 'mycommand',
|
||||
* aliases: ['mc'], // Optional aliases
|
||||
* description: 'My custom command description',
|
||||
*
|
||||
* async search(args: string, locale: string = 'en') {
|
||||
* // Return search results based on args
|
||||
* return [{
|
||||
* id: 'my-result',
|
||||
* title: 'My Command Result',
|
||||
* description: 'Description of the result',
|
||||
* type: 'command' as const,
|
||||
* data: { command: 'my.action', args: { value: args } }
|
||||
* }]
|
||||
* },
|
||||
*
|
||||
* register(deps: MyCommandDeps) {
|
||||
* registerCommands({
|
||||
* 'my.action': async (args) => {
|
||||
* await deps.myService?.(args?.value)
|
||||
* }
|
||||
* })
|
||||
* },
|
||||
*
|
||||
* unregister() {
|
||||
* unregisterCommands(['my.action'])
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* **Example for Self-Contained Command (no external dependencies):**
|
||||
* ```typescript
|
||||
* // commands/calculator-command.ts
|
||||
* export const calculatorCommand: SlashCommandHandler = {
|
||||
* name: 'calc',
|
||||
* aliases: ['calculator'],
|
||||
* description: 'Simple calculator',
|
||||
*
|
||||
* async search(args: string) {
|
||||
* if (!args.trim()) return []
|
||||
* try {
|
||||
* // Safe math evaluation (implement proper parser in real use)
|
||||
* const result = Function('"use strict"; return (' + args + ')')()
|
||||
* return [{
|
||||
* id: 'calc-result',
|
||||
* title: `${args} = ${result}`,
|
||||
* description: 'Calculator result',
|
||||
* type: 'command' as const,
|
||||
* data: { command: 'calc.copy', args: { result: result.toString() } }
|
||||
* }]
|
||||
* } catch {
|
||||
* return [{
|
||||
* id: 'calc-error',
|
||||
* title: 'Invalid expression',
|
||||
* description: 'Please enter a valid math expression',
|
||||
* type: 'command' as const,
|
||||
* data: { command: 'calc.noop', args: {} }
|
||||
* }]
|
||||
* }
|
||||
* },
|
||||
*
|
||||
* register() {
|
||||
* registerCommands({
|
||||
* 'calc.copy': (args) => navigator.clipboard.writeText(args.result),
|
||||
* 'calc.noop': () => {} // No operation
|
||||
* })
|
||||
* },
|
||||
*
|
||||
* unregister() {
|
||||
* unregisterCommands(['calc.copy', 'calc.noop'])
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* 2. **Register Command** (in `./commands/slash.tsx`):
|
||||
* ```typescript
|
||||
* import { myCommand } from './my-command'
|
||||
* import { calculatorCommand } from './calculator-command' // For self-contained commands
|
||||
*
|
||||
* export const registerSlashCommands = (deps: Record<string, any>) => {
|
||||
* slashCommandRegistry.register(themeCommand, { setTheme: deps.setTheme })
|
||||
* slashCommandRegistry.register(languageCommand, { setLocale: deps.setLocale })
|
||||
* slashCommandRegistry.register(myCommand, { myService: deps.myService }) // With dependencies
|
||||
* slashCommandRegistry.register(calculatorCommand) // Self-contained, no dependencies
|
||||
* }
|
||||
*
|
||||
* export const unregisterSlashCommands = () => {
|
||||
* slashCommandRegistry.unregister('theme')
|
||||
* slashCommandRegistry.unregister('language')
|
||||
* slashCommandRegistry.unregister('mycommand')
|
||||
* slashCommandRegistry.unregister('calc') // Add this line
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
*
|
||||
* 3. **Update SlashCommandProvider** (in `./commands/slash.tsx`):
|
||||
* ```typescript
|
||||
* export const SlashCommandProvider = () => {
|
||||
* const theme = useTheme()
|
||||
* const myService = useMyService() // Add external dependency if needed
|
||||
*
|
||||
* useEffect(() => {
|
||||
* registerSlashCommands({
|
||||
* setTheme: theme.setTheme, // Required for theme command
|
||||
* setLocale: setLocaleOnClient, // Required for language command
|
||||
* myService: myService, // Required for your custom command
|
||||
* // Note: calculatorCommand doesn't need dependencies, so not listed here
|
||||
* })
|
||||
* return () => unregisterSlashCommands()
|
||||
* }, [theme.setTheme, myService]) // Update dependency array for all dynamic deps
|
||||
*
|
||||
* return null
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* **Note:** Self-contained commands (like calculator) don't require dependencies but are
|
||||
* still registered through the same system for consistent lifecycle management.
|
||||
*
|
||||
* 4. **Usage**: Users can now type `/mycommand` or `/mc` to use your command
|
||||
*
|
||||
* ## Command System Architecture
|
||||
* - Commands are registered via `SlashCommandRegistry`
|
||||
* - Each command is self-contained with its own dependencies
|
||||
* - Commands support aliases for easier access
|
||||
* - Command execution is handled by the command bus system
|
||||
* - All commands should be registered through `SlashCommandProvider` for consistent lifecycle management
|
||||
*
|
||||
* ## Command Types
|
||||
* **Commands with External Dependencies:**
|
||||
* - Require external services, APIs, or React hooks
|
||||
* - Must provide dependencies in `SlashCommandProvider`
|
||||
* - Example: theme commands (needs useTheme), API commands (needs service)
|
||||
*
|
||||
* **Self-Contained Commands:**
|
||||
* - Pure logic operations, no external dependencies
|
||||
* - Still recommended to register through `SlashCommandProvider` for consistency
|
||||
* - Example: calculator, text manipulation commands
|
||||
*
|
||||
* ## Available Actions
|
||||
* - `@app` - Search applications
|
||||
* - `@knowledge` / `@kb` - Search knowledge bases
|
||||
* - `@plugin` - Search plugins
|
||||
* - `@node` - Search workflow nodes (workflow pages only)
|
||||
* - `/` - Execute slash commands (theme, language, etc.)
|
||||
*/
|
||||
|
||||
import type { ActionItem, SearchResult } from './types'
|
||||
import type { ActionItem } from './types'
|
||||
import { appAction } from './app'
|
||||
import { slashCommandRegistry } from './commands/registry'
|
||||
import { slashAction } from './commands/slash'
|
||||
@ -172,120 +7,38 @@ import { pluginAction } from './plugin'
|
||||
import { ragPipelineNodesAction } from './rag-pipeline-nodes'
|
||||
import { workflowNodesAction } from './workflow-nodes'
|
||||
|
||||
// Create dynamic Actions based on context
|
||||
export const createActions = (isWorkflowPage: boolean, isRagPipelinePage: boolean) => {
|
||||
const baseActions = {
|
||||
slash: slashAction,
|
||||
app: appAction,
|
||||
knowledge: knowledgeAction,
|
||||
plugin: pluginAction,
|
||||
}
|
||||
|
||||
// Add appropriate node search based on context
|
||||
if (isRagPipelinePage) {
|
||||
return {
|
||||
...baseActions,
|
||||
node: ragPipelineNodesAction,
|
||||
}
|
||||
} else if (isWorkflowPage) {
|
||||
return {
|
||||
...baseActions,
|
||||
node: workflowNodesAction,
|
||||
}
|
||||
}
|
||||
|
||||
// Default actions without node search
|
||||
return baseActions
|
||||
}
|
||||
|
||||
// Legacy export for backward compatibility
|
||||
export const Actions = {
|
||||
const defaultActions = {
|
||||
slash: slashAction,
|
||||
app: appAction,
|
||||
knowledge: knowledgeAction,
|
||||
plugin: pluginAction,
|
||||
node: workflowNodesAction,
|
||||
} satisfies Record<string, ActionItem>
|
||||
|
||||
export function createActions(isWorkflowPage: boolean, isRagPipelinePage: boolean) {
|
||||
if (isRagPipelinePage) return { ...defaultActions, node: ragPipelineNodesAction }
|
||||
if (isWorkflowPage) return { ...defaultActions, node: workflowNodesAction }
|
||||
return defaultActions
|
||||
}
|
||||
|
||||
export const searchAnything = async (
|
||||
locale: string,
|
||||
query: string,
|
||||
actionItem?: ActionItem,
|
||||
dynamicActions?: Record<string, ActionItem>,
|
||||
): Promise<SearchResult[]> => {
|
||||
const trimmedQuery = query.trim()
|
||||
|
||||
if (actionItem) {
|
||||
const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const prefixPattern = new RegExp(
|
||||
`^(${escapeRegExp(actionItem.key)}|${escapeRegExp(actionItem.shortcut)})\\s*`,
|
||||
)
|
||||
const searchTerm = trimmedQuery.replace(prefixPattern, '').trim()
|
||||
try {
|
||||
return await actionItem.search(query, searchTerm, locale)
|
||||
} catch (error) {
|
||||
console.warn(`Search failed for ${actionItem.key}:`, error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
if (trimmedQuery.startsWith('@') || trimmedQuery.startsWith('/')) return []
|
||||
|
||||
const globalSearchActions = Object.values(dynamicActions || Actions)
|
||||
// Exclude slash commands from general search results
|
||||
.filter((action) => action.key !== '/')
|
||||
|
||||
// Use Promise.allSettled to handle partial failures gracefully
|
||||
const searchPromises = globalSearchActions.map(async (action) => {
|
||||
try {
|
||||
const results = await action.search(query, query, locale)
|
||||
return { success: true, data: results, actionType: action.key }
|
||||
} catch (error) {
|
||||
console.warn(`Search failed for ${action.key}:`, error)
|
||||
return { success: false, data: [], actionType: action.key, error }
|
||||
}
|
||||
})
|
||||
|
||||
const settledResults = await Promise.allSettled(searchPromises)
|
||||
|
||||
const allResults: SearchResult[] = []
|
||||
const failedActions: string[] = []
|
||||
|
||||
settledResults.forEach((result, index) => {
|
||||
if (result.status === 'fulfilled' && result.value.success) {
|
||||
allResults.push(...result.value.data)
|
||||
} else {
|
||||
const actionKey = globalSearchActions[index]?.key || 'unknown'
|
||||
failedActions.push(actionKey)
|
||||
}
|
||||
})
|
||||
|
||||
if (failedActions.length > 0)
|
||||
console.warn(`Some search actions failed: ${failedActions.join(', ')}`)
|
||||
|
||||
return allResults
|
||||
export function getActionSearchTerm(query: string, action: ActionItem) {
|
||||
const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const prefixPattern = new RegExp(
|
||||
`^(${escapeRegExp(action.key)}|${escapeRegExp(action.shortcut)})\\s*`,
|
||||
)
|
||||
return query.trim().replace(prefixPattern, '').trim()
|
||||
}
|
||||
|
||||
export const matchAction = (query: string, actions: Record<string, ActionItem>) => {
|
||||
export function matchAction(query: string, actions: Record<string, ActionItem>) {
|
||||
return Object.values(actions).find((action) => {
|
||||
// Special handling for slash commands
|
||||
if (action.key === '/') {
|
||||
// Get all registered commands from the registry
|
||||
const allCommands = slashCommandRegistry.getAllCommands()
|
||||
return slashCommandRegistry.getAllCommands().some((command) => {
|
||||
if (command.mode === 'direct') return false
|
||||
|
||||
// Check if query matches any registered command
|
||||
return allCommands.some((cmd) => {
|
||||
const cmdPattern = `/${cmd.name}`
|
||||
|
||||
// For direct mode commands, don't match (keep in command selector)
|
||||
if (cmd.mode === 'direct') return false
|
||||
|
||||
// For submenu mode commands, match when complete command is entered
|
||||
return query === cmdPattern || query.startsWith(`${cmdPattern} `)
|
||||
const commandPattern = `/${command.name}`
|
||||
return query === commandPattern || query.startsWith(`${commandPattern} `)
|
||||
})
|
||||
}
|
||||
|
||||
const reg = new RegExp(`^(${action.key}|${action.shortcut})(?:\\s|$)`)
|
||||
return reg.test(query)
|
||||
return new RegExp(`^(${action.key}|${action.shortcut})(?:\\s|$)`).test(query)
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
import type { DatasetListItemResponse } from '@dify/contracts/api/console/datasets/types.gen'
|
||||
import type { ActionItem, KnowledgeSearchResult } from './types'
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { fetchDatasets } from '@/service/datasets'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { Folder } from '../../base/icons/src/vender/solid/files'
|
||||
|
||||
const EXTERNAL_PROVIDER = 'external' as const
|
||||
const isExternalProvider = (provider: string): boolean => provider === EXTERNAL_PROVIDER
|
||||
|
||||
const parser = (datasets: DataSet[]): KnowledgeSearchResult[] => {
|
||||
function getKnowledgeResults(datasets: DatasetListItemResponse[]): KnowledgeSearchResult[] {
|
||||
return datasets.map((dataset) => {
|
||||
const path = isExternalProvider(dataset.provider)
|
||||
? `/datasets/${dataset.id}/hitTesting`
|
||||
@ -15,7 +15,7 @@ const parser = (datasets: DataSet[]): KnowledgeSearchResult[] => {
|
||||
return {
|
||||
id: dataset.id,
|
||||
title: dataset.name,
|
||||
description: dataset.description,
|
||||
description: dataset.description ?? undefined,
|
||||
type: 'knowledge' as const,
|
||||
path,
|
||||
icon: (
|
||||
@ -38,22 +38,19 @@ export const knowledgeAction: ActionItem = {
|
||||
shortcut: '@kb',
|
||||
title: 'Search Knowledge Bases',
|
||||
description: 'Search and navigate to your knowledge bases',
|
||||
// action,
|
||||
search: async (_, searchTerm = '', _locale) => {
|
||||
try {
|
||||
const response = await fetchDatasets({
|
||||
url: '/datasets',
|
||||
params: {
|
||||
page: 1,
|
||||
limit: 10,
|
||||
keyword: searchTerm,
|
||||
},
|
||||
})
|
||||
const datasets = response?.data || []
|
||||
return parser(datasets)
|
||||
} catch (error) {
|
||||
console.warn('Knowledge search failed:', error)
|
||||
return []
|
||||
}
|
||||
},
|
||||
source: 'remote',
|
||||
}
|
||||
|
||||
export function knowledgeSearchQueryOptions(searchTerm: string) {
|
||||
return consoleQuery.datasets.get.queryOptions({
|
||||
input: {
|
||||
query: {
|
||||
page: 1,
|
||||
limit: 10,
|
||||
keyword: searchTerm,
|
||||
},
|
||||
},
|
||||
retry: false,
|
||||
select: (response) => getKnowledgeResults(response.data),
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
import type { PluginsFromMarketplaceResponse } from '@dify/contracts/marketplace'
|
||||
import type { Plugin } from '../../plugins/types'
|
||||
import type { ActionItem, PluginSearchResult } from './types'
|
||||
import { renderI18nObject } from '@/i18n-config'
|
||||
import { postMarketplace } from '@/service/base'
|
||||
import { marketplaceQuery } from '@/service/client'
|
||||
import Icon from '../../plugins/card/base/card-icon'
|
||||
import { getFormattedPlugin } from '../../plugins/marketplace/utils'
|
||||
|
||||
@ -24,30 +23,26 @@ export const pluginAction: ActionItem = {
|
||||
shortcut: '@plugin',
|
||||
title: 'Search Plugins',
|
||||
description: 'Search and navigate to your plugins',
|
||||
search: async (_, searchTerm = '', locale) => {
|
||||
try {
|
||||
const response = await postMarketplace<{ data: PluginsFromMarketplaceResponse }>(
|
||||
'/plugins/search/advanced',
|
||||
{
|
||||
body: {
|
||||
page: 1,
|
||||
page_size: 10,
|
||||
query: searchTerm,
|
||||
type: 'plugin',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
if (!response?.data?.plugins) {
|
||||
console.warn('Plugin search: Unexpected response structure', response)
|
||||
return []
|
||||
}
|
||||
|
||||
const list = response.data.plugins.map((plugin) => getFormattedPlugin(plugin))
|
||||
return parser(list, locale!)
|
||||
} catch (error) {
|
||||
console.warn('Plugin search failed:', error)
|
||||
return []
|
||||
}
|
||||
},
|
||||
source: 'remote',
|
||||
}
|
||||
|
||||
export function pluginSearchQueryOptions(searchTerm: string, locale: string) {
|
||||
return marketplaceQuery.searchAdvanced.queryOptions({
|
||||
input: {
|
||||
params: { kind: 'plugins' },
|
||||
body: {
|
||||
page: 1,
|
||||
page_size: 10,
|
||||
query: searchTerm,
|
||||
},
|
||||
},
|
||||
retry: false,
|
||||
select: (response) => {
|
||||
const plugins = response.data?.plugins ?? []
|
||||
return parser(
|
||||
plugins.map((plugin) => getFormattedPlugin(plugin)),
|
||||
locale,
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,19 +1,15 @@
|
||||
import type { ActionItem } from './types'
|
||||
import { findRagPipelineNodes } from '@/app/components/rag-pipeline/goto-anything-search'
|
||||
|
||||
// Create the RAG pipeline nodes action
|
||||
export const ragPipelineNodesAction: ActionItem = {
|
||||
key: '@node',
|
||||
shortcut: '@node',
|
||||
title: 'Search RAG Pipeline Nodes',
|
||||
description: 'Find and jump to nodes in the current RAG pipeline by name or type',
|
||||
searchFn: undefined, // Will be set by useRagPipelineSearch hook
|
||||
search: async (_, searchTerm = '', _locale) => {
|
||||
source: 'local',
|
||||
search: (_, searchTerm = '', _locale) => {
|
||||
try {
|
||||
// Use the searchFn if available (set by useRagPipelineSearch hook)
|
||||
if (ragPipelineNodesAction.searchFn) return ragPipelineNodesAction.searchFn(searchTerm)
|
||||
|
||||
// If not in RAG pipeline context, return empty array
|
||||
return []
|
||||
return findRagPipelineNodes(searchTerm)
|
||||
} catch (error) {
|
||||
console.warn('RAG pipeline nodes search failed:', error)
|
||||
return []
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
import type { AppPartial } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import type { DatasetListItemResponse } from '@dify/contracts/api/console/datasets/types.gen'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { TypeWithI18N } from '../../base/form/types'
|
||||
import type { Plugin } from '../../plugins/types'
|
||||
import type { CommonNodeType } from '../../workflow/types'
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import type { App } from '@/types/app'
|
||||
|
||||
type SearchResultType = 'app' | 'knowledge' | 'plugin' | 'workflow-node' | 'command' | 'recent'
|
||||
|
||||
type BaseSearchResult<T = any> = {
|
||||
type BaseSearchResult<T> = {
|
||||
id: string
|
||||
title: string
|
||||
description?: string
|
||||
@ -19,7 +19,7 @@ type BaseSearchResult<T = any> = {
|
||||
|
||||
export type AppSearchResult = {
|
||||
type: 'app'
|
||||
} & BaseSearchResult<App>
|
||||
} & BaseSearchResult<AppPartial>
|
||||
|
||||
export type PluginSearchResult = {
|
||||
type: 'plugin'
|
||||
@ -27,7 +27,7 @@ export type PluginSearchResult = {
|
||||
|
||||
export type KnowledgeSearchResult = {
|
||||
type: 'knowledge'
|
||||
} & BaseSearchResult<DataSet>
|
||||
} & BaseSearchResult<DatasetListItemResponse>
|
||||
|
||||
type WorkflowNodeSearchResult = {
|
||||
type: 'workflow-node'
|
||||
@ -39,7 +39,7 @@ type WorkflowNodeSearchResult = {
|
||||
|
||||
export type CommandSearchResult = {
|
||||
type: 'command'
|
||||
} & BaseSearchResult<{ command: string; args?: Record<string, any> }>
|
||||
} & BaseSearchResult<{ command: string; args?: Record<string, unknown> }>
|
||||
|
||||
export type RecentSearchResult = {
|
||||
type: 'recent'
|
||||
@ -54,16 +54,21 @@ export type SearchResult =
|
||||
| CommandSearchResult
|
||||
| RecentSearchResult
|
||||
|
||||
export type ActionItem = {
|
||||
type ActionItemBase = {
|
||||
key: '@app' | '@knowledge' | '@plugin' | '@node' | '/'
|
||||
shortcut: string
|
||||
title: string | TypeWithI18N
|
||||
description: string
|
||||
action?: (data: SearchResult) => void
|
||||
searchFn?: (searchTerm: string) => SearchResult[]
|
||||
search: (
|
||||
query: string,
|
||||
searchTerm: string,
|
||||
locale?: string,
|
||||
) => Promise<SearchResult[]> | SearchResult[]
|
||||
}
|
||||
|
||||
type RemoteActionItem = ActionItemBase & {
|
||||
source: 'remote'
|
||||
}
|
||||
|
||||
type LocalActionItem = ActionItemBase & {
|
||||
source: 'local'
|
||||
search: (query: string, searchTerm: string, locale?: string) => SearchResult[]
|
||||
}
|
||||
|
||||
export type ActionItem = RemoteActionItem | LocalActionItem
|
||||
|
||||
@ -1,19 +1,15 @@
|
||||
import type { ActionItem } from './types'
|
||||
import { findWorkflowNodes } from '@/app/components/workflow/goto-anything-search'
|
||||
|
||||
// Create the workflow nodes action
|
||||
export const workflowNodesAction: ActionItem = {
|
||||
key: '@node',
|
||||
shortcut: '@node',
|
||||
title: 'Search Workflow Nodes',
|
||||
description: 'Find and jump to nodes in the current workflow by name or type',
|
||||
searchFn: undefined, // Will be set by useWorkflowSearch hook
|
||||
search: async (_, searchTerm = '', _locale) => {
|
||||
source: 'local',
|
||||
search: (_, searchTerm = '', _locale) => {
|
||||
try {
|
||||
// Use the searchFn if available (set by useWorkflowSearch hook)
|
||||
if (workflowNodesAction.searchFn) return workflowNodesAction.searchFn(searchTerm)
|
||||
|
||||
// If not in workflow context, return empty array
|
||||
return []
|
||||
return findWorkflowNodes(searchTerm)
|
||||
} catch (error) {
|
||||
console.warn('Workflow nodes search failed:', error)
|
||||
return []
|
||||
|
||||
@ -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)
|
||||
}
|
||||
@ -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
|
||||
@ -1,5 +1,5 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import EmptyState from '../empty-state'
|
||||
import { EmptyState } from '../empty-state'
|
||||
|
||||
describe('EmptyState', () => {
|
||||
describe('loading variant', () => {
|
||||
@ -73,11 +73,11 @@ describe('EmptyState', () => {
|
||||
})
|
||||
|
||||
it('should show specific search hint with shortcuts', () => {
|
||||
const Actions = {
|
||||
const actions = {
|
||||
app: { key: '@app', shortcut: '@app' },
|
||||
plugin: { key: '@plugin', shortcut: '@plugin' },
|
||||
} as unknown as Record<string, import('../../actions/types').ActionItem>
|
||||
render(<EmptyState variant="no-results" searchMode="general" Actions={Actions} />)
|
||||
render(<EmptyState variant="no-results" searchMode="general" actions={actions} />)
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
@ -145,7 +145,7 @@ describe('EmptyState', () => {
|
||||
expect(screen.getByText('app.gotoAnything.noResults')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use empty object as default Actions', () => {
|
||||
it('should use empty object as default actions', () => {
|
||||
render(<EmptyState variant="no-results" searchMode="general" />)
|
||||
|
||||
expect(
|
||||
|
||||
@ -1,261 +1,53 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import Footer from '../footer'
|
||||
import { Footer } from '../footer'
|
||||
|
||||
const defaultProps = {
|
||||
resultCount: 0,
|
||||
searchMode: 'general',
|
||||
isLoading: false,
|
||||
hasUnavailableServices: false,
|
||||
isCommandsMode: false,
|
||||
hasQuery: false,
|
||||
}
|
||||
|
||||
describe('Footer', () => {
|
||||
describe('left content', () => {
|
||||
describe('when there are results', () => {
|
||||
it('should show result count', () => {
|
||||
render(
|
||||
<Footer
|
||||
resultCount={5}
|
||||
searchMode="general"
|
||||
isError={false}
|
||||
isCommandsMode={false}
|
||||
hasQuery={true}
|
||||
/>,
|
||||
)
|
||||
it('shows the result count and active scope', () => {
|
||||
render(<Footer {...defaultProps} resultCount={3} searchMode="@app" hasQuery />)
|
||||
|
||||
expect(screen.getByText('app.gotoAnything.resultCount:{"count":5}')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show scope when not in general mode', () => {
|
||||
render(
|
||||
<Footer
|
||||
resultCount={3}
|
||||
searchMode="@app"
|
||||
isError={false}
|
||||
isCommandsMode={false}
|
||||
hasQuery={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('app.gotoAnything.inScope:{"scope":"app"}')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should NOT show scope when in general mode', () => {
|
||||
render(
|
||||
<Footer
|
||||
resultCount={3}
|
||||
searchMode="general"
|
||||
isError={false}
|
||||
isCommandsMode={false}
|
||||
hasQuery={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText(/inScope/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('when there is an error', () => {
|
||||
it('should show error message', () => {
|
||||
render(
|
||||
<Footer
|
||||
resultCount={0}
|
||||
searchMode="general"
|
||||
isError={true}
|
||||
isCommandsMode={false}
|
||||
hasQuery={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('app.gotoAnything.someServicesUnavailable')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have red text styling', () => {
|
||||
const { container } = render(
|
||||
<Footer
|
||||
resultCount={0}
|
||||
searchMode="general"
|
||||
isError={true}
|
||||
isCommandsMode={false}
|
||||
hasQuery={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
const errorText = container.querySelector('.text-red-500')
|
||||
expect(errorText).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show error even with results', () => {
|
||||
render(
|
||||
<Footer
|
||||
resultCount={5}
|
||||
searchMode="general"
|
||||
isError={true}
|
||||
isCommandsMode={false}
|
||||
hasQuery={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('app.gotoAnything.someServicesUnavailable')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('when no results and no error', () => {
|
||||
it('should show select to navigate in commands mode', () => {
|
||||
render(
|
||||
<Footer
|
||||
resultCount={0}
|
||||
searchMode="general"
|
||||
isError={false}
|
||||
isCommandsMode={true}
|
||||
hasQuery={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('app.gotoAnything.selectToNavigate')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show searching when has query', () => {
|
||||
render(
|
||||
<Footer
|
||||
resultCount={0}
|
||||
searchMode="general"
|
||||
isError={false}
|
||||
isCommandsMode={false}
|
||||
hasQuery={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('app.gotoAnything.searching')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show start typing when no query', () => {
|
||||
render(
|
||||
<Footer
|
||||
resultCount={0}
|
||||
searchMode="general"
|
||||
isError={false}
|
||||
isCommandsMode={false}
|
||||
hasQuery={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('app.gotoAnything.startTyping')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
expect(screen.getByText('app.gotoAnything.resultCount:{"count":3}')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.gotoAnything.inScope:{"scope":"app"}')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.gotoAnything.clearToSearchAll')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
describe('right content', () => {
|
||||
describe('when there are results or error', () => {
|
||||
it('should show clear to search all when in specific mode', () => {
|
||||
render(
|
||||
<Footer
|
||||
resultCount={5}
|
||||
searchMode="@app"
|
||||
isError={false}
|
||||
isCommandsMode={false}
|
||||
hasQuery={true}
|
||||
/>,
|
||||
)
|
||||
it('reports partial provider failure even when results remain available', () => {
|
||||
render(<Footer {...defaultProps} resultCount={2} hasUnavailableServices hasQuery />)
|
||||
|
||||
expect(screen.getByText('app.gotoAnything.clearToSearchAll')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show use @ for specific when in general mode', () => {
|
||||
render(
|
||||
<Footer
|
||||
resultCount={5}
|
||||
searchMode="general"
|
||||
isError={false}
|
||||
isCommandsMode={false}
|
||||
hasQuery={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('app.gotoAnything.useAtForSpecific')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show same hint when error', () => {
|
||||
render(
|
||||
<Footer
|
||||
resultCount={0}
|
||||
searchMode="general"
|
||||
isError={true}
|
||||
isCommandsMode={false}
|
||||
hasQuery={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('app.gotoAnything.useAtForSpecific')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('when no results and no error', () => {
|
||||
it('should show tips when has query', () => {
|
||||
render(
|
||||
<Footer
|
||||
resultCount={0}
|
||||
searchMode="general"
|
||||
isError={false}
|
||||
isCommandsMode={false}
|
||||
hasQuery={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('app.gotoAnything.tips')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show tips when in commands mode', () => {
|
||||
render(
|
||||
<Footer
|
||||
resultCount={0}
|
||||
searchMode="general"
|
||||
isError={false}
|
||||
isCommandsMode={true}
|
||||
hasQuery={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('app.gotoAnything.tips')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show press ESC to close when no query and not in commands mode', () => {
|
||||
render(
|
||||
<Footer
|
||||
resultCount={0}
|
||||
searchMode="general"
|
||||
isError={false}
|
||||
isCommandsMode={false}
|
||||
hasQuery={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('app.gotoAnything.pressEscToClose')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
expect(screen.getByText('app.gotoAnything.someServicesUnavailable')).toHaveClass('text-red-500')
|
||||
expect(screen.getByText('app.gotoAnything.useAtForSpecific')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
describe('styling', () => {
|
||||
it('should have border and background classes', () => {
|
||||
const { container } = render(
|
||||
<Footer
|
||||
resultCount={0}
|
||||
searchMode="general"
|
||||
isError={false}
|
||||
isCommandsMode={false}
|
||||
hasQuery={false}
|
||||
/>,
|
||||
)
|
||||
it('reports pending remote search', () => {
|
||||
render(<Footer {...defaultProps} isLoading hasQuery />)
|
||||
|
||||
const footer = container.firstChild
|
||||
expect(footer).toHaveClass('border-t', 'border-divider-subtle', 'bg-components-panel-bg-blur')
|
||||
})
|
||||
expect(screen.getByText('app.gotoAnything.searching')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.gotoAnything.tips')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have flex layout for content', () => {
|
||||
const { container } = render(
|
||||
<Footer
|
||||
resultCount={0}
|
||||
searchMode="general"
|
||||
isError={false}
|
||||
isCommandsMode={false}
|
||||
hasQuery={false}
|
||||
/>,
|
||||
)
|
||||
it('reports command selection mode', () => {
|
||||
render(<Footer {...defaultProps} isCommandsMode />)
|
||||
|
||||
const flexContainer = container.querySelector('.flex.items-center.justify-between')
|
||||
expect(flexContainer).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByText('app.gotoAnything.selectToNavigate')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the idle shortcut hint', () => {
|
||||
const { container } = render(<Footer {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText('app.gotoAnything.startTyping')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.gotoAnything.pressEscToClose')).toBeInTheDocument()
|
||||
expect(container.firstChild).toHaveClass(
|
||||
'border-t',
|
||||
'border-divider-subtle',
|
||||
'bg-components-panel-bg-blur',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,6 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import type { ActionItem } from '../actions/types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
@ -10,15 +9,15 @@ type EmptyStateProps = {
|
||||
variant: EmptyStateVariant
|
||||
searchMode?: string
|
||||
error?: Error | null
|
||||
Actions?: Record<string, ActionItem>
|
||||
actions?: Record<string, ActionItem>
|
||||
}
|
||||
|
||||
const EmptyState: FC<EmptyStateProps> = ({
|
||||
export function EmptyState({
|
||||
variant,
|
||||
searchMode = 'general',
|
||||
error,
|
||||
Actions = {},
|
||||
}) => {
|
||||
actions = {},
|
||||
}: EmptyStateProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (variant === 'loading') {
|
||||
@ -67,7 +66,6 @@ const EmptyState: FC<EmptyStateProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
// variant === 'no-results'
|
||||
const isCommandSearch = searchMode !== 'general'
|
||||
const commandType = isCommandSearch ? searchMode.replace('@', '') : ''
|
||||
|
||||
@ -93,7 +91,7 @@ const EmptyState: FC<EmptyStateProps> = ({
|
||||
return t(($) => $['gotoAnything.emptyState.tryDifferentTerm'], { ns: 'app' })
|
||||
}
|
||||
|
||||
const shortcuts = Object.values(Actions)
|
||||
const shortcuts = Object.values(actions)
|
||||
.map((action) => action.shortcut)
|
||||
.join(', ')
|
||||
return t(($) => $['gotoAnything.emptyState.trySpecificSearch'], { ns: 'app', shortcuts })
|
||||
@ -108,5 +106,3 @@ const EmptyState: FC<EmptyStateProps> = ({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EmptyState
|
||||
|
||||
@ -1,35 +1,36 @@
|
||||
'use client'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type FooterProps = {
|
||||
resultCount: number
|
||||
searchMode: string
|
||||
isError: boolean
|
||||
isLoading: boolean
|
||||
hasUnavailableServices: boolean
|
||||
isCommandsMode: boolean
|
||||
hasQuery: boolean
|
||||
}
|
||||
|
||||
const Footer: FC<FooterProps> = ({
|
||||
export function Footer({
|
||||
resultCount,
|
||||
searchMode,
|
||||
isError,
|
||||
isLoading,
|
||||
hasUnavailableServices,
|
||||
isCommandsMode,
|
||||
hasQuery,
|
||||
}) => {
|
||||
}: FooterProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const renderLeftContent = () => {
|
||||
if (resultCount > 0 || isError) {
|
||||
if (isError) {
|
||||
return (
|
||||
<span className="text-red-500">
|
||||
{t(($) => $['gotoAnything.someServicesUnavailable'], { ns: 'app' })}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (hasUnavailableServices) {
|
||||
return (
|
||||
<span className="text-red-500">
|
||||
{t(($) => $['gotoAnything.someServicesUnavailable'], { ns: 'app' })}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (resultCount > 0) {
|
||||
return (
|
||||
<>
|
||||
{t(($) => $['gotoAnything.resultCount'], { ns: 'app', count: resultCount })}
|
||||
@ -50,7 +51,7 @@ const Footer: FC<FooterProps> = ({
|
||||
{(() => {
|
||||
if (isCommandsMode) return t(($) => $['gotoAnything.selectToNavigate'], { ns: 'app' })
|
||||
|
||||
if (hasQuery) return t(($) => $['gotoAnything.searching'], { ns: 'app' })
|
||||
if (isLoading) return t(($) => $['gotoAnything.searching'], { ns: 'app' })
|
||||
|
||||
return t(($) => $['gotoAnything.startTyping'], { ns: 'app' })
|
||||
})()}
|
||||
@ -59,7 +60,7 @@ const Footer: FC<FooterProps> = ({
|
||||
}
|
||||
|
||||
const renderRightContent = () => {
|
||||
if (resultCount > 0 || isError) {
|
||||
if (resultCount > 0 || hasUnavailableServices) {
|
||||
return (
|
||||
<span className="opacity-60">
|
||||
{searchMode !== 'general'
|
||||
@ -87,5 +88,3 @@ const Footer: FC<FooterProps> = ({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Footer
|
||||
|
||||
@ -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'
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
5
web/app/components/goto-anything/dialog-handle.ts
Normal file
5
web/app/components/goto-anything/dialog-handle.ts
Normal file
@ -0,0 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import { createDialogHandle } from '@langgenius/dify-ui/dialog'
|
||||
|
||||
export const gotoAnythingDialogHandle = createDialogHandle()
|
||||
@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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' },
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -1,191 +1,599 @@
|
||||
'use client'
|
||||
|
||||
import type { FC, KeyboardEvent } from 'react'
|
||||
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
|
||||
import { Command } from 'cmdk'
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import type { AutocompleteChangeEventDetails } from '@langgenius/dify-ui/autocomplete'
|
||||
import type { Plugin } from '../plugins/types'
|
||||
import type { ActionItem, RecentSearchResult, SearchResult } from './actions/types'
|
||||
import {
|
||||
Autocomplete,
|
||||
AutocompleteCollection,
|
||||
AutocompleteGroup,
|
||||
AutocompleteGroupLabel,
|
||||
AutocompleteInput,
|
||||
AutocompleteInputGroup,
|
||||
AutocompleteItem,
|
||||
AutocompleteList,
|
||||
AutocompleteStatus,
|
||||
} from '@langgenius/dify-ui/autocomplete'
|
||||
import {
|
||||
Dialog,
|
||||
DialogBackdrop,
|
||||
DialogCloseButton,
|
||||
DialogPopup,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
} from '@langgenius/dify-ui/dialog'
|
||||
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
|
||||
import {
|
||||
ScrollAreaContent,
|
||||
ScrollAreaRoot,
|
||||
ScrollAreaScrollbar,
|
||||
ScrollAreaThumb,
|
||||
ScrollAreaViewport,
|
||||
} from '@langgenius/dify-ui/scroll-area'
|
||||
import { formatForDisplay, useHotkey } from '@tanstack/react-hotkeys'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useDebounce } from 'ahooks'
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { selectWorkflowNode } from '@/app/components/workflow/utils/node-navigation'
|
||||
import { useGetLanguage } from '@/context/i18n'
|
||||
import { usePathname, useRouter } from '@/next/navigation'
|
||||
import { PluginInstallPermissionProvider } from '../plugins/install-plugin/components/plugin-install-permission-provider'
|
||||
import useWorkspacePluginInstallPermission from '../plugins/install-plugin/hooks/use-workspace-plugin-install-permission'
|
||||
import InstallFromMarketplace from '../plugins/install-plugin/install-from-marketplace'
|
||||
import { createActions, getActionSearchTerm, matchAction } from './actions'
|
||||
import { appSearchQueryOptions } from './actions/app'
|
||||
import { slashCommandRegistry } from './actions/commands/registry'
|
||||
import { SlashCommandProvider } from './actions/commands/slash-provider'
|
||||
import CommandSelector from './command-selector'
|
||||
import { EmptyState, Footer, ResultList, SearchInput } from './components'
|
||||
import { GotoAnythingProvider, useGotoAnythingContext } from './context'
|
||||
import { useGotoAnythingModal } from './hooks/use-goto-anything-modal'
|
||||
import { useGotoAnythingNavigation } from './hooks/use-goto-anything-navigation'
|
||||
import { useGotoAnythingResults } from './hooks/use-goto-anything-results'
|
||||
import { useGotoAnythingSearch } from './hooks/use-goto-anything-search'
|
||||
import { knowledgeSearchQueryOptions } from './actions/knowledge'
|
||||
import { pluginSearchQueryOptions } from './actions/plugin'
|
||||
import { addRecentItem, getRecentItems } from './actions/recent-store'
|
||||
import { EmptyState } from './components/empty-state'
|
||||
import { Footer } from './components/footer'
|
||||
import { gotoAnythingDialogHandle } from './dialog-handle'
|
||||
|
||||
type Props = Readonly<{
|
||||
onHide?: () => void
|
||||
}>
|
||||
const appWorkflowPathPattern = /^\/app\/[^/]+\/workflow$/
|
||||
const sharedWorkflowPathPattern = /^\/workflow\/[^/]+$/
|
||||
const ragPipelinePathPattern = /^\/datasets\/[^/]+\/pipeline$/
|
||||
const searchHotkey = 'Mod+K'
|
||||
const searchShortcut = searchHotkey.split('+')
|
||||
|
||||
const GotoAnythingDialog: FC<Props> = ({ onHide }) => {
|
||||
const { t } = useTranslation()
|
||||
const { isWorkflowPage, isRagPipelinePage } = useGotoAnythingContext()
|
||||
const { canInstallPlugin, currentDifyVersion } = useWorkspacePluginInstallPermission()
|
||||
const prevShowRef = useRef(false)
|
||||
type CommandOption = {
|
||||
kind: 'command-option'
|
||||
shortcut: string
|
||||
description: string
|
||||
}
|
||||
|
||||
// Search state management (called first so setSearchQuery is available)
|
||||
const {
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
searchQueryDebouncedValue,
|
||||
searchMode,
|
||||
isCommandsMode,
|
||||
cmdVal,
|
||||
setCmdVal,
|
||||
clearSelection,
|
||||
Actions,
|
||||
} = useGotoAnythingSearch()
|
||||
type GotoAnythingOption = CommandOption | SearchResult
|
||||
|
||||
// Modal state management
|
||||
const { open, onOpenChange, inputRef } = useGotoAnythingModal()
|
||||
const slashCommandDescriptionKeys = {
|
||||
'/create': 'gotoAnything.actions.createCategoryDesc',
|
||||
'/refine': 'gotoAnything.actions.refineCategoryDesc',
|
||||
'/theme': 'gotoAnything.actions.themeCategoryDesc',
|
||||
'/language': 'gotoAnything.actions.languageChangeDesc',
|
||||
'/account': 'gotoAnything.actions.accountDesc',
|
||||
'/feedback': 'gotoAnything.actions.feedbackDesc',
|
||||
'/docs': 'gotoAnything.actions.docDesc',
|
||||
'/community': 'gotoAnything.actions.communityDesc',
|
||||
} as const
|
||||
|
||||
// Reset state when modal opens/closes
|
||||
useEffect(() => {
|
||||
if (open && !prevShowRef.current) {
|
||||
// Modal just opened - reset search
|
||||
setSearchQuery('')
|
||||
} else if (!open && prevShowRef.current) {
|
||||
// Modal just closed
|
||||
setSearchQuery('')
|
||||
clearSelection()
|
||||
onHide?.()
|
||||
}
|
||||
prevShowRef.current = open
|
||||
}, [open, setSearchQuery, clearSelection, onHide])
|
||||
const actionDescriptionKeys = {
|
||||
'@app': 'gotoAnything.actions.searchApplicationsDesc',
|
||||
'@plugin': 'gotoAnything.actions.searchPluginsDesc',
|
||||
'@knowledge': 'gotoAnything.actions.searchKnowledgeBasesDesc',
|
||||
'@node': 'gotoAnything.actions.searchWorkflowNodesDesc',
|
||||
} as const
|
||||
|
||||
// Results fetching and processing
|
||||
const { dedupedResults, groupedResults, isLoading, isError, error } = useGotoAnythingResults({
|
||||
searchQueryDebouncedValue,
|
||||
searchMode,
|
||||
isCommandsMode,
|
||||
Actions,
|
||||
isWorkflowPage,
|
||||
isRagPipelinePage,
|
||||
cmdVal,
|
||||
setCmdVal,
|
||||
const groupLabelKeys = {
|
||||
app: 'gotoAnything.groups.apps',
|
||||
plugin: 'gotoAnything.groups.plugins',
|
||||
knowledge: 'gotoAnything.groups.knowledgeBases',
|
||||
'workflow-node': 'gotoAnything.groups.workflowNodes',
|
||||
command: 'gotoAnything.groups.commands',
|
||||
recent: 'gotoAnything.groups.recent',
|
||||
} as const
|
||||
|
||||
function getCommandOptions(actions: Record<string, ActionItem>, query: string): CommandOption[] {
|
||||
const trimmedQuery = query.trim()
|
||||
const filter = trimmedQuery.slice(1).toLowerCase()
|
||||
|
||||
if (trimmedQuery.startsWith('/')) {
|
||||
return slashCommandRegistry
|
||||
.getAvailableCommands()
|
||||
.filter((command) => !filter || command.name.toLowerCase().includes(filter))
|
||||
.map((command) => ({
|
||||
kind: 'command-option',
|
||||
shortcut: `/${command.name}`,
|
||||
description: command.description,
|
||||
}))
|
||||
}
|
||||
|
||||
return Object.values(actions)
|
||||
.filter((action) => action.key !== '/')
|
||||
.filter((action) => !filter || action.shortcut.toLowerCase().includes(filter))
|
||||
.map((action) => ({
|
||||
kind: 'command-option',
|
||||
shortcut: action.shortcut,
|
||||
description: action.description,
|
||||
}))
|
||||
}
|
||||
|
||||
function isCommandOption(option: GotoAnythingOption): option is CommandOption {
|
||||
return 'kind' in option && option.kind === 'command-option'
|
||||
}
|
||||
|
||||
function optionToInputValue(option: GotoAnythingOption) {
|
||||
return isCommandOption(option) ? `${option.shortcut} ` : option.title
|
||||
}
|
||||
|
||||
function isEditableShortcutTarget(target: EventTarget | null) {
|
||||
if (!(target instanceof HTMLElement)) return false
|
||||
|
||||
return target.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)
|
||||
}
|
||||
|
||||
function getSearchModeLabel(searchMode: string) {
|
||||
if (searchMode === 'scopes') return 'SCOPES'
|
||||
if (searchMode === 'commands') return 'COMMANDS'
|
||||
return searchMode.replace('@', '').toUpperCase()
|
||||
}
|
||||
|
||||
function getSearchMode(
|
||||
searchQuery: string,
|
||||
isCommandsMode: boolean,
|
||||
actions: Record<string, ActionItem>,
|
||||
) {
|
||||
if (isCommandsMode) return searchQuery.trim().startsWith('@') ? 'scopes' : 'commands'
|
||||
|
||||
const action = matchAction(searchQuery.trim().toLowerCase(), actions)
|
||||
if (!action) return 'general'
|
||||
|
||||
return action.key === '/' ? '@command' : action.key
|
||||
}
|
||||
|
||||
function isCommandSelectionQuery(query: string, actions: Record<string, ActionItem>) {
|
||||
const trimmedQuery = query.trim()
|
||||
if (trimmedQuery === '@' || trimmedQuery === '/') return true
|
||||
|
||||
return (
|
||||
(trimmedQuery.startsWith('@') || trimmedQuery.startsWith('/')) &&
|
||||
!matchAction(trimmedQuery, actions)
|
||||
)
|
||||
}
|
||||
|
||||
function getRecentSearchResults(): RecentSearchResult[] {
|
||||
return getRecentItems().map((item) => ({
|
||||
id: `recent-${item.id}`,
|
||||
title: item.title,
|
||||
description: item.description,
|
||||
type: 'recent',
|
||||
originalType: item.originalType,
|
||||
path: item.path,
|
||||
icon: (
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-panel-bg">
|
||||
<span aria-hidden className="i-ri-time-line size-4 text-text-tertiary" />
|
||||
</div>
|
||||
),
|
||||
data: { path: item.path },
|
||||
}))
|
||||
}
|
||||
|
||||
function dedupeSearchResults(results: SearchResult[]) {
|
||||
const seen = new Set<string>()
|
||||
return results.filter((result) => {
|
||||
const key = `${result.type}-${result.id}`
|
||||
if (seen.has(key)) return false
|
||||
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// Navigation handlers
|
||||
const { handleCommandSelect, handleNavigate, activePlugin, setActivePlugin } =
|
||||
useGotoAnythingNavigation({
|
||||
Actions,
|
||||
setSearchQuery,
|
||||
clearSelection,
|
||||
inputRef,
|
||||
onClose: () => onOpenChange(false),
|
||||
function groupSearchResults(results: SearchResult[]) {
|
||||
return results.reduce<Record<string, SearchResult[]>>((groups, result) => {
|
||||
const group = groups[result.type] ?? []
|
||||
group.push(result)
|
||||
groups[result.type] = group
|
||||
return groups
|
||||
}, {})
|
||||
}
|
||||
|
||||
function GotoAnythingDialog() {
|
||||
const { t } = useTranslation()
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const defaultLocale = useGetLanguage()
|
||||
const isWorkflowPage =
|
||||
appWorkflowPathPattern.test(pathname) || sharedWorkflowPathPattern.test(pathname)
|
||||
const isRagPipelinePage = ragPipelinePathPattern.test(pathname)
|
||||
const { canInstallPlugin, currentDifyVersion } = useWorkspacePluginInstallPermission()
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [activePlugin, setActivePlugin] = useState<Plugin>()
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const actions = useMemo(
|
||||
() => createActions(isWorkflowPage, isRagPipelinePage),
|
||||
[isWorkflowPage, isRagPipelinePage],
|
||||
)
|
||||
const trimmedSearchQuery = searchQuery.trim()
|
||||
const isCommandsMode = isCommandSelectionQuery(searchQuery, actions)
|
||||
const searchMode = getSearchMode(searchQuery, isCommandsMode, actions)
|
||||
const debouncedSearchQuery = useDebounce(searchQuery, { wait: 300 })
|
||||
const normalizedDebouncedQuery = debouncedSearchQuery.trim().toLowerCase()
|
||||
const isDebouncedCommandsMode = isCommandSelectionQuery(debouncedSearchQuery, actions)
|
||||
const debouncedAction = matchAction(normalizedDebouncedQuery, actions)
|
||||
const debouncedSearchTerm = debouncedAction
|
||||
? getActionSearchTerm(normalizedDebouncedQuery, debouncedAction)
|
||||
: normalizedDebouncedQuery
|
||||
const remoteSearchEnabled = Boolean(normalizedDebouncedQuery) && !isDebouncedCommandsMode
|
||||
const appSearchEnabled =
|
||||
remoteSearchEnabled && (!debouncedAction || debouncedAction.key === '@app')
|
||||
const knowledgeSearchEnabled =
|
||||
remoteSearchEnabled && (!debouncedAction || debouncedAction.key === '@knowledge')
|
||||
const pluginSearchEnabled =
|
||||
remoteSearchEnabled && (!debouncedAction || debouncedAction.key === '@plugin')
|
||||
const appSearchQuery = useQuery({
|
||||
...appSearchQueryOptions(debouncedSearchTerm, debouncedAction?.key === '@app'),
|
||||
enabled: appSearchEnabled,
|
||||
})
|
||||
const knowledgeSearchQuery = useQuery({
|
||||
...knowledgeSearchQueryOptions(debouncedSearchTerm),
|
||||
enabled: knowledgeSearchEnabled,
|
||||
})
|
||||
const pluginSearchQuery = useQuery({
|
||||
...pluginSearchQueryOptions(debouncedSearchTerm, defaultLocale),
|
||||
enabled: pluginSearchEnabled,
|
||||
})
|
||||
const localSearchResults = useMemo(() => {
|
||||
if (!trimmedSearchQuery || isCommandsMode) return []
|
||||
|
||||
const normalizedQuery = trimmedSearchQuery.toLowerCase()
|
||||
const action = matchAction(normalizedQuery, actions)
|
||||
if (action?.source === 'local') {
|
||||
return action.search(
|
||||
normalizedQuery,
|
||||
getActionSearchTerm(normalizedQuery, action),
|
||||
defaultLocale,
|
||||
)
|
||||
}
|
||||
if (action) return []
|
||||
|
||||
return Object.values(actions).flatMap((candidate) => {
|
||||
if (candidate.source !== 'local' || candidate.key === '/') return []
|
||||
return candidate.search(normalizedQuery, normalizedQuery, defaultLocale)
|
||||
})
|
||||
}, [actions, defaultLocale, isCommandsMode, trimmedSearchQuery])
|
||||
const activeRemoteQueries = [
|
||||
appSearchEnabled ? appSearchQuery : undefined,
|
||||
knowledgeSearchEnabled ? knowledgeSearchQuery : undefined,
|
||||
pluginSearchEnabled ? pluginSearchQuery : undefined,
|
||||
].filter((query) => query !== undefined)
|
||||
const isDebouncing = remoteSearchEnabled && searchQuery.trim() !== debouncedSearchQuery.trim()
|
||||
const isLoading = isDebouncing || activeRemoteQueries.some((query) => query.isLoading)
|
||||
const failedRemoteQueries = activeRemoteQueries.filter((query) => query.isError)
|
||||
const isError =
|
||||
activeRemoteQueries.length > 0 && failedRemoteQueries.length === activeRemoteQueries.length
|
||||
const hasUnavailableServices = failedRemoteQueries.length > 0
|
||||
const queryError = failedRemoteQueries[0]?.error
|
||||
const error = queryError instanceof Error ? queryError : null
|
||||
const remoteSearchResults = isDebouncing
|
||||
? []
|
||||
: activeRemoteQueries.flatMap((query) => query.data ?? [])
|
||||
const searchResults = [...localSearchResults, ...remoteSearchResults]
|
||||
const recentResults = trimmedSearchQuery || isCommandsMode ? [] : getRecentSearchResults()
|
||||
const dedupedResults = dedupeSearchResults(recentResults.length ? recentResults : searchResults)
|
||||
const groupedResults = groupSearchResults(dedupedResults)
|
||||
|
||||
function resetSearch() {
|
||||
setSearchQuery('')
|
||||
}
|
||||
|
||||
useHotkey(
|
||||
searchHotkey,
|
||||
(event) => {
|
||||
if (event.defaultPrevented) return
|
||||
if (!gotoAnythingDialogHandle.isOpen && isEditableShortcutTarget(event.target)) return
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
if (!gotoAnythingDialogHandle.isOpen) gotoAnythingDialogHandle.open(null)
|
||||
},
|
||||
{
|
||||
ignoreInputs: false,
|
||||
preventDefault: false,
|
||||
stopPropagation: false,
|
||||
},
|
||||
)
|
||||
|
||||
function handleCommandSelect(commandKey: string) {
|
||||
if (commandKey.startsWith('/')) {
|
||||
const handler = slashCommandRegistry.findCommand(commandKey.slice(1))
|
||||
if (handler?.mode === 'direct' && handler.execute) {
|
||||
handler.execute()
|
||||
gotoAnythingDialogHandle.close()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setSearchQuery(`${commandKey} `)
|
||||
}
|
||||
|
||||
function handleNavigate(result: SearchResult) {
|
||||
gotoAnythingDialogHandle.close()
|
||||
|
||||
switch (result.type) {
|
||||
case 'command':
|
||||
actions.slash.action?.(result)
|
||||
break
|
||||
case 'plugin':
|
||||
setActivePlugin(result.data)
|
||||
break
|
||||
case 'workflow-node':
|
||||
if (result.metadata?.nodeId) selectWorkflowNode(result.metadata.nodeId, true)
|
||||
break
|
||||
case 'recent':
|
||||
if (result.path) router.push(result.path)
|
||||
break
|
||||
default:
|
||||
if ((result.type === 'app' || result.type === 'knowledge') && result.path) {
|
||||
addRecentItem({
|
||||
id: result.id,
|
||||
title: result.title,
|
||||
description: result.description,
|
||||
path: result.path,
|
||||
originalType: result.type,
|
||||
})
|
||||
}
|
||||
if (result.path) router.push(result.path)
|
||||
}
|
||||
}
|
||||
|
||||
function handleAutocompleteOpenChange(
|
||||
nextOpen: boolean,
|
||||
eventDetails: AutocompleteChangeEventDetails,
|
||||
) {
|
||||
if (!nextOpen && eventDetails.reason === 'escape-key') gotoAnythingDialogHandle.close()
|
||||
}
|
||||
|
||||
function handleAutocompleteValueChange(
|
||||
nextValue: string,
|
||||
eventDetails: AutocompleteChangeEventDetails,
|
||||
) {
|
||||
if (eventDetails.reason !== 'item-press') setSearchQuery(nextValue)
|
||||
}
|
||||
|
||||
function selectOption(option: GotoAnythingOption) {
|
||||
if (isCommandOption(option)) handleCommandSelect(option.shortcut)
|
||||
else handleNavigate(option)
|
||||
}
|
||||
|
||||
const commandOptions = getCommandOptions(actions, searchQuery)
|
||||
const autocompleteOptions: GotoAnythingOption[] = isCommandsMode ? commandOptions : dedupedResults
|
||||
const visibleOptions = isLoading || isError ? [] : autocompleteOptions
|
||||
const autocompleteResultCount = visibleOptions.length
|
||||
const isSlashMode = searchQuery.trim().startsWith('/')
|
||||
|
||||
let autocompleteStatus: string | null = null
|
||||
if (isLoading) autocompleteStatus = t(($) => $['gotoAnything.searching'], { ns: 'app' })
|
||||
else if (isError) autocompleteStatus = t(($) => $['gotoAnything.searchFailed'], { ns: 'app' })
|
||||
else if (hasUnavailableServices)
|
||||
autocompleteStatus = t(($) => $['gotoAnything.someServicesUnavailable'], { ns: 'app' })
|
||||
else if (trimmedSearchQuery)
|
||||
autocompleteStatus = t(($) => $['gotoAnything.resultCount'], {
|
||||
ns: 'app',
|
||||
count: autocompleteResultCount,
|
||||
})
|
||||
|
||||
// Handle search input change
|
||||
const handleSearchChange = useCallback(
|
||||
(value: string) => {
|
||||
setSearchQuery(value)
|
||||
if (!value.startsWith('@') && !value.startsWith('/')) clearSelection()
|
||||
},
|
||||
[setSearchQuery, clearSelection],
|
||||
)
|
||||
|
||||
// Handle search input keydown for slash commands
|
||||
const handleSearchKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
const query = searchQuery.trim()
|
||||
// Check if it's a complete slash command
|
||||
if (query.startsWith('/')) {
|
||||
const commandName = query.substring(1).split(' ')[0]
|
||||
const handler = slashCommandRegistry.findCommand(commandName!)
|
||||
|
||||
// If it's a direct mode command, execute immediately
|
||||
const isAvailable = handler?.isAvailable?.() ?? true
|
||||
if (handler?.mode === 'direct' && handler.execute && isAvailable) {
|
||||
e.preventDefault()
|
||||
handler.execute()
|
||||
onOpenChange(false)
|
||||
setSearchQuery('')
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[searchQuery, onOpenChange, setSearchQuery],
|
||||
)
|
||||
|
||||
// Determine which empty state to show
|
||||
const emptyStateVariant = useMemo(() => {
|
||||
if (isLoading) return 'loading'
|
||||
if (isError) return 'error'
|
||||
if (!searchQuery.trim()) {
|
||||
// Show default hint only when there are no recent items to display
|
||||
return dedupedResults.length === 0 ? 'default' : null
|
||||
}
|
||||
if (dedupedResults.length === 0 && !isCommandsMode) return 'no-results'
|
||||
return null
|
||||
}, [isLoading, isError, searchQuery, dedupedResults.length, isCommandsMode])
|
||||
let emptyStateVariant: 'loading' | 'error' | 'default' | 'no-results' | null = null
|
||||
if (isLoading) emptyStateVariant = 'loading'
|
||||
else if (isError) emptyStateVariant = 'error'
|
||||
else if (!trimmedSearchQuery && autocompleteResultCount === 0) emptyStateVariant = 'default'
|
||||
else if (autocompleteResultCount === 0 && !isCommandsMode) emptyStateVariant = 'no-results'
|
||||
|
||||
return (
|
||||
<>
|
||||
<SlashCommandProvider />
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-[480px]! overflow-hidden p-0!">
|
||||
<Command
|
||||
className="outline-hidden"
|
||||
value={cmdVal}
|
||||
onValueChange={setCmdVal}
|
||||
disablePointerSelection
|
||||
loop
|
||||
<Dialog handle={gotoAnythingDialogHandle} onOpenChange={resetSearch}>
|
||||
<DialogPortal>
|
||||
<DialogBackdrop />
|
||||
<DialogPopup
|
||||
initialFocus={inputRef}
|
||||
className="fixed top-1/2 left-1/2 max-h-[80dvh] w-[480px]! max-w-[calc(100vw-2rem)] -translate-x-1/2 -translate-y-1/2 overflow-hidden p-0!"
|
||||
>
|
||||
<SearchInput
|
||||
inputRef={inputRef}
|
||||
<DialogTitle className="sr-only">
|
||||
{t(($) => $['gotoAnything.searchTitle'], { ns: 'app' })}
|
||||
</DialogTitle>
|
||||
<Autocomplete<GotoAnythingOption>
|
||||
items={visibleOptions}
|
||||
value={searchQuery}
|
||||
onChange={handleSearchChange}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
searchMode={searchMode}
|
||||
placeholder={t(($) => $['gotoAnything.searchPlaceholder'], { ns: 'app' })}
|
||||
onValueChange={handleAutocompleteValueChange}
|
||||
onOpenChange={handleAutocompleteOpenChange}
|
||||
itemToStringValue={optionToInputValue}
|
||||
filter={null}
|
||||
open
|
||||
inline
|
||||
autoHighlight="always"
|
||||
keepHighlight
|
||||
loopFocus
|
||||
>
|
||||
<AutocompleteInputGroup
|
||||
size="medium"
|
||||
className="h-auto gap-3 rounded-none border-0 border-b border-divider-subtle bg-components-panel-bg-blur px-4 py-3 shadow-none focus-within:border-divider-subtle focus-within:bg-components-panel-bg-blur focus-within:shadow-none hover:border-divider-subtle hover:bg-components-panel-bg-blur data-focused:border-divider-subtle data-focused:bg-components-panel-bg-blur data-focused:shadow-none"
|
||||
>
|
||||
<span aria-hidden className="i-ri-search-line size-4 text-text-quaternary" />
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<AutocompleteInput
|
||||
ref={inputRef}
|
||||
size="medium"
|
||||
aria-label={t(($) => $['gotoAnything.searchTitle'], { ns: 'app' })}
|
||||
placeholder={t(($) => $['gotoAnything.searchPlaceholder'], { ns: 'app' })}
|
||||
className="px-0"
|
||||
/>
|
||||
{searchMode !== 'general' && (
|
||||
<div className="flex items-center gap-1 rounded-sm bg-gray-100 px-2 py-[2px] text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-300">
|
||||
<span>{getSearchModeLabel(searchMode)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<KbdGroup>
|
||||
{searchShortcut.map((key) => (
|
||||
<Kbd key={key}>{formatForDisplay(key)}</Kbd>
|
||||
))}
|
||||
</KbdGroup>
|
||||
</AutocompleteInputGroup>
|
||||
|
||||
<AutocompleteStatus className="sr-only">{autocompleteStatus}</AutocompleteStatus>
|
||||
|
||||
<ScrollAreaRoot
|
||||
aria-busy={isLoading || undefined}
|
||||
className="relative h-[240px] min-h-0 overflow-hidden"
|
||||
>
|
||||
<ScrollAreaViewport className="scroll-py-1 overscroll-contain">
|
||||
<ScrollAreaContent
|
||||
className="min-h-full w-full max-w-full"
|
||||
style={{ minWidth: '100%' }}
|
||||
>
|
||||
{emptyStateVariant === 'loading' && <EmptyState variant="loading" />}
|
||||
|
||||
{emptyStateVariant === 'error' && <EmptyState variant="error" error={error} />}
|
||||
|
||||
{!isLoading && !isError && isCommandsMode && autocompleteResultCount === 0 && (
|
||||
<div className="flex items-center justify-center py-8 text-center text-text-tertiary">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-text-tertiary">
|
||||
{t(($) => $['gotoAnything.noMatchingCommands'], { ns: 'app' })}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-text-quaternary">
|
||||
{t(($) => $['gotoAnything.tryDifferentSearch'], { ns: 'app' })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && isCommandsMode && autocompleteResultCount > 0 && (
|
||||
<AutocompleteList className="max-h-none overflow-visible p-0">
|
||||
<AutocompleteGroup items={commandOptions}>
|
||||
<AutocompleteGroupLabel className="px-4 pt-3 pb-2 text-left text-sm font-medium text-text-secondary">
|
||||
{isSlashMode
|
||||
? t(($) => $['gotoAnything.groups.commands'], { ns: 'app' })
|
||||
: t(($) => $['gotoAnything.selectSearchType'], { ns: 'app' })}
|
||||
</AutocompleteGroupLabel>
|
||||
<AutocompleteCollection>
|
||||
{(option: CommandOption) => (
|
||||
<AutocompleteItem
|
||||
key={option.shortcut}
|
||||
value={option}
|
||||
className="mx-4 p-2"
|
||||
onClick={() => selectOption(option)}
|
||||
>
|
||||
<span className="min-w-18 text-left font-mono text-xs text-text-tertiary">
|
||||
{option.shortcut}
|
||||
</span>
|
||||
<span className="ml-3 text-sm text-text-secondary">
|
||||
{isSlashMode
|
||||
? t(
|
||||
($) =>
|
||||
$[
|
||||
slashCommandDescriptionKeys[
|
||||
option.shortcut as keyof typeof slashCommandDescriptionKeys
|
||||
] || option.description
|
||||
],
|
||||
{ ns: 'app' },
|
||||
)
|
||||
: t(
|
||||
($) =>
|
||||
$[
|
||||
actionDescriptionKeys[
|
||||
option.shortcut as keyof typeof actionDescriptionKeys
|
||||
]
|
||||
],
|
||||
{ ns: 'app' },
|
||||
)}
|
||||
</span>
|
||||
</AutocompleteItem>
|
||||
)}
|
||||
</AutocompleteCollection>
|
||||
</AutocompleteGroup>
|
||||
</AutocompleteList>
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && !isCommandsMode && emptyStateVariant && (
|
||||
<EmptyState
|
||||
variant={emptyStateVariant}
|
||||
searchMode={searchMode}
|
||||
actions={actions}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isLoading &&
|
||||
!isError &&
|
||||
!isCommandsMode &&
|
||||
!emptyStateVariant &&
|
||||
autocompleteResultCount > 0 && (
|
||||
<AutocompleteList className="max-h-none overflow-visible p-0">
|
||||
{Object.entries(groupedResults).map(([type, results]) => (
|
||||
<AutocompleteGroup key={type} items={results}>
|
||||
<AutocompleteGroupLabel className="px-4 pt-3 pb-2 text-text-secondary capitalize">
|
||||
{t(
|
||||
($) =>
|
||||
$[
|
||||
groupLabelKeys[type as keyof typeof groupLabelKeys] ||
|
||||
`${type}s`
|
||||
],
|
||||
{ ns: 'app' },
|
||||
)}
|
||||
</AutocompleteGroupLabel>
|
||||
<AutocompleteCollection>
|
||||
{(result: SearchResult) => (
|
||||
<AutocompleteItem
|
||||
key={`${result.type}-${result.id}`}
|
||||
value={result}
|
||||
className="mx-2 gap-3 p-3"
|
||||
onClick={() => selectOption(result)}
|
||||
>
|
||||
{result.icon}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium text-text-secondary">
|
||||
{result.title}
|
||||
</div>
|
||||
{result.description && (
|
||||
<div className="mt-0.5 truncate text-xs text-text-quaternary">
|
||||
{result.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-text-quaternary capitalize">
|
||||
{result.type}
|
||||
</div>
|
||||
</AutocompleteItem>
|
||||
)}
|
||||
</AutocompleteCollection>
|
||||
</AutocompleteGroup>
|
||||
))}
|
||||
</AutocompleteList>
|
||||
)}
|
||||
</ScrollAreaContent>
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaScrollbar>
|
||||
<ScrollAreaThumb />
|
||||
</ScrollAreaScrollbar>
|
||||
</ScrollAreaRoot>
|
||||
|
||||
<Footer
|
||||
resultCount={autocompleteResultCount}
|
||||
searchMode={searchMode}
|
||||
isLoading={isLoading}
|
||||
hasUnavailableServices={hasUnavailableServices}
|
||||
isCommandsMode={isCommandsMode}
|
||||
hasQuery={!!searchQuery.trim()}
|
||||
/>
|
||||
</Autocomplete>
|
||||
<DialogCloseButton
|
||||
className="sr-only"
|
||||
aria-label={t(($) => $['operation.close'], { ns: 'common' })}
|
||||
/>
|
||||
|
||||
<Command.List className="h-[240px] overflow-y-auto">
|
||||
{emptyStateVariant === 'loading' && <EmptyState variant="loading" />}
|
||||
|
||||
{emptyStateVariant === 'error' && <EmptyState variant="error" error={error} />}
|
||||
|
||||
{!isLoading && !isError && (
|
||||
<>
|
||||
{isCommandsMode ? (
|
||||
<CommandSelector
|
||||
actions={Actions}
|
||||
onCommandSelect={handleCommandSelect}
|
||||
searchFilter={searchQuery.trim().substring(1)}
|
||||
commandValue={cmdVal}
|
||||
onCommandValueChange={setCmdVal}
|
||||
originalQuery={searchQuery.trim()}
|
||||
/>
|
||||
) : (
|
||||
<ResultList groupedResults={groupedResults} onSelect={handleNavigate} />
|
||||
)}
|
||||
|
||||
{!isCommandsMode && emptyStateVariant === 'no-results' && (
|
||||
<EmptyState variant="no-results" searchMode={searchMode} Actions={Actions} />
|
||||
)}
|
||||
|
||||
{!isCommandsMode && emptyStateVariant === 'default' && (
|
||||
<EmptyState variant="default" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Command.List>
|
||||
|
||||
<Footer
|
||||
resultCount={dedupedResults.length}
|
||||
searchMode={searchMode}
|
||||
isError={isError}
|
||||
isCommandsMode={isCommandsMode}
|
||||
hasQuery={!!searchQuery.trim()}
|
||||
/>
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</DialogPopup>
|
||||
</DialogPortal>
|
||||
</Dialog>
|
||||
|
||||
{activePlugin && canInstallPlugin && (
|
||||
@ -205,10 +613,6 @@ const GotoAnythingDialog: FC<Props> = ({ onHide }) => {
|
||||
)
|
||||
}
|
||||
|
||||
export const GotoAnything: FC<Props> = (props) => {
|
||||
return (
|
||||
<GotoAnythingProvider>
|
||||
<GotoAnythingDialog {...props} />
|
||||
</GotoAnythingProvider>
|
||||
)
|
||||
export function GotoAnything() {
|
||||
return <GotoAnythingDialog />
|
||||
}
|
||||
|
||||
@ -5,8 +5,9 @@ import type { ModalContextState } from '@/context/modal-context'
|
||||
import type { ProviderContextState } from '@/context/provider-context'
|
||||
import type { ICurrentWorkspace, IWorkspace } from '@/models/common'
|
||||
import type { InstalledApp } from '@/models/explore'
|
||||
import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { createStore, Provider as JotaiProvider } from 'jotai'
|
||||
import { Provider as JotaiProvider } from 'jotai'
|
||||
import {
|
||||
createTestQueryClient,
|
||||
renderWithSystemFeatures,
|
||||
@ -14,7 +15,7 @@ import {
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
import { DETAIL_SIDEBAR_STORAGE_KEY } from '@/app/components/detail-sidebar/storage'
|
||||
import { LEARN_DIFY_HIDDEN_STORAGE_KEY } from '@/app/components/explore/learn-dify/storage'
|
||||
import { useGotoAnythingOpen } from '@/app/components/goto-anything/atoms'
|
||||
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
|
||||
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
@ -263,7 +264,7 @@ const defaultMainNavSystemFeatures: MainNavSystemFeatures = {
|
||||
|
||||
const renderMainNav = (
|
||||
systemFeatures: MainNavSystemFeatures = defaultMainNavSystemFeatures,
|
||||
options: { store?: ReturnType<typeof createStore>; extra?: ReactNode } = {},
|
||||
options: { extra?: ReactNode } = {},
|
||||
) => {
|
||||
const queryClient = createTestQueryClient()
|
||||
const currentAppContext = mockAppContextState.current ?? appContextValue
|
||||
@ -282,7 +283,7 @@ const renderMainNav = (
|
||||
},
|
||||
}
|
||||
return renderWithSystemFeatures(
|
||||
<JotaiProvider store={options.store}>
|
||||
<JotaiProvider>
|
||||
<MainNav />
|
||||
{options.extra}
|
||||
</JotaiProvider>,
|
||||
@ -290,14 +291,10 @@ const renderMainNav = (
|
||||
)
|
||||
}
|
||||
|
||||
function GotoAnythingOpenProbe() {
|
||||
const open = useGotoAnythingOpen()
|
||||
return <div data-testid="goto-anything-open">{String(open)}</div>
|
||||
}
|
||||
|
||||
describe('MainNav', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
gotoAnythingDialogHandle.close()
|
||||
localStorage.clear()
|
||||
mockPathname = '/apps'
|
||||
mockInstalledApps = []
|
||||
@ -686,14 +683,20 @@ describe('MainNav', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('opens goto anything from the search button', () => {
|
||||
const store = createStore()
|
||||
it('opens goto anything from the search button', async () => {
|
||||
renderMainNav(undefined, {
|
||||
extra: (
|
||||
<Dialog handle={gotoAnythingDialogHandle}>
|
||||
<DialogContent>
|
||||
<DialogTitle>Goto Anything</DialogTitle>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
),
|
||||
})
|
||||
|
||||
renderMainNav(undefined, { store, extra: <GotoAnythingOpenProbe /> })
|
||||
expect(screen.getByTestId('goto-anything-open')).toHaveTextContent('false')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'app.gotoAnything.searchTitle' }))
|
||||
|
||||
expect(screen.getByTestId('goto-anything-open')).toHaveTextContent('true')
|
||||
expect(await screen.findByRole('dialog', { name: 'Goto Anything' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows Learn Dify switch in help menu and restores it from localStorage', async () => {
|
||||
|
||||
@ -1,22 +1,26 @@
|
||||
'use client'
|
||||
|
||||
import { DialogTrigger } from '@langgenius/dify-ui/dialog'
|
||||
import { Kbd } from '@langgenius/dify-ui/kbd'
|
||||
import { formatForDisplay } from '@tanstack/react-hotkeys'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSetGotoAnythingOpen } from '@/app/components/goto-anything/atoms'
|
||||
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
|
||||
|
||||
const searchShortcut = ['Mod', 'K']
|
||||
|
||||
export function MainNavSearchButton() {
|
||||
const { t } = useTranslation()
|
||||
const setGotoAnythingOpen = useSetGotoAnythingOpen()
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t(($) => $['gotoAnything.searchTitle'], { ns: 'app' })}
|
||||
className="flex h-8 items-center gap-1.5 overflow-hidden rounded-[10px] p-2 text-text-tertiary transition-colors hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
|
||||
onClick={() => setGotoAnythingOpen(true)}
|
||||
<DialogTrigger
|
||||
handle={gotoAnythingDialogHandle}
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t(($) => $['gotoAnything.searchTitle'], { ns: 'app' })}
|
||||
className="flex h-8 items-center gap-1.5 overflow-hidden rounded-[10px] p-2 text-text-tertiary transition-colors hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span aria-hidden className="i-custom-vender-main-nav-quick-search h-4 w-4" />
|
||||
<Kbd className="h-4.5 min-w-0 rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-1 py-0.5 system-2xs-medium-uppercase text-text-tertiary">
|
||||
@ -24,6 +28,6 @@ export function MainNavSearchButton() {
|
||||
<span key={key}>{formatForDisplay(key)}</span>
|
||||
))}
|
||||
</Kbd>
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
17
web/app/components/rag-pipeline/goto-anything-search.ts
Normal file
17
web/app/components/rag-pipeline/goto-anything-search.ts
Normal 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) ?? []
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { findRagPipelineNodes } from '../../goto-anything-search'
|
||||
import { useRagPipelineSearch } from '../use-rag-pipeline-search'
|
||||
|
||||
const mockNodes: Array<{ id: string; data: Record<string, unknown> }> = []
|
||||
@ -23,20 +24,6 @@ vi.mock('@/app/components/workflow/block-icon', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
type MockSearchResult = {
|
||||
title: string
|
||||
type: string
|
||||
description?: string
|
||||
metadata?: { nodeId: string }
|
||||
}
|
||||
|
||||
const mockRagPipelineNodesAction = vi.hoisted(() => {
|
||||
return { searchFn: undefined as undefined | ((query: string) => MockSearchResult[]) }
|
||||
})
|
||||
vi.mock('@/app/components/goto-anything/actions/rag-pipeline-nodes', () => ({
|
||||
ragPipelineNodesAction: mockRagPipelineNodesAction,
|
||||
}))
|
||||
|
||||
const mockCleanupListener = vi.fn()
|
||||
vi.mock('@/app/components/workflow/utils/node-navigation', () => ({
|
||||
setupNodeSelectionListener: () => mockCleanupListener,
|
||||
@ -46,7 +33,6 @@ describe('useRagPipelineSearch', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockNodes.length = 0
|
||||
mockRagPipelineNodesAction.searchFn = undefined
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@ -65,15 +51,21 @@ describe('useRagPipelineSearch', () => {
|
||||
data: { type: BlockEnum.LLM, title: 'LLM Node', desc: '' },
|
||||
})
|
||||
|
||||
renderHook(() => useRagPipelineSearch())
|
||||
const { unmount } = renderHook(() => useRagPipelineSearch())
|
||||
|
||||
expect(mockRagPipelineNodesAction.searchFn).toBeDefined()
|
||||
expect(findRagPipelineNodes('LLM')).toEqual([
|
||||
expect.objectContaining({ id: 'node-1', title: 'LLM Node' }),
|
||||
])
|
||||
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('should not register search function when no nodes', () => {
|
||||
renderHook(() => useRagPipelineSearch())
|
||||
it('should expose empty search results when no nodes exist', () => {
|
||||
const { unmount } = renderHook(() => useRagPipelineSearch())
|
||||
|
||||
expect(mockRagPipelineNodesAction.searchFn).toBeUndefined()
|
||||
expect(findRagPipelineNodes('LLM')).toEqual([])
|
||||
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('should cleanup search function on unmount', () => {
|
||||
@ -84,11 +76,11 @@ describe('useRagPipelineSearch', () => {
|
||||
|
||||
const { unmount } = renderHook(() => useRagPipelineSearch())
|
||||
|
||||
expect(mockRagPipelineNodesAction.searchFn).toBeDefined()
|
||||
expect(findRagPipelineNodes('Start')).not.toEqual([])
|
||||
|
||||
unmount()
|
||||
|
||||
expect(mockRagPipelineNodesAction.searchFn).toBeUndefined()
|
||||
expect(findRagPipelineNodes('Start')).toEqual([])
|
||||
})
|
||||
|
||||
it('should setup node selection listener', () => {
|
||||
@ -136,8 +128,7 @@ describe('useRagPipelineSearch', () => {
|
||||
it('should find nodes by title', () => {
|
||||
renderHook(() => useRagPipelineSearch())
|
||||
|
||||
const searchFn = mockRagPipelineNodesAction.searchFn!
|
||||
const results = searchFn('GPT')
|
||||
const results = findRagPipelineNodes('GPT')
|
||||
|
||||
expect(results.length).toBeGreaterThan(0)
|
||||
expect(results[0]!.title).toBe('GPT Model')
|
||||
@ -146,8 +137,7 @@ describe('useRagPipelineSearch', () => {
|
||||
it('should find nodes by type', () => {
|
||||
renderHook(() => useRagPipelineSearch())
|
||||
|
||||
const searchFn = mockRagPipelineNodesAction.searchFn!
|
||||
const results = searchFn(BlockEnum.LLM)
|
||||
const results = findRagPipelineNodes(BlockEnum.LLM)
|
||||
|
||||
expect(results.some((r) => r.title === 'GPT Model')).toBe(true)
|
||||
})
|
||||
@ -155,8 +145,7 @@ describe('useRagPipelineSearch', () => {
|
||||
it('should find nodes by description', () => {
|
||||
renderHook(() => useRagPipelineSearch())
|
||||
|
||||
const searchFn = mockRagPipelineNodesAction.searchFn!
|
||||
const results = searchFn('knowledge')
|
||||
const results = findRagPipelineNodes('knowledge')
|
||||
|
||||
expect(results.some((r) => r.title === 'Knowledge Base')).toBe(true)
|
||||
})
|
||||
@ -164,8 +153,7 @@ describe('useRagPipelineSearch', () => {
|
||||
it('should return all nodes when search term is empty', () => {
|
||||
renderHook(() => useRagPipelineSearch())
|
||||
|
||||
const searchFn = mockRagPipelineNodesAction.searchFn!
|
||||
const results = searchFn('')
|
||||
const results = findRagPipelineNodes('')
|
||||
|
||||
expect(results.length).toBe(4)
|
||||
})
|
||||
@ -173,8 +161,7 @@ describe('useRagPipelineSearch', () => {
|
||||
it('should sort by alphabetical order when no search term', () => {
|
||||
renderHook(() => useRagPipelineSearch())
|
||||
|
||||
const searchFn = mockRagPipelineNodesAction.searchFn!
|
||||
const results = searchFn('')
|
||||
const results = findRagPipelineNodes('')
|
||||
const titles = results.map((r) => r.title)
|
||||
|
||||
const sortedTitles = [...titles].sort((a, b) => a.localeCompare(b))
|
||||
@ -184,8 +171,7 @@ describe('useRagPipelineSearch', () => {
|
||||
it('should sort by relevance score when search term provided', () => {
|
||||
renderHook(() => useRagPipelineSearch())
|
||||
|
||||
const searchFn = mockRagPipelineNodesAction.searchFn!
|
||||
const results = searchFn('Search')
|
||||
const results = findRagPipelineNodes('Search')
|
||||
|
||||
expect(results[0]!.title).toBe('Web Search')
|
||||
})
|
||||
@ -193,8 +179,7 @@ describe('useRagPipelineSearch', () => {
|
||||
it('should return empty array when no nodes match', () => {
|
||||
renderHook(() => useRagPipelineSearch())
|
||||
|
||||
const searchFn = mockRagPipelineNodesAction.searchFn!
|
||||
const results = searchFn('nonexistent-xyz-12345')
|
||||
const results = findRagPipelineNodes('nonexistent-xyz-12345')
|
||||
|
||||
expect(results).toEqual([])
|
||||
})
|
||||
@ -202,8 +187,7 @@ describe('useRagPipelineSearch', () => {
|
||||
it('should enhance Tool node description from tool_description', () => {
|
||||
renderHook(() => useRagPipelineSearch())
|
||||
|
||||
const searchFn = mockRagPipelineNodesAction.searchFn!
|
||||
const results = searchFn('web')
|
||||
const results = findRagPipelineNodes('web')
|
||||
|
||||
const toolResult = results.find((r) => r.title === 'Web Search')
|
||||
expect(toolResult).toBeDefined()
|
||||
@ -213,18 +197,18 @@ describe('useRagPipelineSearch', () => {
|
||||
it('should include metadata with nodeId', () => {
|
||||
renderHook(() => useRagPipelineSearch())
|
||||
|
||||
const searchFn = mockRagPipelineNodesAction.searchFn!
|
||||
const results = searchFn('Start')
|
||||
const results = findRagPipelineNodes('Start')
|
||||
|
||||
const startResult = results.find((r) => r.title === 'Start Node')
|
||||
expect(startResult?.type).toBe('workflow-node')
|
||||
if (startResult?.type !== 'workflow-node') throw new Error('Expected a workflow node result')
|
||||
expect(startResult?.metadata?.nodeId).toBe('node-4')
|
||||
})
|
||||
|
||||
it('should set result type as workflow-node', () => {
|
||||
renderHook(() => useRagPipelineSearch())
|
||||
|
||||
const searchFn = mockRagPipelineNodesAction.searchFn!
|
||||
const results = searchFn('Start')
|
||||
const results = findRagPipelineNodes('Start')
|
||||
|
||||
expect(results[0]!.type).toBe('workflow-node')
|
||||
})
|
||||
|
||||
@ -5,7 +5,7 @@ import type { LLMNodeType } from '@/app/components/workflow/nodes/llm/types'
|
||||
import type { ToolNodeType } from '@/app/components/workflow/nodes/tool/types'
|
||||
import type { CommonNodeType } from '@/app/components/workflow/types'
|
||||
import { useCallback, useEffect, useMemo } from 'react'
|
||||
import { ragPipelineNodesAction } from '@/app/components/goto-anything/actions/rag-pipeline-nodes'
|
||||
import { registerRagPipelineNodeSearch } from '@/app/components/rag-pipeline/goto-anything-search'
|
||||
import BlockIcon from '@/app/components/workflow/block-icon'
|
||||
import { useNodesInteractions } from '@/app/components/workflow/hooks/use-nodes-interactions'
|
||||
import { useGetToolIcon } from '@/app/components/workflow/hooks/use-tool-icon'
|
||||
@ -107,7 +107,7 @@ export const useRagPipelineSearch = () => {
|
||||
)
|
||||
|
||||
// Create search function for RAG pipeline nodes
|
||||
const searchRagPipelineNodes = useCallback(
|
||||
const findRagPipelineNodes = useCallback(
|
||||
(query: string) => {
|
||||
if (!searchableNodes.length) return []
|
||||
|
||||
@ -154,18 +154,9 @@ export const useRagPipelineSearch = () => {
|
||||
[searchableNodes, calculateScore],
|
||||
)
|
||||
|
||||
// Directly set the search function on the action object
|
||||
useEffect(() => {
|
||||
if (searchableNodes.length > 0) {
|
||||
// Set the search function directly on the action
|
||||
ragPipelineNodesAction.searchFn = searchRagPipelineNodes
|
||||
}
|
||||
|
||||
return () => {
|
||||
// Clean up when component unmounts
|
||||
ragPipelineNodesAction.searchFn = undefined
|
||||
}
|
||||
}, [searchableNodes, searchRagPipelineNodes])
|
||||
return registerRagPipelineNodeSearch(findRagPipelineNodes)
|
||||
}, [findRagPipelineNodes])
|
||||
|
||||
// Set up node selection event listener using the utility function
|
||||
useEffect(() => {
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { DialogTrigger } from '@langgenius/dify-ui/dialog'
|
||||
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { formatForDisplay } from '@tanstack/react-hotkeys'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import SidebarLeftArrowIcon from '@/app/components/base/icons/src/vender/SidebarLeftArrowIcon'
|
||||
import { useSetGotoAnythingOpen } from '@/app/components/goto-anything/atoms'
|
||||
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
|
||||
import Link from '@/next/link'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import ToggleButton from '../../app-sidebar/toggle-button'
|
||||
@ -20,7 +21,6 @@ const SEARCH_SHORTCUT = ['Mod', 'K']
|
||||
const SnippetDetailTop = ({ expand = true, onToggle }: SnippetDetailTopProps) => {
|
||||
const { t } = useTranslation()
|
||||
const router = useRouter()
|
||||
const setGotoAnythingOpen = useSetGotoAnythingOpen()
|
||||
|
||||
if (!expand) {
|
||||
return (
|
||||
@ -68,14 +68,18 @@ const SnippetDetailTop = ({ expand = true, onToggle }: SnippetDetailTopProps) =>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t(($) => $['gotoAnything.searchTitle'], { ns: 'app' })}
|
||||
className="flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-[10px] text-text-tertiary transition-colors hover:bg-state-base-hover hover:text-text-secondary"
|
||||
onClick={() => setGotoAnythingOpen(true)}
|
||||
>
|
||||
<span aria-hidden className="i-custom-vender-main-nav-quick-search size-4" />
|
||||
</button>
|
||||
<DialogTrigger
|
||||
handle={gotoAnythingDialogHandle}
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t(($) => $['gotoAnything.searchTitle'], { ns: 'app' })}
|
||||
className="flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-[10px] text-text-tertiary transition-colors hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
|
||||
>
|
||||
<span aria-hidden className="i-custom-vender-main-nav-quick-search size-4" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<TooltipContent
|
||||
|
||||
17
web/app/components/workflow/goto-anything-search.ts
Normal file
17
web/app/components/workflow/goto-anything-search.ts
Normal 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) ?? []
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
import type { CommonNodeType, Node, ToolWithProvider } from '../../types'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { workflowNodesAction } from '@/app/components/goto-anything/actions/workflow-nodes'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
import { findWorkflowNodes } from '../../goto-anything-search'
|
||||
import { BlockEnum } from '../../types'
|
||||
import { useWorkflowSearch } from '../use-workflow-search'
|
||||
|
||||
@ -49,7 +49,6 @@ describe('useWorkflowSearch', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
runtimeNodes.length = 0
|
||||
workflowNodesAction.searchFn = undefined
|
||||
})
|
||||
|
||||
it('registers workflow node search results with tool icons and llm metadata scoring', async () => {
|
||||
@ -89,17 +88,17 @@ describe('useWorkflowSearch', () => {
|
||||
|
||||
const { unmount } = renderHook(() => useWorkflowSearch())
|
||||
|
||||
const llmResults = await workflowNodesAction.search('', 'gpt')
|
||||
const llmResults = findWorkflowNodes('gpt')
|
||||
expect(llmResults.map((item) => item.id)).toEqual(['llm-1'])
|
||||
expect(llmResults[0]?.title).toBe('Writer')
|
||||
|
||||
const toolResults = await workflowNodesAction.search('', 'search')
|
||||
const toolResults = findWorkflowNodes('search')
|
||||
expect(toolResults.map((item) => item.id)).toEqual(['tool-1'])
|
||||
expect(toolResults[0]?.description).toBe('Search the web')
|
||||
|
||||
unmount()
|
||||
|
||||
expect(workflowNodesAction.searchFn).toBeUndefined()
|
||||
expect(findWorkflowNodes('gpt')).toEqual([])
|
||||
})
|
||||
|
||||
it('binds the node selection listener to handleNodeSelect', () => {
|
||||
|
||||
@ -5,9 +5,9 @@ import type { CommonNodeType } from '../types'
|
||||
import type { Emoji } from '@/app/components/tools/types'
|
||||
import { useCallback, useEffect, useMemo } from 'react'
|
||||
import { useNodes } from 'reactflow'
|
||||
import { workflowNodesAction } from '@/app/components/goto-anything/actions/workflow-nodes'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
import BlockIcon from '@/app/components/workflow/block-icon'
|
||||
import { registerWorkflowNodeSearch } from '@/app/components/workflow/goto-anything-search'
|
||||
import {
|
||||
useAllBuiltInTools,
|
||||
useAllCustomTools,
|
||||
@ -135,7 +135,7 @@ export const useWorkflowSearch = () => {
|
||||
)
|
||||
|
||||
// Create search function for workflow nodes
|
||||
const searchWorkflowNodes = useCallback(
|
||||
const findWorkflowNodes = useCallback(
|
||||
(query: string) => {
|
||||
if (!searchableNodes.length) return []
|
||||
|
||||
@ -182,18 +182,9 @@ export const useWorkflowSearch = () => {
|
||||
[searchableNodes, calculateScore],
|
||||
)
|
||||
|
||||
// Directly set the search function on the action object
|
||||
useEffect(() => {
|
||||
if (searchableNodes.length > 0) {
|
||||
// Set the search function directly on the action
|
||||
workflowNodesAction.searchFn = searchWorkflowNodes
|
||||
}
|
||||
|
||||
return () => {
|
||||
// Clean up when component unmounts
|
||||
workflowNodesAction.searchFn = undefined
|
||||
}
|
||||
}, [searchableNodes, searchWorkflowNodes])
|
||||
return registerWorkflowNodeSearch(findWorkflowNodes)
|
||||
}, [findWorkflowNodes])
|
||||
|
||||
// Set up node selection event listener using the utility function
|
||||
useEffect(() => {
|
||||
|
||||
@ -5,6 +5,7 @@ import type { ComponentProps } from 'react'
|
||||
import type { AgentDetailSectionKey } from './section'
|
||||
import type { NavIcon } from '@/app/components/app-sidebar/nav-link'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { DialogTrigger } from '@langgenius/dify-ui/dialog'
|
||||
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { formatForDisplay } from '@tanstack/react-hotkeys'
|
||||
@ -15,7 +16,7 @@ import ToggleButton from '@/app/components/app-sidebar/toggle-button'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import SidebarLeftArrowIcon from '@/app/components/base/icons/src/vender/SidebarLeftArrowIcon'
|
||||
import { useSetGotoAnythingOpen } from '@/app/components/goto-anything/atoms'
|
||||
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
|
||||
import Link from '@/next/link'
|
||||
import { usePathname } from '@/next/navigation'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
@ -86,7 +87,6 @@ const getAgentDetailNavigation = (agentId: string): AgentDetailNavItem[] => [
|
||||
export function AgentDetailTop({ expand = true, onToggle }: AgentDetailTopProps) {
|
||||
const { t: tApp } = useTranslation('app')
|
||||
const { t: tCommon } = useTranslation('common')
|
||||
const setGotoAnythingOpen = useSetGotoAnythingOpen()
|
||||
|
||||
if (!expand) {
|
||||
return (
|
||||
@ -125,14 +125,18 @@ export function AgentDetailTop({ expand = true, onToggle }: AgentDetailTopProps)
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
aria-label={tApp(($) => $['gotoAnything.searchTitle'])}
|
||||
className="flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-[10px] text-text-tertiary transition-colors hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
|
||||
onClick={() => setGotoAnythingOpen(true)}
|
||||
>
|
||||
<span aria-hidden className="i-custom-vender-main-nav-quick-search size-4" />
|
||||
</button>
|
||||
<DialogTrigger
|
||||
handle={gotoAnythingDialogHandle}
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
aria-label={tApp(($) => $['gotoAnything.searchTitle'])}
|
||||
className="flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-[10px] text-text-tertiary transition-colors hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
|
||||
>
|
||||
<span aria-hidden className="i-custom-vender-main-nav-quick-search size-4" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<TooltipContent
|
||||
|
||||
@ -4,6 +4,7 @@ import type { ComponentProps, PropsWithoutRef } from 'react'
|
||||
import type { InstanceDetailTabKey } from './tabs'
|
||||
import type { NavIcon } from '@/app/components/app-sidebar/nav-link'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { DialogTrigger } from '@langgenius/dify-ui/dialog'
|
||||
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { formatForDisplay } from '@tanstack/react-hotkeys'
|
||||
@ -14,7 +15,7 @@ import ToggleButton from '@/app/components/app-sidebar/toggle-button'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import SidebarLeftArrowIcon from '@/app/components/base/icons/src/vender/SidebarLeftArrowIcon'
|
||||
import { SkeletonContainer, SkeletonRectangle } from '@/app/components/base/skeleton'
|
||||
import { useSetGotoAnythingOpen } from '@/app/components/goto-anything/atoms'
|
||||
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
|
||||
import Link from '@/next/link'
|
||||
import { usePathname } from '@/next/navigation'
|
||||
import { DeploymentActionsMenu } from '../deployment-actions'
|
||||
@ -192,7 +193,6 @@ export function DeploymentDetailTop({
|
||||
onToggle?: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const setGotoAnythingOpen = useSetGotoAnythingOpen()
|
||||
|
||||
if (!expand) {
|
||||
return (
|
||||
@ -231,14 +231,18 @@ export function DeploymentDetailTop({
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t(($) => $['gotoAnything.searchTitle'], { ns: 'app' })}
|
||||
className="flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-[10px] text-text-tertiary transition-colors hover:bg-state-base-hover hover:text-text-secondary"
|
||||
onClick={() => setGotoAnythingOpen(true)}
|
||||
>
|
||||
<span aria-hidden className="i-custom-vender-main-nav-quick-search size-4" />
|
||||
</button>
|
||||
<DialogTrigger
|
||||
handle={gotoAnythingDialogHandle}
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t(($) => $['gotoAnything.searchTitle'], { ns: 'app' })}
|
||||
className="flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-[10px] text-text-tertiary transition-colors hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
|
||||
>
|
||||
<span aria-hidden className="i-custom-vender-main-nav-quick-search size-4" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<TooltipContent
|
||||
|
||||
@ -73,7 +73,6 @@
|
||||
"abcjs": "catalog:",
|
||||
"ahooks": "catalog:",
|
||||
"class-variance-authority": "catalog:",
|
||||
"cmdk": "catalog:",
|
||||
"copy-to-clipboard": "catalog:",
|
||||
"cron-parser": "catalog:",
|
||||
"dayjs": "catalog:",
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import type { TracingProvider } from '@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/type'
|
||||
import type {
|
||||
AppDetailResponse,
|
||||
AppListResponse,
|
||||
CreateApiKeyResponse,
|
||||
DSLImportMode,
|
||||
DSLImportResponse,
|
||||
@ -15,16 +14,6 @@ import type { CommonResponse } from '@/models/common'
|
||||
import type { AppIconType, AppModeEnum, ModelConfig } from '@/types/app'
|
||||
import { del, get, patch, post, put } from './base'
|
||||
|
||||
export const fetchAppList = ({
|
||||
url,
|
||||
params,
|
||||
}: {
|
||||
url: string
|
||||
params?: Record<string, any>
|
||||
}): Promise<AppListResponse> => {
|
||||
return get<AppListResponse>(url, { params })
|
||||
}
|
||||
|
||||
export const fetchAppDetail = ({
|
||||
url,
|
||||
id,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user