From bd1c866bbb8bed03b80a4064bbb26cabe8688667 Mon Sep 17 00:00:00 2001 From: b-tsammmons <233864410+b-tsammons@users.noreply.github.com> Date: Wed, 12 Nov 2025 15:49:11 -1000 Subject: [PATCH] Add ProcessingLogDialog for displaying processing logs and enhance SettingsDialog with package status checks --- application/app_components/main_gui.py | 391 ++++++++++------- .../app_components/processing_log_dialog.py | 125 ++++++ application/app_components/settings_dialog.py | 401 ++++++++++++++++-- 3 files changed, 743 insertions(+), 174 deletions(-) create mode 100644 application/app_components/processing_log_dialog.py diff --git a/application/app_components/main_gui.py b/application/app_components/main_gui.py index 788a337..47d48ee 100644 --- a/application/app_components/main_gui.py +++ b/application/app_components/main_gui.py @@ -16,6 +16,7 @@ from pathlib import Path from .utils import Logger from .settings_dialog import SettingsDialog +from .processing_log_dialog import ProcessingLogDialog class DryRunVar: @@ -47,6 +48,7 @@ class MainGUI: self.edit_mode = False self.workflow_items = {} self.current_workflow_items = [] + self.active_workflow_item = None # Currently selected item from All Items list # Repository data self.target_repos = [] @@ -77,6 +79,7 @@ class MainGUI: self.target_repo_dropdown_ref = ft.Ref[ft.Dropdown]() self.forked_repo_dropdown_ref = ft.Ref[ft.Dropdown]() self.workflow_item_dropdown_ref = ft.Ref[ft.Dropdown]() + self.active_item_display_ref = ft.Ref[ft.Container]() self.item_counter_ref = ft.Ref[ft.Text]() # DataTable ref for all items @@ -84,6 +87,9 @@ class MainGUI: # All items display self.all_items_container_ref = ft.Ref[ft.Column]() + self.all_items_search_ref = ft.Ref[ft.TextField]() + self.all_items_type_filter_ref = ft.Ref[ft.RadioGroup]() + self.all_items_repo_filter_ref = ft.Ref[ft.RadioGroup]() self.item_detail_dialog_ref = ft.Ref[ft.AlertDialog]() # Sidebar state @@ -121,9 +127,9 @@ class MainGUI: ), ft.Container(expand=True), ft.IconButton( - icon=ft.icons.PSYCHOLOGY, - tooltip="Check AI Modules", - on_click=self._check_ai_modules_manual, + icon=ft.icons.LIST_ALT, + tooltip="Processing Log", + on_click=self._open_processing_log, ), ft.IconButton( icon=ft.icons.SETTINGS, @@ -172,12 +178,22 @@ class MainGUI: vertical_alignment=ft.CrossAxisAlignment.STRETCH, ) - # Overall layout: Top nav + bottom section + # Create hidden log text field for the processing log dialog + hidden_log_text = ft.TextField( + ref=self.log_text_ref, + multiline=True, + read_only=True, + text_style=ft.TextStyle(font_family="Courier New"), + visible=False, # Hidden from main UI + ) + + # Overall layout: Top nav + bottom section + hidden log field app_layout = ft.Column( [ top_nav, ft.Divider(height=1), bottom_section, + hidden_log_text, # Hidden but accessible for dialog ], spacing=0, expand=True, @@ -221,11 +237,6 @@ class MainGUI: content=ft.Row( [ ft.Container(expand=True), - ft.IconButton( - icon=ft.icons.PSYCHOLOGY, - tooltip="Check AI Modules", - on_click=self._check_ai_modules_manual, - ), ft.IconButton( icon=ft.icons.SETTINGS, tooltip="Settings", @@ -303,43 +314,62 @@ class MainGUI: # Action controls (for action mode) action_controls = ft.Column( [ - ft.Text("View", weight=ft.FontWeight.BOLD), - ft.RadioGroup( - ref=self.repo_source_ref, - content=ft.Row([ - ft.Radio(value="target", label="Target"), - ft.Radio(value="fork", label="Fork"), - ]), - value="target", - on_change=lambda e: self._filter_workflow_items(), - ), - ft.Text("Item Type", weight=ft.FontWeight.BOLD), - ft.RadioGroup( - ref=self.item_type_ref, - content=ft.Row([ - ft.Radio(value="pull_request", label="PRs"), - ft.Radio(value="issue", label="Issues"), - ]), - value="pull_request", - on_change=lambda e: self._filter_workflow_items(), - ), + ft.Text("Active Item", weight=ft.FontWeight.BOLD, size=14), + ft.Row([ + ft.Container( + ref=self.active_item_display_ref, + content=ft.Text( + "No item selected", + color=ft.colors.GREY_500, + italic=True, + text_align=ft.TextAlign.CENTER, + ), + padding=10, + border=ft.border.all(1, ft.colors.OUTLINE), + border_radius=8, + bgcolor=ft.colors.GREY_900, + expand=True, + ), + ], spacing=5), + ft.Divider(height=10), + ft.Text("All Items", weight=ft.FontWeight.BOLD, size=14), ft.Row([ ft.ElevatedButton( - "📥 Load Items", + "📥 Pull PRs/Issues", on_click=lambda e: self.page.run_task(self._load_workflow_items_async), ), ft.Text(ref=self.item_counter_ref, value="No items loaded"), ]), - ft.Dropdown( - ref=self.workflow_item_dropdown_ref, - label="Select Workflow Item", - hint_text="Select an item", - options=[], - expand=True, - on_change=self._on_workflow_item_selected, + ft.TextField( + ref=self.all_items_search_ref, + hint_text="Search items...", + prefix_icon=ft.icons.SEARCH, + dense=True, + on_change=self._on_all_items_search_changed, + border_radius=8, + ), + ft.Text("Source Repo", weight=ft.FontWeight.BOLD), + ft.RadioGroup( + ref=self.all_items_type_filter_ref, + content=ft.Row([ + ft.Radio(value="both", label="Both"), + ft.Radio(value="prs", label="PRs"), + ft.Radio(value="issues", label="Issues"), + ], spacing=5), + value="both", + on_change=self._on_all_items_filter_changed, + ), + ft.Text("Item Type", weight=ft.FontWeight.BOLD), + ft.RadioGroup( + ref=self.all_items_repo_filter_ref, + content=ft.Row([ + ft.Radio(value="both", label="Both"), + ft.Radio(value="target", label="Target"), + ft.Radio(value="fork", label="Fork"), + ], spacing=5), + value="both", + on_change=self._on_all_items_filter_changed, ), - ft.Divider(height=10), - ft.Text("All Items", weight=ft.FontWeight.BOLD, size=14), ft.Container( content=ft.Column( ref=self.all_items_container_ref, @@ -427,16 +457,6 @@ class MainGUI: icon=ft.icons.DIFFERENCE, content=self._create_diff_tab() ), - ft.Tab( - text="Processing Log", - icon=ft.icons.LIST_ALT, - content=self._create_log_tab() - ), - ft.Tab( - text="All Items", - icon=ft.icons.VIEW_LIST, - content=self._create_all_items_tab() - ), ], expand=True, ) @@ -607,22 +627,6 @@ class MainGUI: expand=True, ) - def _create_log_tab(self) -> ft.Container: - """Create the processing log tab""" - log_text = ft.TextField( - ref=self.log_text_ref, - multiline=True, - read_only=True, - expand=True, - text_style=ft.TextStyle(font_family="Courier New"), - ) - - return ft.Container( - content=log_text, - padding=20, - expand=True, - ) - def _create_all_items_tab(self) -> ft.Container: """Create the all items tab""" # DataTable for items @@ -732,77 +736,65 @@ class MainGUI: self._display_workflow_item(item) break - def _filter_workflow_items(self): - """Filter workflow items based on current selections""" - print("=" * 60) - print("FILTER METHOD CALLED") - print("=" * 60) - - if not self.repo_source_ref.current or not self.item_type_ref.current: - print("ERROR: repo_source or item_type ref not available") - if self.logger: - self.logger.log("Cannot filter: repo source or item type not selected") + def _on_all_items_search_changed(self, e): + """Handle search field change in All Items list""" + if not self.all_items_search_ref.current: return - source = self.repo_source_ref.current.value - item_type = self.item_type_ref.current.value - print(f"DEBUG: source='{source}', item_type='{item_type}'") + search_query = self.all_items_search_ref.current.value or "" + type_filter = self.all_items_type_filter_ref.current.value if self.all_items_type_filter_ref.current else "both" + repo_filter = self.all_items_repo_filter_ref.current.value if self.all_items_repo_filter_ref.current else "both" + self._populate_all_items(search_query, type_filter, repo_filter) - # Map item_type to the correct key suffix - # "pull_request" → "prs", "issue" → "issues" - if item_type == "pull_request": - type_suffix = "prs" - elif item_type == "issue": - type_suffix = "issues" - else: - type_suffix = f"{item_type}s" + def _on_all_items_filter_changed(self, e): + """Handle filter change in All Items list (type or repo source)""" + search_query = self.all_items_search_ref.current.value if self.all_items_search_ref.current else "" + type_filter = self.all_items_type_filter_ref.current.value if self.all_items_type_filter_ref.current else "both" + repo_filter = self.all_items_repo_filter_ref.current.value if self.all_items_repo_filter_ref.current else "both" + self._populate_all_items(search_query, type_filter, repo_filter) - key = f"{source}_{type_suffix}" - print(f"DEBUG: Mapped item_type '{item_type}' to suffix '{type_suffix}'") - print(f"DEBUG: Looking for key '{key}'") + def _filter_workflow_items(self): + """Collect all workflow items (no filtering since toggles were removed)""" + print("=" * 60) + print("COLLECTING WORKFLOW ITEMS") + print("=" * 60) + + # Collect all items from all categories since filter toggles are removed + all_items = [] + for key, items in self.workflow_items.items(): + all_items.extend(items) + + self.current_workflow_items = all_items + print(f"DEBUG: Collected {len(all_items)} total items") print(f"DEBUG: Available keys in workflow_items: {list(self.workflow_items.keys())}") - self.current_workflow_items = self.workflow_items.get(key, []) - print(f"DEBUG: Found {len(self.current_workflow_items)} items for key '{key}'") - if self.logger: - self.logger.log(f"Filtering workflow items: source={source}, type={item_type}, key={key}") + self.logger.log(f"Collected {len(all_items)} workflow items from all categories") self.logger.log(f"Available workflow item keys: {list(self.workflow_items.keys())}") - self.logger.log(f"Found {len(self.current_workflow_items)} items for key '{key}'") - # Update dropdown - if self.workflow_item_dropdown_ref.current: - options = [] - for item in self.current_workflow_items: - if hasattr(item, 'title'): - options.append(ft.dropdown.Option(item.title)) - print(f" - Added item: {item.title}") - else: - print(f" - WARNING: Item has no title attribute: {item}") + # Update item counter if it exists + if self.item_counter_ref.current: + count_text = f"{len(all_items)} item(s) loaded" + self.item_counter_ref.current.value = count_text + print(f"DEBUG: Counter text set to: {count_text}") - print(f"DEBUG: Created {len(options)} dropdown options") - self.workflow_item_dropdown_ref.current.options = options - - if self.item_counter_ref.current: - count_text = f"{len(options)} item(s) loaded" - if len(options) == 0: - count_text = f"No {item_type}s found in {source} repo" - self.item_counter_ref.current.value = count_text - print(f"DEBUG: Counter text set to: {count_text}") - - print("DEBUG: Calling page.update()...") - self.page.update() - print("DEBUG: page.update() completed") - else: - print("ERROR: workflow_item_dropdown_ref.current is None!") + print("DEBUG: Calling page.update()...") + self.page.update() + print("DEBUG: page.update() completed") def _display_workflow_item(self, item): """Display a workflow item""" # Implementation would populate fields with workflow item data pass - def _populate_all_items(self): - """Populate the all items list with all loaded PRs and Issues""" + def _populate_all_items(self, search_query: str = "", type_filter: str = "both", repo_filter: str = "both"): + """Populate the all items list with all loaded PRs and Issues + + Args: + search_query: Optional search string to filter items + type_filter: Filter by item type - "both", "prs", or "issues" + repo_filter: Filter by repo source - "both", "target", or "fork" + """ if not self.all_items_container_ref.current: return @@ -811,10 +803,56 @@ class MainGUI: for key, items in self.workflow_items.items(): all_items.extend(items) + # Apply repo source filter + if repo_filter == "target": + all_items = [item for item in all_items if item.repo_source == "target"] + elif repo_filter == "fork": + all_items = [item for item in all_items if item.repo_source == "fork"] + # "both" shows everything, no filtering needed + + # Apply type filter + if type_filter == "prs": + all_items = [item for item in all_items if item.item_type == "pull_request"] + elif type_filter == "issues": + all_items = [item for item in all_items if item.item_type == "issue"] + # "both" shows everything, no filtering needed + + # Apply search filter if provided + if search_query: + search_lower = search_query.lower() + filtered_items = [] + for item in all_items: + # Search in title, number, state, author, and labels + if (search_lower in item.title.lower() or + search_lower in str(item.number) or + search_lower in item.state.lower() or + (item.author and search_lower in item.author.lower()) or + any(search_lower in label.lower() for label in (item.labels or []))): + filtered_items.append(item) + all_items = filtered_items + if not all_items: - self.all_items_container_ref.current.controls = [ - ft.Text("No items loaded", color=ft.colors.GREY_500, italic=True) - ] + if search_query or type_filter != "both" or repo_filter != "both": + filter_desc = [] + if search_query: + filter_desc.append(f"matching '{search_query}'") + if type_filter == "prs": + filter_desc.append("PRs only") + elif type_filter == "issues": + filter_desc.append("Issues only") + if repo_filter == "target": + filter_desc.append("Target repo only") + elif repo_filter == "fork": + filter_desc.append("Fork repo only") + + msg = "No items " + " and ".join(filter_desc) if filter_desc else "No items loaded" + self.all_items_container_ref.current.controls = [ + ft.Text(msg, color=ft.colors.GREY_500, italic=True) + ] + else: + self.all_items_container_ref.current.controls = [ + ft.Text("No items loaded", color=ft.colors.GREY_500, italic=True) + ] else: # Sort by updated_at (most recent first) all_items.sort(key=lambda x: x.updated_at if hasattr(x, 'updated_at') else '', reverse=True) @@ -942,25 +980,55 @@ class MainGUI: self.page.update() def _select_item_as_current(self, item): - """Select an item as the current workflow item in the dropdown""" - if not self.workflow_item_dropdown_ref.current: + """Select an item as the current active workflow item""" + if not self.active_item_display_ref.current: return - # Update filters to match the selected item - # Set repo source (target/fork) - if self.repo_source_ref.current: - self.repo_source_ref.current.value = item.repo_source + # Store the active item + self.active_workflow_item = item - # Set item type (pull_request/issue) - if self.item_type_ref.current: - self.item_type_ref.current.value = item.item_type + # Determine display labels + repo_label = "Target" if item.repo_source == "target" else "Fork" + repo_color = ft.colors.BLUE if item.repo_source == "target" else ft.colors.PURPLE + type_label = "PR" if item.item_type == "pull_request" else "Issue" + type_color = ft.colors.GREEN if item.item_type == "pull_request" else ft.colors.ORANGE - # Re-filter the workflow items with the new settings + # Update the active item display with a nice card + self.active_item_display_ref.current.content = ft.Column([ + ft.Row([ + # Repo badge + ft.Container( + content=ft.Text(repo_label, size=10, weight=ft.FontWeight.BOLD, color=ft.colors.WHITE), + bgcolor=repo_color, + padding=ft.padding.symmetric(horizontal=6, vertical=2), + border_radius=4, + ), + # Type badge + ft.Container( + content=ft.Text(type_label, size=10, weight=ft.FontWeight.BOLD, color=ft.colors.WHITE), + bgcolor=type_color, + padding=ft.padding.symmetric(horizontal=6, vertical=2), + border_radius=4, + ), + ft.Container(expand=True), + # Clear button + ft.IconButton( + icon=ft.icons.CLOSE, + icon_size=16, + tooltip="Clear selection", + on_click=self._clear_active_item, + ), + ], spacing=5), + ft.Text( + f"#{item.number}: {item.title}", + size=12, + weight=ft.FontWeight.BOLD, + ), + ], spacing=5) + + # Collect workflow items (filter toggles were removed, so this just collects all items) self._filter_workflow_items() - # Set the dropdown value to this item's title - self.workflow_item_dropdown_ref.current.value = item.title - # Display the item self._display_workflow_item(item) @@ -972,6 +1040,28 @@ class MainGUI: repo_label = "Target" if item.repo_source == "target" else "Fork" self._show_snackbar(f"Selected {item_type_label} from {repo_label}: {item.title}", error=False) + def _clear_active_item(self, e=None): + """Clear the active item selection""" + if not self.active_item_display_ref.current: + return + + # Clear the stored active item + self.active_workflow_item = None + + # Reset the display to default "No item selected" + self.active_item_display_ref.current.content = ft.Text( + "No item selected", + color=ft.colors.GREY_500, + italic=True, + text_align=ft.TextAlign.CENTER, + ) + + # Update the page + self.page.update() + + # Show confirmation + self._show_snackbar("Active item cleared", error=False) + def _show_item_detail(self, item): """Show detail dialog for a workflow item""" # Get repo string for fetching comments @@ -1314,9 +1404,6 @@ class MainGUI: # Populate all items list in sidebar self._populate_all_items() - # Populate all items table in the All Items tab - self._populate_all_items_table() - print("✅ Auto-load completed successfully") else: print("No cached items found, waiting for manual load") @@ -1394,9 +1481,6 @@ class MainGUI: # Populate all items list in sidebar self._populate_all_items() - # Populate all items table in the All Items tab - self._populate_all_items_table() - print("✅ Cached items loaded for selected repositories") if self.logger: self.logger.log("✅ Cached items loaded for selected repositories") @@ -1868,9 +1952,6 @@ class MainGUI: # Populate all items list in sidebar self._populate_all_items() - # Populate all items table in the All Items tab - self._populate_all_items_table() - except Exception as e: if self.logger: self.logger.log(f"Error loading workflow items: {e}") @@ -1971,6 +2052,26 @@ class MainGUI: traceback.print_exc() self._show_snackbar(f"Error opening settings: {ex}", error=True) + def _open_processing_log(self, e): + """Open processing log dialog""" + try: + print("Processing Log button clicked!") + + processing_log_dialog = ProcessingLogDialog( + self.page, + self.log_text_ref + ) + print("ProcessingLogDialog created") + + processing_log_dialog.show() + print("ProcessingLogDialog.show() completed") + + except Exception as ex: + print(f"Error in _open_processing_log: {ex}") + import traceback + traceback.print_exc() + self._show_snackbar(f"Error opening processing log: {ex}", error=True) + def _show_real_settings(self): """Show the real settings dialog""" try: diff --git a/application/app_components/processing_log_dialog.py b/application/app_components/processing_log_dialog.py new file mode 100644 index 0000000..998549f --- /dev/null +++ b/application/app_components/processing_log_dialog.py @@ -0,0 +1,125 @@ +""" +Processing Log Dialog +Displays the processing log in a separate dialog window +""" + +import flet as ft +# Compatibility fix for Flet 0.28+ (Icons vs icons, Colors vs colors) +ft.icons = ft.Icons +ft.colors = ft.Colors + + +class ProcessingLogDialog: + """Processing log display dialog""" + + def __init__(self, page: ft.Page, log_text_ref: ft.Ref): + self.page = page + self.log_text_ref = log_text_ref + self.dialog_ref = ft.Ref[ft.AlertDialog]() + self.log_display_ref = ft.Ref[ft.TextField]() + + def show(self): + """Show the processing log dialog""" + try: + print("ProcessingLogDialog.show() called") + + # Create the dialog + dialog = self._create_dialog() + self.dialog_ref.current = dialog + + # Sync the log content before showing + self._sync_log_content() + + # Open the dialog + self.page.open(dialog) + self.page.update() + + except Exception as ex: + print(f"Error in ProcessingLogDialog.show(): {ex}") + import traceback + traceback.print_exc() + + def _sync_log_content(self): + """Sync log content from main log to dialog display""" + if self.log_text_ref.current and self.log_display_ref.current: + self.log_display_ref.current.value = self.log_text_ref.current.value + if self.page: + self.page.update() + + def _create_dialog(self) -> ft.AlertDialog: + """Create the processing log dialog""" + # Create a display field that will show a copy of the log + # This is synced from the main log field + log_display = ft.TextField( + ref=self.log_display_ref, + value=self.log_text_ref.current.value if self.log_text_ref.current else "", + multiline=True, + read_only=True, + expand=True, + text_style=ft.TextStyle(font_family="Courier New"), + min_lines=20, + max_lines=30, + ) + + # Refresh button + refresh_button = ft.TextButton( + "Refresh", + icon=ft.icons.REFRESH, + on_click=self._refresh_log, + ) + + # Clear button + clear_button = ft.TextButton( + "Clear Log", + icon=ft.icons.DELETE_OUTLINE, + on_click=self._clear_log, + ) + + # Close button + close_button = ft.TextButton( + "Close", + on_click=self._close_clicked, + ) + + dialog = ft.AlertDialog( + ref=self.dialog_ref, + modal=True, + title=ft.Row( + [ + ft.Icon(ft.icons.LIST_ALT, color="blue"), + ft.Text("Processing Log", size=20, weight=ft.FontWeight.BOLD), + ], + alignment=ft.MainAxisAlignment.START, + ), + content=ft.Container( + content=log_display, + width=800, + height=500, + ), + actions=[ + refresh_button, + clear_button, + close_button, + ], + actions_alignment=ft.MainAxisAlignment.SPACE_BETWEEN, + ) + + return dialog + + def _refresh_log(self, e): + """Refresh the log content from the main log""" + self._sync_log_content() + + def _clear_log(self, e): + """Clear the log""" + # Clear both the main log and the display + if self.log_text_ref.current: + self.log_text_ref.current.value = "" + if self.log_display_ref.current: + self.log_display_ref.current.value = "" + self.page.update() + + def _close_clicked(self, e): + """Handle close button click""" + if self.dialog_ref.current: + self.page.close(self.dialog_ref.current) diff --git a/application/app_components/settings_dialog.py b/application/app_components/settings_dialog.py index 495f0e5..e01cced 100644 --- a/application/app_components/settings_dialog.py +++ b/application/app_components/settings_dialog.py @@ -7,9 +7,11 @@ import flet as ft # Compatibility fix for Flet 0.28+ (Icons vs icons, Colors vs colors) ft.icons = ft.Icons ft.colors = ft.Colors -from typing import Dict, Any, Optional +from typing import Dict, Any, Optional, List, Tuple import os import asyncio +import sys +import subprocess class SettingsDialog: @@ -28,6 +30,9 @@ class SettingsDialog: self.detected_repos_dropdown_ref = ft.Ref[ft.Dropdown]() self.ollama_model_dropdown_ref = ft.Ref[ft.Dropdown]() + # Package checker refs + self.package_status_ref = ft.Ref[ft.Container]() + def show(self, on_result=None): """Show the settings dialog""" try: @@ -62,6 +67,8 @@ class SettingsDialog: """Initialize async operations""" await asyncio.sleep(0.1) await self._scan_repos_async() + # Check packages for current AI provider + await self._check_packages_for_current_provider() def _create_dialog(self) -> ft.AlertDialog: """Create the settings dialog""" @@ -232,6 +239,31 @@ class SettingsDialog: """Create AI settings tab""" controls = [] + # Package Status Section (at the top) + controls.append(ft.Container( + content=ft.Column([ + ft.Row([ + ft.Text("Package Status", size=16, weight=ft.FontWeight.BOLD), + ft.IconButton( + icon=ft.icons.REFRESH, + tooltip="Refresh package status", + on_click=lambda e: self.page.run_task(self._check_packages_for_current_provider), + ), + ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN), + ft.Container( + ref=self.package_status_ref, + content=ft.Row([ + ft.ProgressRing(width=20, height=20), + ft.Text("Checking packages...", color=ft.colors.BLUE), + ]), + padding=10, + bgcolor=ft.colors.BLUE_100, + border_radius=5, + ), + ], spacing=10), + padding=ft.padding.only(bottom=10), + )) + # AI Provider Section controls.append(self._create_section_header("🤖 AI Provider Configuration")) @@ -247,6 +279,7 @@ class SettingsDialog: ft.dropdown.Option("ollama", "Ollama"), ], expand=True, + on_change=lambda e: self.page.run_task(self._check_packages_for_current_provider), ) self.entries['AI_PROVIDER'] = ai_provider controls.append(ai_provider) @@ -375,6 +408,304 @@ class SettingsDialog: padding=ft.padding.only(top=20, bottom=10), ) + def _check_ai_packages(self, provider_name: str) -> Tuple[bool, List[str]]: + """Check if required packages for AI provider are installed""" + try: + from .ai_manager import AIManager + ai_manager = AIManager() + available, missing = ai_manager.check_ai_module_availability(provider_name) + return available, missing + except Exception as e: + print(f"Error checking AI packages: {e}") + return False, [] + + def _detect_environment(self) -> Tuple[bool, str]: + """Detect if running in virtual environment""" + in_venv = (hasattr(sys, 'real_prefix') or + (hasattr(sys, 'base_prefix') and sys.base_prefix != sys.prefix) or + os.environ.get('VIRTUAL_ENV') is not None) + + if in_venv: + venv_path = os.environ.get('VIRTUAL_ENV', sys.prefix) + venv_name = os.path.basename(venv_path) + return True, venv_name + else: + return False, "system-wide" + + async def _check_packages_for_current_provider(self): + """Check packages for the currently selected AI provider""" + if not self.package_status_ref.current: + return + + # Get current provider selection + ai_provider_dropdown = self.entries.get('AI_PROVIDER') + if not ai_provider_dropdown: + return + + provider = ai_provider_dropdown.value + if not provider or provider == 'none': + self.package_status_ref.current.content = ft.Container( + content=ft.Row([ + ft.Icon(ft.icons.INFO, color=ft.colors.BLUE), + ft.Text("No AI provider selected", color=ft.colors.BLUE), + ]), + padding=10, + bgcolor=ft.colors.BLUE_100, + border_radius=5, + ) + self.page.update() + return + + # Check packages in background thread + def check_packages(): + return self._check_ai_packages(provider) + + available, missing = await asyncio.to_thread(check_packages) + + # Update UI with results + if available: + self.package_status_ref.current.content = ft.Container( + content=ft.Row([ + ft.Icon(ft.icons.CHECK_CIRCLE, color=ft.colors.GREEN), + ft.Text(f"All required packages for {provider} are installed", color=ft.colors.GREEN), + ]), + padding=10, + bgcolor=ft.colors.GREEN_100, + border_radius=5, + ) + else: + in_venv, env_name = self._detect_environment() + env_text = f"Virtual environment: {env_name}" if in_venv else "System-wide installation" + + self.package_status_ref.current.content = ft.Container( + content=ft.Column([ + ft.Row([ + ft.Icon(ft.icons.WARNING, color=ft.colors.ORANGE), + ft.Text(f"Missing packages for {provider}", color=ft.colors.ORANGE, weight=ft.FontWeight.BOLD), + ]), + ft.Text(f"Required: {', '.join(missing)}", size=12), + ft.Text(f"Environment: {env_text}", size=12, italic=True), + ft.ElevatedButton( + "Install Packages", + icon=ft.icons.DOWNLOAD, + on_click=lambda e: self._install_packages(missing, provider), + ), + ], spacing=5), + padding=10, + bgcolor=ft.colors.ORANGE_100, + border_radius=5, + ) + + self.page.update() + + def _install_packages(self, packages: List[str], provider: str): + """Install missing packages""" + in_venv, env_name = self._detect_environment() + env_text = f"virtual environment '{env_name}'" if in_venv else "system-wide (may require administrator rights)" + + # Create confirmation dialog + package_list = ', '.join(packages) + message = (f"Install the following packages for {provider}?\n\n" + f"Packages: {package_list}\n\n" + f"Installation location: {env_text}\n\n" + f"Command: pip install {' '.join(packages)}") + + def handle_install(e): + self.page.close(install_dialog) + # Run installation in background + self.page.run_task(lambda: self._do_install_packages(packages, provider)) + + def handle_cancel(e): + self.page.close(install_dialog) + + install_dialog = ft.AlertDialog( + modal=True, + title=ft.Text("Install AI Packages"), + content=ft.Text(message), + actions=[ + ft.TextButton("Cancel", on_click=handle_cancel), + ft.FilledButton("Install", on_click=handle_install), + ], + actions_alignment=ft.MainAxisAlignment.END, + ) + + self.page.open(install_dialog) + + async def _do_install_packages(self, packages: List[str], provider: str): + """Actually install the packages""" + in_venv, env_name = self._detect_environment() + + # Update status to show installation in progress + if self.package_status_ref.current: + self.package_status_ref.current.content = ft.Container( + content=ft.Row([ + ft.ProgressRing(width=20, height=20), + ft.Text(f"Installing packages for {provider}...", color=ft.colors.BLUE), + ]), + padding=10, + bgcolor=ft.colors.BLUE_100, + border_radius=5, + ) + self.page.update() + + # Install packages in background thread + def install(): + try: + for package in packages: + print(f"Installing {package}...") + pip_cmd = [sys.executable, '-m', 'pip', 'install', package] + result = subprocess.run(pip_cmd, capture_output=True, text=True, timeout=300) + + # If direct install fails and we're not in venv, try with --user flag + if result.returncode != 0 and not in_venv: + print(f" Direct installation failed, trying with --user flag...") + pip_cmd_user = [sys.executable, '-m', 'pip', 'install', '--user', package] + result = subprocess.run(pip_cmd_user, capture_output=True, text=True, timeout=300) + + if result.returncode != 0: + return False, f"Failed to install {package}: {result.stderr}" + + return True, "All packages installed successfully" + + except subprocess.TimeoutExpired: + return False, "Installation timed out" + except Exception as e: + return False, f"Error installing packages: {str(e)}" + + success, message = await asyncio.to_thread(install) + + # Show result and offer to restart + if success: + def handle_restart(e): + self.page.close(result_dialog) + self._restart_application() + + def handle_later(e): + self.page.close(result_dialog) + # Re-check packages after installation + self.page.run_task(self._check_packages_for_current_provider) + + result_dialog = ft.AlertDialog( + modal=True, + title=ft.Text("Installation Complete"), + content=ft.Text(f"{message}\n\nThe application needs to restart to use the newly installed packages."), + actions=[ + ft.TextButton("Restart Later", on_click=handle_later), + ft.FilledButton("Restart Now", on_click=handle_restart), + ], + actions_alignment=ft.MainAxisAlignment.END, + ) + + self.page.open(result_dialog) + else: + self._show_alert("Installation Failed", message) + # Re-check packages to update status + await self._check_packages_for_current_provider() + + def _restart_application(self): + """Restart the application""" + try: + # Close the dialog first + if self.dialog_ref.current: + self.page.close(self.dialog_ref.current) + + # Show restart message + restart_msg = ft.SnackBar( + content=ft.Text("Restarting application..."), + bgcolor=ft.colors.BLUE, + ) + self.page.open(restart_msg) + self.page.update() + + # Restart the application + python = sys.executable + os.execl(python, python, *sys.argv) + except Exception as e: + self._show_alert("Restart Failed", f"Could not restart application: {str(e)}\n\nPlease restart manually.") + + async def _install_and_save(self, packages: List[str], provider: str, config_values: Dict[str, Any]): + """Install packages and then save configuration""" + # Install packages + in_venv, _ = self._detect_environment() + + def install(): + try: + for package in packages: + print(f"Installing {package}...") + pip_cmd = [sys.executable, '-m', 'pip', 'install', package] + result = subprocess.run(pip_cmd, capture_output=True, text=True, timeout=300) + + # If direct install fails and we're not in venv, try with --user flag + if result.returncode != 0 and not in_venv: + print(f" Direct installation failed, trying with --user flag...") + pip_cmd_user = [sys.executable, '-m', 'pip', 'install', '--user', package] + result = subprocess.run(pip_cmd_user, capture_output=True, text=True, timeout=300) + + if result.returncode != 0: + return False, f"Failed to install {package}: {result.stderr}" + + return True, "All packages installed successfully" + + except subprocess.TimeoutExpired: + return False, "Installation timed out" + except Exception as e: + return False, f"Error installing packages: {str(e)}" + + success, message = await asyncio.to_thread(install) + + if success: + # Save configuration after successful installation + self._do_save(config_values) + + # Offer to restart + def handle_restart(e): + self.page.close(restart_dialog) + self._restart_application() + + def handle_later(e): + self.page.close(restart_dialog) + + restart_dialog = ft.AlertDialog( + modal=True, + title=ft.Text("Installation Complete"), + content=ft.Text( + f"Packages installed successfully!\n" + f"Settings have been saved.\n\n" + f"The application needs to restart to use the newly installed packages." + ), + actions=[ + ft.TextButton("Restart Later", on_click=handle_later), + ft.FilledButton("Restart Now", on_click=handle_restart), + ], + actions_alignment=ft.MainAxisAlignment.END, + ) + + self.page.open(restart_dialog) + else: + self._show_alert("Installation Failed", f"{message}\n\nSettings were not saved.") + + def _do_save(self, config_values: Dict[str, Any]): + """Actually save the configuration""" + try: + # Save configuration + if self.config_manager: + success = self.config_manager.save_configuration(config_values) + else: + success = self._save_to_env_file(config_values) + + if success: + self.result = config_values + self._show_alert( + "Settings Saved", + "Settings saved successfully!\n\nChanges applied immediately - no restart needed! ✨" + ) + self._close_dialog() + else: + self._show_alert("Save Error", "Failed to save settings to .env file.") + + except Exception as e: + self._show_alert("Save Error", f"Error saving settings:\n{str(e)}") + async def _scan_repos_async(self): """Scan for git repositories in the local repo path""" try: @@ -590,36 +921,48 @@ class SettingsDialog: # Check AI provider setup ai_provider = config_values.get('AI_PROVIDER', '').strip().lower() if ai_provider and ai_provider not in ['none', '']: - if ai_provider in ['chatgpt', 'claude', 'anthropic', 'github-copilot', 'copilot', 'github_copilot']: - try: - from .ai_manager import AIManager - ai_manager = AIManager() - available, missing = ai_manager.check_ai_module_availability(ai_provider) - if not available: - # Show warning but continue - self._show_alert( - "AI Modules Not Installed", - f"Settings saved, but AI provider '{ai_provider}' requires additional packages: {', '.join(missing)}\n\n" - f"You can install them later with:\npip install {' '.join(missing)}" - ) - except ImportError: - pass + if ai_provider in ['chatgpt', 'claude', 'anthropic', 'github-copilot', 'copilot', 'github_copilot', 'ollama']: + available, missing = self._check_ai_packages(ai_provider) + if not available and missing: + # Offer to install missing packages + in_venv, env_name = self._detect_environment() + env_text = f"virtual environment '{env_name}'" if in_venv else "system-wide" - # Save configuration - if self.config_manager: - success = self.config_manager.save_configuration(config_values) - else: - success = self._save_to_env_file(config_values) + def handle_install_and_save(e): + self.page.close(package_warning_dialog) + # Install packages and then save + self.page.run_task(lambda: self._install_and_save(missing, ai_provider, config_values)) - if success: - self.result = config_values - self._show_alert( - "Settings Saved", - "Settings saved successfully!\n\nChanges applied immediately - no restart needed! ✨" - ) - self._close_dialog() - else: - self._show_alert("Save Error", "Failed to save settings to .env file.") + def handle_save_anyway(e): + self.page.close(package_warning_dialog) + # Continue with save + self._do_save(config_values) + + def handle_cancel_save(e): + self.page.close(package_warning_dialog) + + package_warning_dialog = ft.AlertDialog( + modal=True, + title=ft.Text("Missing AI Packages"), + content=ft.Text( + f"AI provider '{ai_provider}' requires additional packages:\n\n" + f"{', '.join(missing)}\n\n" + f"Installation location: {env_text}\n\n" + f"Would you like to install them now?" + ), + actions=[ + ft.TextButton("Cancel", on_click=handle_cancel_save), + ft.TextButton("Save Without Installing", on_click=handle_save_anyway), + ft.FilledButton("Install & Save", on_click=handle_install_and_save), + ], + actions_alignment=ft.MainAxisAlignment.END, + ) + + self.page.open(package_warning_dialog) + return # Don't save yet, wait for user choice + + # Save configuration (packages are already installed or not needed) + self._do_save(config_values) except Exception as e: self._show_alert("Save Error", f"Error saving settings:\n{str(e)}")