# File: cpp_python_debug/gui/action_editor_window.py import tkinter as tk from tkinter import ttk, scrolledtext, messagebox, filedialog import logging import os from typing import Dict, Any, Optional, List, Union # Added Union # Importa dialoghi dal modulo corretto from .dialogs import FunctionSelectorDialog, SymbolListViewerDialog from ..core.gdb_controller import GDBSession from ..core.config_manager import AppSettings logger = logging.getLogger(__name__) class ActionEditorWindow(tk.Toplevel): def __init__(self, parent: tk.Widget, action_data: Optional[Dict[str, Any]] = None, is_new: bool = True, target_executable_path: Optional[str] = None, app_settings: Optional[AppSettings] = None, symbol_analysis_data: Optional[Dict[str, Any]] = None): super().__init__(parent) self.parent_window = parent self.is_new_action = is_new self.result: Optional[Dict[str, Any]] = None self.target_executable_path = target_executable_path self.app_settings = app_settings self.symbol_analysis_data = symbol_analysis_data title = "Add New Action" if self.is_new_action else "Edit Action" self.title(title) self.geometry("700x600") # Slightly wider for new buttons self.resizable(False, False) self.transient(parent) self.grab_set() self.breakpoint_var = tk.StringVar() self.output_format_var = tk.StringVar() self.output_directory_var = tk.StringVar() self.filename_pattern_var = tk.StringVar() self.continue_after_dump_var = tk.BooleanVar() self._initial_action_data = action_data.copy() if action_data else None self._create_widgets() self._load_action_data(action_data) self._update_browse_button_states() # NEW: Update state of new buttons self.protocol("WM_DELETE_WINDOW", self._on_cancel) self.wait_window() def _create_widgets(self) -> None: main_frame = ttk.Frame(self, padding="15") main_frame.pack(expand=True, fill=tk.BOTH) main_frame.columnconfigure(1, weight=1) row_idx = 0 ttk.Label(main_frame, text="Breakpoint Location:").grid(row=row_idx, column=0, sticky=tk.W, padx=5, pady=5) bp_input_frame = ttk.Frame(main_frame) bp_input_frame.grid(row=row_idx, column=1, columnspan=2, sticky="ew", padx=5, pady=5) bp_input_frame.columnconfigure(0, weight=1) # Entry expands bp_input_frame.columnconfigure(1, weight=0) # Button for functions bp_input_frame.columnconfigure(2, weight=0) # Button for files self.bp_entry = ttk.Entry(bp_input_frame, textvariable=self.breakpoint_var) self.bp_entry.grid(row=0, column=0, sticky="ew", padx=(0,5)) self.browse_funcs_button = ttk.Button(bp_input_frame, text="Funcs...", command=self._browse_functions, width=8) self.browse_funcs_button.grid(row=0, column=1, sticky=tk.E, padx=(0,2)) # NEW: Button to browse source files self.browse_files_button = ttk.Button(bp_input_frame, text="Files...", command=self._browse_source_files, width=8) self.browse_files_button.grid(row=0, column=2, sticky=tk.E, padx=(0,5)) row_idx += 1 bp_help_label = ttk.Label(main_frame, text="(e.g., main, file.cpp:123, MyClass::foo)", foreground="gray") bp_help_label.grid(row=row_idx, column=1, columnspan=2, sticky=tk.W, padx=7, pady=(0,10)) row_idx += 1 # Variables to Dump section vars_label_frame = ttk.Frame(main_frame) vars_label_frame.grid(row=row_idx, column=0, sticky="nw", padx=5, pady=5) ttk.Label(vars_label_frame, text="Variables to Dump:").pack(side=tk.TOP, anchor=tk.W) # NEW: Button to browse global variables self.browse_globals_button = ttk.Button(vars_label_frame, text="Globals...", command=self._browse_global_variables, width=10) self.browse_globals_button.pack(side=tk.TOP, anchor=tk.W, pady=(2,0)) self.variables_text = scrolledtext.ScrolledText(main_frame, wrap=tk.WORD, height=5, width=58, font=("Consolas", 9)) self.variables_text.grid(row=row_idx, column=1, columnspan=2, sticky="ew", padx=5, pady=5) row_idx += 1 vars_help_label = ttk.Label(main_frame, text="(One variable/expression per line)", foreground="gray") vars_help_label.grid(row=row_idx, column=1, columnspan=2, sticky=tk.W, padx=7, pady=(0,10)) row_idx += 1 ttk.Label(main_frame, text="Output Format:").grid(row=row_idx, column=0, sticky=tk.W, padx=5, pady=5) self.output_format_combo = ttk.Combobox(main_frame, textvariable=self.output_format_var, values=["json", "csv"], state="readonly", width=10) self.output_format_combo.grid(row=row_idx, column=1, sticky="w", padx=5, pady=5) row_idx += 1 ttk.Label(main_frame, text="Output Directory:").grid(row=row_idx, column=0, sticky=tk.W, padx=5, pady=5) self.output_dir_entry = ttk.Entry(main_frame, textvariable=self.output_directory_var) self.output_dir_entry.grid(row=row_idx, column=1, sticky="ew", padx=5, pady=5) ttk.Button(main_frame, text="Browse...", command=self._browse_output_dir).grid(row=row_idx, column=2, padx=5, pady=5) row_idx += 1 ttk.Label(main_frame, text="Filename Pattern:").grid(row=row_idx, column=0, sticky=tk.W, padx=5, pady=5) self.filename_pattern_entry = ttk.Entry(main_frame, textvariable=self.filename_pattern_var) self.filename_pattern_entry.grid(row=row_idx, column=1, columnspan=2, sticky="ew", padx=5, pady=5) row_idx += 1 pattern_help_label = ttk.Label(main_frame, text="(Placeholders: {profile_name}, {breakpoint}, {variable}, {timestamp}, {format})", foreground="gray") pattern_help_label.grid(row=row_idx, column=1, columnspan=2, sticky=tk.W, padx=7, pady=(0,10)) row_idx += 1 self.continue_check = ttk.Checkbutton(main_frame, text="Continue execution after dump", variable=self.continue_after_dump_var) self.continue_check.grid(row=row_idx, column=0, columnspan=3, sticky="w", padx=5, pady=10) row_idx += 1 buttons_frame = ttk.Frame(main_frame) buttons_frame.grid(row=row_idx, column=0, columnspan=3, pady=(15,5), sticky="e") ttk.Button(buttons_frame, text="OK", command=self._on_ok, width=10).pack(side=tk.RIGHT, padx=5) ttk.Button(buttons_frame, text="Cancel", command=self._on_cancel, width=10).pack(side=tk.RIGHT, padx=5) def _update_browse_button_states(self) -> None: """Updates the state of the browse buttons based on available data.""" analysis_available = bool(self.symbol_analysis_data) target_valid = bool(self.target_executable_path and os.path.isfile(str(self.target_executable_path))) # Browse Functions button # Requires either cached analysis or a valid target + GDB settings for live query if analysis_available: self.browse_funcs_button.config(state=tk.NORMAL) elif target_valid and self.app_settings: self.browse_funcs_button.config(state=tk.NORMAL) else: self.browse_funcs_button.config(state=tk.DISABLED) # Browse Source Files button - requires symbol analysis data if analysis_available and self.symbol_analysis_data.get("symbols", {}).get("source_files"): self.browse_files_button.config(state=tk.NORMAL) else: self.browse_files_button.config(state=tk.DISABLED) # Browse Global Variables button - requires symbol analysis data if analysis_available and self.symbol_analysis_data.get("symbols", {}).get("global_variables"): self.browse_globals_button.config(state=tk.NORMAL) else: self.browse_globals_button.config(state=tk.DISABLED) def _load_action_data(self, action_data: Optional[Dict[str, Any]]) -> None: _DEFAULT_ACTION_LOCAL = { "breakpoint_location": "main", "variables_to_dump": ["my_variable"], "output_format": "json", "output_directory": "./debug_dumps", "filename_pattern": "{profile_name}_{breakpoint}_{variable}_{timestamp}.{format}", "continue_after_dump": True } data_to_load = action_data if action_data else _DEFAULT_ACTION_LOCAL.copy() self.breakpoint_var.set(data_to_load.get("breakpoint_location", _DEFAULT_ACTION_LOCAL["breakpoint_location"])) variables_list = data_to_load.get("variables_to_dump", _DEFAULT_ACTION_LOCAL["variables_to_dump"]) if isinstance(variables_list, list): self.variables_text.insert(tk.END, "\n".join(variables_list)) else: self.variables_text.insert(tk.END, str(variables_list)) self.output_format_var.set(data_to_load.get("output_format", _DEFAULT_ACTION_LOCAL["output_format"])) self.output_directory_var.set(data_to_load.get("output_directory", _DEFAULT_ACTION_LOCAL["output_directory"])) self.filename_pattern_var.set(data_to_load.get("filename_pattern", _DEFAULT_ACTION_LOCAL["filename_pattern"])) self.continue_after_dump_var.set(data_to_load.get("continue_after_dump", _DEFAULT_ACTION_LOCAL["continue_after_dump"])) def _browse_output_dir(self) -> None: current_path = self.output_directory_var.get() initial_dir = current_path if current_path and os.path.isdir(current_path) else None path = filedialog.askdirectory( title="Select Output Directory for Dumps", initialdir=initial_dir, parent=self ) if path: self.output_directory_var.set(path) def _validate_data(self) -> bool: if not self.breakpoint_var.get().strip(): messagebox.showerror("Validation Error", "Breakpoint Location cannot be empty.", parent=self) return False variables_str = self.variables_text.get("1.0", tk.END).strip() if not variables_str: messagebox.showerror("Validation Error", "Variables to Dump cannot be empty.", parent=self) return False if not self.output_directory_var.get().strip(): messagebox.showerror("Validation Error", "Output Directory cannot be empty.", parent=self) return False if not self.filename_pattern_var.get().strip(): messagebox.showerror("Validation Error", "Filename Pattern cannot be empty.", parent=self) return False if not any(p in self.filename_pattern_var.get() for p in ["{breakpoint}", "{variable}", "{timestamp}"]): messagebox.showwarning("Validation Warning", "Filename Pattern seems to be missing common placeholders like {breakpoint}, {variable}, or {timestamp}. This might lead to overwritten files.", parent=self) return True def _on_ok(self) -> None: if not self._validate_data(): return variables_list = [line.strip() for line in self.variables_text.get("1.0", tk.END).strip().splitlines() if line.strip()] if not variables_list : messagebox.showerror("Validation Error", "Variables to Dump cannot be empty after processing.", parent=self) return self.result = { "breakpoint_location": self.breakpoint_var.get().strip(), "variables_to_dump": variables_list, "output_format": self.output_format_var.get(), "output_directory": self.output_directory_var.get().strip(), "filename_pattern": self.filename_pattern_var.get().strip(), "continue_after_dump": self.continue_after_dump_var.get() } self.destroy() def _on_cancel(self) -> None: self.result = None self.destroy() def get_result(self) -> Optional[Dict[str, Any]]: return self.result def _browse_functions(self) -> None: functions_to_show_dicts: List[Dict[str, Any]] = [] # Will hold dicts if available functions_to_show_names: List[str] = [] # Fallback to names for live query source_of_functions = "live GDB query" if self.symbol_analysis_data and isinstance(self.symbol_analysis_data, dict): analyzed_exe = self.symbol_analysis_data.get("analyzed_executable_path") if analyzed_exe and os.path.normpath(analyzed_exe) == os.path.normpath(str(self.target_executable_path)): cached_functions = self.symbol_analysis_data.get("symbols", {}).get("functions", []) if isinstance(cached_functions, list) and cached_functions: # Ensure all items are dicts, otherwise this data is not suitable for SymbolListViewerDialog in dict mode if all(isinstance(item, dict) for item in cached_functions): functions_to_show_dicts = cached_functions source_of_functions = "cached analysis (rich)" logger.info(f"Using {len(functions_to_show_dicts)} cached functions (rich data) for browsing.") elif all(isinstance(item, str) for item in cached_functions): # Legacy or simple cache functions_to_show_names = cached_functions source_of_functions = "cached analysis (names only)" logger.info(f"Using {len(functions_to_show_names)} cached function names for browsing.") else: logger.info("Cached symbol analysis is for a different executable or path mismatch. Will perform live query if target is valid.") if analyzed_exe: logger.debug(f"Analyzed exe: '{analyzed_exe}', Current target: '{self.target_executable_path}'") # Live query if no suitable cached data if not functions_to_show_dicts and not functions_to_show_names: if not self.target_executable_path or not os.path.isfile(str(self.target_executable_path)): messagebox.showerror("Error", "Target executable for the profile is not set or invalid. Cannot browse functions.", parent=self) return if not self.app_settings: messagebox.showerror("Error", "Application settings not available. Cannot determine GDB path.", parent=self) return gdb_exe_path = self.app_settings.get_setting("general", "gdb_executable_path") if not gdb_exe_path or not os.path.isfile(gdb_exe_path): messagebox.showerror("GDB Error", f"GDB executable not found or not configured: {gdb_exe_path}", parent=self) return self.config(cursor="watch") self.update_idletasks() temp_gdb_session: Optional[GDBSession] = None live_query_error = False try: logger.info(f"Performing live GDB query for functions. Target: {self.target_executable_path}") temp_gdb_session = GDBSession(gdb_exe_path, str(self.target_executable_path), None, {}) startup_timeout = self.app_settings.get_setting("timeouts", "gdb_start", 30) // 2 command_timeout = self.app_settings.get_setting("timeouts", "gdb_command", 30) // 2 temp_gdb_session.start(timeout=startup_timeout) if not temp_gdb_session.symbols_found: messagebox.showwarning("No Debug Symbols", "GDB reported no debugging symbols in the executable. " "The function list (live query) might be empty or incomplete.", parent=self) functions_to_show_names = temp_gdb_session.list_functions(timeout=command_timeout) # GDBSession.list_functions returns List[str] except Exception as e: logger.error(f"Error during live function query: {e}", exc_info=True) messagebox.showerror("GDB Query Error", f"Could not retrieve functions from GDB: {e}", parent=self) live_query_error = True finally: if temp_gdb_session and temp_gdb_session.is_alive(): quit_timeout = self.app_settings.get_setting("timeouts", "gdb_quit", 10) // 2 try: temp_gdb_session.quit(timeout=quit_timeout) except Exception: pass self.config(cursor="") if live_query_error: return # Display the dialog selected_function: Optional[Union[str, Dict[str, Any]]] = None if functions_to_show_dicts: logger.info(f"Displaying SymbolListViewerDialog with {len(functions_to_show_dicts)} functions from '{source_of_functions}'.") dialog = SymbolListViewerDialog(self, functions_to_show_dicts, title=f"Select Function ({source_of_functions})", return_full_dict=False) # We want the name for the breakpoint selected_function = dialog.result elif functions_to_show_names: logger.info(f"Displaying FunctionSelectorDialog with {len(functions_to_show_names)} function names from '{source_of_functions}'.") # Use FunctionSelectorDialog for simple list of names dialog = FunctionSelectorDialog(self, functions_to_show_names, title=f"Select Function ({source_of_functions})") selected_function = dialog.result else: messagebox.showinfo("No Functions", f"No functions found (source: {source_of_functions}). Ensure target is compiled with debug symbols.", parent=self) return if selected_function: # selected_function will be a string (name) due to return_full_dict=False or from FunctionSelectorDialog if isinstance(selected_function, str): self.breakpoint_var.set(selected_function) elif isinstance(selected_function, dict) and 'name' in selected_function: # Should not happen with return_full_dict=False self.breakpoint_var.set(selected_function['name']) def _browse_source_files(self) -> None: """Allows browsing and selecting a source file for the breakpoint.""" if not self.symbol_analysis_data or not self.symbol_analysis_data.get("symbols", {}).get("source_files"): messagebox.showinfo("No Source Data", "Symbol analysis data with source files is not available.", parent=self) return source_files_list = self.symbol_analysis_data["symbols"]["source_files"] if not source_files_list: messagebox.showinfo("No Source Files", "No source files found in the analysis.", parent=self) return dialog = SymbolListViewerDialog(self, source_files_list, title="Select Source File (from analysis)", return_full_dict=False) # We want the path (which is the 'name' effectively for list of strings) selected_file_path = dialog.result if selected_file_path and isinstance(selected_file_path, str): # Normalize and append colon, user adds line number normalized_path = os.path.normpath(selected_file_path) self.breakpoint_var.set(f"{normalized_path}:") self.bp_entry.focus_set() self.bp_entry.icursor(tk.END) # Place cursor at the end def _browse_global_variables(self) -> None: """Allows browsing and selecting global variables to dump.""" if not self.symbol_analysis_data or not self.symbol_analysis_data.get("symbols", {}).get("global_variables"): messagebox.showinfo("No Global Variables Data", "Symbol analysis data with global variables is not available.", parent=self) return global_vars_list = self.symbol_analysis_data["symbols"]["global_variables"] if not global_vars_list: messagebox.showinfo("No Global Variables", "No global variables found in the analysis.", parent=self) return dialog = SymbolListViewerDialog(self, global_vars_list, title="Select Global Variables to Dump (from analysis)", allow_multiple_selection=True, return_full_dict=False) # We want the names selected_vars = dialog.result if selected_vars and isinstance(selected_vars, list): current_vars_text = self.variables_text.get("1.0", tk.END).strip() current_vars_list = [line.strip() for line in current_vars_text.splitlines() if line.strip()] new_vars_added_count = 0 for var_name in selected_vars: if isinstance(var_name, str) and var_name not in current_vars_list: if current_vars_text: # If not empty, add newline before new var self.variables_text.insert(tk.END, f"\n{var_name}") else: self.variables_text.insert(tk.END, var_name) current_vars_text = self.variables_text.get("1.0", tk.END).strip() # Update for next iteration current_vars_list.append(var_name) # Keep track of added ones new_vars_added_count +=1 if new_vars_added_count > 0: self.variables_text.see(tk.END) logger.info(f"Added {new_vars_added_count} global variables to dump list.")