srcodex 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. srcodex/__init__.py +0 -0
  2. srcodex/backend/__init__.py +0 -0
  3. srcodex/backend/chat.py +79 -0
  4. srcodex/backend/main.py +98 -0
  5. srcodex/backend/services/__init__.py +0 -0
  6. srcodex/backend/services/claude_service.py +754 -0
  7. srcodex/backend/services/config_loader.py +113 -0
  8. srcodex/backend/services/file_access_tools.py +279 -0
  9. srcodex/backend/services/file_tree.py +480 -0
  10. srcodex/backend/services/graph_tools.py +874 -0
  11. srcodex/backend/services/logger_setup.py +91 -0
  12. srcodex/backend/services/session_manager.py +81 -0
  13. srcodex/backend/services/status_tracker.py +91 -0
  14. srcodex/cli.py +255 -0
  15. srcodex/core/__init__.py +0 -0
  16. srcodex/core/config.py +113 -0
  17. srcodex/core/logger.py +23 -0
  18. srcodex/indexer/__init__.py +0 -0
  19. srcodex/indexer/cscope_client.py +183 -0
  20. srcodex/indexer/ctags_compat.py +223 -0
  21. srcodex/indexer/ctags_parser.py +456 -0
  22. srcodex/indexer/explorer.py +135 -0
  23. srcodex/indexer/field_access_analyzer.py +436 -0
  24. srcodex/indexer/indexer.py +664 -0
  25. srcodex/indexer/reference_ingestor.py +293 -0
  26. srcodex/indexer/reference_resolver.py +544 -0
  27. srcodex/tui/__init__.py +0 -0
  28. srcodex/tui/app.py +103 -0
  29. srcodex/tui/app.tcss +24 -0
  30. srcodex/tui/components/__init__.py +0 -0
  31. srcodex/tui/components/bars/__init__.py +0 -0
  32. srcodex/tui/components/bars/chat_header.py +48 -0
  33. srcodex/tui/components/bars/code_tab_bar.py +157 -0
  34. srcodex/tui/components/bars/footer_bar.py +128 -0
  35. srcodex/tui/components/bars/left_tab.py +54 -0
  36. srcodex/tui/components/logger.py +57 -0
  37. srcodex/tui/components/panels/__init__.py +0 -0
  38. srcodex/tui/components/panels/chat_panel.py +523 -0
  39. srcodex/tui/components/panels/code_panel.py +229 -0
  40. srcodex/tui/components/panels/side_panel.py +128 -0
  41. srcodex/tui/components/views/__init__.py +0 -0
  42. srcodex/tui/components/views/explorer_view.py +20 -0
  43. srcodex/tui/components/views/search_view.py +148 -0
  44. srcodex/tui/components/widgets/__init__.py +0 -0
  45. srcodex/tui/components/widgets/file_browser.py +16 -0
  46. srcodex/tui/components/widgets/find_box.py +85 -0
  47. srcodex-0.2.0.dist-info/METADATA +170 -0
  48. srcodex-0.2.0.dist-info/RECORD +52 -0
  49. srcodex-0.2.0.dist-info/WHEEL +5 -0
  50. srcodex-0.2.0.dist-info/entry_points.txt +2 -0
  51. srcodex-0.2.0.dist-info/licenses/LICENSE +21 -0
  52. srcodex-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,480 @@
1
+ """
2
+ File Tree Service - Left Panel Navigation
3
+
4
+ Provides hierarchical file tree structure from the database for UI rendering.
5
+ Queries the 'files' table to build directory/file trees.
6
+ """
7
+
8
+ import sqlite3
9
+ from typing import Dict, List, Optional
10
+ from pathlib import Path
11
+
12
+
13
+ class FileTreeService:
14
+ def __init__(self, db_path: str):
15
+ self.db_path = db_path
16
+
17
+ def _get_connection(self) -> sqlite3.Connection:
18
+ """Get database connection"""
19
+ conn = sqlite3.connect(self.db_path)
20
+ conn.row_factory = sqlite3.Row
21
+ return conn
22
+
23
+ def get_root(self) -> Dict:
24
+ """
25
+ Get metadata about the project's logical root directory.
26
+
27
+ Root = the directory that was indexed (anchor for all relative paths).
28
+
29
+ Returns metadata only
30
+ - Project ID (derived from db filename)
31
+ - Display name
32
+ - Physical path (where source was indexed from)
33
+ - Logical path (always "" for root)
34
+ - Statistics (total files, symbols, children count)
35
+ """
36
+ with self._get_connection() as conn:
37
+ cursor = conn.cursor()
38
+
39
+ # Get project metadata from metadata table
40
+ cursor.execute("SELECT key, value FROM metadata")
41
+ metadata = {row['key']: row['value'] for row in cursor.fetchall()}
42
+
43
+ # Count immediate children (top-level directories/files)
44
+ cursor.execute("""
45
+ SELECT COUNT(*) as count FROM (
46
+ SELECT DISTINCT
47
+ CASE
48
+ WHEN instr(path, '/') > 0
49
+ THEN substr(path, 1, instr(path, '/') - 1)
50
+ ELSE path
51
+ END as top_level
52
+ FROM files
53
+ )
54
+ """)
55
+ children_count = cursor.fetchone()['count']
56
+ project_id = Path(self.db_path).stem
57
+ physical_path = metadata.get('source_root', '')
58
+ display_name = Path(physical_path).name.upper() if physical_path else project_id.upper()
59
+
60
+ return {
61
+ "id": project_id,
62
+ "path": "", # Logical root is always empty string
63
+ "physical_path": physical_path,
64
+ "is_dir": True,
65
+ "children_count": children_count,
66
+ "total_files": int(metadata.get('total_files', 0)),
67
+ "total_symbols": int(metadata.get('total_symbols', 0)),
68
+ "indexed_at": metadata.get('indexed_at', '')
69
+ }
70
+
71
+ def get_children(self, path: str = "") -> List[Dict]:
72
+ """
73
+ Get immediate children of a directory.
74
+
75
+ Input contract:
76
+ - path == "" → root children
77
+
78
+ Output contract:
79
+ - Returns immediate children only (not recursive)
80
+ - Each child includes:
81
+ * name: basename
82
+ * path: full relative path (dirs end with /)
83
+ * kind: "file" or "dir"
84
+ * children_count: (dirs only) number of immediate children
85
+ * symbol_count: total symbols (files: direct, dirs: recursive)
86
+ * size: (files only) file size in bytes
87
+
88
+ Algorithm:
89
+ 1. Get all files matching prefix
90
+ 2. Strip prefix, take next segment up to /
91
+ 3. If segment has / after it → dir, else → file
92
+ """
93
+
94
+ if path and not path.endswith('/'):
95
+ path = path + '/'
96
+
97
+ with self._get_connection() as conn:
98
+ cursor = conn.cursor()
99
+
100
+ if path:
101
+
102
+ pattern = f"{path}%"
103
+ cursor.execute("""
104
+ SELECT path, size FROM files
105
+ WHERE path LIKE ?
106
+ ORDER BY path
107
+ """, (pattern,))
108
+ else:
109
+ # Root: get all files
110
+ cursor.execute("SELECT path, size FROM files ORDER BY path")
111
+
112
+ all_files = cursor.fetchall()
113
+
114
+ # Build immediate children
115
+ children = {} # Use dict to dedupe (key = child_path)
116
+
117
+ for file_row in all_files:
118
+ file_path = file_row['path']
119
+
120
+ # Strip the parent path prefix
121
+ if path:
122
+ if not file_path.startswith(path):
123
+ continue
124
+ relative = file_path[len(path):]
125
+ else:
126
+ relative = file_path
127
+
128
+ # Find next segment (up to first /)
129
+ slash_pos = relative.find('/')
130
+
131
+ if slash_pos == -1:
132
+ # No slash → immediate file child
133
+ child_name = relative
134
+ child_path = file_path
135
+
136
+ if child_path not in children:
137
+ children[child_path] = {
138
+ "name": child_name,
139
+ "path": child_path,
140
+ "kind": "file",
141
+ "size": file_row['size'],
142
+ "symbol_count": 0
143
+ }
144
+ else:
145
+ # Has slash → directory child
146
+ child_name = relative[:slash_pos]
147
+ child_path = path + child_name + '/'
148
+
149
+ if child_path not in children:
150
+ children[child_path] = {
151
+ "name": child_name,
152
+ "path": child_path,
153
+ "kind": "dir",
154
+ "children_count": 0,
155
+ "symbol_count": 0
156
+ }
157
+
158
+ # Now populate counts
159
+ for child_path, child_data in children.items():
160
+ if child_data["kind"] == "file":
161
+ # Count symbols in this file
162
+ cursor.execute("""
163
+ SELECT COUNT(*) as count FROM symbols WHERE file_path = ?
164
+ """, (child_path,))
165
+ child_data["symbol_count"] = cursor.fetchone()['count']
166
+
167
+ else: # dir
168
+ # Count symbols recursively under this directory
169
+ cursor.execute("""
170
+ SELECT COUNT(*) as count FROM symbols
171
+ WHERE file_path LIKE ?
172
+ """, (f"{child_path}%",))
173
+ child_data["symbol_count"] = cursor.fetchone()['count']
174
+
175
+ # Count immediate children of this directory
176
+ child_children = self._count_immediate_children(cursor, child_path)
177
+ child_data["children_count"] = child_children
178
+
179
+ # Convert to list and sort: directories first, then alphabetically
180
+ result = list(children.values())
181
+ result.sort(key=lambda x: (x["kind"] == "file", x["name"]))
182
+
183
+ return result
184
+
185
+ def _count_immediate_children(self, cursor, parent_path: str) -> int:
186
+ """Count immediate children of a directory (helper for get_children)"""
187
+ cursor.execute("""
188
+ SELECT path FROM files WHERE path LIKE ?
189
+ """, (f"{parent_path}%",))
190
+
191
+ files = cursor.fetchall()
192
+ children = set()
193
+
194
+ for row in files:
195
+ file_path = row['path']
196
+ relative = file_path[len(parent_path):]
197
+
198
+ slash_pos = relative.find('/')
199
+ if slash_pos == -1:
200
+ children.add(relative)
201
+ else:
202
+ children.add(relative[:slash_pos])
203
+
204
+ return len(children)
205
+
206
+ def search_file(self, query: str) -> List[Dict]:
207
+ """
208
+ Search for files by name or path (like Ctrl+P in VSCode).
209
+
210
+ Fuzzy matching:
211
+ - "pow" → matches "power.c", "power.h", "mp1/src/app/power.c"
212
+ - "app/pow" → matches "mp1/src/app/power.c"
213
+ - Case-insensitive
214
+
215
+ Returns ranked results (best matches first):
216
+ - Exact filename match (highest priority)
217
+ - Filename contains query
218
+ - Path contains query
219
+ - Shorter paths ranked higher
220
+
221
+ Each result includes:
222
+ - name: filename only
223
+ - path: full relative path
224
+ - size: file size
225
+ - symbol_count: number of symbols
226
+ """
227
+ if not query:
228
+ return []
229
+
230
+ with self._get_connection() as conn:
231
+ cursor = conn.cursor()
232
+
233
+ # Search pattern (case-insensitive)
234
+ pattern = f"%{query}%"
235
+
236
+ cursor.execute("""
237
+ SELECT
238
+ f.path,
239
+ f.size,
240
+ COUNT(s.id) as symbol_count
241
+ FROM files f
242
+ LEFT JOIN symbols s ON f.path = s.file_path
243
+ WHERE f.path LIKE ? COLLATE NOCASE
244
+ GROUP BY f.path
245
+ ORDER BY LENGTH(f.path), f.path
246
+ LIMIT 100
247
+ """, (pattern,))
248
+
249
+ results = []
250
+ for row in cursor.fetchall():
251
+ file_path = row['path']
252
+ filename = Path(file_path).name
253
+
254
+ # Calculate match score for ranking
255
+ score = self._calculate_match_score(file_path, filename, query.lower())
256
+
257
+ results.append({
258
+ "name": filename,
259
+ "path": file_path,
260
+ "size": row['size'],
261
+ "symbol_count": row['symbol_count'],
262
+ "score": score
263
+ })
264
+
265
+ # Sort by score (higher = better match)
266
+ results.sort(key=lambda x: (-x['score'], len(x['path']), x['path']))
267
+
268
+ # Remove score from output
269
+ for r in results:
270
+ del r['score']
271
+
272
+ return results
273
+
274
+ def _calculate_match_score(self, path: str, filename: str, query: str) -> int:
275
+ """
276
+ Calculate match score for ranking search results.
277
+ Higher score = better match.
278
+ """
279
+ path_lower = path.lower()
280
+ filename_lower = filename.lower()
281
+
282
+ score = 0
283
+
284
+ # Exact filename match (highest priority)
285
+ if filename_lower == query:
286
+ score += 1000
287
+
288
+ # Filename starts with query
289
+ if filename_lower.startswith(query):
290
+ score += 500
291
+
292
+ # Filename contains query
293
+ if query in filename_lower:
294
+ score += 100
295
+
296
+ # Path contains query (lower priority)
297
+ if query in path_lower:
298
+ score += 10
299
+
300
+ # Bonus: shorter paths are better
301
+ score -= len(path) // 10
302
+
303
+ return score
304
+
305
+ def search_symbol_global(self, query: str) -> List[Dict]:
306
+ """
307
+ Global search across everything (like Ctrl+Shift+F in VSCode).
308
+
309
+ Searches:
310
+ - Symbol names (functions, structs, macros, variables, enums, etc.)
311
+ - File paths
312
+ - Signatures
313
+
314
+ Uses FTS5 for fast full-text search with ranking.
315
+
316
+ Returns ranked results with:
317
+ - id: symbol ID
318
+ - name: symbol name
319
+ - type: symbol type (function, struct, macro, etc.)
320
+ - file_path: where it's defined
321
+ - line_number: line number
322
+ - signature: function signature (if applicable)
323
+ - match_context: what matched (name, file, signature)
324
+ """
325
+ if not query:
326
+ return []
327
+
328
+ with self._get_connection() as conn:
329
+ cursor = conn.cursor()
330
+
331
+ # Use FTS5 for fast full-text search across name, signature, and file_path
332
+ # FTS5 automatically ranks results by relevance
333
+ # Escape FTS5 special characters: " - ( ) * to prevent syntax errors
334
+ fts_query = self._escape_fts5_query(query)
335
+
336
+ cursor.execute("""
337
+ SELECT
338
+ s.id,
339
+ s.name,
340
+ s.type,
341
+ s.file_path,
342
+ s.line_number,
343
+ s.signature,
344
+ s.scope_kind,
345
+ s.scope_name
346
+ FROM symbols_fts sf
347
+ JOIN symbols s ON sf.rowid = s.id
348
+ WHERE symbols_fts MATCH ?
349
+ ORDER BY rank
350
+ LIMIT 100
351
+ """, (fts_query,))
352
+
353
+ results = []
354
+ for row in cursor.fetchall():
355
+ # Determine what matched (for context)
356
+ match_context = self._get_match_context(
357
+ row['name'],
358
+ row['file_path'],
359
+ row['signature'],
360
+ query.lower()
361
+ )
362
+
363
+ result = {
364
+ "id": row['id'],
365
+ "name": row['name'],
366
+ "type": row['type'],
367
+ "file_path": row['file_path'],
368
+ "line_number": row['line_number'],
369
+ "signature": row['signature'] or "",
370
+ "scope": self._format_scope(row['scope_kind'], row['scope_name']),
371
+ "match_context": match_context
372
+ }
373
+
374
+ results.append(result)
375
+
376
+ return results
377
+
378
+ def _get_match_context(self, name: str, file_path: str, signature: str, query: str) -> str:
379
+ """Determine what part of the symbol matched the query"""
380
+ name_lower = name.lower()
381
+ file_lower = file_path.lower()
382
+ sig_lower = (signature or "").lower()
383
+
384
+ if query in name_lower:
385
+ return "name"
386
+ elif signature and query in sig_lower:
387
+ return "signature"
388
+ elif query in file_lower:
389
+ return "file"
390
+ else:
391
+ return "fuzzy"
392
+
393
+ def _format_scope(self, scope_kind: str, scope_name: str) -> str:
394
+ """Format scope for display (e.g., 'struct PowerState')"""
395
+ if scope_kind and scope_name:
396
+ return f"{scope_kind} {scope_name}"
397
+ elif scope_name:
398
+ return scope_name
399
+ else:
400
+ return ""
401
+
402
+ def _escape_fts5_query(self, query: str) -> str:
403
+ """
404
+ Escape FTS5 special characters to prevent syntax errors.
405
+ FTS5 special chars: " - ( ) *
406
+ We wrap the query in quotes to treat it as a phrase.
407
+ """
408
+ # Remove/escape problematic characters
409
+ escaped = query.replace('"', '""') # Escape double quotes
410
+ # Wrap in quotes to treat as literal phrase
411
+ return f'"{escaped}"'
412
+
413
+ def search_symbol_infile(self, query: str, file_path: str) -> List[Dict]:
414
+ """
415
+ Search symbols within a specific file (like Ctrl+F in VSCode on open file).
416
+
417
+ Use case:
418
+ - User has opened "mp1/src/app/msg.c"
419
+ - User presses Ctrl+F and searches "Isr"
420
+ - Returns all symbols matching "Isr" in that file only
421
+
422
+ Args:
423
+ query: Search term (e.g., "Isr", "Message", "init")
424
+ file_path: File to search in (e.g., "mp1/src/app/msg.c")
425
+
426
+ Returns:
427
+ List of symbols in the file matching query, ordered by line number:
428
+ - id: symbol ID
429
+ - name: symbol name
430
+ - type: symbol type
431
+ - line_number: where it's defined
432
+ - signature: function signature (if applicable)
433
+ - scope: parent scope (if any)
434
+ """
435
+ if not query or not file_path:
436
+ return []
437
+
438
+ with self._get_connection() as conn:
439
+ cursor = conn.cursor()
440
+
441
+ # Search for symbols in specific file (case-insensitive partial match)
442
+ pattern = f"%{query}%"
443
+
444
+ cursor.execute("""
445
+ SELECT
446
+ id,
447
+ name,
448
+ type,
449
+ file_path,
450
+ line_number,
451
+ signature,
452
+ scope_kind,
453
+ scope_name
454
+ FROM symbols
455
+ WHERE file_path = ?
456
+ AND (name LIKE ? OR signature LIKE ?)
457
+ ORDER BY line_number
458
+ """, (file_path, pattern, pattern))
459
+
460
+ results = []
461
+ for row in cursor.fetchall():
462
+ # Determine what matched
463
+ name_match = query.lower() in row['name'].lower()
464
+ sig_match = row['signature'] and query.lower() in row['signature'].lower()
465
+
466
+ match_in = "name" if name_match else "signature" if sig_match else "unknown"
467
+
468
+ result = {
469
+ "id": row['id'],
470
+ "name": row['name'],
471
+ "type": row['type'],
472
+ "line_number": row['line_number'],
473
+ "signature": row['signature'] or "",
474
+ "scope": self._format_scope(row['scope_kind'], row['scope_name']),
475
+ "match_in": match_in
476
+ }
477
+
478
+ results.append(result)
479
+
480
+ return results