webdrop-bridge/src/webdrop_bridge/ui/settings_dialog.py
claudi 7873d0a060
Some checks are pending
Tests & Quality Checks / Test on Python 3.11 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.10 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-2 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-2 (push) Waiting to run
Tests & Quality Checks / Build Artifacts (push) Blocked by required conditions
Tests & Quality Checks / Build Artifacts-1 (push) Blocked by required conditions
Fix import order by moving QTabWidget to the correct position in settings_dialog.py
2026-02-20 08:45:54 +01:00

643 lines
23 KiB
Python

"""Settings dialog for configuration management."""
import logging
from pathlib import Path
from typing import List, Optional
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
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=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()
local_path = self.url_mappings_table.item(current_row, 1).text()
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):
"""Create log level selection widget."""
from PySide6.QtWidgets import QComboBox
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) -> 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:
"""Get updated configuration data from dialog.
Returns:
Configuration dictionary
Raises:
ConfigurationError: If configuration is invalid
"""
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(),
"local_path": self.url_mappings_table.item(i, 1).text()
}
for i in range(self.url_mappings_table.rowCount())
],
"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)