+ (fileContent.filepath.toLowerCase().endsWith('.md') || fileContent.filepath.toLowerCase().endsWith('.markdown')) ? (
+
diff --git a/frontend/src/components/navigator/types.ts b/frontend/src/components/navigator/types.ts
index 26f5dc2..5e50001 100644
--- a/frontend/src/components/navigator/types.ts
+++ b/frontend/src/components/navigator/types.ts
@@ -118,3 +118,28 @@ export interface FileContentResult {
source?: string;
language?: string;
}
+
+export type OmniSearchMatchKind = 'symbol' | 'file' | 'code' | 'route' | 'doc';
+
+export interface OmniSearchResultItem {
+ id: string;
+ type: OmniSearchMatchKind;
+ name: string;
+ kind?: string;
+ filepath: string;
+ repo?: string;
+ symbol_id?: number;
+ start_line: number;
+ end_line: number;
+ score: number;
+ score_label: string;
+ preview: string;
+}
+
+export interface OmniSearchResponse {
+ query: string;
+ repo: string;
+ total_matches: number;
+ matches: OmniSearchResultItem[];
+}
+
diff --git a/frontend/src/styles/navigator.css b/frontend/src/styles/navigator.css
index 2369b20..eb632c4 100644
--- a/frontend/src/styles/navigator.css
+++ b/frontend/src/styles/navigator.css
@@ -180,6 +180,563 @@
color: var(--text, #f8fafc);
}
+/* ==========================================================================
+ Omni-Search Floating Command Palette
+ ========================================================================== */
+.nav-omni-container {
+ position: relative;
+ width: 100%;
+}
+
+.nav-omni-input-wrapper {
+ position: relative;
+ display: flex;
+ align-items: center;
+ width: 100%;
+ background: rgba(0, 0, 0, 0.35);
+ border: 1px solid var(--border-card, #15474d);
+ border-radius: 6px;
+ padding: 0 10px;
+ transition: all 0.2s;
+ box-sizing: border-box;
+}
+
+.nav-omni-input-wrapper:focus-within {
+ border-color: var(--primary, #0891b2);
+ box-shadow: 0 0 0 2px rgba(8, 145, 178, 0.25);
+}
+
+.nav-omni-search-icon {
+ margin-right: 8px;
+ color: var(--text-muted, #94a3b8);
+ flex-shrink: 0;
+ pointer-events: none;
+}
+
+.nav-omni-input {
+ flex: 1;
+ background: transparent;
+ border: none;
+ padding: 6px 0;
+ color: var(--text, #f8fafc);
+ font-size: 0.82rem;
+ outline: none;
+ min-width: 0;
+}
+
+.nav-omni-clear-btn {
+ background: transparent;
+ border: none;
+ color: var(--text-muted, #94a3b8);
+ cursor: pointer;
+ font-size: 0.75rem;
+ padding: 2px 4px;
+ flex-shrink: 0;
+ margin-left: 4px;
+}
+
+.nav-omni-clear-btn:hover {
+ color: var(--text, #f8fafc);
+}
+
+.nav-omni-spinner {
+ width: 12px;
+ height: 12px;
+ border: 2px solid rgba(8, 145, 178, 0.3);
+ border-top-color: var(--primary, #0891b2);
+ border-radius: 50%;
+ animation: nav-spin 0.6s linear infinite;
+ margin-left: 6px;
+ flex-shrink: 0;
+}
+
+@keyframes nav-spin {
+ to { transform: rotate(360deg); }
+}
+
+/* Floating Dropdown - Absolute overlay with high z-index */
+.nav-omni-dropdown {
+ position: absolute;
+ top: calc(100% + 4px);
+ left: 0;
+ right: 0;
+ z-index: 50;
+ background: var(--bg-card, #0d2c2f);
+ border: 1px solid var(--border-card, #15474d);
+ border-radius: 8px;
+ box-shadow: 0 15px 30px rgba(0, 0, 0, 0.6), 0 5px 15px rgba(0, 0, 0, 0.4);
+ max-height: 420px;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ backdrop-filter: blur(8px);
+}
+
+.nav-omni-dropdown-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 12px;
+ background: rgba(0, 0, 0, 0.3);
+ border-bottom: 1px solid var(--border-card, #15474d);
+ font-size: 0.72rem;
+ font-weight: 600;
+ color: var(--text-muted, #94a3b8);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.nav-omni-hint {
+ font-size: 0.68rem;
+ color: var(--text-muted, #64748b);
+ text-transform: none;
+}
+
+.nav-omni-empty {
+ padding: 16px;
+ text-align: center;
+ font-size: 0.8rem;
+ color: var(--text-muted, #94a3b8);
+}
+
+.nav-omni-list {
+ overflow-y: auto;
+ padding: 4px;
+}
+
+.nav-omni-item {
+ display: flex;
+ flex-direction: column;
+ padding: 8px 10px;
+ border-radius: 6px;
+ cursor: pointer;
+ transition: background-color 0.12s ease;
+ margin-bottom: 2px;
+ border: 1px solid transparent;
+}
+
+.nav-omni-item:hover,
+.nav-omni-item.active {
+ background: rgba(8, 145, 178, 0.15);
+ border-color: rgba(8, 145, 178, 0.35);
+}
+
+.nav-omni-item-top {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 4px;
+ gap: 8px;
+}
+
+.nav-omni-item-title-group {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ overflow: hidden;
+}
+
+.nav-omni-badge {
+ font-size: 0.65rem;
+ font-weight: 700;
+ padding: 1px 5px;
+ border-radius: 3px;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ font-family: var(--font-mono, monospace);
+ flex-shrink: 0;
+}
+
+.badge-symbol {
+ background: rgba(14, 165, 233, 0.2);
+ color: #38bdf8;
+ border: 1px solid rgba(56, 189, 248, 0.35);
+}
+
+.badge-code {
+ background: rgba(168, 85, 247, 0.2);
+ color: #c084fc;
+ border: 1px solid rgba(192, 132, 252, 0.35);
+}
+
+.badge-file {
+ background: rgba(20, 184, 166, 0.2);
+ color: #2dd4bf;
+ border: 1px solid rgba(45, 212, 191, 0.35);
+}
+
+.badge-route {
+ background: rgba(34, 197, 94, 0.2);
+ color: #4ade80;
+ border: 1px solid rgba(74, 222, 128, 0.35);
+}
+
+.badge-doc {
+ background: rgba(245, 158, 11, 0.2);
+ color: #fbbf24;
+ border: 1px solid rgba(251, 191, 36, 0.35);
+}
+
+.badge-default {
+ background: rgba(148, 163, 184, 0.2);
+ color: #cbd5e1;
+}
+
+.nav-omni-item-name {
+ font-size: 0.82rem;
+ font-weight: 600;
+ color: var(--text, #f8fafc);
+ font-family: var(--font-mono, monospace);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.nav-omni-item-kind {
+ font-size: 0.72rem;
+ color: var(--text-muted, #94a3b8);
+}
+
+.nav-omni-score-pill {
+ font-size: 0.68rem;
+ font-weight: 600;
+ padding: 1px 7px;
+ border-radius: 10px;
+ background: rgba(16, 185, 129, 0.15);
+ color: #34d399;
+ border: 1px solid rgba(52, 211, 153, 0.3);
+ white-space: nowrap;
+ flex-shrink: 0;
+}
+
+.nav-omni-item-bottom {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 0.72rem;
+ color: var(--text-muted, #94a3b8);
+ font-family: var(--font-mono, monospace);
+ overflow: hidden;
+}
+
+.nav-omni-item-path {
+ color: var(--primary, #38bdf8);
+ flex-shrink: 0;
+}
+
+.nav-omni-item-preview {
+ color: var(--text-muted, #94a3b8);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+/* ==========================================================================
+ Full Source Code Viewer (NavigatorCodeViewer) & Target Line Highlighting
+ ========================================================================== */
+.nav-code-viewer-container {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ max-width: 100%;
+ background: var(--bg-card, #0d2c2f);
+ border-radius: 8px;
+ overflow: hidden;
+ position: relative;
+ border: 1px solid var(--border-card, #15474d);
+}
+
+.nav-code-viewer-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 14px;
+ background: rgba(0, 0, 0, 0.4);
+ border-bottom: 1px solid var(--border-card, #15474d);
+ gap: 12px;
+ flex-wrap: wrap;
+ max-width: 100%;
+}
+
+.nav-code-viewer-meta {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ overflow: hidden;
+ max-width: 100%;
+}
+
+.nav-code-file-icon {
+ color: var(--primary, #38bdf8);
+ flex-shrink: 0;
+}
+
+.nav-code-filename {
+ font-size: 0.84rem;
+ font-weight: 700;
+ color: var(--text, #f8fafc);
+ font-family: var(--font-mono, monospace);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.nav-code-stat-chip {
+ font-size: 0.72rem;
+ padding: 1px 6px;
+ background: rgba(255, 255, 255, 0.08);
+ border-radius: 4px;
+ color: var(--text-muted, #94a3b8);
+ font-family: var(--font-mono, monospace);
+ flex-shrink: 0;
+}
+
+.nav-code-viewer-actions {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex-wrap: wrap;
+}
+
+.nav-code-action-btn {
+ background: rgba(255, 255, 255, 0.06);
+ border: 1px solid var(--border-card, #15474d);
+ color: var(--text-muted, #cbd5e1);
+ padding: 3px 8px;
+ border-radius: 4px;
+ font-size: 0.74rem;
+ cursor: pointer;
+ transition: all 0.15s;
+}
+
+.nav-code-action-btn:hover {
+ background: rgba(255, 255, 255, 0.12);
+ color: var(--text, #f8fafc);
+ border-color: var(--primary, #0891b2);
+}
+
+.nav-code-impact-toggle-btn {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ background: rgba(168, 85, 247, 0.15);
+ border: 1px solid rgba(168, 85, 247, 0.35);
+ color: #c084fc;
+ padding: 3px 9px;
+ border-radius: 4px;
+ font-size: 0.74rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.15s;
+}
+
+.nav-code-impact-toggle-btn:hover,
+.nav-code-impact-toggle-btn.active {
+ background: rgba(168, 85, 247, 0.28);
+ border-color: #a855f7;
+ color: #f3e8ff;
+}
+
+.nav-code-body {
+ flex: 1;
+ overflow-y: auto;
+ overflow-x: hidden;
+ max-width: 100%;
+ background: #061113;
+ padding: 8px 0;
+ position: relative;
+ outline: none;
+}
+
+.nav-code-lines-wrapper {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ max-width: 100%;
+}
+
+.nav-code-line-row {
+ display: flex;
+ min-height: 20px;
+ line-height: 20px;
+ transition: background-color 0.1s ease;
+ padding: 0 4px;
+ max-width: 100%;
+ box-sizing: border-box;
+}
+
+.nav-code-line-row:hover {
+ background: rgba(255, 255, 255, 0.03);
+}
+
+.nav-code-line-number {
+ width: 44px;
+ min-width: 44px;
+ padding-right: 12px;
+ text-align: right;
+ font-size: 0.72rem;
+ font-family: var(--font-mono, monospace);
+ color: #475569;
+ user-select: none;
+ flex-shrink: 0;
+}
+
+.nav-code-line-content {
+ flex: 1;
+ font-size: 0.78rem;
+ font-family: var(--font-mono, monospace);
+ color: #cbd5e1;
+ white-space: pre-wrap;
+ word-break: break-all;
+ overflow-wrap: anywhere;
+ tab-size: 4;
+ min-width: 0;
+}
+
+/* Target line highlight */
+.nav-code-line-target {
+ background: rgba(14, 165, 233, 0.18) !important;
+ border-left: 3px solid #38bdf8 !important;
+}
+
+.nav-code-line-target .nav-code-line-number {
+ color: #38bdf8 !important;
+ font-weight: 700;
+}
+
+.nav-code-line-target .nav-code-line-content {
+ color: #ffffff !important;
+ font-weight: 500;
+}
+
+/* Docked Impact Drawer */
+.nav-code-impact-drawer {
+ border-top: 1px solid var(--border-card, #15474d);
+ background: rgba(9, 29, 32, 0.95);
+ backdrop-filter: blur(8px);
+ max-height: 260px;
+ display: flex;
+ flex-direction: column;
+ animation: slide-up 0.18s ease-out;
+}
+
+@keyframes slide-up {
+ from { transform: translateY(12px); opacity: 0; }
+ to { transform: translateY(0); opacity: 1; }
+}
+
+.nav-impact-drawer-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 6px 12px;
+ background: rgba(0, 0, 0, 0.35);
+ border-bottom: 1px solid var(--border-card, #15474d);
+}
+
+.nav-impact-drawer-title {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 0.76rem;
+}
+
+.nav-impact-symbol-name {
+ color: var(--text, #f8fafc);
+ font-family: var(--font-mono, monospace);
+}
+
+.nav-impact-symbol-lines {
+ color: var(--text-muted, #94a3b8);
+ font-size: 0.7rem;
+}
+
+.nav-impact-close-btn {
+ background: transparent;
+ border: none;
+ color: var(--text-muted, #94a3b8);
+ cursor: pointer;
+ font-size: 0.8rem;
+ padding: 2px 6px;
+}
+
+.nav-impact-close-btn:hover {
+ color: var(--text, #f8fafc);
+}
+
+.nav-impact-drawer-body {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 12px;
+ padding: 10px 14px;
+ overflow-y: auto;
+}
+
+.nav-impact-column-title {
+ font-size: 0.72rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ color: var(--text-muted, #94a3b8);
+ margin-bottom: 6px;
+}
+
+.nav-impact-empty {
+ font-size: 0.72rem;
+ color: var(--text-muted, #64748b);
+ font-style: italic;
+}
+
+.nav-impact-chips-list {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.nav-impact-chip {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ background: rgba(0, 0, 0, 0.3);
+ border: 1px solid var(--border-card, #15474d);
+ border-radius: 4px;
+ padding: 3px 8px;
+ font-size: 0.72rem;
+ cursor: pointer;
+ text-align: left;
+ transition: all 0.12s;
+ color: var(--text, #f8fafc);
+}
+
+.nav-impact-chip:hover {
+ background: rgba(8, 145, 178, 0.2);
+ border-color: var(--primary, #0891b2);
+}
+
+.nav-impact-chip.caller .chip-name {
+ color: #38bdf8;
+ font-weight: 600;
+ font-family: var(--font-mono, monospace);
+}
+
+.nav-impact-chip.callee .chip-name {
+ color: #a7f3d0;
+ font-weight: 600;
+ font-family: var(--font-mono, monospace);
+}
+
+.nav-impact-chip .chip-path {
+ color: var(--text-muted, #94a3b8);
+ font-size: 0.68rem;
+ font-family: var(--font-mono, monospace);
+}
+
+.nav-impact-chip .chip-line {
+ color: #facc15;
+ font-size: 0.68rem;
+}
+
+
+
.nav-toolbar-right {
display: flex;
align-items: center;
diff --git a/frontend/src/tests/NavigatorCodeViewer.test.tsx b/frontend/src/tests/NavigatorCodeViewer.test.tsx
new file mode 100644
index 0000000..2bc71a6
--- /dev/null
+++ b/frontend/src/tests/NavigatorCodeViewer.test.tsx
@@ -0,0 +1,149 @@
+import { render, screen, fireEvent } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { NavigatorCodeViewer } from '../components/navigator/NavigatorCodeViewer';
+import type { SymbolImpact } from '../components/navigator/types';
+
+describe('NavigatorCodeViewer Component', () => {
+ const sampleCode = `import os\n\ndef hello():\n return "world"\n\ndef goodbye():\n pass`;
+
+ const mockImpact: SymbolImpact = {
+ symbol: {
+ id: 1,
+ name: 'hello',
+ kind: 'function',
+ filepath: 'test.py',
+ start_line: 3,
+ end_line: 4,
+ repo: 'test-repo',
+ },
+ route: null,
+ callers: [
+ {
+ source_symbol: 'main_caller',
+ source_filepath: 'app/main.py',
+ line_number: 12,
+ call_count: 1,
+ },
+ ],
+ callees: [],
+ imports: [],
+ };
+
+ beforeEach(() => {
+ // Mock scrollIntoView
+ window.HTMLElement.prototype.scrollIntoView = vi.fn();
+ Object.assign(navigator, {
+ clipboard: {
+ writeText: vi.fn().mockResolvedValue(undefined),
+ },
+ });
+ });
+
+ it('renders line numbers and code lines', () => {
+ render(
+
+ );
+
+ expect(screen.getByText('test.py')).toBeInTheDocument();
+ expect(screen.getByText('6 lines')).toBeInTheDocument();
+ // Check line numbers 1 and 3 are present
+ expect(screen.getByText('1')).toBeInTheDocument();
+ expect(screen.getByText('3')).toBeInTheDocument();
+ expect(screen.getByText(/def hello\(\):/)).toBeInTheDocument();
+ });
+
+ it('highlights target line range and calls scrollIntoView', () => {
+ render(
+
+ );
+
+ const targetLine = screen.getByTestId('code-line-3');
+ expect(targetLine).toHaveClass('nav-code-line-target');
+ expect(window.HTMLElement.prototype.scrollIntoView).toHaveBeenCalled();
+ });
+
+ it('toggles callers and impact drawer', () => {
+ const onSelectCaller = vi.fn();
+ render(
+
+ );
+
+ const toggleBtn = screen.getByRole('button', { name: /callers & impact/i });
+ expect(toggleBtn).toBeInTheDocument();
+
+ // Click toggle to open drawer
+ fireEvent.click(toggleBtn);
+ expect(screen.getByText('main_caller')).toBeInTheDocument();
+
+ // Click caller chip
+ fireEvent.click(screen.getByText('main_caller'));
+ expect(onSelectCaller).toHaveBeenCalledWith('app/main.py', 'main_caller', undefined);
+ });
+
+ it('copies full code to clipboard when clicking Copy Code button', async () => {
+ const writeTextSpy = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined);
+
+ render(
+
+ );
+
+ const copyBtn = screen.getByRole('button', { name: /copy file code/i });
+ fireEvent.click(copyBtn);
+
+ expect(writeTextSpy).toHaveBeenCalledWith(sampleCode);
+ expect(await screen.findByText('Copied!')).toBeInTheDocument();
+ });
+
+ it('copies permalink to clipboard when clicking Copy Link button', async () => {
+ const writeTextSpy = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined);
+
+ render(
+
+ );
+
+ const copyLinkBtn = screen.getByRole('button', { name: /copy permalink/i });
+ fireEvent.click(copyLinkBtn);
+
+ expect(writeTextSpy).toHaveBeenCalledWith('test.py#L3-L4');
+ expect(await screen.findByText('Link Copied!')).toBeInTheDocument();
+ });
+
+ it('renders fallback when content is empty', () => {
+ render(
+
+ );
+
+ expect(screen.getByText('File is empty.')).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/tests/NavigatorOmniSearch.test.tsx b/frontend/src/tests/NavigatorOmniSearch.test.tsx
new file mode 100644
index 0000000..992bebe
--- /dev/null
+++ b/frontend/src/tests/NavigatorOmniSearch.test.tsx
@@ -0,0 +1,224 @@
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { NavigatorOmniSearch } from '../components/navigator/NavigatorOmniSearch';
+import type { OmniSearchResultItem } from '../components/navigator/types';
+
+describe('NavigatorOmniSearch Component', () => {
+ const mockMatches: OmniSearchResultItem[] = [
+ {
+ id: 'sym_1',
+ type: 'symbol',
+ symbol_id: 10,
+ name: 'api_read_file',
+ kind: 'function',
+ filepath: 'app/api/routers/files.py',
+ repo: 'test-repo',
+ start_line: 22,
+ end_line: 49,
+ score: 0.99,
+ score_label: '99% AST exact match',
+ preview: 'async def api_read_file(path: str)...',
+ },
+ {
+ id: 'file_1',
+ type: 'file',
+ name: 'files.py',
+ kind: 'file',
+ filepath: 'app/api/routers/files.py',
+ repo: 'test-repo',
+ start_line: 1,
+ end_line: 83,
+ score: 0.92,
+ score_label: '92% Filename match',
+ preview: 'app/api/routers/files.py',
+ },
+ ];
+
+ beforeEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('renders omni-search input with placeholder', () => {
+ render(
);
+ const input = screen.getByPlaceholderText(/search files, symbols, routes, or code text/i);
+ expect(input).toBeInTheDocument();
+ });
+
+ it('fetches matches when user types and displays floating overlay', async () => {
+ const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ query: 'api_read',
+ repo: 'test-repo',
+ total_matches: 2,
+ matches: mockMatches,
+ }),
+ } as Response);
+
+ const onSelect = vi.fn();
+ render(
);
+
+ const input = screen.getByPlaceholderText(/search files, symbols, routes, or code text/i);
+ fireEvent.change(input, { target: { value: 'api_read' } });
+
+ await waitFor(() => {
+ expect(fetchSpy).toHaveBeenCalled();
+ });
+
+ // Check overlay items rendered
+ const symbolBadge = await screen.findByText('SYMBOL');
+ expect(symbolBadge).toBeInTheDocument();
+ expect(screen.getByText('api_read_file')).toBeInTheDocument();
+ expect(screen.getByText('99% AST exact match')).toBeInTheDocument();
+
+ // Click result triggers onSelectResult
+ fireEvent.click(screen.getByText('api_read_file'));
+ expect(onSelect).toHaveBeenCalledWith(mockMatches[0]);
+ });
+
+ it('navigates with keyboard and selects on Enter', async () => {
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ query: 'api_read',
+ repo: 'test-repo',
+ total_matches: 2,
+ matches: mockMatches,
+ }),
+ } as Response);
+
+ const onSelect = vi.fn();
+ render(
);
+
+ const input = screen.getByPlaceholderText(/search files, symbols, routes, or code text/i);
+ fireEvent.change(input, { target: { value: 'api_read' } });
+
+ await screen.findByText('api_read_file');
+
+ // ArrowDown to first match, then Enter
+ fireEvent.keyDown(input, { key: 'ArrowDown' });
+ fireEvent.keyDown(input, { key: 'Enter' });
+
+ expect(onSelect).toHaveBeenCalledWith(mockMatches[0]);
+ });
+
+ it('closes dropdown on Escape key', async () => {
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ query: 'api_read',
+ repo: 'test-repo',
+ total_matches: 2,
+ matches: mockMatches,
+ }),
+ } as Response);
+
+ render(
);
+
+ const input = screen.getByPlaceholderText(/search files, symbols, routes, or code text/i);
+ fireEvent.change(input, { target: { value: 'api_read' } });
+
+ await screen.findByText('api_read_file');
+ expect(screen.getByTestId('nav-omni-dropdown')).toBeInTheDocument();
+
+ fireEvent.keyDown(input, { key: 'Escape' });
+ expect(screen.queryByTestId('nav-omni-dropdown')).not.toBeInTheDocument();
+ });
+
+ it('displays empty state when query returns no matches', async () => {
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ query: 'unknown_query',
+ repo: 'test-repo',
+ total_matches: 0,
+ matches: [],
+ }),
+ } as Response);
+
+ render(
);
+
+ const input = screen.getByPlaceholderText(/search files, symbols, routes, or code text/i);
+ fireEvent.change(input, { target: { value: 'unknown_query' } });
+
+ const emptyMsg = await screen.findByText(/no matching files, symbols, or code found/i);
+ expect(emptyMsg).toBeInTheDocument();
+ });
+
+ it('handles fetch error gracefully without crashing', async () => {
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('Network failure'));
+
+ render(
);
+
+ const input = screen.getByPlaceholderText(/search files, symbols, routes, or code text/i);
+ fireEvent.change(input, { target: { value: 'api_read' } });
+
+ // Wait debounce duration
+ await new Promise((r) => setTimeout(r, 200));
+
+ expect(screen.queryByTestId('nav-omni-dropdown')).not.toBeInTheDocument();
+ errorSpy.mockRestore();
+ });
+
+ it('closes dropdown when clicking outside the container', async () => {
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ query: 'api_read',
+ repo: 'test-repo',
+ total_matches: 2,
+ matches: mockMatches,
+ }),
+ } as Response);
+
+ render(
+
+ );
+
+ const input = screen.getByPlaceholderText(/search files, symbols, routes, or code text/i);
+ fireEvent.change(input, { target: { value: 'api_read' } });
+
+ await screen.findByText('api_read_file');
+ expect(screen.getByTestId('nav-omni-dropdown')).toBeInTheDocument();
+
+ // Fire mousedown on outside element
+ fireEvent.mouseDown(screen.getByTestId('outside-area'));
+ expect(screen.queryByTestId('nav-omni-dropdown')).not.toBeInTheDocument();
+ });
+
+ it('supports ArrowUp navigation within bounds', async () => {
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ query: 'api_read',
+ repo: 'test-repo',
+ total_matches: 2,
+ matches: mockMatches,
+ }),
+ } as Response);
+
+ const onSelect = vi.fn();
+ render(
);
+
+ const input = screen.getByPlaceholderText(/search files, symbols, routes, or code text/i);
+ fireEvent.change(input, { target: { value: 'api_read' } });
+
+ await screen.findByText('api_read_file');
+
+ // ArrowDown to 0 (api_read_file), ArrowDown to 1 (files.py), ArrowUp back to 0 (api_read_file), Enter
+ fireEvent.keyDown(input, { key: 'ArrowDown' });
+ fireEvent.keyDown(input, { key: 'ArrowDown' });
+ fireEvent.keyDown(input, { key: 'ArrowUp' });
+ fireEvent.keyDown(input, { key: 'Enter' });
+
+ expect(onSelect).toHaveBeenCalledWith(mockMatches[0]);
+ });
+});
diff --git a/tests/backend/test_navigator_router.py b/tests/backend/test_navigator_router.py
index e58de44..7b58b83 100644
--- a/tests/backend/test_navigator_router.py
+++ b/tests/backend/test_navigator_router.py
@@ -187,3 +187,64 @@ def test_api_get_symbol_impact_not_found(client: TestClient, test_db):
assert response.status_code == 404
data = response.json()
assert "error" in data
+
+
+def test_api_get_omni_search_symbols_and_files(client: TestClient, test_db):
+ # 1. Exact symbol match
+ res = client.get("/admin/api/navigator/omni-search?repo=test-repo&q=root_handler")
+ assert res.status_code == 200
+ data = res.json()
+ assert data["query"] == "root_handler"
+ assert len(data["matches"]) >= 1
+ match = data["matches"][0]
+ assert match["type"] == "symbol"
+ assert match["name"] == "root_handler"
+ assert match["filepath"] == "app/main.py"
+ assert match["score"] >= 0.95
+ assert "AST exact" in match["score_label"]
+
+ # 2. Prefix symbol match
+ res_prefix = client.get("/admin/api/navigator/omni-search?repo=test-repo&q=compute")
+ assert res_prefix.status_code == 200
+ prefix_data = res_prefix.json()
+ assert len(prefix_data["matches"]) >= 1
+ p_match = prefix_data["matches"][0]
+ assert p_match["name"] == "compute_value"
+ assert p_match["score"] == 0.94
+ assert "prefix match" in p_match["score_label"]
+
+ # 3. File path match
+ res_file = client.get("/admin/api/navigator/omni-search?repo=test-repo&q=helper.py")
+ assert res_file.status_code == 200
+ file_matches = res_file.json()["matches"]
+ assert any(m["type"] == "file" and "helper.py" in m["filepath"] for m in file_matches)
+
+ # 4. Route match
+ res_route = client.get("/admin/api/navigator/omni-search?repo=test-repo&q=/api/v1/root")
+ assert res_route.status_code == 200
+ route_matches = res_route.json()["matches"]
+ assert any(m["type"] == "route" and "/api/v1/root" in m["name"] for m in route_matches)
+
+ # 5. Repo isolation
+ res_isolated = client.get("/admin/api/navigator/omni-search?repo=other-repo&q=root_handler")
+ assert res_isolated.status_code == 200
+ assert res_isolated.json()["matches"] == []
+
+ # 6. Sorting / ranking order (higher scores first)
+ res_all = client.get("/admin/api/navigator/omni-search?repo=test-repo&q=main")
+ assert res_all.status_code == 200
+ all_matches = res_all.json()["matches"]
+ if len(all_matches) > 1:
+ scores = [m["score"] for m in all_matches]
+ assert scores == sorted(scores, reverse=True), "Matches must be ordered by score descending"
+
+ # 7. Empty query
+ res_empty = client.get("/admin/api/navigator/omni-search?repo=test-repo&q=")
+ assert res_empty.status_code == 200
+ assert res_empty.json()["matches"] == []
+
+ # 8. No matches
+ res_none = client.get("/admin/api/navigator/omni-search?repo=test-repo&q=nonexistent_xyz_123")
+ assert res_none.status_code == 200
+ assert res_none.json()["matches"] == []
+