mirror of
https://github.com/langgenius/dify.git
synced 2026-04-15 09:57:03 +08:00
feat(goto-anything): recent items, /go navigation command, deep app sub-sections (#35078)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b0c4d8c541
commit
175290fa04
@ -41,12 +41,59 @@ describe('appAction', () => {
|
||||
url: 'apps',
|
||||
params: { page: 1, name: 'test' },
|
||||
})
|
||||
expect(results).toHaveLength(1)
|
||||
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([
|
||||
'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 () => {
|
||||
|
||||
@ -0,0 +1,78 @@
|
||||
import { addRecentItem, getRecentItems } from '../recent-store'
|
||||
|
||||
describe('recent-store', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
describe('getRecentItems', () => {
|
||||
it('returns an empty array when nothing is stored', () => {
|
||||
expect(getRecentItems()).toEqual([])
|
||||
})
|
||||
|
||||
it('parses stored items from localStorage', () => {
|
||||
const items = [
|
||||
{ id: 'app-1', title: 'App 1', path: '/app/1', originalType: 'app' as const },
|
||||
]
|
||||
localStorage.setItem('goto-anything:recent', JSON.stringify(items))
|
||||
|
||||
expect(getRecentItems()).toEqual(items)
|
||||
})
|
||||
|
||||
it('returns an empty array when stored JSON is invalid', () => {
|
||||
localStorage.setItem('goto-anything:recent', 'not-json')
|
||||
|
||||
expect(getRecentItems()).toEqual([])
|
||||
})
|
||||
|
||||
it('returns an empty array when localStorage throws', () => {
|
||||
const spy = vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
||||
throw new Error('boom')
|
||||
})
|
||||
|
||||
expect(getRecentItems()).toEqual([])
|
||||
spy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('addRecentItem', () => {
|
||||
it('prepends a new item to the stored list', () => {
|
||||
addRecentItem({ id: 'a', title: 'A', path: '/a', originalType: 'app' })
|
||||
addRecentItem({ id: 'b', title: 'B', path: '/b', originalType: 'knowledge' })
|
||||
|
||||
const stored = getRecentItems()
|
||||
expect(stored.map(i => i.id)).toEqual(['b', 'a'])
|
||||
})
|
||||
|
||||
it('deduplicates by id, moving the existing entry to the front', () => {
|
||||
addRecentItem({ id: 'a', title: 'A', path: '/a', originalType: 'app' })
|
||||
addRecentItem({ id: 'b', title: 'B', path: '/b', originalType: 'app' })
|
||||
addRecentItem({ id: 'a', title: 'A updated', path: '/a', originalType: 'app' })
|
||||
|
||||
const stored = getRecentItems()
|
||||
expect(stored.map(i => i.id)).toEqual(['a', 'b'])
|
||||
expect(stored[0].title).toBe('A updated')
|
||||
})
|
||||
|
||||
it('caps the list at 8 items, evicting the oldest', () => {
|
||||
for (let i = 0; i < 10; i++)
|
||||
addRecentItem({ id: `item-${i}`, title: `Item ${i}`, path: `/i/${i}`, originalType: 'app' })
|
||||
|
||||
const stored = getRecentItems()
|
||||
expect(stored).toHaveLength(8)
|
||||
expect(stored[0].id).toBe('item-9')
|
||||
expect(stored[7].id).toBe('item-2')
|
||||
})
|
||||
|
||||
it('silently swallows storage errors', () => {
|
||||
const spy = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||
throw new Error('quota')
|
||||
})
|
||||
|
||||
expect(() =>
|
||||
addRecentItem({ id: 'x', title: 'X', path: '/x', originalType: 'app' }),
|
||||
).not.toThrow()
|
||||
spy.mockRestore()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,10 +1,51 @@
|
||||
import type { ActionItem, AppSearchResult } from './types'
|
||||
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 { 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])
|
||||
|
||||
type AppSection = { id: string, label: string, path: string, icon: React.ElementType }
|
||||
|
||||
const getAppSections = (app: App): AppSection[] => {
|
||||
const base = `/app/${app.id}`
|
||||
if (WORKFLOW_MODES.has(app.mode)) {
|
||||
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 },
|
||||
]
|
||||
}
|
||||
return [
|
||||
{ id: 'configuration', label: 'Configuration', path: `${base}/configuration`, icon: RiLayoutLine },
|
||||
{ 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) => (
|
||||
<div className="relative shrink-0">
|
||||
<AppIcon
|
||||
size="large"
|
||||
iconType={app.icon_type}
|
||||
icon={app.icon}
|
||||
background={app.icon_background}
|
||||
imageUrl={app.icon_url}
|
||||
/>
|
||||
<AppTypeIcon
|
||||
wrapperClassName="absolute -bottom-0.5 -right-0.5 w-4 h-4 radius-xs border border-divider-regular outline-solid outline-components-panel-on-panel-item-bg"
|
||||
className="h-3 w-3"
|
||||
type={app.mode}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
const parser = (apps: App[]): AppSearchResult[] => {
|
||||
return apps.map(app => ({
|
||||
id: app.id,
|
||||
@ -15,33 +56,50 @@ const parser = (apps: App[]): AppSearchResult[] => {
|
||||
id: app.id,
|
||||
mode: app.mode,
|
||||
}),
|
||||
icon: (
|
||||
<div className="relative shrink-0">
|
||||
<AppIcon
|
||||
size="large"
|
||||
iconType={app.icon_type}
|
||||
icon={app.icon}
|
||||
background={app.icon_background}
|
||||
imageUrl={app.icon_url}
|
||||
/>
|
||||
<AppTypeIcon
|
||||
wrapperClassName="absolute -bottom-0.5 -right-0.5 w-4 h-4 radius-xs border border-divider-regular outline-solid outline-components-panel-on-panel-item-bg"
|
||||
className="h-3 w-3"
|
||||
type={app.mode}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
icon: appIcon(app),
|
||||
data: app,
|
||||
}))
|
||||
}
|
||||
|
||||
// Generate sub-section results for matched apps when in scoped @app search
|
||||
const parserWithSections = (apps: App[]): SearchResult[] => {
|
||||
const results: SearchResult[] = []
|
||||
for (const app of apps) {
|
||||
results.push({
|
||||
id: app.id,
|
||||
title: app.name,
|
||||
description: app.description,
|
||||
type: 'app' as const,
|
||||
path: getRedirectionPath(true, { id: app.id, mode: app.mode }),
|
||||
icon: appIcon(app),
|
||||
data: app,
|
||||
})
|
||||
for (const section of getAppSections(app)) {
|
||||
results.push({
|
||||
id: `${app.id}:${section.id}`,
|
||||
title: `${app.name} / ${section.label}`,
|
||||
description: section.path,
|
||||
type: 'app' as const,
|
||||
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="h-4 w-4 text-text-tertiary" />
|
||||
</div>
|
||||
),
|
||||
data: app,
|
||||
})
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
export const appAction: ActionItem = {
|
||||
key: '@app',
|
||||
shortcut: '@app',
|
||||
title: 'Search Applications',
|
||||
description: 'Search and navigate to your applications',
|
||||
// action,
|
||||
search: async (_, searchTerm = '', _locale) => {
|
||||
search: async (query, searchTerm = '', _locale) => {
|
||||
const isScoped = query.trimStart().startsWith('@app') || query.trimStart().startsWith('@App')
|
||||
try {
|
||||
const response = await fetchAppList({
|
||||
url: 'apps',
|
||||
@ -51,7 +109,7 @@ export const appAction: ActionItem = {
|
||||
},
|
||||
})
|
||||
const apps = response?.data || []
|
||||
return parser(apps)
|
||||
return isScoped ? parserWithSections(apps) : parser(apps)
|
||||
}
|
||||
catch (error) {
|
||||
console.warn('App search failed:', error)
|
||||
|
||||
@ -0,0 +1,106 @@
|
||||
import { registerCommands, unregisterCommands } from '../command-bus'
|
||||
import { goCommand } from '../go'
|
||||
|
||||
vi.mock('../command-bus')
|
||||
|
||||
describe('goCommand', () => {
|
||||
let originalHref: string
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
originalHref = window.location.href
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(window, 'location', { value: { href: originalHref }, writable: true })
|
||||
})
|
||||
|
||||
it('has correct metadata', () => {
|
||||
expect(goCommand.name).toBe('go')
|
||||
expect(goCommand.mode).toBe('submenu')
|
||||
expect(goCommand.aliases).toEqual(['navigate', 'nav'])
|
||||
expect(goCommand.execute).toBeUndefined()
|
||||
})
|
||||
|
||||
describe('search', () => {
|
||||
it('returns all navigation items when query is empty', async () => {
|
||||
const results = await goCommand.search('', 'en')
|
||||
|
||||
expect(results.map(r => r.id)).toEqual([
|
||||
'go-apps',
|
||||
'go-datasets',
|
||||
'go-plugins',
|
||||
'go-tools',
|
||||
'go-explore',
|
||||
'go-account',
|
||||
])
|
||||
})
|
||||
|
||||
it('filters by id match', async () => {
|
||||
const results = await goCommand.search('plugins', 'en')
|
||||
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0].id).toBe('go-plugins')
|
||||
})
|
||||
|
||||
it('filters by label match (case-insensitive)', async () => {
|
||||
const results = await goCommand.search('Knowledge', 'en')
|
||||
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0].id).toBe('go-datasets')
|
||||
expect(results[0].title).toBe('Knowledge')
|
||||
})
|
||||
|
||||
it('returns command results with navigation.go data', async () => {
|
||||
const results = await goCommand.search('apps', 'en')
|
||||
|
||||
expect(results[0]).toMatchObject({
|
||||
type: 'command',
|
||||
title: 'Apps',
|
||||
description: '/apps',
|
||||
data: { command: 'navigation.go', args: { path: '/apps' } },
|
||||
})
|
||||
})
|
||||
|
||||
it('returns an empty list when nothing matches', async () => {
|
||||
const results = await goCommand.search('no-such-section', 'en')
|
||||
|
||||
expect(results).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('register / unregister', () => {
|
||||
it('registers navigation.go command', () => {
|
||||
goCommand.register?.({} as Record<string, never>)
|
||||
|
||||
expect(registerCommands).toHaveBeenCalledWith({ 'navigation.go': expect.any(Function) })
|
||||
})
|
||||
|
||||
it('unregisters navigation.go command', () => {
|
||||
goCommand.unregister?.()
|
||||
|
||||
expect(unregisterCommands).toHaveBeenCalledWith(['navigation.go'])
|
||||
})
|
||||
|
||||
it('registered handler navigates to the provided path', async () => {
|
||||
Object.defineProperty(window, 'location', { value: { href: '' }, writable: true })
|
||||
goCommand.register?.({} as Record<string, never>)
|
||||
const handlers = vi.mocked(registerCommands).mock.calls[0][0]
|
||||
|
||||
await handlers['navigation.go']({ path: '/datasets' })
|
||||
|
||||
expect(window.location.href).toBe('/datasets')
|
||||
})
|
||||
|
||||
it('registered handler does nothing when path is missing', async () => {
|
||||
Object.defineProperty(window, 'location', { value: { href: '/current' }, writable: true })
|
||||
goCommand.register?.({} as Record<string, never>)
|
||||
const handlers = vi.mocked(registerCommands).mock.calls[0][0]
|
||||
|
||||
await handlers['navigation.go']()
|
||||
await handlers['navigation.go']({})
|
||||
|
||||
expect(window.location.href).toBe('/current')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -105,6 +105,7 @@ describe('SlashCommandProvider', () => {
|
||||
'community',
|
||||
'account',
|
||||
'zen',
|
||||
'go',
|
||||
])
|
||||
expect(mockRegister).toHaveBeenCalledWith(expect.objectContaining({ name: 'theme' }), { setTheme: mockSetTheme })
|
||||
expect(mockRegister).toHaveBeenCalledWith(expect.objectContaining({ name: 'language' }), { setLocale: mockSetLocale })
|
||||
@ -119,6 +120,7 @@ describe('SlashCommandProvider', () => {
|
||||
'community',
|
||||
'account',
|
||||
'zen',
|
||||
'go',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
62
web/app/components/goto-anything/actions/commands/go.tsx
Normal file
62
web/app/components/goto-anything/actions/commands/go.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
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 },
|
||||
]
|
||||
|
||||
/**
|
||||
* Go command - Navigate to a top-level section of the app
|
||||
*/
|
||||
export const goCommand: SlashCommandHandler = {
|
||||
name: 'go',
|
||||
aliases: ['navigate', 'nav'],
|
||||
description: 'Navigate to a section',
|
||||
mode: 'submenu',
|
||||
|
||||
async 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),
|
||||
)
|
||||
return items.map(item => ({
|
||||
id: `go-${item.id}`,
|
||||
title: item.label,
|
||||
description: item.path,
|
||||
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="h-4 w-4 text-text-tertiary" />
|
||||
</div>
|
||||
),
|
||||
data: { command: 'navigation.go', args: { path: item.path } },
|
||||
}))
|
||||
},
|
||||
|
||||
register() {
|
||||
registerCommands({
|
||||
'navigation.go': async (args) => {
|
||||
if (args?.path)
|
||||
window.location.href = args.path
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
unregister() {
|
||||
unregisterCommands(['navigation.go'])
|
||||
},
|
||||
}
|
||||
@ -9,6 +9,7 @@ import { executeCommand } from './command-bus'
|
||||
import { communityCommand } from './community'
|
||||
import { docsCommand } from './docs'
|
||||
import { forumCommand } from './forum'
|
||||
import { goCommand } from './go'
|
||||
import { languageCommand } from './language'
|
||||
import { slashCommandRegistry } from './registry'
|
||||
import { themeCommand } from './theme'
|
||||
@ -48,6 +49,7 @@ const registerSlashCommands = (deps: Record<string, any>) => {
|
||||
slashCommandRegistry.register(communityCommand, {})
|
||||
slashCommandRegistry.register(accountCommand, {})
|
||||
slashCommandRegistry.register(zenCommand, {})
|
||||
slashCommandRegistry.register(goCommand, {})
|
||||
}
|
||||
|
||||
const unregisterSlashCommands = () => {
|
||||
@ -59,6 +61,7 @@ const unregisterSlashCommands = () => {
|
||||
slashCommandRegistry.unregister('community')
|
||||
slashCommandRegistry.unregister('account')
|
||||
slashCommandRegistry.unregister('zen')
|
||||
slashCommandRegistry.unregister('go')
|
||||
}
|
||||
|
||||
export const SlashCommandProvider = () => {
|
||||
|
||||
30
web/app/components/goto-anything/actions/recent-store.ts
Normal file
30
web/app/components/goto-anything/actions/recent-store.ts
Normal file
@ -0,0 +1,30 @@
|
||||
const RECENT_ITEMS_KEY = 'goto-anything:recent'
|
||||
const MAX_RECENT_ITEMS = 8
|
||||
|
||||
export function getRecentItems() {
|
||||
try {
|
||||
const stored = localStorage.getItem(RECENT_ITEMS_KEY)
|
||||
if (!stored)
|
||||
return []
|
||||
return JSON.parse(stored) as Array<{
|
||||
id: string
|
||||
title: string
|
||||
description?: string
|
||||
path: string
|
||||
originalType: 'app' | 'knowledge'
|
||||
}>
|
||||
}
|
||||
catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function addRecentItem(item: ReturnType<typeof getRecentItems>[number]): void {
|
||||
try {
|
||||
const recent = getRecentItems()
|
||||
const filtered = recent.filter(r => r.id !== item.id)
|
||||
const updated = [item, ...filtered].slice(0, MAX_RECENT_ITEMS)
|
||||
localStorage.setItem(RECENT_ITEMS_KEY, JSON.stringify(updated))
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
@ -5,7 +5,7 @@ import type { CommonNodeType } from '../../workflow/types'
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import type { App } from '@/types/app'
|
||||
|
||||
export type SearchResultType = 'app' | 'knowledge' | 'plugin' | 'workflow-node' | 'command'
|
||||
export type SearchResultType = 'app' | 'knowledge' | 'plugin' | 'workflow-node' | 'command' | 'recent'
|
||||
|
||||
export type BaseSearchResult<T = any> = {
|
||||
id: string
|
||||
@ -41,7 +41,12 @@ export type CommandSearchResult = {
|
||||
type: 'command'
|
||||
} & BaseSearchResult<{ command: string, args?: Record<string, any> }>
|
||||
|
||||
export type SearchResult = AppSearchResult | PluginSearchResult | KnowledgeSearchResult | WorkflowNodeSearchResult | CommandSearchResult
|
||||
export type RecentSearchResult = {
|
||||
type: 'recent'
|
||||
originalType: 'app' | 'knowledge'
|
||||
} & BaseSearchResult<{ path: string }>
|
||||
|
||||
export type SearchResult = AppSearchResult | PluginSearchResult | KnowledgeSearchResult | WorkflowNodeSearchResult | CommandSearchResult | RecentSearchResult
|
||||
|
||||
export type ActionItem = {
|
||||
key: '@app' | '@knowledge' | '@plugin' | '@node' | '/'
|
||||
|
||||
@ -21,6 +21,7 @@ const ResultList: FC<ResultListProps> = ({ groupedResults, onSelect }) => {
|
||||
'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' })
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@ import { useGotoAnythingNavigation } from '../use-goto-anything-navigation'
|
||||
|
||||
const mockRouterPush = vi.fn()
|
||||
const mockSelectWorkflowNode = vi.fn()
|
||||
const mockAddRecentItem = vi.fn()
|
||||
|
||||
type MockCommandResult = {
|
||||
mode: string
|
||||
@ -32,6 +33,10 @@ vi.mock('../../actions/commands/registry', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../actions/recent-store', () => ({
|
||||
addRecentItem: (...args: unknown[]) => mockAddRecentItem(...args),
|
||||
}))
|
||||
|
||||
const createMockActionItem = (
|
||||
key: '@app' | '@knowledge' | '@plugin' | '@node' | '/',
|
||||
extra: Record<string, unknown> = {},
|
||||
@ -314,6 +319,107 @@ describe('useGotoAnythingNavigation', () => {
|
||||
|
||||
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', () => {
|
||||
|
||||
@ -29,12 +29,17 @@ vi.mock('@/context/i18n', () => ({
|
||||
|
||||
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,
|
||||
@ -61,6 +66,7 @@ describe('useGotoAnythingResults', () => {
|
||||
capturedQueryFn = null
|
||||
mockMatchAction.mockReset()
|
||||
mockSearchAnything.mockReset()
|
||||
mockGetRecentItems.mockReturnValue([])
|
||||
})
|
||||
|
||||
describe('initialization', () => {
|
||||
@ -297,6 +303,59 @@ describe('useGotoAnythingResults', () => {
|
||||
})
|
||||
})
|
||||
|
||||
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') }
|
||||
|
||||
@ -7,6 +7,7 @@ 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
|
||||
@ -80,8 +81,23 @@ export const useGotoAnythingNavigation = (
|
||||
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)
|
||||
}
|
||||
|
||||
@ -1,10 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import type { ActionItem, SearchResult } from '../actions/types'
|
||||
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[]
|
||||
@ -70,16 +73,37 @@ export const useGotoAnythingResults = (
|
||||
},
|
||||
)
|
||||
|
||||
// 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 searchResults.filter((result) => {
|
||||
return allResults.filter((result) => {
|
||||
const key = `${result.type}-${result.id}`
|
||||
if (seen.has(key))
|
||||
return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
}, [searchResults])
|
||||
}, [searchResults, recentResults])
|
||||
|
||||
// Group results by type
|
||||
const groupedResults = useMemo(() => dedupedResults.reduce((acc, result) => {
|
||||
|
||||
@ -131,8 +131,10 @@ const GotoAnything: FC<Props> = ({
|
||||
return 'loading'
|
||||
if (isError)
|
||||
return 'error'
|
||||
if (!searchQuery.trim())
|
||||
return 'default'
|
||||
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
|
||||
|
||||
@ -91,6 +91,7 @@
|
||||
"gotoAnything.groups.commands": "Commands",
|
||||
"gotoAnything.groups.knowledgeBases": "Knowledge Bases",
|
||||
"gotoAnything.groups.plugins": "Plugins",
|
||||
"gotoAnything.groups.recent": "Recent",
|
||||
"gotoAnything.groups.workflowNodes": "Workflow Nodes",
|
||||
"gotoAnything.inScope": "in {{scope}}s",
|
||||
"gotoAnything.noMatchingCommands": "No matching commands found",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user