From 950c0a7b93b2f863c062ddd5c0342cb2bb2b9eaf Mon Sep 17 00:00:00 2001 From: evolution Date: Thu, 9 Jul 2026 16:42:47 +0800 Subject: [PATCH] fix(web): improve repository dropdown search (#2381) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(web): make repo dropdown scrollable * fix(web): add repository dropdown search * fix(web): filter repositories by name only * fix(web): key repository rows by path * chore(web): apply prettier formatting * chore(web): apply ci autofix formatting --------- Co-authored-by: Gergő Magyar --- gitnexus-web/src/components/Header.tsx | 287 ++++++++++++--------- gitnexus-web/src/locales/en/header.json | 2 + gitnexus-web/src/locales/zh-CN/header.json | 2 + gitnexus-web/test/unit/header.test.tsx | 155 +++++++++++ 4 files changed, 321 insertions(+), 125 deletions(-) create mode 100644 gitnexus-web/test/unit/header.test.tsx diff --git a/gitnexus-web/src/components/Header.tsx b/gitnexus-web/src/components/Header.tsx index 03bdd6041..5a9ce4277 100644 --- a/gitnexus-web/src/components/Header.tsx +++ b/gitnexus-web/src/components/Header.tsx @@ -71,6 +71,7 @@ export const Header = ({ setHelpDialogBoxOpen, } = useAppState(); const [searchQuery, setSearchQuery] = useState(''); + const [repoSearchQuery, setRepoSearchQuery] = useState(''); const [isRepoDropdownOpen, setIsRepoDropdownOpen] = useState(false); const [showAnalyzer, setShowAnalyzer] = useState(false); const [reanalyzing, setReanalyzing] = useState(null); // repo name being re-analyzed @@ -96,6 +97,13 @@ export const Header = ({ .slice(0, 10); // Limit to 10 results }, [graph, searchQuery]); + const filteredRepos = useMemo(() => { + const query = repoSearchQuery.trim().toLowerCase(); + if (!query) return availableRepos; + + return availableRepos.filter((repo) => repo.name.toLowerCase().includes(query)); + }, [availableRepos, repoSearchQuery]); + // Handle clicking outside search or repo dropdown to close them useEffect(() => { const handleClickOutside = (e: MouseEvent) => { @@ -105,6 +113,7 @@ export const Header = ({ if (repoDropdownRef.current && !repoDropdownRef.current.contains(e.target as Node)) { setIsRepoDropdownOpen(false); setShowAnalyzer(false); + setRepoSearchQuery(''); } }; document.addEventListener('mousedown', handleClickOutside); @@ -179,8 +188,10 @@ export const Header = ({
{isRepoDropdownOpen && ( -
+
{showAnalyzer ? ( -
+
{ setShowAnalyzer(false); setIsRepoDropdownOpen(false); + setRepoSearchQuery(''); onAnalyzeComplete?.(repoName); }} onCancel={() => setShowAnalyzer(false)} />
) : ( - <> +
{/* Repo list */} {availableRepos.length > 0 && ( -
-
+
+
{t('header:repositories')}
- {availableRepos.map((repo) => ( -
- - {/* Re-analyze */} - - {/* Delete */} - +
+
+ + setRepoSearchQuery(e.target.value)} + className="min-w-0 flex-1 border-none bg-transparent text-xs text-text-primary outline-none placeholder:text-text-muted" + />
- ))} +
+
+ {filteredRepos.length === 0 ? ( +
+ {t('header:noRepositoriesFound', { query: repoSearchQuery })} +
+ ) : ( + filteredRepos.map((repo) => ( +
+ + {/* Re-analyze */} + + {/* Delete */} + +
+ )) + )} +
)} @@ -375,7 +409,10 @@ export const Header = ({ } >
- +
)}
)} diff --git a/gitnexus-web/src/locales/en/header.json b/gitnexus-web/src/locales/en/header.json index 3b5318315..26ca3f232 100644 --- a/gitnexus-web/src/locales/en/header.json +++ b/gitnexus-web/src/locales/en/header.json @@ -6,6 +6,8 @@ "deleteRepo": "Delete {{repoName}}", "reanalyzingRepo": "Re-analyzing {{repoName}}: {{message}}", "analyzeNew": "Analyze a new repository...", + "searchRepositories": "Search repositories...", + "noRepositoriesFound": "No repositories found for \"{{query}}\"", "searchNodes": "Search nodes...", "noNodesFound": "No nodes found for \"{{query}}\"", "starIfCool": "Star if cool", diff --git a/gitnexus-web/src/locales/zh-CN/header.json b/gitnexus-web/src/locales/zh-CN/header.json index 303ba02c8..d68415c98 100644 --- a/gitnexus-web/src/locales/zh-CN/header.json +++ b/gitnexus-web/src/locales/zh-CN/header.json @@ -6,6 +6,8 @@ "deleteRepo": "删除 {{repoName}}", "reanalyzingRepo": "正在重新分析 {{repoName}}:{{message}}", "analyzeNew": "分析新仓库...", + "searchRepositories": "搜索仓库...", + "noRepositoriesFound": "未找到“{{query}}”相关仓库", "searchNodes": "搜索节点...", "noNodesFound": "未找到“{{query}}”相关节点", "starIfCool": "觉得不错就点星", diff --git a/gitnexus-web/test/unit/header.test.tsx b/gitnexus-web/test/unit/header.test.tsx new file mode 100644 index 000000000..1a45bd705 --- /dev/null +++ b/gitnexus-web/test/unit/header.test.tsx @@ -0,0 +1,155 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import { Header } from '../../src/components/Header'; +import type { BackendRepo } from '../../src/services/backend-client'; + +vi.mock('../../src/hooks/useAppState', () => ({ + useAppState: () => ({ + projectName: 'reels', + graph: null, + graphMode: 'full', + openChatPanel: vi.fn(), + isRightPanelOpen: false, + rightPanelTab: 'chat', + setSettingsPanelOpen: vi.fn(), + setHelpDialogBoxOpen: vi.fn(), + }), +})); + +vi.mock('../../src/components/EmbeddingStatus', () => ({ + EmbeddingStatus: () =>
, +})); + +vi.mock('../../src/components/LanguageSwitcher', () => ({ + LanguageSwitcher: () =>
, +})); + +vi.mock('../../src/components/RepoAnalyzer', () => ({ + RepoAnalyzer: () =>
, +})); + +vi.mock('../../src/services/backend-client', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + deleteRepo: vi.fn(), + fetchRepos: vi.fn(), + startAnalyze: vi.fn(), + streamAnalyzeProgress: vi.fn(), + }; +}); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: Record) => { + if (key === 'header:repositories') return 'Repositories'; + if (key === 'header:active') return 'Active'; + if (key === 'header:reanalyzeRepo') return `Re-analyze ${options?.repoName ?? ''}`; + if (key === 'header:deleteRepo') return `Delete ${options?.repoName ?? ''}`; + if (key === 'header:analyzeNew') return 'Analyze new'; + if (key === 'header:searchRepositories') return 'Search repositories...'; + if (key === 'header:noRepositoriesFound') + return `No repositories found for ${options?.query}`; + return key; + }, + }), +})); + +function makeRepo(index: number): BackendRepo { + return { + name: index === 0 ? 'reels' : `repo-${index}`, + path: `/tmp/repo-${index}`, + stats: { + files: 1, + nodes: 1, + edges: 0, + communities: 0, + processes: 0, + }, + }; +} + +describe('Header', () => { + it('keeps a large repository menu scrollable inside the viewport', () => { + render(
makeRepo(index))} />); + + fireEvent.click(screen.getByRole('button', { name: /reels/i })); + + const menu = screen.getByText('Repositories').closest('.absolute'); + expect(menu).not.toBeNull(); + expect(menu).toHaveClass('max-h-[calc(100vh-4.5rem)]'); + expect(menu).toHaveClass('overflow-hidden'); + + const scrollableRepoList = screen.getByText('repo-29').closest('.scrollbar-thin'); + expect(scrollableRepoList).not.toBeNull(); + expect(scrollableRepoList).toHaveClass('overflow-y-auto'); + expect(scrollableRepoList).toHaveClass('flex-1'); + }); + + it('filters repositories locally by displayed name', async () => { + const user = userEvent.setup(); + render( +
, + ); + + fireEvent.click(screen.getByRole('button', { name: /reels/i })); + + const input = screen.getByRole('textbox', { name: 'Search repositories...' }); + await user.type(input, 'gitnexus'); + + expect(screen.getByText('gitnexus-web')).toBeInTheDocument(); + expect(screen.queryByText('api-server')).not.toBeInTheDocument(); + + await user.clear(input); + await user.type(input, 'api'); + + expect(screen.getByText('api-server')).toBeInTheDocument(); + expect(screen.queryByText('gitnexus-web')).not.toBeInTheDocument(); + }); + + it('shows an empty state when no repositories match the local search', async () => { + const user = userEvent.setup(); + render(
makeRepo(index))} />); + + fireEvent.click(screen.getByRole('button', { name: /reels/i })); + + await user.type(screen.getByRole('textbox', { name: 'Search repositories...' }), 'missing'); + + expect(screen.getByText('No repositories found for missing')).toBeInTheDocument(); + expect(screen.queryByText('repo-1')).not.toBeInTheDocument(); + }); + + it('does not leave stale rows when duplicate repository names are filtered', async () => { + const user = userEvent.setup(); + render( +
, + ); + + fireEvent.click(screen.getByRole('button', { name: /reels/i })); + + await user.type(screen.getByRole('textbox', { name: 'Search repositories...' }), 'tab'); + + const repoList = screen.getAllByText('tab_server')[0].closest('.scrollbar-thin'); + expect(repoList).not.toBeNull(); + expect(repoList).toHaveTextContent('tab_server'); + expect(repoList).not.toHaveTextContent('search_sync'); + expect(repoList).not.toHaveTextContent('feed_sync'); + expect(repoList).not.toHaveTextContent('reels'); + }); +});