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
« 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"""
6import curses
7import os
8import webbrowser
9from pathlib import Path
10from typing import Dict, List, Any, Tuple, Set, Optional
13class MenuState:
14 """Class to manage the state of the interactive menu."""
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
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 = ""
39 # Check for updates
40 self._check_for_updates()
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
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..."
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',
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',
67 # Java/Kotlin/Android
68 'target', '.gradle', '.m2', 'build', 'out', '.idea', '.settings', 'bin', 'gen',
69 'classes', 'obj', 'proguard', 'captures', '.externalNativeBuild', '.cxx',
71 # C/C++/C#
72 'Debug', 'Release', 'x64', 'x86', 'bin', 'obj', 'ipch', '.vs', 'packages',
73 'CMakeFiles', 'CMakeCache.txt', 'cmake-build-*', 'vcpkg_installed',
75 # Go
76 'vendor', '.glide', 'Godeps', '_output', 'bazel-*',
78 # Rust
79 'target', 'Cargo.lock', '.cargo',
81 # Swift/iOS
82 'Pods', '.build', 'DerivedData', '.swiftpm', '*.xcworkspace', '*.xcodeproj/xcuserdata',
84 # Docker/Kubernetes
85 '.docker', 'docker-data', 'k8s-data',
87 # Version control
88 '.git', '.hg', '.svn', '.bzr', '_darcs', 'CVS', '.pijul',
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',
95 # Logs and databases
96 'logs', '*.log', 'npm-debug.log*', 'yarn-debug.log*', 'yarn-error.log*',
97 '*.sqlite', '*.sqlite3', '*.db', 'db.json',
99 # OS specific
100 '.DS_Store', 'Thumbs.db', 'ehthumbs.db', 'Desktop.ini', '$RECYCLE.BIN',
101 '.directory', '*.swp', '*.swo', '*~',
103 # Documentation
104 'docs/_build', 'docs/site', 'site', 'public', '_site', '.docz', '.docusaurus',
106 # Jupyter
107 '.ipynb_checkpoints', '.jupyter', '.ipython',
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',
114 # LLM Code Lens specific
115 '.codelens'
116 ]
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 }
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()
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
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
188 # Load saved state if available - will be done after scanning completes
189 # self._load_state()
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()
200 def toggle_selection(self, path: Path, fully_select: bool = False) -> None:
201 """
202 Toggle selection status of an item.
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)
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
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)
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)
235 # Mark directory structure as dirty to force rescan
236 self.dirty_scan = True
238 # If item was explicitly selected, move to excluded
239 elif is_selected:
240 # Remove from selected
241 self.selected_items.discard(path_str)
243 # Add to excluded
244 self.excluded_items.add(path_str)
246 # Recursively exclude all children
247 self._recursively_exclude(path)
249 # Mark directory structure as dirty to force rescan
250 self.dirty_scan = True
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)
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)
268 # Mark directory structure as dirty to force rescan
269 self.dirty_scan = True
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)
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)
300 # Update parent directory's selection state
301 self._update_parent_selection_state(path.parent)
303 # Mark directory structure as dirty to force rescan
304 self.dirty_scan = True
306 def is_selected(self, path: Path) -> bool:
307 """Check if a path is selected."""
308 path_str = str(path)
310 # If the item is explicitly selected, it's included
311 if path_str in self.selected_items:
312 return True
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
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
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
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
338 # If not explicitly excluded and not in a common directory, it's included by default
339 return True
341 def is_partially_selected(self, path: Path) -> bool:
342 """Check if a path is partially selected."""
343 path_str = str(path)
345 # Only directories can be partially selected
346 if not path.is_dir():
347 return False
349 # If the item is explicitly partially selected
350 if path_str in self.partially_selected_items:
351 return True
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
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
369 return False
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
376 dir_str = str(directory)
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
382 # Initialize counters
383 total_children = 0
384 selected_children = 0
385 excluded_children = 0
386 partially_selected_children = 0
388 try:
389 # Count all immediate children
390 for child in directory.iterdir():
391 child_str = str(child)
392 total_children += 1
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
408 # Skip empty directories
409 if total_children == 0:
410 return
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)
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)
436 def is_excluded(self, path: Path) -> bool:
437 """Check if a path is excluded (updated to include gitignore)."""
438 path_str = str(path)
440 # Check gitignore first if enabled
441 if self.gitignore_parser and self.gitignore_parser.should_ignore(path):
442 return True
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
448 # Check if this path is explicitly excluded
449 if path_str in self.excluded_items:
450 return True
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
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
465 # For files in common directories, they're excluded by default
466 if path.parent.name in self.common_excludes:
467 return True
469 return False
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
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
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
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
495 try:
496 # Auto-exclude common directories before building the list
497 if not self.auto_exclude_complete:
498 self._auto_exclude_common_dirs()
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
506 # Update status
507 self.status_message = "Building directory tree..."
509 # Reset visible items
510 self.visible_items = []
512 # Build the item list
513 self._build_item_list(self.root_path, 0)
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
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)
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
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
541 # Set scanning state
542 self.scanning_in_progress = True
543 self.status_message = "Scanning directory structure..."
545 try:
546 # First, count total directories for progress reporting
547 self.scan_total = 0
548 self.scan_progress = 0
550 # Count directories first to provide progress percentage
551 self.status_message = "Counting directories for progress tracking..."
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
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..."
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)
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
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
587 # Update progress
588 self.scan_progress += 1
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}%)"
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)
604 # Force screen refresh periodically
605 if self.scan_progress % 50 == 0:
606 import time
607 time.sleep(0.01)
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
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)
630 # Process immediate children first to avoid excessive recursion
631 try:
632 for item in directory.iterdir():
633 item_str = str(item)
635 # Remove from excluded and partially selected items
636 self.excluded_items.discard(item_str)
637 self.partially_selected_items.discard(item_str)
639 # Add to selected items
640 self.selected_items.add(item_str)
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)
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
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)}"
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)
672 # Process immediate children first to avoid excessive recursion
673 try:
674 for item in directory.iterdir():
675 item_str = str(item)
677 # Add to excluded items
678 self.excluded_items.add(item_str)
680 # Remove from selected and partially selected items
681 self.selected_items.discard(item_str)
682 self.partially_selected_items.discard(item_str)
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
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)}"
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))
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()))
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))
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
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
736 if option_name == 'respect_gitignore':
737 # Toggle gitignore support
738 self.options[option_name] = not self.options[option_name]
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"
750 # Mark for rescan since ignore patterns changed
751 self.dirty_scan = True
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]
766 self.status_message = f"Option '{option_name}' set to: {self.options[option_name]}"
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}"
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."
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}"
793 self.editing_option = None
794 self.edit_buffer = ""
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}"
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}"
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'
816 self.status_message = f"Switched to {self.active_section} section"
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
823 new_pos = self.option_cursor + direction
824 if 0 <= new_pos < total_options:
825 self.option_cursor = new_pos
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 }
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)
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)
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)
862 return stats
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]
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)
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)
892 # Save state for future runs
893 if not self.cancelled:
894 self._save_state()
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 }
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
919 # Get current and latest versions
920 self.current_version = _get_current_version()
921 self.latest_version, _ = _get_latest_version()
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)}"
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..."
938 try:
939 import subprocess
940 import sys
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 )
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 )
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
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'
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 }
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
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)
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', []))
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
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)}"
1025 def _open_in_llm(self) -> bool:
1026 """
1027 Open selected files in the configured LLM provider.
1029 Returns:
1030 bool: True if successful, False otherwise
1031 """
1032 # Get the provider name
1033 provider = self.options['llm_provider']
1035 # Handle 'none' option
1036 if provider.lower() == 'none':
1037 self.status_message = "LLM integration is disabled (set to 'none')"
1038 return True
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
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
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
1056def draw_menu(stdscr, state: MenuState) -> None:
1057 """Draw the menu interface."""
1058 curses.curs_set(0) # Hide cursor
1059 stdscr.clear()
1061 # Get terminal dimensions
1062 max_y, max_x = stdscr.getmaxyx()
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
1074 # If scanning is in progress, show a progress screen
1075 if state.scanning_in_progress:
1076 stdscr.clear()
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
1086 # Calculate progress percentage
1087 progress_pct = min(100, int((state.scan_progress / max(1, state.scan_total)) * 100))
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)
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):]
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)
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}")
1110 # Draw progress bar with better visual feedback
1111 bar_width = max_x - 20
1112 filled_width = int((bar_width * progress_pct) / 100)
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
1121 # Draw progress percentage
1122 stdscr.addstr(8, 2, f"Progress: {progress_pct}% ")
1124 # Draw progress bar background
1125 stdscr.addstr(8, 15, "[" + " " * bar_width + "]")
1127 # Draw filled portion of progress bar
1128 if filled_width > 0:
1129 stdscr.addstr(8, 16, "=" * filled_width, bar_color | curses.A_BOLD)
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)
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.")
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
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
1157 stdscr.refresh()
1158 return
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)
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
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
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 "
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)
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)
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))
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
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)
1209 for i in range(visible_count):
1210 idx = i + state.scroll_offset
1211 if idx >= len(state.visible_items):
1212 break
1214 path, depth = state.visible_items[idx]
1215 is_dir = path.is_dir()
1216 is_excluded = state.is_excluded(path)
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 " "
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
1234 item_str = f"{indent}{prefix}{sel_indicator} {path.name}"
1236 # Truncate if too long
1237 if len(item_str) > max_x - 2:
1238 item_str = item_str[:max_x - 5] + "..."
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
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)
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
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))
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 ]
1287 # Add exclude patterns
1288 for i, pattern in enumerate(state.options['exclude_patterns']):
1289 options.append((f"Exclude Pattern {i+1}", pattern, "Del"))
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
1296 # Determine if this option is selected
1297 is_selected = state.active_section == 'options' and i == state.option_cursor
1299 # Format the option string
1300 option_str = f" {name}: {value}"
1301 key_str = f"[{key}]"
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
1308 display_str = f"{option_str}{' ' * padding}{key_str}"
1310 # Truncate if too long
1311 if len(display_str) > max_x - 2:
1312 display_str = display_str[:max_x - 5] + "..."
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
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)
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
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
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 ]
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"]
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
1371 # Draw footer with improved controls
1372 footer_y = max_y - 2
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
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
1394 # Draw status message or editing prompt
1395 status_y = max_y - 1
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 "
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)"
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 + " "
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
1440 stdscr.refresh()
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
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
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
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
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
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
1513 # Files section controls
1514 if state.active_section == 'files':
1515 current_item = state.get_current_item()
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)
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
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)
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
1602 return False
1605def run_menu(path: Path, initial_settings: Dict[str, Any] = None) -> Dict[str, Any]:
1606 """
1607 Run the interactive file selection menu.
1609 Args:
1610 path: Root path to start the file browser
1611 initial_settings: Initial settings from command line arguments
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
1620 # Initialize menu state with initial settings
1621 state = MenuState(path, initial_settings)
1622 state.expanded_dirs.add(str(path)) # Start with root expanded
1624 # Set a shorter timeout for responsive UI updates during scanning
1625 stdscr.timeout(50) # Even shorter timeout for more responsive UI
1627 # Initial scan phase - block UI until complete or cancelled
1628 state.scanning_in_progress = True
1629 state.dirty_scan = True
1631 # Start the scan in a separate thread to keep UI responsive
1632 import threading
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
1645 # Start the scan thread
1646 scan_thread = threading.Thread(target=perform_scan)
1647 scan_thread.daemon = True
1648 scan_thread.start()
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)
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)
1664 key = stdscr.getch()
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
1673 # Skip other input processing during scanning
1674 if state.scanning_in_progress:
1675 continue
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
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
1694 return state.get_results()
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': []}