From 9eea6ed0b392b752ac9ab4ec79a586a73ad5cae7 Mon Sep 17 00:00:00 2001 From: Antigravity Agent Date: Wed, 30 Sep 2026 14:35:21 -0500 Subject: [PATCH 1/3] style(navigator): refine symbol scope highlighting with subtle ambient tint and distinct header indicator --- frontend/dist/index.html | 4 ++-- .../navigator/NavigatorCodeViewer.tsx | 2 +- frontend/src/styles/navigator.css | 19 +++++++++++++++---- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/frontend/dist/index.html b/frontend/dist/index.html index 53204b2..bbda938 100644 --- a/frontend/dist/index.html +++ b/frontend/dist/index.html @@ -17,7 +17,7 @@ } catch (e) {} })(); - + @@ -33,7 +33,7 @@ - +
diff --git a/frontend/src/components/navigator/NavigatorCodeViewer.tsx b/frontend/src/components/navigator/NavigatorCodeViewer.tsx index 9fd6ba4..d55f924 100644 --- a/frontend/src/components/navigator/NavigatorCodeViewer.tsx +++ b/frontend/src/components/navigator/NavigatorCodeViewer.tsx @@ -413,7 +413,7 @@ export const NavigatorCodeViewer: React.FC = ({
diff --git a/frontend/src/styles/navigator.css b/frontend/src/styles/navigator.css index 2c0ec8d..4b1444d 100644 --- a/frontend/src/styles/navigator.css +++ b/frontend/src/styles/navigator.css @@ -840,6 +840,7 @@ padding: 0 4px; max-width: 100%; box-sizing: border-box; + border-left: 3px solid transparent; } .nav-code-line-row:hover { @@ -870,18 +871,28 @@ min-width: 0; } -/* Target line highlight */ +/* Enclosing symbol scope range highlight */ .nav-code-line-target { - background: rgba(14, 165, 233, 0.18) !important; - border-left: 3px solid #38bdf8 !important; + background: rgba(14, 165, 233, 0.04) !important; + border-left: 3px solid rgba(56, 189, 248, 0.22) !important; } .nav-code-line-target .nav-code-line-number { + color: #64748b; +} + +/* Primary declaration / target line highlight */ +.nav-code-line-primary { + background: rgba(14, 165, 233, 0.16) !important; + border-left: 3px solid #38bdf8 !important; +} + +.nav-code-line-primary .nav-code-line-number { color: #38bdf8 !important; font-weight: 700; } -.nav-code-line-target .nav-code-line-content { +.nav-code-line-primary .nav-code-line-content { font-weight: 500; } From 10925957ee515634a8c5acb54fbf31ec73f08cb2 Mon Sep 17 00:00:00 2001 From: Antigravity Agent Date: Wed, 30 Sep 2026 15:23:30 -0500 Subject: [PATCH 2/3] fix(navigator): resolve 0 callers/impact by aggregating container members and fixing ast extraction --- app/services/chunking/symbol_extractor.py | 8 +-- app/services/chunking/text_chunker.py | 40 +++++++------ app/services/indexing/git_syncer.py | 2 + app/services/indexing/local_syncer.py | 2 + app/services/navigator.py | 70 ++++++++++++++++++----- tests/backend/test_chunker_languages.py | 35 ++++++++++++ tests/backend/test_navigator_service.py | 47 +++++++++++++++ 7 files changed, 168 insertions(+), 36 deletions(-) diff --git a/app/services/chunking/symbol_extractor.py b/app/services/chunking/symbol_extractor.py index f1de461..aa7e19e 100644 --- a/app/services/chunking/symbol_extractor.py +++ b/app/services/chunking/symbol_extractor.py @@ -154,12 +154,12 @@ def traverse(node, parent_symbol: Optional[str] = None): elif node.type in CALL_NODE_TYPES: target = extract_target_from_call_node(node, source_bytes) - if target and target not in ("self", "this", "super"): + if target and target not in ("self", "this", "super", "new", "var"): # Clean method prefix if full_symbol has parent active_src = parent_symbol if parent_symbol else file_symbol - # if current active symbol is a method like Foo.bar, extract just bar or Foo.bar - if active_src and "." in active_src: - active_src_name = active_src.split(".")[-1] + # If current active symbol is a method like Foo.bar, extract just bar + if parent_symbol and "." in parent_symbol: + active_src_name = parent_symbol.split(".")[-1] else: active_src_name = active_src relationships.append({ diff --git a/app/services/chunking/text_chunker.py b/app/services/chunking/text_chunker.py index 66cc5d1..ee0ad76 100644 --- a/app/services/chunking/text_chunker.py +++ b/app/services/chunking/text_chunker.py @@ -216,9 +216,18 @@ def extract_node_name(node, source_bytes: bytes) -> Optional[str]: def extract_target_from_call_node(node, source_bytes: bytes) -> Optional[str]: """Extract function, method, constructor, or macro name from a call node.""" - fn_node = node.child_by_field_name("function") or node.child_by_field_name("method") or node.child_by_field_name("expression") + fn_node = ( + node.child_by_field_name("function") + or node.child_by_field_name("method") + or node.child_by_field_name("expression") + or node.child_by_field_name("type") + or node.child_by_field_name("constructor") + ) if not fn_node and len(node.children) > 0: - fn_node = node.children[0] + for child in node.children: + if child.type not in ("new", "(", ")", ";", "{", "}", "[", "]"): + fn_node = child + break if fn_node: call_str = source_bytes[fn_node.start_byte:fn_node.end_byte].decode("utf-8", errors="ignore").strip() @@ -228,22 +237,17 @@ def extract_target_from_call_node(node, source_bytes: bytes) -> Optional[str]: # Clean method call like self.foo() or obj.bar() or math.sqrt() -> get target symbol name if "(" in call_str: call_str = call_str.split("(")[0].strip() - if "." in call_str: - parts = [p for p in call_str.split(".") if p] - if parts: - return parts[-1] - if "::" in call_str: - parts = [p for p in call_str.split("::") if p] - if parts: - return parts[-1] - if "->" in call_str: - parts = [p for p in call_str.split("->") if p] - if parts: - return parts[-1] - if "\\" in call_str: - parts = [p for p in call_str.split("\\") if p] - if parts: - return parts[-1] + # Strip generics e.g. Foo or Bar -> Foo or Bar + call_str = re.sub(r'<.*?>', '', call_str).strip() + call_str = call_str.strip('; >') + for sep in ('.', '::', '->', '\\'): + if sep in call_str: + parts = [p for p in call_str.split(sep) if p] + if parts: + call_str = parts[-1] + call_str = re.sub(r'<.*?>', '', call_str).strip('; >()') + if not call_str or call_str in ("new", "var", "self", "this", "super", "return", "throw", "yield", "void"): + return None return call_str return None diff --git a/app/services/indexing/git_syncer.py b/app/services/indexing/git_syncer.py index a899de2..f83875c 100644 --- a/app/services/indexing/git_syncer.py +++ b/app/services/indexing/git_syncer.py @@ -345,6 +345,8 @@ def sync_single_git_repo(repo_id: int): for s in batch_symbols: if "inserted_id" in s: sym_map[(s["repo"], s["filepath"], s["name"])] = s["inserted_id"] + if s.get("full_symbol"): + sym_map[(s["repo"], s["filepath"], s["full_symbol"])] = s["inserted_id"] rel_tuples = [] for r in batch_relationships: diff --git a/app/services/indexing/local_syncer.py b/app/services/indexing/local_syncer.py index 40a0dbe..32ae5e8 100644 --- a/app/services/indexing/local_syncer.py +++ b/app/services/indexing/local_syncer.py @@ -181,6 +181,8 @@ def sync_local_paths(): for s in all_symbols: if "inserted_id" in s: sym_map[(s["repo"], s["filepath"], s["name"])] = s["inserted_id"] + if s.get("full_symbol"): + sym_map[(s["repo"], s["filepath"], s["full_symbol"])] = s["inserted_id"] rel_tuples = [] for r in all_relationships: diff --git a/app/services/navigator.py b/app/services/navigator.py index b1266d7..bb9c6b1 100644 --- a/app/services/navigator.py +++ b/app/services/navigator.py @@ -218,10 +218,39 @@ def get_symbol_impact(repo: str, symbol_id: int) -> Optional[Dict[str, Any]]: repo_filter_clause = "" if target_repo == "__all__" else " AND r.repo = ?" repo_params = [] if target_repo == "__all__" else [target_repo] + clean_sym_fp = _clean_path(sym["filepath"]) + + # Check for child member symbols in the same file (e.g. methods of a class or members of a struct/interface) + member_rows = conn.execute( + """ + SELECT id, name, full_symbol + FROM ast_symbols + WHERE repo = ? + AND (filepath = ? OR filepath = ? OR filepath LIKE ?) + AND start_line > ? AND end_line <= ? AND id != ? + """, + (sym["repo"], sym["filepath"], f"/{clean_sym_fp}", f"%/{clean_sym_fp}", sym["start_line"], sym["end_line"], sym["id"]) + ).fetchall() + + target_names = {sym["name"]} + if sym["full_symbol"]: + target_names.add(sym["full_symbol"]) + for m in member_rows: + target_names.add(m["name"]) + if m["full_symbol"]: + target_names.add(m["full_symbol"]) + target_names_list = list(target_names) + + exclude_source_names = list(target_names) + exclude_source_ids = [sym["id"]] + [m["id"] for m in member_rows] + # 1. Fetch incoming callers: - # Matches relationships where this symbol is called/used. - # Never includes outgoing calls made by this symbol. - # Resolves source_symbol_id from ast_symbols if missing, and groups multiple calls from same caller. + # Matches relationships where this symbol (or any of its member methods) is called/used. + # Excludes self-calls originating from within this symbol or its member methods. + callers_placeholders = ",".join(["?"] * len(target_names_list)) + ex_name_placeholders = ",".join(["?"] * len(exclude_source_names)) + ex_id_placeholders = ",".join(["?"] * len(exclude_source_ids)) + callers_query = f""" SELECT MIN(r.id) as id, @@ -240,20 +269,25 @@ def get_symbol_impact(repo: str, symbol_id: int) -> Optional[Dict[str, Any]]: FROM ast_symbols ) src_sym ON ( r.source_symbol_id = src_sym.id - OR (r.source_symbol = src_sym.name AND (r.source_filepath = src_sym.filepath OR r.source_filepath LIKE '%/' || src_sym.filepath) AND r.repo = src_sym.repo) + OR ((r.source_symbol = src_sym.name OR r.source_symbol = src_sym.full_symbol) AND (r.source_filepath = src_sym.filepath OR r.source_filepath LIKE '%/' || src_sym.filepath) AND r.repo = src_sym.repo) ) AND src_sym.rn = 1 - WHERE (r.target_symbol = ? OR (r.target_symbol = ? AND ? != '')){repo_filter_clause} + WHERE r.target_symbol IN ({callers_placeholders}) + AND (r.source_symbol_id IS NULL OR r.source_symbol_id NOT IN ({ex_id_placeholders})) + AND r.source_symbol NOT IN ({ex_name_placeholders}) + AND r.relationship_type != 'IMPORTS'{repo_filter_clause} GROUP BY r.source_filepath, r.source_symbol, r.relationship_type ORDER BY r.source_filepath, MIN(r.line_number) ASC """ - full_sym = sym["full_symbol"] or "" - caller_params = [sym["name"], full_sym, full_sym] + repo_params + caller_params = target_names_list + exclude_source_ids + exclude_source_names + repo_params callers = conn.execute(callers_query, caller_params).fetchall() # 2. Fetch outgoing dependencies (callees): - # Matches calls originating from this symbol. - # Resolves target_filepath and target_symbol_id from ast_symbols so links work across usages! - # Groups repeated calls to the same target and sorts resolved codebase targets to the top. + # Matches calls originating from this symbol or any of its member methods. + callee_source_ids = [sym["id"]] + [m["id"] for m in member_rows] + callee_source_names = list(target_names) + src_id_placeholders = ",".join(["?"] * len(callee_source_ids)) + src_name_placeholders = ",".join(["?"] * len(callee_source_names)) + callees_query = f""" SELECT MIN(r.id) as id, @@ -274,17 +308,19 @@ def get_symbol_impact(repo: str, symbol_id: int) -> Optional[Dict[str, Any]]: AND (r.repo = tgt_sym.repo OR ? = '__all__') AND tgt_sym.rn = 1 ) - WHERE (r.source_symbol_id = ? OR (r.source_symbol = ? AND (r.source_filepath = ? OR r.source_filepath LIKE ?))) + WHERE (r.source_symbol_id IN ({src_id_placeholders}) + OR (r.source_symbol IN ({src_name_placeholders}) AND (r.source_filepath = ? OR r.source_filepath = ? OR r.source_filepath LIKE ?))) AND r.relationship_type != 'IMPORTS'{repo_filter_clause} GROUP BY r.target_symbol, r.relationship_type ORDER BY CASE WHEN tgt_sym.filepath IS NOT NULL THEN 0 ELSE 1 END ASC, MIN(r.line_number) ASC """ - callee_params = [target_repo, sym["id"], sym["name"], sym["filepath"], f"%/{_clean_path(sym['filepath'])}"] + repo_params + callee_params = [target_repo] + callee_source_ids + callee_source_names + [sym["filepath"], f"/{clean_sym_fp}", f"%/{clean_sym_fp}"] + repo_params callees = conn.execute(callees_query, callee_params).fetchall() # 3. Fetch imports: + # Imports in codebases are module/file-level. Include both symbol-specific imports (if any) and containing file-level imports. imports_query = f""" SELECT MIN(r.id) as id, @@ -293,12 +329,18 @@ def get_symbol_impact(repo: str, symbol_id: int) -> Optional[Dict[str, Any]]: COUNT(*) as import_count, GROUP_CONCAT(DISTINCT r.line_number) as all_lines FROM ast_relationships r - WHERE (r.source_symbol_id = ? OR (r.source_symbol = ? AND (r.source_filepath = ? OR r.source_filepath LIKE ?))) + WHERE (r.source_symbol_id = ? + OR (r.source_symbol = ? AND (r.source_filepath = ? OR r.source_filepath = ? OR r.source_filepath LIKE ?)) + OR (r.source_filepath = ? OR r.source_filepath = ? OR r.source_filepath LIKE ?)) AND r.relationship_type = 'IMPORTS'{repo_filter_clause} GROUP BY r.target_symbol ORDER BY MIN(r.line_number) ASC """ - import_params = [sym["id"], sym["name"], sym["filepath"], f"%/{_clean_path(sym['filepath'])}"] + repo_params + import_params = [ + sym["id"], + sym["name"], sym["filepath"], f"/{clean_sym_fp}", f"%/{clean_sym_fp}", + sym["filepath"], f"/{clean_sym_fp}", f"%/{clean_sym_fp}" + ] + repo_params imports = conn.execute(imports_query, import_params).fetchall() # 4. Fetch API route mapping: diff --git a/tests/backend/test_chunker_languages.py b/tests/backend/test_chunker_languages.py index b4526f2..cb0279a 100644 --- a/tests/backend/test_chunker_languages.py +++ b/tests/backend/test_chunker_languages.py @@ -258,3 +258,38 @@ def test_markdown_chunking_with_nested_headings_and_empty(): assert "Section 1" in headings assert "Subsection 1.1" in headings assert "Section 2" in headings + + +def test_call_extraction_constructors_and_generics(): + code = """using System; + +namespace App +{ + public class Startup + { + public void Configure() + { + var s = new MyService(); + app.UseMiddleware(); + } + } +} +""" + res = extract_symbols_and_chunks(code, "Startup.cs", repo="test") + targets = [r.target_symbol for r in res.relationships if r.relationship_type == "CALLS"] + assert "MyService" in targets + assert "UseMiddleware" in targets + assert "new" not in targets + assert "var" not in targets + + +def test_toplevel_call_source_symbol_preservation(): + code = """import math +print(math.sqrt(16)) +run_task() +""" + res = extract_symbols_and_chunks(code, "main.py", repo="test") + sources = [r.source_symbol for r in res.relationships if r.relationship_type == "CALLS"] + assert all(src == "main.py" for src in sources) + assert "py" not in sources + diff --git a/tests/backend/test_navigator_service.py b/tests/backend/test_navigator_service.py index 6c54f3f..8ec5942 100644 --- a/tests/backend/test_navigator_service.py +++ b/tests/backend/test_navigator_service.py @@ -288,6 +288,53 @@ def test_no_outgoing_calls_in_callers(test_db): assert len(res["imports"]) == 0 +def test_class_symbol_impact_aggregation(test_db): + # Seed a class with methods, callers, callees, and file-level imports + conn = sqlite3.connect(test_db) + conn.executescript(""" + INSERT INTO indexed_files (filepath, repo, doc_type, language) + VALUES ('app/services/user_service.py', 'test-repo', 'code', 'python'); + + INSERT INTO ast_symbols (id, repo, filepath, name, full_symbol, kind, start_line, end_line, signature, language) + VALUES (10, 'test-repo', 'app/services/user_service.py', 'UserService', 'UserService', 'class_definition', 1, 50, 'class UserService:', 'python'), + (11, 'test-repo', 'app/services/user_service.py', 'get_user', 'UserService.get_user', 'function_definition', 5, 20, 'def get_user(self, id):', 'python'), + (12, 'test-repo', 'app/services/user_service.py', 'save_user', 'UserService.save_user', 'function_definition', 22, 45, 'def save_user(self, user):', 'python'); + + -- File-level import + INSERT INTO ast_relationships (id, repo, source_symbol_id, source_filepath, source_symbol, target_symbol, relationship_type, line_number) + VALUES (20, 'test-repo', NULL, 'app/services/user_service.py', 'user_service.py', 'typing', 'IMPORTS', 1); + + -- External caller calling UserService.get_user + INSERT INTO ast_relationships (id, repo, source_symbol_id, source_filepath, source_symbol, target_symbol, relationship_type, line_number) + VALUES (21, 'test-repo', NULL, 'app/api/endpoints.py', 'handle_request', 'get_user', 'CALLS', 10); + + -- Method save_user calling external function + INSERT INTO ast_relationships (id, repo, source_symbol_id, source_filepath, source_symbol, target_symbol, relationship_type, line_number) + VALUES (22, 'test-repo', 12, 'app/services/user_service.py', 'save_user', 'db_commit', 'CALLS', 30); + """) + conn.commit() + conn.close() + + # Inspect the class UserService (id=10) + res = get_symbol_impact("test-repo", 10) + assert res is not None + assert res["symbol"]["name"] == "UserService" + + # Aggregated callers should find handle_request calling get_user + assert len(res["callers"]) == 1 + assert res["callers"][0]["source_symbol"] == "handle_request" + assert res["callers"][0]["target_symbol"] == "get_user" + + # Aggregated callees should find db_commit called from save_user + assert len(res["callees"]) == 1 + assert res["callees"][0]["target_symbol"] == "db_commit" + + # File-level imports should be available for the class + assert len(res["imports"]) == 1 + assert res["imports"][0]["target_symbol"] == "typing" + + + def test_real_codebase_symbol_extraction_and_navigation(tmp_path): from app.services.chunking import extract_symbols_and_chunks From 698754653b2a5b90597c9d7648b0baba1105035b Mon Sep 17 00:00:00 2001 From: Antigravity Agent Date: Wed, 30 Sep 2026 15:38:52 -0500 Subject: [PATCH 3/3] fix(navigator): declutter omni-search, add match highlighting and full symbol display --- app/services/navigator.py | 16 +- .../navigator/NavigatorOmniSearch.tsx | 116 +++++++++++++- frontend/src/components/navigator/types.ts | 1 + frontend/src/styles/navigator.css | 143 +++++++++++++----- .../src/tests/NavigatorOmniSearch.test.tsx | 58 +++++++ 5 files changed, 284 insertions(+), 50 deletions(-) diff --git a/app/services/navigator.py b/app/services/navigator.py index bb9c6b1..9972c00 100644 --- a/app/services/navigator.py +++ b/app/services/navigator.py @@ -386,23 +386,28 @@ def get_omni_search(repo: str, query: str, limit: int = 25) -> Dict[str, Any]: sym_sql = f""" SELECT id, repo, filepath, name, full_symbol, kind, start_line, end_line, signature FROM ast_symbols - WHERE (name LIKE ? OR full_symbol LIKE ?){repo_clause} + WHERE (name LIKE ? OR full_symbol LIKE ? OR signature LIKE ?){repo_clause} LIMIT ? """ - sym_params = [like_q, like_q] + repo_params + [limit] + sym_params = [like_q, like_q, like_q] + repo_params + [limit] for row in conn.execute(sym_sql, sym_params).fetchall(): sym_name = row["name"] or "" + full_sym = row["full_symbol"] or sym_name sym_lower = sym_name.lower() + full_lower = full_sym.lower() - if sym_lower == lower_q: + if sym_lower == lower_q or full_lower == lower_q: score = 0.99 label = "99% AST exact match" - elif sym_lower.startswith(lower_q): + elif sym_lower.startswith(lower_q) or full_lower.startswith(lower_q): score = 0.94 label = "94% AST prefix match" - else: + elif lower_q in sym_lower or lower_q in full_lower: score = 0.88 label = "88% AST symbol match" + else: + score = 0.82 + label = "82% Signature match" preview = row["signature"] or f"{row['kind']} {sym_name}" matches.append({ @@ -410,6 +415,7 @@ def get_omni_search(repo: str, query: str, limit: int = 25) -> Dict[str, Any]: "type": "symbol", "symbol_id": row["id"], "name": sym_name, + "full_symbol": full_sym, "kind": row["kind"], "filepath": _clean_path(row["filepath"]), "repo": row["repo"], diff --git a/frontend/src/components/navigator/NavigatorOmniSearch.tsx b/frontend/src/components/navigator/NavigatorOmniSearch.tsx index 009b5ea..117e04d 100644 --- a/frontend/src/components/navigator/NavigatorOmniSearch.tsx +++ b/frontend/src/components/navigator/NavigatorOmniSearch.tsx @@ -24,6 +24,59 @@ export function getMatchBadgeClass(type: OmniSearchMatchKind): string { } } +export function HighlightMatch({ text, query }: { text: string; query: string }) { + if (!query || !query.trim() || !text) { + return <>{text}; + } + const trimmed = query.trim(); + const escaped = trimmed.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const regex = new RegExp(`(${escaped})`, 'gi'); + const parts = text.split(regex); + if (parts.length <= 1) { + return <>{text}; + } + return ( + <> + {text} + + + ); +} + +export function formatKind(kind?: string): string { + if (!kind) return ''; + const k = kind.toLowerCase(); + if (k.includes('method')) return 'method'; + if (k.includes('func')) return 'func'; + if (k.includes('class')) return 'class'; + if (k.includes('interface')) return 'interface'; + if (k.includes('struct')) return 'struct'; + if (k.includes('type')) return 'type'; + if (k.includes('enum')) return 'enum'; + if (k.includes('property') || k.includes('field')) return 'prop'; + if (k === 'route') return 'route'; + if (k === 'file') return 'file'; + if (k === 'code') return 'code'; + return k.replace(/_declaration|_definition|_specifier/g, ''); +} + +export function formatDisplayPath(path: string): string { + if (path.includes('://')) { + return path.split('://')[1]; + } + return path; +} + export const NavigatorOmniSearch: React.FC = ({ repo, onSelectResult, @@ -39,6 +92,19 @@ export const NavigatorOmniSearch: React.FC = ({ const inputRef = useRef(null); const abortControllerRef = useRef(null); + // Global Ctrl+K / Cmd+K listener + useEffect(() => { + const handleGlobalKey = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') { + e.preventDefault(); + inputRef.current?.focus(); + inputRef.current?.select(); + } + }; + window.addEventListener('keydown', handleGlobalKey); + return () => window.removeEventListener('keydown', handleGlobalKey); + }, []); + // Debounced search useEffect(() => { const trimmed = query.trim(); @@ -172,6 +238,12 @@ export const NavigatorOmniSearch: React.FC = ({ aria-expanded={isOpen} /> + {!query && ( + + )} + {loading && ( = ({ )}
- {/* Floating Absolute Overlay */} + {/* Floating Centered Overlay */} {isOpen && (
= ({ const isSelected = index === activeIndex; const badgeClass = getMatchBadgeClass(item.type); + // Determine container prefix if full_symbol has parent + let containerPrefix = ''; + let symbolName = item.name; + if (item.full_symbol && item.full_symbol.includes('.') && item.type === 'symbol') { + const lastDot = item.full_symbol.lastIndexOf('.'); + containerPrefix = item.full_symbol.substring(0, lastDot + 1); + symbolName = item.full_symbol.substring(lastDot + 1); + } + return (
= ({ onMouseEnter={() => setActiveIndex(index)} role="option" aria-selected={isSelected} + title={item.full_symbol || item.name} >
{item.type.toUpperCase()} - {item.name} + {containerPrefix && ( + + + + )} + + + {item.kind && item.kind !== item.type && ( - ({item.kind}) + + {formatKind(item.kind)} + )}
- {item.score_label} + + {item.score_label} +
- - {item.filepath} - {item.start_line > 0 && `:${item.start_line}`} + + + {item.start_line > 0 && ( + :{item.start_line} + )} {item.preview && item.preview !== item.name && ( - {item.preview} + <> + + + + + )}
diff --git a/frontend/src/components/navigator/types.ts b/frontend/src/components/navigator/types.ts index 02c1e9a..387acb2 100644 --- a/frontend/src/components/navigator/types.ts +++ b/frontend/src/components/navigator/types.ts @@ -126,6 +126,7 @@ export interface OmniSearchResultItem { id: string; type: OmniSearchMatchKind; name: string; + full_symbol?: string; kind?: string; filepath: string; repo?: string; diff --git a/frontend/src/styles/navigator.css b/frontend/src/styles/navigator.css index 4b1444d..333a368 100644 --- a/frontend/src/styles/navigator.css +++ b/frontend/src/styles/navigator.css @@ -393,7 +393,7 @@ .nav-toolbar-center { flex: 1; - max-width: 400px; + max-width: 580px; min-width: 200px; } @@ -531,30 +531,55 @@ to { transform: rotate(360deg); } } -/* Floating Dropdown - Absolute overlay with high z-index */ +.nav-omni-kbd { + font-size: 0.65rem; + font-family: var(--font-mono, monospace); + background: rgba(255, 255, 255, 0.08); + color: var(--text-muted, #94a3b8); + padding: 2px 6px; + border-radius: 4px; + border: 1px solid rgba(255, 255, 255, 0.12); + letter-spacing: 0.05em; + flex-shrink: 0; + margin-left: 6px; + user-select: none; +} + +/* Floating Dropdown - Spacious Centered Command Palette Overlay */ .nav-omni-dropdown { position: absolute; - top: calc(100% + 4px); - left: 0; - right: 0; - z-index: 50; + top: calc(100% + 6px); + left: 50%; + transform: translateX(-50%); + width: max(100%, 640px); + max-width: min(92vw, 760px); + z-index: 100; 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; + box-shadow: 0 20px 45px rgba(0, 0, 0, 0.75), 0 8px 20px rgba(0, 0, 0, 0.5); + max-height: 480px; display: flex; flex-direction: column; overflow: hidden; - backdrop-filter: blur(8px); + backdrop-filter: blur(12px); +} + +@media (max-width: 768px) { + .nav-omni-dropdown { + left: 0; + transform: none; + width: 100%; + max-width: 100%; + } } .nav-omni-dropdown-header { display: flex; align-items: center; justify-content: space-between; - padding: 8px 12px; - background: rgba(0, 0, 0, 0.3); + padding: 8px 14px; + background: rgba(0, 0, 0, 0.35); border-bottom: 1px solid var(--border-card, #15474d); font-size: 0.72rem; font-weight: 600; @@ -570,54 +595,56 @@ } .nav-omni-empty { - padding: 16px; + padding: 20px 16px; text-align: center; - font-size: 0.8rem; + font-size: 0.82rem; color: var(--text-muted, #94a3b8); } .nav-omni-list { overflow-y: auto; - padding: 4px; + padding: 6px; } .nav-omni-item { display: flex; flex-direction: column; - padding: 8px 10px; + padding: 9px 12px; border-radius: 6px; cursor: pointer; - transition: background-color 0.12s ease; - margin-bottom: 2px; + transition: all 0.15s ease; + margin-bottom: 3px; border: 1px solid transparent; + gap: 5px; } .nav-omni-item:hover, .nav-omni-item.active { - background: rgba(8, 145, 178, 0.15); - border-color: rgba(8, 145, 178, 0.35); + background: rgba(14, 165, 233, 0.12); + border-color: rgba(56, 189, 248, 0.35); } .nav-omni-item-top { display: flex; align-items: center; justify-content: space-between; - margin-bottom: 4px; - gap: 8px; + gap: 12px; } .nav-omni-item-title-group { display: flex; align-items: center; - gap: 6px; + gap: 8px; + flex: 1; + min-width: 0; overflow: hidden; } .nav-omni-badge { font-size: 0.65rem; font-weight: 700; - padding: 1px 5px; - border-radius: 3px; + padding: 2px 6px; + border-radius: 4px; text-transform: uppercase; letter-spacing: 0.04em; font-family: var(--font-mono, monospace); @@ -659,53 +686,95 @@ color: #cbd5e1; } -.nav-omni-item-name { +.nav-omni-item-container { font-size: 0.82rem; + color: #94a3b8; + font-family: var(--font-mono, monospace); + flex-shrink: 0; + white-space: nowrap; +} + +.nav-omni-item-name { + font-size: 0.85rem; font-weight: 600; - color: var(--text, #f8fafc); + color: #f8fafc; font-family: var(--font-mono, monospace); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + min-width: 0; } .nav-omni-item-kind { - font-size: 0.72rem; - color: var(--text-muted, #94a3b8); + font-size: 0.65rem; + font-weight: 500; + color: #94a3b8; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.09); + padding: 1px 5px; + border-radius: 4px; + text-transform: lowercase; + flex-shrink: 0; } .nav-omni-score-pill { - font-size: 0.68rem; + font-size: 0.65rem; font-weight: 600; - padding: 1px 7px; - border-radius: 10px; - background: rgba(16, 185, 129, 0.15); + padding: 2px 7px; + border-radius: 6px; + background: rgba(16, 185, 129, 0.12); color: #34d399; - border: 1px solid rgba(52, 211, 153, 0.3); + border: 1px solid rgba(52, 211, 153, 0.25); white-space: nowrap; flex-shrink: 0; } +.nav-omni-match { + background: rgba(56, 189, 248, 0.25); + color: #38bdf8; + font-weight: 700; + border-radius: 2px; + padding: 0 1px; +} + .nav-omni-item-bottom { display: flex; align-items: center; gap: 8px; - font-size: 0.72rem; - color: var(--text-muted, #94a3b8); + font-size: 0.73rem; + color: #94a3b8; font-family: var(--font-mono, monospace); overflow: hidden; + line-height: 1.3; } .nav-omni-item-path { - color: var(--primary, #38bdf8); + color: #38bdf8; + flex-shrink: 0; + max-width: 48%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.nav-omni-line-badge { + color: #64748b; + font-weight: 600; +} + +.nav-omni-sep { + color: #475569; + font-size: 0.65rem; flex-shrink: 0; } .nav-omni-item-preview { - color: var(--text-muted, #94a3b8); + color: #cbd5e1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + flex: 1; + min-width: 0; } /* ========================================================================== diff --git a/frontend/src/tests/NavigatorOmniSearch.test.tsx b/frontend/src/tests/NavigatorOmniSearch.test.tsx index 992bebe..36c3251 100644 --- a/frontend/src/tests/NavigatorOmniSearch.test.tsx +++ b/frontend/src/tests/NavigatorOmniSearch.test.tsx @@ -221,4 +221,62 @@ describe('NavigatorOmniSearch Component', () => { expect(onSelect).toHaveBeenCalledWith(mockMatches[0]); }); + + it('renders container prefix and highlights query match in symbol and path', async () => { + const symbolWithContainer: OmniSearchResultItem = { + id: 'sym_nested', + type: 'symbol', + symbol_id: 15, + name: 'GetAllProviders', + full_symbol: 'ProvidersController.GetAllProviders', + kind: 'method_declaration', + filepath: 'mcp-router-code://Components/Providers/ProvidersController.cs', + repo: 'test-repo', + start_line: 25, + end_line: 60, + score: 0.95, + score_label: '95% Prefix match', + preview: 'public async Task GetAllProviders()', + }; + + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ + query: 'Providers', + repo: 'test-repo', + total_matches: 1, + matches: [symbolWithContainer], + }), + } as Response); + + render(); + + const input = screen.getByPlaceholderText(/search files, symbols, routes, or code text/i); + fireEvent.change(input, { target: { value: 'Providers' } }); + + await screen.findByText('GetAllProviders'); + + // Container prefix should be displayed + expect(screen.getByText('ProvidersController.')).toBeInTheDocument(); + + // Kind should be cleaned from method_declaration to method + expect(screen.getByText('method')).toBeInTheDocument(); + + // Path should be cleaned of protocol scheme + expect(screen.getByText('Components/Providers/ProvidersController.cs')).toBeInTheDocument(); + + // Query match marks should exist + const marks = document.querySelectorAll('mark.nav-omni-match'); + expect(marks.length).toBeGreaterThan(0); + }); + + it('focuses search input when pressing Ctrl+K', () => { + render(); + const input = screen.getByPlaceholderText(/search files, symbols, routes, or code text/i); + + expect(document.activeElement).not.toBe(input); + fireEvent.keyDown(window, { key: 'k', ctrlKey: true }); + expect(document.activeElement).toBe(input); + }); }); +