"""Settings dialog for configuration management.""" import logging from pathlib import Path from typing import Any, Dict, List, Optional from PySide6.QtCore import Qt from PySide6.QtWidgets import ( QComboBox, QDialog, QDialogButtonBox, QFileDialog, QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, QPushButton, QSpinBox, QTableWidget, QTableWidgetItem, QTabWidget, QVBoxLayout, QWidget, ) from webdrop_bridge.config import Config, ConfigurationError from webdrop_bridge.core.config_manager import ConfigExporter, ConfigProfile, ConfigValidator from webdrop_bridge.utils.logging import reconfigure_logging logger = logging.getLogger(__name__) class SettingsDialog(QDialog): """Dialog for managing application settings and configuration. Provides tabs for: - Paths: Manage allowed root directories - URLs: Manage allowed web URLs - Logging: Configure logging settings - Window: Manage window size and behavior - Profiles: Save/load/delete configuration profiles """ def __init__(self, config: Config, parent: Optional[QWidget] = None): """Initialize the settings dialog. Args: config: Current application configuration parent: Parent widget """ super().__init__(parent) self.config = config self.profile_manager = ConfigProfile() self.setWindowTitle("Settings") self.setGeometry(100, 100, 600, 500) self.setup_ui() def setup_ui(self) -> None: """Set up the dialog UI with tabs.""" layout = QVBoxLayout() # Create tab widget self.tabs = QTabWidget() # Add tabs self.tabs.addTab(self._create_web_source_tab(), "Web Source") self.tabs.addTab(self._create_paths_tab(), "Paths") self.tabs.addTab(self._create_urls_tab(), "URLs") self.tabs.addTab(self._create_logging_tab(), "Logging") self.tabs.addTab(self._create_window_tab(), "Window") self.tabs.addTab(self._create_profiles_tab(), "Profiles") layout.addWidget(self.tabs) # Add buttons button_box = QDialogButtonBox( QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel ) button_box.accepted.connect(self.accept) button_box.rejected.connect(self.reject) layout.addWidget(button_box) self.setLayout(layout) def accept(self) -> None: """Handle OK button - save configuration changes to file. Validates configuration and saves to the default config path. Applies log level changes immediately in the running application. If validation or save fails, shows error and stays in dialog. """ try: # Get updated configuration data from UI config_data = self.get_config_data() # Convert URL mappings from dict to URLMapping objects from webdrop_bridge.config import URLMapping url_mappings = [ URLMapping(url_prefix=m["url_prefix"], local_path=m["local_path"]) for m in config_data["url_mappings"] ] # Update the config object with new values old_log_level = self.config.log_level self.config.log_level = config_data["log_level"] self.config.log_file = ( Path(config_data["log_file"]) if config_data["log_file"] else None ) self.config.allowed_roots = [Path(r).resolve() for r in config_data["allowed_roots"]] self.config.allowed_urls = config_data["allowed_urls"] self.config.webapp_url = config_data["webapp_url"] self.config.url_mappings = url_mappings self.config.window_width = config_data["window_width"] self.config.window_height = config_data["window_height"] # Save to file (creates parent dirs if needed) config_path = Config.get_default_config_path() self.config.to_file(config_path) logger.info(f"Configuration saved to {config_path}") logger.info(f" Log level: {self.config.log_level} (was: {old_log_level})") logger.info(f" Window size: {self.config.window_width}x{self.config.window_height}") # Apply log level change immediately to running application if old_log_level != self.config.log_level: logger.info(f"🔄 Updating log level: {old_log_level} → {self.config.log_level}") reconfigure_logging( logger_name="webdrop_bridge", level=self.config.log_level, log_file=self.config.log_file, ) logger.info(f"✅ Log level updated to {self.config.log_level}") # Call parent accept to close dialog super().accept() except ConfigurationError as e: logger.error(f"Configuration error: {e}") self._show_error(f"Configuration Error:\n\n{e}") except Exception as e: logger.error(f"Failed to save configuration: {e}", exc_info=True) self._show_error(f"Failed to save configuration:\n\n{e}") def _create_web_source_tab(self) -> QWidget: """Create web source configuration tab.""" from PySide6.QtWidgets import QTableWidget, QTableWidgetItem widget = QWidget() layout = QVBoxLayout() # Webapp URL configuration layout.addWidget(QLabel("Web Application URL:")) url_layout = QHBoxLayout() self.webapp_url_input = QLineEdit() self.webapp_url_input.setText(self.config.webapp_url) self.webapp_url_input.setPlaceholderText( "e.g., http://localhost:8080 or file:///./webapp/index.html" ) url_layout.addWidget(self.webapp_url_input) open_btn = QPushButton("Open") open_btn.clicked.connect(self._open_webapp_url) url_layout.addWidget(open_btn) layout.addLayout(url_layout) # URL Mappings (Azure Blob URL → Local Path) layout.addWidget(QLabel("URL Mappings (Azure Blob Storage → Local Paths):")) # Create table for URL mappings self.url_mappings_table = QTableWidget() self.url_mappings_table.setColumnCount(2) self.url_mappings_table.setHorizontalHeaderLabels(["URL Prefix", "Local Path"]) self.url_mappings_table.horizontalHeader().setStretchLastSection(True) # Populate from config for mapping in self.config.url_mappings: row = self.url_mappings_table.rowCount() self.url_mappings_table.insertRow(row) self.url_mappings_table.setItem(row, 0, QTableWidgetItem(mapping.url_prefix)) self.url_mappings_table.setItem(row, 1, QTableWidgetItem(mapping.local_path)) layout.addWidget(self.url_mappings_table) # Buttons for URL mapping management button_layout = QHBoxLayout() add_mapping_btn = QPushButton("Add Mapping") add_mapping_btn.clicked.connect(self._add_url_mapping) button_layout.addWidget(add_mapping_btn) edit_mapping_btn = QPushButton("Edit Selected") edit_mapping_btn.clicked.connect(self._edit_url_mapping) button_layout.addWidget(edit_mapping_btn) remove_mapping_btn = QPushButton("Remove Selected") remove_mapping_btn.clicked.connect(self._remove_url_mapping) button_layout.addWidget(remove_mapping_btn) layout.addLayout(button_layout) layout.addStretch() widget.setLayout(layout) return widget def _open_webapp_url(self) -> None: """Open the webapp URL in the default browser.""" import webbrowser url = self.webapp_url_input.text().strip() if url: # Handle file:// URLs try: webbrowser.open(url) except Exception as e: logger.error(f"Failed to open URL: {e}") self._show_error(f"Failed to open URL:\n\n{e}") def _add_url_mapping(self) -> None: """Add new URL mapping.""" from PySide6.QtWidgets import QInputDialog url_prefix, ok1 = QInputDialog.getText( self, "Add URL Mapping", "Enter Azure Blob Storage URL prefix:\n(e.g., https://myblob.blob.core.windows.net/container/)", ) if ok1 and url_prefix: local_path, ok2 = QInputDialog.getText( self, "Add URL Mapping", "Enter local file system path:\n(e.g., C:\\Share or /mnt/share)", ) if ok2 and local_path: row = self.url_mappings_table.rowCount() self.url_mappings_table.insertRow(row) self.url_mappings_table.setItem(row, 0, QTableWidgetItem(url_prefix)) self.url_mappings_table.setItem(row, 1, QTableWidgetItem(local_path)) def _edit_url_mapping(self) -> None: """Edit selected URL mapping.""" from PySide6.QtWidgets import QInputDialog current_row = self.url_mappings_table.currentRow() if current_row < 0: self._show_error("Please select a mapping to edit") return url_prefix = self.url_mappings_table.item(current_row, 0).text() # type: ignore local_path = self.url_mappings_table.item(current_row, 1).text() # type: ignore new_url_prefix, ok1 = QInputDialog.getText( self, "Edit URL Mapping", "Enter Azure Blob Storage URL prefix:", text=url_prefix ) if ok1 and new_url_prefix: new_local_path, ok2 = QInputDialog.getText( self, "Edit URL Mapping", "Enter local file system path:", text=local_path ) if ok2 and new_local_path: self.url_mappings_table.setItem(current_row, 0, QTableWidgetItem(new_url_prefix)) self.url_mappings_table.setItem(current_row, 1, QTableWidgetItem(new_local_path)) def _remove_url_mapping(self) -> None: """Remove selected URL mapping.""" current_row = self.url_mappings_table.currentRow() if current_row >= 0: self.url_mappings_table.removeRow(current_row) def _create_paths_tab(self) -> QWidget: """Create paths configuration tab.""" widget = QWidget() layout = QVBoxLayout() layout.addWidget(QLabel("Allowed root directories for file access:")) # List widget for paths self.paths_list = QListWidget() for path in self.config.allowed_roots: self.paths_list.addItem(str(path)) layout.addWidget(self.paths_list) # Buttons for path management button_layout = QHBoxLayout() add_path_btn = QPushButton("Add Path") add_path_btn.clicked.connect(self._add_path) button_layout.addWidget(add_path_btn) remove_path_btn = QPushButton("Remove Selected") remove_path_btn.clicked.connect(self._remove_path) button_layout.addWidget(remove_path_btn) layout.addLayout(button_layout) layout.addStretch() widget.setLayout(layout) return widget def _create_urls_tab(self) -> QWidget: """Create URLs configuration tab.""" widget = QWidget() layout = QVBoxLayout() layout.addWidget(QLabel("Allowed web URLs (supports wildcards like http://*.example.com):")) # List widget for URLs self.urls_list = QListWidget() for url in self.config.allowed_urls: self.urls_list.addItem(url) layout.addWidget(self.urls_list) # Buttons for URL management button_layout = QHBoxLayout() add_url_btn = QPushButton("Add URL") add_url_btn.clicked.connect(self._add_url) button_layout.addWidget(add_url_btn) remove_url_btn = QPushButton("Remove Selected") remove_url_btn.clicked.connect(self._remove_url) button_layout.addWidget(remove_url_btn) layout.addLayout(button_layout) layout.addStretch() widget.setLayout(layout) return widget def _create_logging_tab(self) -> QWidget: """Create logging configuration tab.""" widget = QWidget() layout = QVBoxLayout() # Log level selection layout.addWidget(QLabel("Log Level:")) from PySide6.QtWidgets import QComboBox self.log_level_combo: QComboBox = self._create_log_level_widget() layout.addWidget(self.log_level_combo) # Log file path layout.addWidget(QLabel("Log File (optional):")) log_file_layout = QHBoxLayout() self.log_file_input = QLineEdit() self.log_file_input.setText(str(self.config.log_file) if self.config.log_file else "") log_file_layout.addWidget(self.log_file_input) browse_btn = QPushButton("Browse...") browse_btn.clicked.connect(self._browse_log_file) log_file_layout.addWidget(browse_btn) layout.addLayout(log_file_layout) layout.addStretch() widget.setLayout(layout) return widget def _create_window_tab(self) -> QWidget: """Create window settings tab.""" widget = QWidget() layout = QVBoxLayout() # Window width width_layout = QHBoxLayout() width_layout.addWidget(QLabel("Window Width:")) self.width_spin = QSpinBox() self.width_spin.setMinimum(400) self.width_spin.setMaximum(5000) self.width_spin.setValue(self.config.window_width) width_layout.addWidget(self.width_spin) width_layout.addStretch() layout.addLayout(width_layout) # Window height height_layout = QHBoxLayout() height_layout.addWidget(QLabel("Window Height:")) self.height_spin = QSpinBox() self.height_spin.setMinimum(300) self.height_spin.setMaximum(5000) self.height_spin.setValue(self.config.window_height) height_layout.addWidget(self.height_spin) height_layout.addStretch() layout.addLayout(height_layout) layout.addStretch() widget.setLayout(layout) return widget def _create_profiles_tab(self) -> QWidget: """Create profiles management tab.""" widget = QWidget() layout = QVBoxLayout() layout.addWidget(QLabel("Saved Configuration Profiles:")) # List of profiles self.profiles_list = QListWidget() self._refresh_profiles_list() layout.addWidget(self.profiles_list) # Profile management buttons button_layout = QHBoxLayout() save_profile_btn = QPushButton("Save as Profile") save_profile_btn.clicked.connect(self._save_profile) button_layout.addWidget(save_profile_btn) load_profile_btn = QPushButton("Load Profile") load_profile_btn.clicked.connect(self._load_profile) button_layout.addWidget(load_profile_btn) delete_profile_btn = QPushButton("Delete Profile") delete_profile_btn.clicked.connect(self._delete_profile) button_layout.addWidget(delete_profile_btn) layout.addLayout(button_layout) # Export/Import buttons export_layout = QHBoxLayout() export_btn = QPushButton("Export Configuration") export_btn.clicked.connect(self._export_config) export_layout.addWidget(export_btn) import_btn = QPushButton("Import Configuration") import_btn.clicked.connect(self._import_config) export_layout.addWidget(import_btn) layout.addLayout(export_layout) layout.addStretch() widget.setLayout(layout) return widget def _create_log_level_widget(self) -> QComboBox: """Create log level selection widget.""" combo = QComboBox() levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] combo.addItems(levels) combo.setCurrentText(self.config.log_level) return combo def _add_path(self) -> None: """Add a new allowed path.""" path = QFileDialog.getExistingDirectory(self, "Select Directory to Allow") if path: self.paths_list.addItem(path) def _remove_path(self) -> None: """Remove selected path.""" if self.paths_list.currentItem(): self.paths_list.takeItem(self.paths_list.row(self.paths_list.currentItem())) def _add_url(self) -> None: """Add a new allowed URL.""" from PySide6.QtWidgets import QInputDialog url, ok = QInputDialog.getText( self, "Add URL", "Enter URL pattern (e.g., http://example.com or http://*.example.com):" ) if ok and url: self.urls_list.addItem(url) def _remove_url(self) -> None: """Remove selected URL.""" if self.urls_list.currentItem(): self.urls_list.takeItem(self.urls_list.row(self.urls_list.currentItem())) def _browse_log_file(self) -> None: """Browse for log file location.""" file_path, _ = QFileDialog.getSaveFileName( self, "Select Log File", str(Path.home()), "Log Files (*.log);;All Files (*)" ) if file_path: self.log_file_input.setText(file_path) def _refresh_profiles_list(self) -> None: """Refresh the list of available profiles.""" self.profiles_list.clear() for profile_name in self.profile_manager.list_profiles(): self.profiles_list.addItem(profile_name) def _save_profile(self) -> None: """Save current configuration as a profile.""" from PySide6.QtWidgets import QInputDialog profile_name, ok = QInputDialog.getText( self, "Save Profile", "Enter profile name (e.g., work, personal):" ) if ok and profile_name: try: self.profile_manager.save_profile(profile_name, self.config) self._refresh_profiles_list() except ConfigurationError as e: self._show_error(f"Failed to save profile: {e}") def _load_profile(self) -> None: """Load a saved profile.""" current_item = self.profiles_list.currentItem() if not current_item: self._show_error("Please select a profile to load") return profile_name = current_item.text() try: config_data = self.profile_manager.load_profile(profile_name) self._apply_config_data(config_data) except ConfigurationError as e: self._show_error(f"Failed to load profile: {e}") def _delete_profile(self) -> None: """Delete a saved profile.""" current_item = self.profiles_list.currentItem() if not current_item: self._show_error("Please select a profile to delete") return profile_name = current_item.text() try: self.profile_manager.delete_profile(profile_name) self._refresh_profiles_list() except ConfigurationError as e: self._show_error(f"Failed to delete profile: {e}") def _export_config(self) -> None: """Export configuration to file.""" file_path, _ = QFileDialog.getSaveFileName( self, "Export Configuration", str(Path.home()), "JSON Files (*.json);;All Files (*)" ) if file_path: try: ConfigExporter.export_to_json(self.config, Path(file_path)) except ConfigurationError as e: self._show_error(f"Failed to export configuration: {e}") def _import_config(self) -> None: """Import configuration from file.""" file_path, _ = QFileDialog.getOpenFileName( self, "Import Configuration", str(Path.home()), "JSON Files (*.json);;All Files (*)" ) if file_path: try: config_data = ConfigExporter.import_from_json(Path(file_path)) self._apply_config_data(config_data) except ConfigurationError as e: self._show_error(f"Failed to import configuration: {e}") def _apply_config_data(self, config_data: Dict[str, Any]) -> None: """Apply imported configuration data to UI. Args: config_data: Configuration dictionary """ # Apply paths self.paths_list.clear() for path in config_data.get("allowed_roots", []): self.paths_list.addItem(str(path)) # Apply URLs self.urls_list.clear() for url in config_data.get("allowed_urls", []): self.urls_list.addItem(url) # Apply logging settings self.log_level_combo.setCurrentText(config_data.get("log_level", "INFO")) log_file = config_data.get("log_file") self.log_file_input.setText(str(log_file) if log_file else "") # Apply window settings self.width_spin.setValue(config_data.get("window_width", 800)) self.height_spin.setValue(config_data.get("window_height", 600)) def get_config_data(self) -> Dict[str, Any]: """Get updated configuration data from dialog. Returns: Configuration dictionary Raises: ConfigurationError: If configuration is invalid """ if self.url_mappings_table: url_mappings_table_count = self.url_mappings_table.rowCount() or 0 else: url_mappings_table_count = 0 config_data = { "app_name": self.config.app_name, "app_version": self.config.app_version, "log_level": self.log_level_combo.currentText(), "log_file": self.log_file_input.text() or None, "allowed_roots": [ self.paths_list.item(i).text() for i in range(self.paths_list.count()) ], "allowed_urls": [self.urls_list.item(i).text() for i in range(self.urls_list.count())], "webapp_url": self.webapp_url_input.text().strip(), "url_mappings": [ { "url_prefix": self.url_mappings_table.item(i, 0).text() if self.url_mappings_table.item(i, 0) else "", # type: ignore "local_path": self.url_mappings_table.item(i, 1).text() if self.url_mappings_table.item(i, 1) else "", # type: ignore } for i in range(url_mappings_table_count) ], "window_width": self.width_spin.value(), "window_height": self.height_spin.value(), "enable_logging": self.config.enable_logging, } # Validate ConfigValidator.validate_or_raise(config_data) return config_data def _show_error(self, message: str) -> None: """Show error message to user. Args: message: Error message """ from PySide6.QtWidgets import QMessageBox QMessageBox.critical(self, "Error", message)