Coverage for src\llm_code_lens\menu.py: 4%

923 statements  

« prev     ^ index     » next       coverage.py v7.7.0, created at 2025-05-25 12:07 +0300

1""" 

2LLM Code Lens - Interactive Menu Module 

3Provides a TUI for selecting files and directories to include/exclude in analysis. 

4""" 

5 

6import curses 

7import os 

8import webbrowser 

9from pathlib import Path 

10from typing import Dict, List, Any, Tuple, Set, Optional 

11 

12 

13class MenuState: 

14 """Class to manage the state of the interactive menu.""" 

15 

16 def __init__(self, root_path: Path, initial_settings: Dict[str, Any] = None): 

17 self.root_path = root_path.resolve() 

18 self.current_path = self.root_path 

19 self.expanded_dirs: Set[str] = set() 

20 self.selected_items: Set[str] = set() # Items explicitly selected (overrides exclusions) 

21 self.partially_selected_items: Set[str] = set() # Items partially selected 

22 self.excluded_items: Set[str] = set() # Items explicitly excluded 

23 self.cursor_pos = 0 

24 self.scroll_offset = 0 

25 self.visible_items: List[Tuple[Path, int]] = [] # (path, depth) 

26 self.max_visible = 0 

27 self.status_message = "" 

28 self.cancelled = False # Flag to indicate if user cancelled 

29 

30 # Add version update related attributes 

31 self.new_version_available = False 

32 self.current_version = "" 

33 self.latest_version = "" 

34 self.update_message = "" 

35 self.show_update_dialog = False 

36 self.update_in_progress = False 

37 self.update_result = "" 

38 

39 # Check for updates 

40 self._check_for_updates() 

41 

42 # New flags for scanning optimization 

43 self.scan_complete = False 

44 self.dirty_scan = True # Indicates directory structure needs rescanning 

45 self.auto_exclude_complete = False # Flag to prevent repeated auto-exclusion scans 

46 

47 # Scanning progress tracking 

48 self.scanning_in_progress = True # Start in scanning state 

49 self.scan_current_dir = "" 

50 self.scan_progress = 0 

51 self.scan_total = 0 

52 self.cancel_scan_requested = False 

53 self.status_message = "Initializing directory scan..." 

54 

55 # Common directories to exclude by default 

56 self.common_excludes = [ 

57 # Python 

58 '__pycache__', '.pytest_cache', '.coverage', '.tox', '.mypy_cache', '.ruff_cache', 

59 'venv', 'env', '.env', '.venv', 'virtualenv', '.virtualenv', 'htmlcov', 'site-packages', 

60 'egg-info', '.eggs', 'dist', 'build', 'wheelhouse', '.pytype', 'instance', 

61 

62 # JavaScript/TypeScript/React 

63 'node_modules', 'bower_components', '.npm', '.yarn', '.pnp', '.next', '.nuxt', 

64 '.cache', '.parcel-cache', '.angular', 'coverage', 'storybook-static', '.storybook', 

65 'cypress/videos', 'cypress/screenshots', '.docusaurus', 'out', 'dist-*', '.turbo', 

66 

67 # Java/Kotlin/Android 

68 'target', '.gradle', '.m2', 'build', 'out', '.idea', '.settings', 'bin', 'gen', 

69 'classes', 'obj', 'proguard', 'captures', '.externalNativeBuild', '.cxx', 

70 

71 # C/C++/C# 

72 'Debug', 'Release', 'x64', 'x86', 'bin', 'obj', 'ipch', '.vs', 'packages', 

73 'CMakeFiles', 'CMakeCache.txt', 'cmake-build-*', 'vcpkg_installed', 

74 

75 # Go 

76 'vendor', '.glide', 'Godeps', '_output', 'bazel-*', 

77 

78 # Rust 

79 'target', 'Cargo.lock', '.cargo', 

80 

81 # Swift/iOS 

82 'Pods', '.build', 'DerivedData', '.swiftpm', '*.xcworkspace', '*.xcodeproj/xcuserdata', 

83 

84 # Docker/Kubernetes 

85 '.docker', 'docker-data', 'k8s-data', 

86 

87 # Version control 

88 '.git', '.hg', '.svn', '.bzr', '_darcs', 'CVS', '.pijul', 

89 

90 # IDE/Editor 

91 '.vscode', '.idea', '.vs', '.fleet', '.atom', '.eclipse', '.settings', '.project', 

92 '.classpath', '.factorypath', '.nbproject', '.sublime-*', '.ensime', '.metals', 

93 '.bloop', '.history', '.ionide', '__pycharm__', '.spyproject', '.spyderproject', 

94 

95 # Logs and databases 

96 'logs', '*.log', 'npm-debug.log*', 'yarn-debug.log*', 'yarn-error.log*', 

97 '*.sqlite', '*.sqlite3', '*.db', 'db.json', 

98 

99 # OS specific 

100 '.DS_Store', 'Thumbs.db', 'ehthumbs.db', 'Desktop.ini', '$RECYCLE.BIN', 

101 '.directory', '*.swp', '*.swo', '*~', 

102 

103 # Documentation 

104 'docs/_build', 'docs/site', 'site', 'public', '_site', '.docz', '.docusaurus', 

105 

106 # Jupyter 

107 '.ipynb_checkpoints', '.jupyter', '.ipython', 

108 

109 # Tool specific 

110 '.eslintcache', '.stylelintcache', '.sass-cache', '.phpunit.result.cache', 

111 '.phpcs-cache', '.php_cs.cache', '.php-cs-fixer.cache', '.sonarqube', 

112 '.scannerwork', '.terraform', '.terragrunt-cache', '.serverless', 

113 

114 # LLM Code Lens specific 

115 '.codelens' 

116 ] 

117 

118 # CLI options (updated) 

119 self.options = { 

120 'format': 'txt', # Output format (txt or json) 

121 'full': False, # Export full file contents 

122 'debug': False, # Enable debug output 

123 'sql_server': '', # SQL Server connection string 

124 'sql_database': '', # SQL Database to analyze 

125 'sql_config': '', # Path to SQL configuration file 

126 'exclude_patterns': [], # Patterns to exclude 

127 'llm_provider': 'claude', # Default LLM provider 

128 'respect_gitignore': True, # NEW OPTION 

129 'llm_options': { # LLM provider-specific options 

130 'provider': 'claude', # Current provider 

131 'prompt_template': 'code_analysis', # Current template 

132 'providers': { 

133 'claude': { 

134 'api_key': '', 

135 'model': 'claude-3-opus-20240229', 

136 'temperature': 0.7, 

137 'max_tokens': 4000 

138 }, 

139 'chatgpt': { 

140 'api_key': '', 

141 'model': 'gpt-4-turbo', 

142 'temperature': 0.7, 

143 'max_tokens': 4000 

144 }, 

145 'gemini': { 

146 'api_key': '', 

147 'model': 'gemini-pro', 

148 'temperature': 0.7, 

149 'max_tokens': 4000 

150 }, 

151 'local': { 

152 'url': 'http://localhost:8000', 

153 'model': 'llama3', 

154 'temperature': 0.7, 

155 'max_tokens': 4000 

156 } 

157 }, 

158 'available_providers': ['claude', 'chatgpt', 'gemini', 'local', 'none'], 

159 'prompt_templates': { 

160 'code_analysis': 'Analyze this code and provide feedback on structure, potential bugs, and improvements:\n\n{code}', 

161 'security_review': 'Review this code for security vulnerabilities and suggest fixes:\n\n{code}', 

162 'documentation': 'Generate documentation for this code:\n\n{code}', 

163 'refactoring': 'Suggest refactoring improvements for this code:\n\n{code}', 

164 'explain': 'Explain how this code works in detail:\n\n{code}' 

165 } 

166 } 

167 } 

168 

169 # Initialize gitignore parser 

170 self.gitignore_parser = None 

171 if self.options['respect_gitignore']: 

172 from ..utils.gitignore import GitignoreParser 

173 self.gitignore_parser = GitignoreParser(self.root_path) 

174 self.gitignore_parser.load_gitignore() 

175 

176 # Apply initial settings if provided 

177 if initial_settings: 

178 for key, value in initial_settings.items(): 

179 if key in self.options: 

180 self.options[key] = value 

181 

182 # UI state 

183 self.active_section = 'files' # Current active section: 'files' or 'options' 

184 self.option_cursor = 0 # Cursor position in options section 

185 self.editing_option = None # Currently editing option (for text input) 

186 self.edit_buffer = "" # Buffer for text input 

187 

188 # Load saved state if available - will be done after scanning completes 

189 # self._load_state() 

190 

191 def toggle_dir_expanded(self, path: Path) -> None: 

192 """Toggle directory expansion state.""" 

193 path_str = str(path) 

194 if path_str in self.expanded_dirs: 

195 self.expanded_dirs.remove(path_str) 

196 else: 

197 self.expanded_dirs.add(path_str) 

198 self.rebuild_visible_items() 

199 

200 def toggle_selection(self, path: Path, fully_select: bool = False) -> None: 

201 """ 

202 Toggle selection status of an item. 

203  

204 Args: 

205 path: The path to toggle 

206 fully_select: If True, fully select the directory and all children 

207 """ 

208 path_str = str(path) 

209 

210 # Determine the current state 

211 is_excluded = path_str in self.excluded_items 

212 is_selected = path_str in self.selected_items 

213 is_partially_selected = path_str in self.partially_selected_items 

214 

215 # If it's a directory, we'll need to handle all children 

216 if path.is_dir(): 

217 # If item was excluded, move to partially selected or fully selected state 

218 if is_excluded: 

219 # Remove this directory from excluded 

220 self.excluded_items.discard(path_str) 

221 

222 if fully_select: 

223 # Move to fully selected 

224 self.selected_items.add(path_str) 

225 self.partially_selected_items.discard(path_str) 

226 # Recursively include all children 

227 self._recursively_include(path) 

228 else: 

229 # Move to partially selected 

230 self.partially_selected_items.add(path_str) 

231 self.selected_items.discard(path_str) 

232 # Expand the directory to show its contents 

233 self.expanded_dirs.add(path_str) 

234 

235 # Mark directory structure as dirty to force rescan 

236 self.dirty_scan = True 

237 

238 # If item was explicitly selected, move to excluded 

239 elif is_selected: 

240 # Remove from selected 

241 self.selected_items.discard(path_str) 

242 

243 # Add to excluded 

244 self.excluded_items.add(path_str) 

245 

246 # Recursively exclude all children 

247 self._recursively_exclude(path) 

248 

249 # Mark directory structure as dirty to force rescan 

250 self.dirty_scan = True 

251 

252 # If item was partially selected, toggle to fully selected or excluded 

253 elif is_partially_selected: 

254 # Remove from partially selected 

255 self.partially_selected_items.discard(path_str) 

256 

257 if fully_select: 

258 # Move to fully selected 

259 self.selected_items.add(path_str) 

260 # Recursively include all children 

261 self._recursively_include(path) 

262 else: 

263 # Move to excluded 

264 self.excluded_items.add(path_str) 

265 # Recursively exclude all children 

266 self._recursively_exclude(path) 

267 

268 # Mark directory structure as dirty to force rescan 

269 self.dirty_scan = True 

270 

271 # If item was neither excluded, selected, nor partially selected 

272 else: 

273 if fully_select: 

274 # Add to selected 

275 self.selected_items.add(path_str) 

276 # Recursively include all children 

277 self._recursively_include(path) 

278 else: 

279 # Add to excluded 

280 self.excluded_items.add(path_str) 

281 # Recursively exclude all children 

282 self._recursively_exclude(path) 

283 

284 # Mark directory structure as dirty to force rescan 

285 self.dirty_scan = True 

286 else: 

287 # For files, toggle between excluded and included 

288 if is_excluded: 

289 self.excluded_items.discard(path_str) 

290 self.selected_items.add(path_str) # Explicitly select the file 

291 elif is_selected: 

292 self.selected_items.discard(path_str) 

293 # If the file is in a common directory, exclude it by default 

294 parent_is_common = any(path.parent.name == common for common in self.common_excludes) 

295 if parent_is_common: 

296 self.excluded_items.add(path_str) 

297 else: 

298 self.excluded_items.add(path_str) 

299 

300 # Update parent directory's selection state 

301 self._update_parent_selection_state(path.parent) 

302 

303 # Mark directory structure as dirty to force rescan 

304 self.dirty_scan = True 

305 

306 def is_selected(self, path: Path) -> bool: 

307 """Check if a path is selected.""" 

308 path_str = str(path) 

309 

310 # If the item is explicitly selected, it's included 

311 if path_str in self.selected_items: 

312 return True 

313 

314 # If the item is explicitly excluded or partially selected, it's not fully selected 

315 if path_str in self.excluded_items or path_str in self.partially_selected_items: 

316 return False 

317 

318 # Check if any parent is explicitly excluded 

319 current = path.parent 

320 while current != self.root_path.parent: 

321 parent_str = str(current) 

322 if parent_str in self.excluded_items: 

323 return False 

324 if parent_str in self.selected_items: 

325 return True 

326 current = current.parent 

327 

328 # Check for common directories that should be excluded by default 

329 if path.is_dir() and path.name in self.common_excludes: 

330 # Auto-exclude common directories unless they're explicitly selected or partially selected 

331 if path_str not in self.selected_items and path_str not in self.partially_selected_items: 

332 return False 

333 

334 # For files in common directories, they're excluded by default 

335 if path.parent.name in self.common_excludes and path_str not in self.selected_items: 

336 return False 

337 

338 # If not explicitly excluded and not in a common directory, it's included by default 

339 return True 

340 

341 def is_partially_selected(self, path: Path) -> bool: 

342 """Check if a path is partially selected.""" 

343 path_str = str(path) 

344 

345 # Only directories can be partially selected 

346 if not path.is_dir(): 

347 return False 

348 

349 # If the item is explicitly partially selected 

350 if path_str in self.partially_selected_items: 

351 return True 

352 

353 # If the item is explicitly selected or excluded, it's not partially selected 

354 if path_str in self.selected_items or path_str in self.excluded_items: 

355 return False 

356 

357 # Check if any parent is partially selected (and this item is not excluded) 

358 current = path.parent 

359 while current != self.root_path.parent: 

360 parent_str = str(current) 

361 if parent_str in self.partially_selected_items: 

362 # If parent is partially selected and this item is not excluded, it inherits partial selection 

363 if path_str not in self.excluded_items: 

364 return True 

365 if parent_str in self.excluded_items: 

366 return False 

367 current = current.parent 

368 

369 return False 

370 

371 def _update_parent_selection_state(self, directory: Path) -> None: 

372 """Update the selection state of a parent directory based on its children.""" 

373 if not directory.exists() or directory == self.root_path.parent: 

374 return 

375 

376 dir_str = str(directory) 

377 

378 # Skip if the directory is explicitly excluded or selected 

379 if dir_str in self.excluded_items or dir_str in self.selected_items: 

380 return 

381 

382 # Initialize counters 

383 total_children = 0 

384 selected_children = 0 

385 excluded_children = 0 

386 partially_selected_children = 0 

387 

388 try: 

389 # Count all immediate children 

390 for child in directory.iterdir(): 

391 child_str = str(child) 

392 total_children += 1 

393 

394 if child_str in self.selected_items: 

395 selected_children += 1 

396 elif child_str in self.excluded_items: 

397 excluded_children += 1 

398 elif child_str in self.partially_selected_items: 

399 partially_selected_children += 1 

400 else: 

401 # If child is neither selected nor excluded, check if it's a common directory 

402 if child.is_dir() and child.name in self.common_excludes: 

403 excluded_children += 1 

404 except (PermissionError, OSError): 

405 # If we can't access the directory, don't change its state 

406 return 

407 

408 # Skip empty directories 

409 if total_children == 0: 

410 return 

411 

412 # Update the directory's state based on its children 

413 if selected_children == total_children: 

414 # All children are selected, so the directory should be fully selected 

415 self.partially_selected_items.discard(dir_str) 

416 self.selected_items.add(dir_str) 

417 elif excluded_children == total_children: 

418 # All children are excluded, so the directory should be excluded 

419 self.partially_selected_items.discard(dir_str) 

420 self.selected_items.discard(dir_str) 

421 self.excluded_items.add(dir_str) 

422 elif selected_children > 0 or partially_selected_children > 0: 

423 # Some children are selected or partially selected, so the directory should be partially selected 

424 self.selected_items.discard(dir_str) 

425 self.excluded_items.discard(dir_str) 

426 self.partially_selected_items.add(dir_str) 

427 else: 

428 # No children are selected or partially selected, so the directory should be neither 

429 self.selected_items.discard(dir_str) 

430 self.partially_selected_items.discard(dir_str) 

431 

432 # Recursively update parent directories, but only if this directory's state changed 

433 if dir_str in self.selected_items or dir_str in self.partially_selected_items or dir_str in self.excluded_items: 

434 self._update_parent_selection_state(directory.parent) 

435 

436 def is_excluded(self, path: Path) -> bool: 

437 """Check if a path is excluded (updated to include gitignore).""" 

438 path_str = str(path) 

439 

440 # Check gitignore first if enabled 

441 if self.gitignore_parser and self.gitignore_parser.should_ignore(path): 

442 return True 

443 

444 # If the item is explicitly selected or partially selected, it's not excluded 

445 if path_str in self.selected_items or path_str in self.partially_selected_items: 

446 return False 

447 

448 # Check if this path is explicitly excluded 

449 if path_str in self.excluded_items: 

450 return True 

451 

452 # Check if any parent is excluded 

453 current = path.parent 

454 while current != self.root_path.parent: 

455 if str(current) in self.excluded_items: 

456 return True 

457 if str(current) in self.selected_items or str(current) in self.partially_selected_items: 

458 return False 

459 current = current.parent 

460 

461 # Check for common directories that should be excluded by default 

462 if path.is_dir() and path.name in self.common_excludes: 

463 return True 

464 

465 # For files in common directories, they're excluded by default 

466 if path.parent.name in self.common_excludes: 

467 return True 

468 

469 return False 

470 

471 def get_current_item(self) -> Optional[Path]: 

472 """Get the currently selected item.""" 

473 if 0 <= self.cursor_pos < len(self.visible_items): 

474 return self.visible_items[self.cursor_pos][0] 

475 return None 

476 

477 def move_cursor(self, direction: int) -> None: 

478 """Move the cursor up or down.""" 

479 new_pos = self.cursor_pos + direction 

480 if 0 <= new_pos < len(self.visible_items): 

481 self.cursor_pos = new_pos 

482 

483 # Adjust scroll if needed 

484 if self.cursor_pos < self.scroll_offset: 

485 self.scroll_offset = self.cursor_pos 

486 elif self.cursor_pos >= self.scroll_offset + self.max_visible: 

487 self.scroll_offset = self.cursor_pos - self.max_visible + 1 

488 

489 def rebuild_visible_items(self) -> None: 

490 """Rebuild the list of visible items based on expanded directories.""" 

491 # Only rebuild if dirty flag is set 

492 if not self.dirty_scan: 

493 return 

494 

495 try: 

496 # Auto-exclude common directories before building the list 

497 if not self.auto_exclude_complete: 

498 self._auto_exclude_common_dirs() 

499 

500 # If scan was cancelled, don't continue 

501 if self.cancel_scan_requested: 

502 self.dirty_scan = False 

503 self.scanning_in_progress = False 

504 return 

505 

506 # Update status 

507 self.status_message = "Building directory tree..." 

508 

509 # Reset visible items 

510 self.visible_items = [] 

511 

512 # Build the item list 

513 self._build_item_list(self.root_path, 0) 

514 

515 # Adjust cursor position if it's now out of bounds 

516 if self.cursor_pos >= len(self.visible_items) and len(self.visible_items) > 0: 

517 self.cursor_pos = len(self.visible_items) - 1 

518 

519 # Adjust scroll offset if needed 

520 if self.cursor_pos < self.scroll_offset: 

521 self.scroll_offset = max(0, self.cursor_pos) 

522 elif self.cursor_pos >= self.scroll_offset + self.max_visible: 

523 self.scroll_offset = max(0, self.cursor_pos - self.max_visible + 1) 

524 

525 # Mark scan as complete and not dirty 

526 self.scan_complete = True 

527 self.dirty_scan = False 

528 self.status_message = "Directory structure loaded" 

529 except Exception as e: 

530 self.status_message = f"Error building directory tree: {str(e)}" 

531 finally: 

532 # Always reset scanning state when done 

533 self.scanning_in_progress = False 

534 

535 def _auto_exclude_common_dirs(self) -> None: 

536 """Automatically exclude common directories that should be ignored.""" 

537 # Prevent repeated scans 

538 if self.auto_exclude_complete: 

539 return 

540 

541 # Set scanning state 

542 self.scanning_in_progress = True 

543 self.status_message = "Scanning directory structure..." 

544 

545 try: 

546 # First, count total directories for progress reporting 

547 self.scan_total = 0 

548 self.scan_progress = 0 

549 

550 # Count directories first to provide progress percentage 

551 self.status_message = "Counting directories for progress tracking..." 

552 

553 # Use a more efficient approach with os.walk 

554 for root, dirs, _ in os.walk(str(self.root_path)): 

555 # Check for cancellation request more frequently 

556 if self.cancel_scan_requested: 

557 self.status_message = "Scan cancelled by user" 

558 self.scanning_in_progress = False 

559 self.dirty_scan = False # Mark as not dirty to prevent automatic rescan 

560 return 

561 

562 # Update progress for UI feedback 

563 self.scan_total += len(dirs) 

564 self.scan_current_dir = os.path.basename(root) 

565 self.status_message = f"Counting directories: {self.scan_total} found so far..." 

566 

567 # Force screen refresh periodically 

568 if self.scan_total % 50 == 0: 

569 # We can't directly refresh the screen here, but we can yield control 

570 # back to the main loop by sleeping briefly 

571 import time 

572 time.sleep(0.01) 

573 

574 # Now find and exclude common directories 

575 self.status_message = "Scanning for common directories to exclude..." 

576 self.scan_progress = 0 # Reset progress for the second phase 

577 

578 # Use a more efficient approach with a single walk 

579 for root, dirs, _ in os.walk(str(self.root_path)): 

580 # Check for cancellation request 

581 if self.cancel_scan_requested: 

582 self.status_message = "Scan cancelled by user" 

583 self.scanning_in_progress = False 

584 self.dirty_scan = False # Mark as not dirty to prevent automatic rescan 

585 return 

586 

587 # Update progress 

588 self.scan_progress += 1 

589 

590 # Update progress percentage more frequently 

591 if self.scan_total > 0: 

592 progress_pct = min(100, int((self.scan_progress / max(1, self.scan_total)) * 100)) 

593 self.scan_current_dir = os.path.basename(root) 

594 self.status_message = f"Scanning: {self.scan_current_dir} ({progress_pct}%)" 

595 

596 # Check if any of the directories match our common excludes 

597 for d in dirs[:]: # Create a copy to safely modify during iteration 

598 if d in self.common_excludes: 

599 path = Path(os.path.join(root, d)) 

600 path_str = str(path) 

601 if path_str not in self.excluded_items: 

602 self.excluded_items.add(path_str) 

603 

604 # Force screen refresh periodically 

605 if self.scan_progress % 50 == 0: 

606 import time 

607 time.sleep(0.01) 

608 

609 # Mark auto-exclusion as complete 

610 self.auto_exclude_complete = True 

611 self.status_message = "Directory scan complete" 

612 except Exception as e: 

613 # Log error but continue 

614 self.status_message = f"Error during directory scan: {str(e)}" 

615 finally: 

616 # Always reset scanning state when done 

617 if self.cancel_scan_requested: 

618 self.status_message = "Scan cancelled by user" 

619 self.dirty_scan = False # Mark as not dirty to prevent automatic rescan 

620 

621 def _recursively_include(self, directory: Path) -> None: 

622 """Recursively include all files and subdirectories.""" 

623 try: 

624 # First, add the directory itself to selected items and remove from other collections 

625 dir_str = str(directory) 

626 self.selected_items.add(dir_str) 

627 self.excluded_items.discard(dir_str) 

628 self.partially_selected_items.discard(dir_str) 

629 

630 # Process immediate children first to avoid excessive recursion 

631 try: 

632 for item in directory.iterdir(): 

633 item_str = str(item) 

634 

635 # Remove from excluded and partially selected items 

636 self.excluded_items.discard(item_str) 

637 self.partially_selected_items.discard(item_str) 

638 

639 # Add to selected items 

640 self.selected_items.add(item_str) 

641 

642 # If it's a directory, process it recursively 

643 if item.is_dir(): 

644 # Expand the directory 

645 self.expanded_dirs.add(item_str) 

646 

647 # Process recursively, but with a try/except to handle permission errors 

648 try: 

649 self._recursively_include(item) 

650 except (PermissionError, OSError): 

651 # If we can't access the directory, just mark it as selected 

652 pass 

653 except (PermissionError, OSError): 

654 # If we can't access the directory, just mark it as selected 

655 pass 

656 

657 # Mark directory structure as dirty to force rescan 

658 self.dirty_scan = True 

659 except Exception as e: 

660 # Log the error but continue 

661 self.status_message = f"Error including directory: {str(e)}" 

662 

663 def _recursively_exclude(self, directory: Path) -> None: 

664 """Recursively exclude all files and subdirectories.""" 

665 try: 

666 # First, add the directory itself to excluded items and remove from other collections 

667 dir_str = str(directory) 

668 self.excluded_items.add(dir_str) 

669 self.selected_items.discard(dir_str) 

670 self.partially_selected_items.discard(dir_str) 

671 

672 # Process immediate children first to avoid excessive recursion 

673 try: 

674 for item in directory.iterdir(): 

675 item_str = str(item) 

676 

677 # Add to excluded items 

678 self.excluded_items.add(item_str) 

679 

680 # Remove from selected and partially selected items 

681 self.selected_items.discard(item_str) 

682 self.partially_selected_items.discard(item_str) 

683 

684 # If it's a directory, process it recursively 

685 if item.is_dir(): 

686 # Process recursively, but with a try/except to handle permission errors 

687 try: 

688 self._recursively_exclude(item) 

689 except (PermissionError, OSError): 

690 # If we can't access the directory, just mark it as excluded 

691 pass 

692 except (PermissionError, OSError): 

693 # If we can't access the directory, just mark it as excluded 

694 pass 

695 

696 # Mark directory structure as dirty to force rescan 

697 self.dirty_scan = True 

698 except Exception as e: 

699 # Log the error but continue 

700 self.status_message = f"Error excluding directory: {str(e)}" 

701 

702 def _build_item_list(self, path: Path, depth: int) -> None: 

703 """Recursively build the list of visible items.""" 

704 try: 

705 # Add the current path 

706 self.visible_items.append((path, depth)) 

707 

708 # If it's a directory and it's expanded, add its children 

709 if path.is_dir() and str(path) in self.expanded_dirs: 

710 try: 

711 # Sort directories first, then files 

712 items = sorted(path.iterdir(), 

713 key=lambda p: (0 if p.is_dir() else 1, p.name.lower())) 

714 

715 # Use the class's common_excludes list 

716 for item in items: 

717 # Auto-exclude common directories but still show them in the list 

718 if item.is_dir() and item.name in self.common_excludes: 

719 if str(item) not in self.excluded_items: 

720 self.excluded_items.add(str(item)) 

721 

722 # Include all files/directories in the visible list 

723 self._build_item_list(item, depth + 1) 

724 except PermissionError: 

725 # Handle permission errors gracefully 

726 pass 

727 except Exception: 

728 # Ignore any errors during item list building 

729 pass 

730 

731 def toggle_option(self, option_name: str) -> None: 

732 """Toggle a boolean option or cycle through value options.""" 

733 if option_name not in self.options: 

734 return 

735 

736 if option_name == 'respect_gitignore': 

737 # Toggle gitignore support 

738 self.options[option_name] = not self.options[option_name] 

739 

740 # Reinitialize gitignore parser 

741 if self.options[option_name]: 

742 from ..utils.gitignore import GitignoreParser 

743 self.gitignore_parser = GitignoreParser(self.root_path) 

744 self.gitignore_parser.load_gitignore() 

745 self.status_message = "Gitignore support enabled" 

746 else: 

747 self.gitignore_parser = None 

748 self.status_message = "Gitignore support disabled" 

749 

750 # Mark for rescan since ignore patterns changed 

751 self.dirty_scan = True 

752 

753 elif option_name == 'format': 

754 # Cycle through format options 

755 self.options[option_name] = 'json' if self.options[option_name] == 'txt' else 'txt' 

756 elif option_name == 'llm_provider': 

757 # Cycle through LLM provider options including 'none' 

758 providers = list(self.options['llm_options']['providers'].keys()) + ['none'] 

759 current_index = providers.index(self.options[option_name]) if self.options[option_name] in providers else 0 

760 next_index = (current_index + 1) % len(providers) 

761 self.options[option_name] = providers[next_index] 

762 elif isinstance(self.options[option_name], bool): 

763 # Toggle boolean options 

764 self.options[option_name] = not self.options[option_name] 

765 

766 self.status_message = f"Option '{option_name}' set to: {self.options[option_name]}" 

767 

768 def set_option(self, option_name: str, value: Any) -> None: 

769 """Set an option to a specific value.""" 

770 if option_name in self.options: 

771 self.options[option_name] = value 

772 self.status_message = f"Option '{option_name}' set to: {value}" 

773 

774 def start_editing_option(self, option_name: str) -> None: 

775 """Start editing a text-based option.""" 

776 if option_name in self.options: 

777 self.editing_option = option_name 

778 self.edit_buffer = str(self.options[option_name]) 

779 self.status_message = f"Editing {option_name}. Press Enter to confirm, Esc to cancel." 

780 

781 def finish_editing(self, save: bool = True) -> None: 

782 """Finish editing the current option.""" 

783 if self.editing_option and save: 

784 if self.editing_option == 'new_exclude': 

785 # Special handling for new exclude pattern 

786 if self.edit_buffer.strip(): 

787 self.add_exclude_pattern(self.edit_buffer.strip()) 

788 else: 

789 # Normal option 

790 self.options[self.editing_option] = self.edit_buffer 

791 self.status_message = f"Option '{self.editing_option}' set to: {self.edit_buffer}" 

792 

793 self.editing_option = None 

794 self.edit_buffer = "" 

795 

796 def add_exclude_pattern(self, pattern: str) -> None: 

797 """Add an exclude pattern.""" 

798 if pattern and pattern not in self.options['exclude_patterns']: 

799 self.options['exclude_patterns'].append(pattern) 

800 self.status_message = f"Added exclude pattern: {pattern}" 

801 

802 def remove_exclude_pattern(self, index: int) -> None: 

803 """Remove an exclude pattern by index.""" 

804 if 0 <= index < len(self.options['exclude_patterns']): 

805 pattern = self.options['exclude_patterns'].pop(index) 

806 self.status_message = f"Removed exclude pattern: {pattern}" 

807 

808 def toggle_section(self) -> None: 

809 """Toggle between files and options sections.""" 

810 if self.active_section == 'files': 

811 self.active_section = 'options' 

812 self.option_cursor = 0 

813 else: 

814 self.active_section = 'files' 

815 

816 self.status_message = f"Switched to {self.active_section} section" 

817 

818 def move_option_cursor(self, direction: int) -> None: 

819 """Move the cursor in the options section.""" 

820 # Count total options (fixed options + exclude patterns) 

821 total_options = 6 + len(self.options['exclude_patterns']) # 6 fixed options + exclude patterns 

822 

823 new_pos = self.option_cursor + direction 

824 if 0 <= new_pos < total_options: 

825 self.option_cursor = new_pos 

826 

827 def validate_selection(self) -> Dict[str, List[str]]: 

828 """Validate the selection and return statistics about selected/excluded items.""" 

829 stats = { 

830 'excluded_count': len(self.excluded_items), 

831 'selected_count': len(self.selected_items), 

832 'partially_selected_count': len(self.partially_selected_items), 

833 'excluded_dirs': [], 

834 'excluded_files': [], 

835 'selected_dirs': [], 

836 'selected_files': [], 

837 'partially_selected_dirs': [] 

838 } 

839 

840 # Categorize excluded items 

841 for path_str in self.excluded_items: 

842 path = Path(path_str) 

843 if path.is_dir(): 

844 stats['excluded_dirs'].append(path_str) 

845 else: 

846 stats['excluded_files'].append(path_str) 

847 

848 # Categorize selected items 

849 for path_str in self.selected_items: 

850 path = Path(path_str) 

851 if path.is_dir(): 

852 stats['selected_dirs'].append(path_str) 

853 else: 

854 stats['selected_files'].append(path_str) 

855 

856 # Categorize partially selected items 

857 for path_str in self.partially_selected_items: 

858 path = Path(path_str) 

859 if path.is_dir(): 

860 stats['partially_selected_dirs'].append(path_str) 

861 

862 return stats 

863 

864 def get_results(self) -> Dict[str, Any]: 

865 """Get the final results of the selection process.""" 

866 # Process selection states to determine include and exclude paths 

867 include_paths = [Path(p) for p in self.selected_items] 

868 exclude_paths = [Path(p) for p in self.excluded_items] 

869 

870 # Add partially selected directories to include_paths 

871 # Their children will be filtered individually 

872 for path_str in self.partially_selected_items: 

873 path = Path(path_str) 

874 if path not in include_paths: 

875 include_paths.append(path) 

876 

877 # Validate selection and log statistics if debug is enabled 

878 validation_stats = self.validate_selection() 

879 if self.options['debug']: 

880 status_message = ( 

881 f"Selection validation: {validation_stats['excluded_count']} items excluded " 

882 f"({len(validation_stats['excluded_dirs'])} directories, " 

883 f"{len(validation_stats['excluded_files'])} files), " 

884 f"{validation_stats['selected_count']} items explicitly included " 

885 f"({len(validation_stats['selected_dirs'])} directories, " 

886 f"{len(validation_stats['selected_files'])} files), " 

887 f"{validation_stats['partially_selected_count']} items partially selected" 

888 ) 

889 self.status_message = status_message 

890 print(status_message) 

891 

892 # Save state for future runs 

893 if not self.cancelled: 

894 self._save_state() 

895 

896 # Return all settings 

897 return { 

898 'path': self.root_path, 

899 'include_paths': include_paths, 

900 'exclude_paths': exclude_paths, 

901 'format': self.options['format'], 

902 'full': self.options['full'], 

903 'debug': self.options['debug'], 

904 'sql_server': self.options['sql_server'], 

905 'sql_database': self.options['sql_database'], 

906 'sql_config': self.options['sql_config'], 

907 'exclude': self.options['exclude_patterns'], 

908 'open_in_llm': self.options['llm_provider'], 

909 'llm_options': self.options['llm_options'], 

910 'validation': validation_stats if self.options['debug'] else None, 

911 'cancelled': self.cancelled 

912 } 

913 

914 def _check_for_updates(self) -> None: 

915 """Check if a newer version is available.""" 

916 try: 

917 from llm_code_lens.version import check_for_newer_version, _get_current_version, _get_latest_version 

918 

919 # Get current and latest versions 

920 self.current_version = _get_current_version() 

921 self.latest_version, _ = _get_latest_version() 

922 

923 if self.latest_version and self.current_version: 

924 # Compare versions 

925 if self.latest_version != self.current_version: 

926 self.new_version_available = True 

927 self.update_message = f"New version available: {self.latest_version} (current: {self.current_version})" 

928 self.show_update_dialog = True 

929 except Exception as e: 

930 # Silently fail if version check fails 

931 self.update_message = f"Failed to check for updates: {str(e)}" 

932 

933 def update_to_latest_version(self) -> None: 

934 """Update to the latest version.""" 

935 self.update_in_progress = True 

936 self.update_result = "Updating..." 

937 

938 try: 

939 import subprocess 

940 import sys 

941 

942 # First attempt - normal upgrade 

943 self.update_result = "Running pip install --upgrade..." 

944 process = subprocess.run( 

945 [sys.executable, "-m", "pip", "install", "--upgrade", "llm-code-lens"], 

946 capture_output=True, 

947 text=True, 

948 check=False 

949 ) 

950 

951 if process.returncode != 0: 

952 # If first attempt failed, try with --no-cache-dir 

953 self.update_result = "First attempt failed. Trying with --no-cache-dir..." 

954 process = subprocess.run( 

955 [sys.executable, "-m", "pip", "install", "--upgrade", "--no-cache-dir", "llm-code-lens"], 

956 capture_output=True, 

957 text=True, 

958 check=False 

959 ) 

960 

961 if process.returncode == 0: 

962 self.update_result = f"Successfully updated to version {self.latest_version}. Please restart LLM Code Lens." 

963 # Hide the dialog after successful update 

964 self.show_update_dialog = False 

965 else: 

966 self.update_result = f"Update failed: {process.stderr}" 

967 except Exception as e: 

968 self.update_result = f"Update failed: {str(e)}" 

969 finally: 

970 self.update_in_progress = False 

971 

972 def _save_state(self) -> None: 

973 """Save the current state to a file.""" 

974 try: 

975 state_dir = self.root_path / '.codelens' 

976 state_dir.mkdir(exist_ok=True) 

977 state_file = state_dir / 'menu_state.json' 

978 

979 # Convert paths to strings for JSON serialization 

980 state = { 

981 'expanded_dirs': list(self.expanded_dirs), 

982 'excluded_items': list(self.excluded_items), 

983 'selected_items': list(self.selected_items), 

984 'partially_selected_items': list(self.partially_selected_items), 

985 'options': self.options 

986 } 

987 

988 import json 

989 with open(state_file, 'w') as f: 

990 json.dump(state, f) 

991 except Exception: 

992 # Silently fail if we can't save state 

993 pass 

994 

995 def _load_state(self) -> None: 

996 """Load the saved state from a file.""" 

997 try: 

998 state_file = self.root_path / '.codelens' / 'menu_state.json' 

999 if state_file.exists(): 

1000 import json 

1001 with open(state_file, 'r') as f: 

1002 state = json.load(f) 

1003 

1004 # Restore state 

1005 self.expanded_dirs = set(state.get('expanded_dirs', [])) 

1006 self.excluded_items = set(state.get('excluded_items', [])) 

1007 self.selected_items = set(state.get('selected_items', [])) 

1008 self.partially_selected_items = set(state.get('partially_selected_items', [])) 

1009 

1010 # Restore options if available 

1011 if 'options' in state: 

1012 for key, value in state['options'].items(): 

1013 if key in self.options: 

1014 self.options[key] = value 

1015 

1016 # Set status message to indicate loaded state 

1017 excluded_count = len(self.excluded_items) 

1018 partially_selected_count = len(self.partially_selected_items) 

1019 if excluded_count > 0 or partially_selected_count > 0: 

1020 self.status_message = f"Loaded {excluded_count} excluded items and {partially_selected_count} partially selected items from saved state" 

1021 except Exception as e: 

1022 # Log the error instead of silently failing 

1023 self.status_message = f"Error loading menu state: {str(e)}" 

1024 

1025 def _open_in_llm(self) -> bool: 

1026 """ 

1027 Open selected files in the configured LLM provider. 

1028  

1029 Returns: 

1030 bool: True if successful, False otherwise 

1031 """ 

1032 # Get the provider name 

1033 provider = self.options['llm_provider'] 

1034 

1035 # Handle 'none' option 

1036 if provider.lower() == 'none': 

1037 self.status_message = "LLM integration is disabled (set to 'none')" 

1038 return True 

1039 

1040 # Get the current item 

1041 current_item = self.get_current_item() 

1042 if not current_item or not current_item.is_file(): 

1043 self.status_message = "Please select a file to open in LLM" 

1044 return False 

1045 

1046 # Check if file exists and is readable 

1047 if not current_item.exists() or not os.access(current_item, os.R_OK): 

1048 self.status_message = f"Cannot read file: {current_item}" 

1049 return False 

1050 

1051 # Show a message that this feature is not yet implemented 

1052 self.status_message = f"Opening in {provider} is not yet implemented" 

1053 return False 

1054 

1055 

1056def draw_menu(stdscr, state: MenuState) -> None: 

1057 """Draw the menu interface.""" 

1058 curses.curs_set(0) # Hide cursor 

1059 stdscr.clear() 

1060 

1061 # Get terminal dimensions 

1062 max_y, max_x = stdscr.getmaxyx() 

1063 

1064 # Set up colors 

1065 curses.start_color() 

1066 curses.init_pair(1, curses.COLOR_WHITE, curses.COLOR_BLUE) # Header/footer 

1067 curses.init_pair(2, curses.COLOR_BLACK, curses.COLOR_WHITE) # Selected item 

1068 curses.init_pair(3, curses.COLOR_GREEN, curses.COLOR_BLACK) # Included item 

1069 curses.init_pair(4, curses.COLOR_RED, curses.COLOR_BLACK) # Excluded item 

1070 curses.init_pair(5, curses.COLOR_YELLOW, curses.COLOR_BLACK) # Directory 

1071 curses.init_pair(6, curses.COLOR_CYAN, curses.COLOR_BLACK) # Options 

1072 curses.init_pair(7, curses.COLOR_WHITE, curses.COLOR_RED) # Active section 

1073 

1074 # If scanning is in progress, show a progress screen 

1075 if state.scanning_in_progress: 

1076 stdscr.clear() 

1077 

1078 # Draw header 

1079 header = " LLM Code Lens - Scanning Repository " 

1080 header = header.center(max_x-1, "=") 

1081 try: 

1082 stdscr.addstr(0, 0, header[:max_x-1], curses.color_pair(1)) 

1083 except curses.error: 

1084 pass 

1085 

1086 # Calculate progress percentage 

1087 progress_pct = min(100, int((state.scan_progress / max(1, state.scan_total)) * 100)) 

1088 

1089 # Show status message with better formatting 

1090 try: 

1091 # Status message with timestamp 

1092 import time 

1093 timestamp = time.strftime("%H:%M:%S") 

1094 status_line = f"Status [{timestamp}]: {state.status_message}" 

1095 stdscr.addstr(3, 2, status_line) 

1096 

1097 # Show current directory with better formatting 

1098 current_dir = state.scan_current_dir 

1099 if len(current_dir) > max_x - 20: 

1100 current_dir = "..." + current_dir[-(max_x - 23):] 

1101 

1102 # Show directory with highlight 

1103 stdscr.addstr(5, 2, "Current directory: ") 

1104 stdscr.addstr(5, 20, current_dir, curses.color_pair(5) | curses.A_BOLD) 

1105 

1106 # Show scan statistics 

1107 stdscr.addstr(6, 2, f"Directories found: {state.scan_total}") 

1108 stdscr.addstr(6, 30, f"Processed: {state.scan_progress}") 

1109 

1110 # Draw progress bar with better visual feedback 

1111 bar_width = max_x - 20 

1112 filled_width = int((bar_width * progress_pct) / 100) 

1113 

1114 # Use different colors for different progress levels 

1115 bar_color = curses.color_pair(3) # Default green 

1116 if progress_pct < 25: 

1117 bar_color = curses.color_pair(4) # Red for early progress 

1118 elif progress_pct < 50: 

1119 bar_color = curses.color_pair(5) # Yellow for mid progress 

1120 

1121 # Draw progress percentage 

1122 stdscr.addstr(8, 2, f"Progress: {progress_pct}% ") 

1123 

1124 # Draw progress bar background 

1125 stdscr.addstr(8, 15, "[" + " " * bar_width + "]") 

1126 

1127 # Draw filled portion of progress bar 

1128 if filled_width > 0: 

1129 stdscr.addstr(8, 16, "=" * filled_width, bar_color | curses.A_BOLD) 

1130 

1131 # Show cancel instruction with highlight 

1132 stdscr.addstr(10, 2, "Press ", curses.A_BOLD) 

1133 stdscr.addstr(10, 8, "ESC", curses.color_pair(7) | curses.A_BOLD) 

1134 stdscr.addstr(10, 12, " to cancel scanning", curses.A_BOLD) 

1135 

1136 # Add more helpful information 

1137 stdscr.addstr(12, 2, "Scanning large repositories may take some time...") 

1138 stdscr.addstr(13, 2, "This helps optimize the analysis by excluding irrelevant files.") 

1139 

1140 # Add animation to show activity even when progress doesn't change 

1141 import time 

1142 animation_chars = "|/-\\" 

1143 animation_idx = int(time.time() * 5) % len(animation_chars) 

1144 stdscr.addstr(15, 2, f"Working {animation_chars[animation_idx]}") 

1145 except curses.error: 

1146 pass 

1147 

1148 # Draw footer 

1149 footer_y = max_y - 2 

1150 footer = " Please wait while we prepare your repository for analysis... " 

1151 footer = footer.center(max_x-1, "=") 

1152 try: 

1153 stdscr.addstr(footer_y, 0, footer[:max_x-1], curses.color_pair(1)) 

1154 except curses.error: 

1155 pass 

1156 

1157 stdscr.refresh() 

1158 return 

1159 

1160 # Calculate layout 

1161 options_height = 10 # Height of options section 

1162 files_height = max_y - options_height - 4 # Height of files section (minus header/footer) 

1163 

1164 # Adjust visible items based on active section 

1165 if state.active_section == 'files': 

1166 state.max_visible = files_height 

1167 else: 

1168 state.max_visible = files_height - 2 # Reduce slightly when in options mode 

1169 

1170 # Draw header 

1171 header = f" LLM Code Lens - {'File Selection' if state.active_section == 'files' else 'Options'} " 

1172 header = header.center(max_x-1, "=") 

1173 try: 

1174 stdscr.addstr(0, 0, header[:max_x-1], curses.color_pair(1)) 

1175 except curses.error: 

1176 pass 

1177 

1178 # Draw section indicator with improved visibility 

1179 section_y = 1 

1180 files_section = " [F]iles " 

1181 options_section = " [O]ptions " 

1182 tab_hint = " [Tab] to switch sections " 

1183 esc_hint = " [Esc] to cancel " 

1184 

1185 try: 

1186 # Files section indicator with better highlighting 

1187 attr = curses.color_pair(7) if state.active_section == 'files' else curses.color_pair(1) 

1188 stdscr.addstr(section_y, 2, files_section, attr) 

1189 

1190 # Options section indicator 

1191 attr = curses.color_pair(7) if state.active_section == 'options' else curses.color_pair(1) 

1192 stdscr.addstr(section_y, 2 + len(files_section) + 2, options_section, attr) 

1193 

1194 # Add Tab hint in the middle 

1195 middle_pos = max_x // 2 - len(tab_hint) // 2 

1196 stdscr.addstr(section_y, middle_pos, tab_hint, curses.color_pair(6)) 

1197 

1198 # Add Escape hint on the right 

1199 right_pos = max_x - len(esc_hint) - 2 

1200 stdscr.addstr(section_y, right_pos, esc_hint, curses.color_pair(6)) 

1201 except curses.error: 

1202 pass 

1203 

1204 # Draw items if in files section or if files section is visible 

1205 if state.active_section == 'files' or True: # Always show files 

1206 start_y = 2 # Start after header and section indicators 

1207 visible_count = min(state.max_visible, len(state.visible_items) - state.scroll_offset) 

1208 

1209 for i in range(visible_count): 

1210 idx = i + state.scroll_offset 

1211 if idx >= len(state.visible_items): 

1212 break 

1213 

1214 path, depth = state.visible_items[idx] 

1215 is_dir = path.is_dir() 

1216 is_excluded = state.is_excluded(path) 

1217 

1218 # Prepare the display string 

1219 indent = " " * depth 

1220 prefix = "+ " if is_dir and str(path) in state.expanded_dirs else \ 

1221 "- " if is_dir else " " 

1222 

1223 # Determine selection indicator based on exclusion/selection status 

1224 path_str = str(path) 

1225 if path_str in state.selected_items: 

1226 sel_indicator = "[*]" # Explicitly selected 

1227 elif path_str in state.partially_selected_items: 

1228 sel_indicator = "[~]" # Partially selected 

1229 elif is_excluded: 

1230 sel_indicator = "[-]" # Excluded 

1231 else: 

1232 sel_indicator = "[+]" # Included 

1233 

1234 item_str = f"{indent}{prefix}{sel_indicator} {path.name}" 

1235 

1236 # Truncate if too long 

1237 if len(item_str) > max_x - 2: 

1238 item_str = item_str[:max_x - 5] + "..." 

1239 

1240 # Determine color 

1241 path_str = str(path) 

1242 if state.active_section == 'files' and idx == state.cursor_pos: 

1243 attr = curses.color_pair(2) # Highlighted 

1244 elif path_str in state.selected_items: 

1245 attr = curses.color_pair(3) | curses.A_BOLD # Explicitly selected (bold) 

1246 elif is_excluded: 

1247 attr = curses.color_pair(4) # Excluded 

1248 elif not is_excluded: 

1249 attr = curses.color_pair(3) # Included 

1250 else: 

1251 attr = 0 # Default 

1252 

1253 # If it's a directory, add directory color (but keep excluded color if excluded) 

1254 if is_dir and not (state.active_section == 'files' and idx == state.cursor_pos) and not is_excluded: 

1255 attr = curses.color_pair(5) 

1256 

1257 # Draw the item 

1258 try: 

1259 stdscr.addstr(i + start_y, 0, " " * (max_x-1)) # Clear line 

1260 # Make sure we don't exceed the screen width 

1261 safe_str = item_str[:max_x-1] if len(item_str) >= max_x else item_str 

1262 stdscr.addstr(i + start_y, 0, safe_str, attr) 

1263 except curses.error: 

1264 # Handle potential curses errors 

1265 pass 

1266 

1267 # Draw options section 

1268 options_start_y = files_height + 2 

1269 try: 

1270 # Draw options header 

1271 options_header = " Analysis Options " 

1272 options_header = options_header.center(max_x-1, "-") 

1273 stdscr.addstr(options_start_y, 0, options_header[:max_x-1], curses.color_pair(6)) 

1274 

1275 # Draw options 

1276 option_y = options_start_y + 1 

1277 options = [ 

1278 ("Format", f"{state.options['format']}", "F1"), 

1279 ("Full Export", f"{state.options['full']}", "F2"), 

1280 ("Debug Mode", f"{state.options['debug']}", "F3"), 

1281 ("SQL Server", f"{state.options['sql_server'] or 'Not set'}", "F4"), 

1282 ("SQL Database", f"{state.options['sql_database'] or 'Not set'}", "F5"), 

1283 ("LLM Provider", f"{state.options['llm_provider']}", "F6"), 

1284 ("Respect .gitignore", f"{state.options['respect_gitignore']}", "F7") # NEW OPTION 

1285 ] 

1286 

1287 # Add exclude patterns 

1288 for i, pattern in enumerate(state.options['exclude_patterns']): 

1289 options.append((f"Exclude Pattern {i+1}", pattern, "Del")) 

1290 

1291 # Draw each option 

1292 for i, (name, value, key) in enumerate(options): 

1293 if option_y + i >= max_y - 2: # Don't draw past footer 

1294 break 

1295 

1296 # Determine if this option is selected 

1297 is_selected = state.active_section == 'options' and i == state.option_cursor 

1298 

1299 # Format the option string 

1300 option_str = f" {name}: {value}" 

1301 key_str = f"[{key}]" 

1302 

1303 # Calculate padding to right-align the key 

1304 padding = max_x - len(option_str) - len(key_str) - 2 

1305 if padding < 1: 

1306 padding = 1 

1307 

1308 display_str = f"{option_str}{' ' * padding}{key_str}" 

1309 

1310 # Truncate if too long 

1311 if len(display_str) > max_x - 2: 

1312 display_str = display_str[:max_x - 5] + "..." 

1313 

1314 # Draw with appropriate highlighting 

1315 attr = curses.color_pair(2) if is_selected else curses.color_pair(6) 

1316 stdscr.addstr(option_y + i, 0, " " * (max_x-1)) # Clear line 

1317 stdscr.addstr(option_y + i, 0, display_str, attr) 

1318 except curses.error: 

1319 pass 

1320 

1321 # Draw update dialog if needed 

1322 if state.show_update_dialog: 

1323 # Calculate dialog dimensions and position 

1324 dialog_width = 60 

1325 dialog_height = 8 

1326 dialog_x = max(0, (max_x - dialog_width) // 2) 

1327 dialog_y = max(0, (max_y - dialog_height) // 2) 

1328 

1329 # Draw dialog box 

1330 for y in range(dialog_height): 

1331 try: 

1332 if y == 0 or y == dialog_height - 1: 

1333 # Draw top and bottom borders 

1334 stdscr.addstr(dialog_y + y, dialog_x, "+" + "-" * (dialog_width - 2) + "+") 

1335 else: 

1336 # Draw side borders 

1337 stdscr.addstr(dialog_y + y, dialog_x, "|" + " " * (dialog_width - 2) + "|") 

1338 except curses.error: 

1339 pass 

1340 

1341 # Draw dialog title 

1342 title = " Update Available " 

1343 title_x = dialog_x + (dialog_width - len(title)) // 2 

1344 try: 

1345 stdscr.addstr(dialog_y, title_x, title, curses.color_pair(1) | curses.A_BOLD) 

1346 except curses.error: 

1347 pass 

1348 

1349 # Draw update message 

1350 message_lines = [ 

1351 state.update_message, 

1352 "", 

1353 "Do you want to update now?", 

1354 "", 

1355 "[Y] Yes [N] No" 

1356 ] 

1357 

1358 if state.update_in_progress: 

1359 message_lines = [state.update_result, "", "Updating, please wait..."] 

1360 elif state.update_result: 

1361 message_lines = [state.update_result, "", "[Enter] Continue"] 

1362 

1363 for i, line in enumerate(message_lines): 

1364 if i < dialog_height - 2: # Ensure we don't draw outside the dialog 

1365 line_x = dialog_x + (dialog_width - len(line)) // 2 

1366 try: 

1367 stdscr.addstr(dialog_y + i + 1, line_x, line) 

1368 except curses.error: 

1369 pass 

1370 

1371 # Draw footer with improved controls 

1372 footer_y = max_y - 2 

1373 

1374 if state.editing_option: 

1375 # Show editing controls 

1376 controls = " Enter: Confirm | Esc: Cancel " 

1377 elif state.active_section == 'files': 

1378 # Show file navigation controls with better organization 

1379 controls = " ↑/↓: Navigate | →: Expand | ←: Collapse | Space: Select | Tab: Switch to Options | Enter: Confirm | Esc: Cancel " 

1380 if state.new_version_available: 

1381 controls = " F8: Update | " + controls 

1382 else: 

1383 # Show options controls 

1384 controls = " ↑/↓: Navigate | Space: Toggle/Edit | Tab: Switch to Files | Enter: Confirm | Esc: Cancel " 

1385 if state.new_version_available: 

1386 controls = " F8: Update | " + controls 

1387 

1388 controls = controls.center(max_x-1, "=") 

1389 try: 

1390 stdscr.addstr(footer_y, 0, controls[:max_x-1], curses.color_pair(1)) 

1391 except curses.error: 

1392 pass 

1393 

1394 # Draw status message or editing prompt 

1395 status_y = max_y - 1 

1396 

1397 if state.editing_option: 

1398 # Show editing prompt 

1399 prompt = f" Editing {state.editing_option}: {state.edit_buffer} " 

1400 stdscr.addstr(status_y, 0, " " * (max_x-1)) # Clear line 

1401 stdscr.addstr(status_y, 0, prompt[:max_x-1]) 

1402 # Show cursor 

1403 curses.curs_set(1) 

1404 stdscr.move(status_y, len(f" Editing {state.editing_option}: ") + len(state.edit_buffer)) 

1405 else: 

1406 # Show status message 

1407 status = f" {state.status_message} " 

1408 if not status.strip(): 

1409 if state.active_section == 'files': 

1410 excluded_count = len(state.excluded_items) 

1411 selected_count = len(state.selected_items) 

1412 if excluded_count > 0 and selected_count > 0: 

1413 status = f" {excluded_count} items excluded, {selected_count} explicitly included | Space: Toggle selection (recursive for directories) | Enter: Confirm " 

1414 elif excluded_count > 0: 

1415 status = f" {excluded_count} items excluded | Space: Toggle selection (recursive for directories) | Enter: Confirm " 

1416 elif selected_count > 0: 

1417 status = f" {selected_count} items explicitly included | Space: Toggle selection (recursive for directories) | Enter: Confirm " 

1418 else: 

1419 status = " All files included by default | Space: Toggle selection (recursive for directories) | Enter: Confirm " 

1420 else: 

1421 status = " Use Space to toggle options or edit text fields | Enter: Confirm " 

1422 

1423 # Add version info if available 

1424 if state.current_version: 

1425 version_info = f"v{state.current_version}" 

1426 if state.new_version_available: 

1427 version_info += f" (New: v{state.latest_version} available! Press F8 to update)" 

1428 

1429 # Add version info to status if there's room 

1430 if len(status) + len(version_info) + 3 < max_x: 

1431 padding = max_x - len(status) - len(version_info) - 3 

1432 status += " " * padding + version_info + " " 

1433 

1434 status = status.ljust(max_x-1) 

1435 try: 

1436 stdscr.addstr(status_y, 0, status[:max_x-1]) 

1437 except curses.error: 

1438 pass 

1439 

1440 stdscr.refresh() 

1441 

1442 

1443def handle_input(key: int, state: MenuState) -> bool: 

1444 """Handle user input. Returns True if user wants to exit.""" 

1445 # Handle update dialog first 

1446 if state.show_update_dialog: 

1447 if state.update_in_progress: 

1448 # Don't handle input during update 

1449 return False 

1450 

1451 if state.update_result: 

1452 # After update is complete, any key dismisses the dialog 

1453 if key == 10 or key == 27: # Enter or Escape 

1454 state.show_update_dialog = False 

1455 state.update_result = "" 

1456 return False 

1457 

1458 # Handle update dialog inputs 

1459 if key == ord('y') or key == ord('Y'): 

1460 # Start the update process 

1461 state.update_to_latest_version() 

1462 return False 

1463 elif key == ord('n') or key == ord('N') or key == 27: # 'n', 'N', or Escape 

1464 # Dismiss the dialog 

1465 state.show_update_dialog = False 

1466 return False 

1467 return False 

1468 

1469 # Handle scanning cancellation 

1470 if state.scanning_in_progress: 

1471 if key == 27: # ESC key 

1472 state.cancel_scan_requested = True 

1473 state.status_message = "Cancelling scan..." 

1474 # Don't exit the menu, just cancel the scan 

1475 return False 

1476 # Ignore all other input during scanning 

1477 return False 

1478 

1479 # Handle editing mode separately 

1480 if state.editing_option: 

1481 if key == 27: # Escape key 

1482 state.finish_editing(save=False) 

1483 elif key == 10: # Enter key 

1484 state.finish_editing(save=True) 

1485 elif key == curses.KEY_BACKSPACE or key == 127: # Backspace 

1486 state.edit_buffer = state.edit_buffer[:-1] 

1487 elif 32 <= key <= 126: # Printable ASCII characters 

1488 state.edit_buffer += chr(key) 

1489 return False 

1490 

1491 # Handle normal navigation mode 

1492 if key == 27: # Escape key 

1493 # Cancel and exit 

1494 state.cancelled = True 

1495 state.status_message = "Operation cancelled by user" 

1496 return True 

1497 elif key == 9: # Tab key 

1498 state.toggle_section() 

1499 elif key == 10: # Enter key 

1500 # Confirm selection and exit 

1501 return True 

1502 elif key == ord('q'): 

1503 # Quit without saving 

1504 state.cancelled = True 

1505 state.status_message = "Operation cancelled by user" 

1506 return True 

1507 elif key == ord('f') or key == ord('F'): 

1508 state.active_section = 'files' 

1509 elif key == ord('o') or key == ord('O'): 

1510 state.active_section = 'options' 

1511 # Removed Ctrl+Space shortcut as Space now does full selection 

1512 

1513 # Files section controls 

1514 if state.active_section == 'files': 

1515 current_item = state.get_current_item() 

1516 

1517 if key == curses.KEY_UP: 

1518 state.move_cursor(-1) 

1519 elif key == curses.KEY_DOWN: 

1520 state.move_cursor(1) 

1521 elif key == curses.KEY_RIGHT and current_item and current_item.is_dir(): 

1522 # Expand directory 

1523 state.expanded_dirs.add(str(current_item)) 

1524 state.rebuild_visible_items() 

1525 elif key == curses.KEY_LEFT and current_item and current_item.is_dir(): 

1526 # Collapse directory 

1527 if str(current_item) in state.expanded_dirs: 

1528 state.expanded_dirs.remove(str(current_item)) 

1529 else: 

1530 # If already collapsed, go to parent 

1531 parent = current_item.parent 

1532 for i, (path, _) in enumerate(state.visible_items): 

1533 if path == parent: 

1534 state.cursor_pos = i 

1535 break 

1536 state.rebuild_visible_items() 

1537 elif key == ord(' ') and current_item: 

1538 # Full select with all sub-elements 

1539 state.toggle_selection(current_item, fully_select=True) 

1540 

1541 # Options section controls 

1542 elif state.active_section == 'options': 

1543 if key == curses.KEY_UP: 

1544 state.move_option_cursor(-1) 

1545 elif key == curses.KEY_DOWN: 

1546 state.move_option_cursor(1) 

1547 elif key == ord(' '): 

1548 # Toggle or edit the current option 

1549 option_index = state.option_cursor 

1550 

1551 # Fixed options 

1552 if option_index == 0: # Format 

1553 state.toggle_option('format') 

1554 elif option_index == 1: # Full Export 

1555 state.toggle_option('full') 

1556 elif option_index == 2: # Debug Mode 

1557 state.toggle_option('debug') 

1558 elif option_index == 3: # SQL Server 

1559 state.start_editing_option('sql_server') 

1560 elif option_index == 4: # SQL Database 

1561 state.start_editing_option('sql_database') 

1562 elif option_index == 5: # LLM Provider 

1563 state.toggle_option('llm_provider') 

1564 elif option_index == 6: # Respect .gitignore 

1565 state.toggle_option('respect_gitignore') 

1566 elif option_index >= 7 and option_index < 7 + len(state.options['exclude_patterns']): 

1567 # Remove exclude pattern 

1568 pattern_index = option_index - 7 

1569 state.remove_exclude_pattern(pattern_index) 

1570 

1571 # Function key controls (work in any section) 

1572 if key == curses.KEY_F1: 

1573 state.toggle_option('format') 

1574 elif key == curses.KEY_F2: 

1575 state.toggle_option('full') 

1576 elif key == curses.KEY_F3: 

1577 state.toggle_option('debug') 

1578 elif key == curses.KEY_F4: 

1579 state.start_editing_option('sql_server') 

1580 elif key == curses.KEY_F5: 

1581 state.start_editing_option('sql_database') 

1582 elif key == curses.KEY_F6: 

1583 # Cycle through available LLM providers including 'none' 

1584 providers = list(state.options['llm_options']['providers'].keys()) + ['none'] 

1585 current_index = providers.index(state.options['llm_provider']) if state.options['llm_provider'] in providers else 0 

1586 next_index = (current_index + 1) % len(providers) 

1587 state.options['llm_provider'] = providers[next_index] 

1588 state.status_message = f"LLM Provider set to: {state.options['llm_provider']}" 

1589 elif key == curses.KEY_F7: 

1590 # Toggle gitignore respect 

1591 state.toggle_option('respect_gitignore') 

1592 elif key == curses.KEY_F8: 

1593 # Show update dialog if updates are available 

1594 if state.new_version_available: 

1595 state.show_update_dialog = True 

1596 elif key == curses.KEY_DC: # Delete key 

1597 if state.active_section == 'options' and state.option_cursor >= 6 and state.option_cursor < 6 + len(state.options['exclude_patterns']): 

1598 pattern_index = state.option_cursor - 6 

1599 state.remove_exclude_pattern(pattern_index) 

1600 # Insert key handling removed 

1601 

1602 return False 

1603 

1604 

1605def run_menu(path: Path, initial_settings: Dict[str, Any] = None) -> Dict[str, Any]: 

1606 """ 

1607 Run the interactive file selection menu. 

1608  

1609 Args: 

1610 path: Root path to start the file browser 

1611 initial_settings: Initial settings from command line arguments 

1612  

1613 Returns: 

1614 Dict with selected paths and settings 

1615 """ 

1616 def _menu_main(stdscr) -> Dict[str, Any]: 

1617 # Initialize curses 

1618 curses.curs_set(0) # Hide cursor 

1619 

1620 # Initialize menu state with initial settings 

1621 state = MenuState(path, initial_settings) 

1622 state.expanded_dirs.add(str(path)) # Start with root expanded 

1623 

1624 # Set a shorter timeout for responsive UI updates during scanning 

1625 stdscr.timeout(50) # Even shorter timeout for more responsive UI 

1626 

1627 # Initial scan phase - block UI until complete or cancelled 

1628 state.scanning_in_progress = True 

1629 state.dirty_scan = True 

1630 

1631 # Start the scan in a separate thread to keep UI responsive 

1632 import threading 

1633 

1634 def perform_scan(): 

1635 try: 

1636 state._auto_exclude_common_dirs() 

1637 if not state.cancel_scan_requested: 

1638 state.rebuild_visible_items() 

1639 state._load_state() 

1640 state.scanning_in_progress = False 

1641 except Exception as e: 

1642 state.status_message = f"Error during scan: {str(e)}" 

1643 state.scanning_in_progress = False 

1644 

1645 # Start the scan thread 

1646 scan_thread = threading.Thread(target=perform_scan) 

1647 scan_thread.daemon = True 

1648 scan_thread.start() 

1649 

1650 # Main loop 

1651 while True: 

1652 # Draw the menu (will show scanning screen if scanning_in_progress is True) 

1653 draw_menu(stdscr, state) 

1654 

1655 # Handle input with shorter timeout during scanning 

1656 try: 

1657 if state.scanning_in_progress: 

1658 # Use a very short timeout during scanning for responsive UI 

1659 stdscr.timeout(50) 

1660 else: 

1661 # Use a longer timeout when not scanning 

1662 stdscr.timeout(-1) 

1663 

1664 key = stdscr.getch() 

1665 

1666 # Handle ESC key during scanning 

1667 if state.scanning_in_progress and key == 27: # ESC key 

1668 state.cancel_scan_requested = True 

1669 state.status_message = "Cancelling scan..." 

1670 # Don't exit, just wait for scan to complete 

1671 continue 

1672 

1673 # Skip other input processing during scanning 

1674 if state.scanning_in_progress: 

1675 continue 

1676 

1677 # Normal input handling when not scanning 

1678 if handle_input(key, state): 

1679 break 

1680 except KeyboardInterrupt: 

1681 # Handle Ctrl+C 

1682 if state.scanning_in_progress: 

1683 state.cancel_scan_requested = True 

1684 state.status_message = "Cancelling scan..." 

1685 else: 

1686 state.cancelled = True 

1687 break 

1688 

1689 # If scan thread is still running, wait for it to finish 

1690 if scan_thread.is_alive(): 

1691 state.cancel_scan_requested = True 

1692 scan_thread.join(timeout=1.0) # Wait up to 1 second 

1693 

1694 return state.get_results() 

1695 

1696 # Use curses wrapper to handle terminal setup/cleanup 

1697 try: 

1698 return curses.wrapper(_menu_main) 

1699 except Exception as e: 

1700 # Fallback if curses fails 

1701 print(f"Error in menu: {str(e)}") 

1702 return {'path': path, 'include_paths': [], 'exclude_paths': []}