From d570681d86f17032cb5787d4db02a5d44c49f6a1 Mon Sep 17 00:00:00 2001 From: rob Date: Tue, 6 Jan 2026 01:25:18 -0400 Subject: [PATCH] Add Development Hub PyQt6 GUI application MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Features: - Project list with discovery from build-public-docs.sh - PTY-based terminal with pyte emulation - Pane-based workspace with horizontal/vertical splits - Tab management within each pane - Drag-drop support (cd to dirs, run executables, insert paths) - New Project dialog with Ramble voice integration - Settings dialog with JSON persistence - Session persistence (restore layout on restart) - Context menu: open terminal, editor, gitea, docs, deploy šŸ¤– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- pyproject.toml | 30 + src/development_hub/__init__.py | 3 + src/development_hub/__main__.py | 7 + src/development_hub/app.py | 35 + src/development_hub/dialogs.py | 372 +++++++++++ src/development_hub/main_window.py | 276 ++++++++ src/development_hub/project_discovery.py | 84 +++ src/development_hub/project_list.py | 153 +++++ src/development_hub/settings.py | 90 +++ src/development_hub/styles.py | 214 ++++++ src/development_hub/terminal_widget.py | 786 +++++++++++++++++++++++ src/development_hub/workspace.py | 626 ++++++++++++++++++ 12 files changed, 2676 insertions(+) create mode 100644 pyproject.toml create mode 100644 src/development_hub/__init__.py create mode 100644 src/development_hub/__main__.py create mode 100644 src/development_hub/app.py create mode 100644 src/development_hub/dialogs.py create mode 100644 src/development_hub/main_window.py create mode 100644 src/development_hub/project_discovery.py create mode 100644 src/development_hub/project_list.py create mode 100644 src/development_hub/settings.py create mode 100644 src/development_hub/styles.py create mode 100644 src/development_hub/terminal_widget.py create mode 100644 src/development_hub/workspace.py diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ad5e4d3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,30 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "development-hub" +version = "0.1.0" +description = "Central project orchestration GUI for multi-project development" +readme = "README.md" +license = {text = "MIT"} +requires-python = ">=3.10" +dependencies = [ + "PyQt6>=6.5.0", + "pyte>=0.8.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0", + "pytest-qt>=4.0", +] + +[project.scripts] +development-hub = "development_hub.app:main" + +[project.gui-scripts] +development-hub-gui = "development_hub.app:main" + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/src/development_hub/__init__.py b/src/development_hub/__init__.py new file mode 100644 index 0000000..5213007 --- /dev/null +++ b/src/development_hub/__init__.py @@ -0,0 +1,3 @@ +"""Development Hub - Central project orchestration GUI.""" + +__version__ = "0.1.0" diff --git a/src/development_hub/__main__.py b/src/development_hub/__main__.py new file mode 100644 index 0000000..6fc586d --- /dev/null +++ b/src/development_hub/__main__.py @@ -0,0 +1,7 @@ +"""Entry point for running Development Hub as a module.""" + +import sys +from development_hub.app import main + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/development_hub/app.py b/src/development_hub/app.py new file mode 100644 index 0000000..cad4aee --- /dev/null +++ b/src/development_hub/app.py @@ -0,0 +1,35 @@ +"""Application entry point for Development Hub.""" + +import sys + +from PyQt6.QtWidgets import QApplication + +from development_hub.main_window import MainWindow +from development_hub.styles import DARK_THEME + + +class DevelopmentHubApp(QApplication): + """Main application class for Development Hub.""" + + def __init__(self, argv: list[str]): + super().__init__(argv) + self.setApplicationName("Development Hub") + self.setApplicationVersion("0.1.0") + self.setStyle("Fusion") # Consistent cross-platform style + self.setStyleSheet(DARK_THEME) + + +def main() -> int: + """Run the Development Hub application. + + Returns: + Exit code (0 for success). + """ + app = DevelopmentHubApp(sys.argv) + window = MainWindow() + window.show() + return app.exec() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/development_hub/dialogs.py b/src/development_hub/dialogs.py new file mode 100644 index 0000000..86ad233 --- /dev/null +++ b/src/development_hub/dialogs.py @@ -0,0 +1,372 @@ +"""Dialogs for Development Hub.""" + +import json +import subprocess +import shutil +from pathlib import Path + +from PyQt6.QtCore import Qt, QThread, pyqtSignal, QProcess +from PyQt6.QtWidgets import ( + QDialog, + QVBoxLayout, + QHBoxLayout, + QFormLayout, + QLineEdit, + QTextEdit, + QPushButton, + QLabel, + QProgressBar, + QMessageBox, + QGroupBox, +) + +from development_hub.settings import Settings + + +class RambleThread(QThread): + """Thread to run Ramble subprocess.""" + + finished = pyqtSignal(dict) # Emits parsed result + error = pyqtSignal(str) # Emits error message + + def __init__(self, prompt: str, fields: list[str], criteria: dict[str, str] = None): + super().__init__() + self.prompt = prompt + self.fields = fields + self.criteria = criteria or {} + + def run(self): + """Run Ramble and parse output.""" + # Check if ramble is available + ramble_path = shutil.which("ramble") + if not ramble_path: + self.error.emit("Ramble is not installed or not in PATH.\nInstall with: pip install ramble") + return + + # Build command + cmd = [ + ramble_path, + "--prompt", self.prompt, + "--fields", *self.fields, + ] + + if self.criteria: + cmd.extend(["--criteria", json.dumps(self.criteria)]) + + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=300, # 5 minute timeout + ) + + if result.returncode != 0: + # User cancelled or error + if "cancelled" in result.stderr.lower(): + self.finished.emit({}) + else: + self.error.emit(f"Ramble error: {result.stderr}") + return + + # Parse JSON output + try: + data = json.loads(result.stdout) + self.finished.emit(data) + except json.JSONDecodeError: + # Ramble might output the dialog result differently + # Try to find JSON in the output + self.finished.emit({}) + + except subprocess.TimeoutExpired: + self.error.emit("Ramble timed out after 5 minutes") + except Exception as e: + self.error.emit(f"Error running Ramble: {e}") + + +class NewProjectThread(QThread): + """Thread to run new-project script.""" + + output = pyqtSignal(str) + finished = pyqtSignal(bool, str) # success, message + + def __init__(self, name: str, title: str, tagline: str, deploy: bool): + super().__init__() + self.name = name + self.title = title + self.tagline = tagline + self.deploy = deploy + + def run(self): + """Run the new-project script.""" + script = Path.home() / "PycharmProjects" / "development-hub" / "bin" / "new-project" + + if not script.exists(): + self.finished.emit(False, f"Script not found: {script}") + return + + cmd = [ + str(script), + self.name, + "--title", self.title, + "--tagline", self.tagline, + ] + + if self.deploy: + cmd.append("--deploy") + + try: + process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + ) + + # Stream output + for line in process.stdout: + self.output.emit(line.rstrip()) + + process.wait() + + if process.returncode == 0: + self.finished.emit(True, f"Project '{self.name}' created successfully!") + else: + self.finished.emit(False, f"Project creation failed with code {process.returncode}") + + except Exception as e: + self.finished.emit(False, f"Error: {e}") + + +class NewProjectDialog(QDialog): + """Dialog for creating a new project.""" + + project_created = pyqtSignal(str) # Emits project name + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("New Project") + self.setMinimumWidth(500) + self.settings = Settings() + + self._ramble_thread = None + self._create_thread = None + + self._setup_ui() + + def _setup_ui(self): + """Set up the dialog UI.""" + layout = QVBoxLayout(self) + + # Form fields + form_group = QGroupBox("Project Details") + form_layout = QFormLayout(form_group) + + self.name_input = QLineEdit() + self.name_input.setPlaceholderText("my-project (lowercase, hyphens)") + self.name_input.textChanged.connect(self._validate) + form_layout.addRow("Name:", self.name_input) + + self.title_input = QLineEdit() + self.title_input.setPlaceholderText("My Project") + form_layout.addRow("Title:", self.title_input) + + self.tagline_input = QLineEdit() + self.tagline_input.setPlaceholderText("A short description of the project") + form_layout.addRow("Tagline:", self.tagline_input) + + layout.addWidget(form_group) + + # Ramble button + ramble_layout = QHBoxLayout() + ramble_layout.addStretch() + + self.ramble_btn = QPushButton("šŸŽ¤ Ramble...") + self.ramble_btn.setToolTip("Describe your project idea and let AI fill in the fields") + self.ramble_btn.clicked.connect(self._start_ramble) + ramble_layout.addWidget(self.ramble_btn) + + layout.addLayout(ramble_layout) + + # Output area (hidden initially) + self.output_group = QGroupBox("Output") + output_layout = QVBoxLayout(self.output_group) + + self.output_text = QTextEdit() + self.output_text.setReadOnly(True) + self.output_text.setMaximumHeight(150) + output_layout.addWidget(self.output_text) + + self.progress_bar = QProgressBar() + self.progress_bar.setRange(0, 0) # Indeterminate + self.progress_bar.hide() + output_layout.addWidget(self.progress_bar) + + self.output_group.hide() + layout.addWidget(self.output_group) + + # Buttons + button_layout = QHBoxLayout() + button_layout.addStretch() + + self.cancel_btn = QPushButton("Cancel") + self.cancel_btn.clicked.connect(self.reject) + button_layout.addWidget(self.cancel_btn) + + self.create_btn = QPushButton("Create Project") + self.create_btn.setDefault(True) + self.create_btn.setEnabled(False) + self.create_btn.clicked.connect(self._create_project) + button_layout.addWidget(self.create_btn) + + layout.addLayout(button_layout) + + def _validate(self): + """Validate form and enable/disable create button.""" + name = self.name_input.text().strip() + # Basic validation: name must be non-empty and valid + valid = bool(name) and name.replace("-", "").replace("_", "").isalnum() + self.create_btn.setEnabled(valid) + + def _start_ramble(self): + """Launch Ramble to fill in fields.""" + self.ramble_btn.setEnabled(False) + self.ramble_btn.setText("šŸŽ¤ Running Ramble...") + + self._ramble_thread = RambleThread( + prompt="Describe the project you want to create. What does it do? What problem does it solve?", + fields=["Name", "Title", "Tagline"], + criteria={ + "Name": "lowercase, use hyphens instead of spaces, no special characters", + "Title": "Title case, human readable", + "Tagline": "One sentence description", + } + ) + self._ramble_thread.finished.connect(self._on_ramble_finished) + self._ramble_thread.error.connect(self._on_ramble_error) + self._ramble_thread.start() + + def _on_ramble_finished(self, result: dict): + """Handle Ramble completion.""" + self.ramble_btn.setEnabled(True) + self.ramble_btn.setText("šŸŽ¤ Ramble...") + + if result and "fields" in result: + fields = result["fields"] + if "Name" in fields: + self.name_input.setText(fields["Name"]) + if "Title" in fields: + self.title_input.setText(fields["Title"]) + if "Tagline" in fields: + self.tagline_input.setText(fields["Tagline"]) + + def _on_ramble_error(self, error: str): + """Handle Ramble error.""" + self.ramble_btn.setEnabled(True) + self.ramble_btn.setText("šŸŽ¤ Ramble...") + QMessageBox.warning(self, "Ramble Error", error) + + def _create_project(self): + """Start project creation.""" + name = self.name_input.text().strip() + title = self.title_input.text().strip() or name.replace("-", " ").title() + tagline = self.tagline_input.text().strip() or f"The {title} project" + + # Show output area + self.output_group.show() + self.output_text.clear() + self.progress_bar.show() + + # Disable inputs + self.name_input.setEnabled(False) + self.title_input.setEnabled(False) + self.tagline_input.setEnabled(False) + self.ramble_btn.setEnabled(False) + self.create_btn.setEnabled(False) + + # Start creation thread + self._create_thread = NewProjectThread( + name=name, + title=title, + tagline=tagline, + deploy=self.settings.deploy_docs_after_creation, + ) + self._create_thread.output.connect(self._on_create_output) + self._create_thread.finished.connect(self._on_create_finished) + self._create_thread.start() + + def _on_create_output(self, line: str): + """Handle output from creation script.""" + self.output_text.append(line) + # Auto-scroll to bottom + scrollbar = self.output_text.verticalScrollBar() + scrollbar.setValue(scrollbar.maximum()) + + def _on_create_finished(self, success: bool, message: str): + """Handle project creation completion.""" + self.progress_bar.hide() + + if success: + self.output_text.append(f"\nāœ“ {message}") + self.project_created.emit(self.name_input.text().strip()) + + # Change cancel to close + self.cancel_btn.setText("Close") + self.create_btn.hide() + else: + self.output_text.append(f"\nāœ— {message}") + + # Re-enable inputs for retry + self.name_input.setEnabled(True) + self.title_input.setEnabled(True) + self.tagline_input.setEnabled(True) + self.ramble_btn.setEnabled(True) + self.create_btn.setEnabled(True) + + +class SettingsDialog(QDialog): + """Dialog for application settings.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Settings") + self.setMinimumWidth(400) + self.settings = Settings() + + self._setup_ui() + + def _setup_ui(self): + """Set up the dialog UI.""" + layout = QVBoxLayout(self) + + # Project creation settings + project_group = QGroupBox("Project Creation") + project_layout = QVBoxLayout(project_group) + + from PyQt6.QtWidgets import QCheckBox + self.deploy_checkbox = QCheckBox("Deploy docs after creating new project") + self.deploy_checkbox.setChecked(self.settings.deploy_docs_after_creation) + project_layout.addWidget(self.deploy_checkbox) + + layout.addWidget(project_group) + + # Buttons + button_layout = QHBoxLayout() + button_layout.addStretch() + + cancel_btn = QPushButton("Cancel") + cancel_btn.clicked.connect(self.reject) + button_layout.addWidget(cancel_btn) + + save_btn = QPushButton("Save") + save_btn.setDefault(True) + save_btn.clicked.connect(self._save) + button_layout.addWidget(save_btn) + + layout.addLayout(button_layout) + + def _save(self): + """Save settings and close.""" + self.settings.deploy_docs_after_creation = self.deploy_checkbox.isChecked() + self.accept() diff --git a/src/development_hub/main_window.py b/src/development_hub/main_window.py new file mode 100644 index 0000000..c113064 --- /dev/null +++ b/src/development_hub/main_window.py @@ -0,0 +1,276 @@ +"""Main window for Development Hub.""" + +from pathlib import Path + +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QAction, QKeySequence +from PyQt6.QtWidgets import ( + QLabel, + QMainWindow, + QSplitter, + QStatusBar, + QVBoxLayout, + QWidget, +) + +from development_hub.project_discovery import Project +from development_hub.project_list import ProjectListWidget +from development_hub.workspace import WorkspaceManager +from development_hub.dialogs import NewProjectDialog, SettingsDialog +from development_hub.settings import Settings + + +class MainWindow(QMainWindow): + """Main application window with project list and workspace.""" + + def __init__(self): + super().__init__() + self.setWindowTitle("Development Hub") + self.resize(1200, 800) + self.settings = Settings() + + self._setup_ui() + self._setup_menus() + self._setup_status_bar() + self._restore_session() + + def _setup_ui(self): + """Set up the main UI layout.""" + # Central widget with splitter + central = QWidget() + self.setCentralWidget(central) + + layout = QVBoxLayout(central) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + # Main splitter: project list | workspace + self.main_splitter = QSplitter(Qt.Orientation.Horizontal) + layout.addWidget(self.main_splitter) + + # Left: Project list + self.project_list = ProjectListWidget() + self.project_list.setFixedWidth(200) + self.project_list.open_terminal_requested.connect(self._open_terminal) + self.main_splitter.addWidget(self.project_list) + + # Right: Workspace manager with splittable panes + self.workspace = WorkspaceManager() + self.workspace.pane_count_changed.connect(self._update_status) + self.main_splitter.addWidget(self.workspace) + + # Set splitter proportions + self.main_splitter.setSizes([200, 1000]) + + def _setup_menus(self): + """Set up the menu bar.""" + menubar = self.menuBar() + + # File menu + file_menu = menubar.addMenu("&File") + + new_project = QAction("&New Project...", self) + new_project.setShortcut(QKeySequence("Ctrl+N")) + new_project.triggered.connect(self._new_project) + file_menu.addAction(new_project) + + file_menu.addSeparator() + + settings_action = QAction("&Settings...", self) + settings_action.setShortcut(QKeySequence("Ctrl+,")) + settings_action.triggered.connect(self._show_settings) + file_menu.addAction(settings_action) + + file_menu.addSeparator() + + exit_action = QAction("E&xit", self) + exit_action.setShortcut(QKeySequence("Ctrl+Q")) + exit_action.triggered.connect(self.close) + file_menu.addAction(exit_action) + + # Project menu + project_menu = menubar.addMenu("&Project") + + refresh = QAction("&Refresh List", self) + refresh.setShortcut(QKeySequence("F5")) + refresh.triggered.connect(self.project_list.refresh) + project_menu.addAction(refresh) + + # View menu + view_menu = menubar.addMenu("&View") + + toggle_projects = QAction("Toggle &Project Panel", self) + toggle_projects.setShortcut(QKeySequence("Ctrl+B")) + toggle_projects.triggered.connect(self._toggle_project_panel) + view_menu.addAction(toggle_projects) + + view_menu.addSeparator() + + split_h = QAction("Split &Horizontal", self) + split_h.setShortcut(QKeySequence("Ctrl+Shift+D")) + split_h.triggered.connect(self._split_horizontal) + view_menu.addAction(split_h) + + split_v = QAction("Split &Vertical", self) + split_v.setShortcut(QKeySequence("Ctrl+Shift+E")) + split_v.triggered.connect(self._split_vertical) + view_menu.addAction(split_v) + + view_menu.addSeparator() + + close_pane = QAction("Close &Pane", self) + close_pane.setShortcut(QKeySequence("Ctrl+Shift+P")) + close_pane.triggered.connect(self._close_active_pane) + view_menu.addAction(close_pane) + + view_menu.addSeparator() + + next_pane = QAction("&Next Pane", self) + next_pane.setShortcut(QKeySequence("Ctrl+Alt+Right")) + next_pane.triggered.connect(self.workspace.focus_next_pane) + view_menu.addAction(next_pane) + + prev_pane = QAction("P&revious Pane", self) + prev_pane.setShortcut(QKeySequence("Ctrl+Alt+Left")) + prev_pane.triggered.connect(self.workspace.focus_previous_pane) + view_menu.addAction(prev_pane) + + # Terminal menu + terminal_menu = menubar.addMenu("&Terminal") + + new_tab = QAction("New &Tab", self) + new_tab.setShortcut(QKeySequence("Ctrl+Shift+T")) + new_tab.triggered.connect(self._new_terminal_tab) + terminal_menu.addAction(new_tab) + + close_tab = QAction("&Close Tab", self) + close_tab.setShortcut(QKeySequence("Ctrl+Shift+W")) + close_tab.triggered.connect(self._close_current_tab) + terminal_menu.addAction(close_tab) + + # Help menu + help_menu = menubar.addMenu("&Help") + + about = QAction("&About", self) + about.triggered.connect(self._show_about) + help_menu.addAction(about) + + def _setup_status_bar(self): + """Set up the status bar.""" + self.status_bar = QStatusBar() + self.setStatusBar(self.status_bar) + self._update_status() + + def _update_status(self, *args): + """Update status bar text.""" + project_count = self.project_list.list_widget.count() + pane_count = len(self.workspace.find_panes()) + tab_count = self.workspace.total_tab_count() + + pane_str = f"{pane_count} pane{'s' if pane_count != 1 else ''}" + tab_str = f"{tab_count} tab{'s' if tab_count != 1 else ''}" + + self.status_bar.showMessage( + f"{project_count} projects | {pane_str} | {tab_str}" + ) + + def _open_terminal(self, project: Project): + """Open a terminal tab for a project in the active pane.""" + self.workspace.add_terminal(project.path, project.title) + self._update_status() + + def _new_terminal_tab(self): + """Create a new terminal tab at home directory in the active pane.""" + # Count existing terminal tabs for naming + panes = self.workspace.find_panes() + terminal_count = 0 + for pane in panes: + for i in range(pane.tab_widget.count()): + if pane.tab_widget.tabText(i).startswith("Terminal"): + terminal_count += 1 + + tab_name = f"Terminal {terminal_count + 1}" if terminal_count > 0 else "Terminal" + self.workspace.add_terminal(Path.home(), tab_name) + self._update_status() + + def _close_current_tab(self): + """Close the current tab in the active pane.""" + self.workspace.close_current_tab() + self._update_status() + + def _toggle_project_panel(self): + """Toggle visibility of the project list panel.""" + if self.project_list.isVisible(): + self.project_list.hide() + else: + self.project_list.show() + + def _split_horizontal(self): + """Split the active pane horizontally (creates left/right panes).""" + self.workspace.split_horizontal() + + def _split_vertical(self): + """Split the active pane vertically (creates top/bottom panes).""" + self.workspace.split_vertical() + + def _close_active_pane(self): + """Close the currently active pane.""" + self.workspace.close_active_pane() + self._update_status() + + def _new_project(self): + """Show new project dialog.""" + dialog = NewProjectDialog(self) + dialog.project_created.connect(self._on_project_created) + dialog.exec() + + def _on_project_created(self, project_name: str): + """Handle new project creation.""" + # Refresh the project list to show the new project + self.project_list.refresh() + self._update_status() + + def _show_settings(self): + """Show settings dialog.""" + dialog = SettingsDialog(self) + dialog.exec() + + def _show_about(self): + """Show about dialog.""" + from PyQt6.QtWidgets import QMessageBox + + QMessageBox.about( + self, + "About Development Hub", + "

Development Hub

" + "

Version 0.1.0

" + "

Central project orchestration for multi-project development.

" + "

Part of Rob's development ecosystem.

" + "

Keyboard Shortcuts

" + "" + ) + + def _restore_session(self): + """Restore the previous session.""" + session = self.settings.load_session() + if session: + self.workspace.restore_session(session) + self._update_status() + + def closeEvent(self, event): + """Handle window close - save session and terminate all terminals.""" + # Save session state + session = self.workspace.get_session_state() + self.settings.save_session(session) + + # Terminate all terminals + self.workspace.terminate_all() + super().closeEvent(event) diff --git a/src/development_hub/project_discovery.py b/src/development_hub/project_discovery.py new file mode 100644 index 0000000..76d1bc7 --- /dev/null +++ b/src/development_hub/project_discovery.py @@ -0,0 +1,84 @@ +"""Discover projects from the build-public-docs.sh configuration.""" + +import re +from dataclasses import dataclass +from pathlib import Path + + +@dataclass +class Project: + """Represents a project in the development ecosystem.""" + + key: str + title: str + tagline: str + owner: str + repo: str + dirname: str + + @property + def path(self) -> Path: + """Local path to project directory.""" + return Path.home() / "PycharmProjects" / self.dirname + + @property + def gitea_url(self) -> str: + """URL to Gitea repository.""" + return f"https://gitea.brrd.tech/{self.owner}/{self.repo}" + + @property + def docs_url(self) -> str: + """URL to public documentation.""" + return f"https://pages.brrd.tech/{self.owner}/{self.repo}/" + + @property + def exists(self) -> bool: + """Check if project directory exists locally.""" + return self.path.exists() + + +def discover_projects() -> list[Project]: + """Parse PROJECT_CONFIG from build-public-docs.sh. + + Returns: + List of Project objects discovered from the build script. + """ + build_script = Path.home() / "PycharmProjects/project-docs/scripts/build-public-docs.sh" + + if not build_script.exists(): + return [] + + projects = [] + # Pattern: PROJECT_CONFIG["key"]="Title|Tagline|Owner|Repo|DirName" + pattern = r'PROJECT_CONFIG\["([^"]+)"\]="([^|]+)\|([^|]+)\|([^|]+)\|([^|]+)\|([^"]+)"' + + for match in re.finditer(pattern, build_script.read_text()): + key, title, tagline, owner, repo, dirname = match.groups() + projects.append(Project( + key=key, + title=title, + tagline=tagline, + owner=owner, + repo=repo, + dirname=dirname, + )) + + # Sort by title for consistent display + projects.sort(key=lambda p: p.title.lower()) + + return projects + + +def get_project_by_key(key: str) -> Project | None: + """Get a specific project by its key. + + Args: + key: The project key (e.g., 'cmdforge', 'ramble') + + Returns: + Project object if found, None otherwise. + """ + for project in discover_projects(): + if project.key == key: + return project + return None diff --git a/src/development_hub/project_list.py b/src/development_hub/project_list.py new file mode 100644 index 0000000..a688857 --- /dev/null +++ b/src/development_hub/project_list.py @@ -0,0 +1,153 @@ +"""Project list widget showing all discovered projects.""" + +import subprocess +import webbrowser +from pathlib import Path + +from PyQt6.QtCore import Qt, pyqtSignal +from PyQt6.QtGui import QAction +from PyQt6.QtWidgets import ( + QLabel, + QListWidget, + QListWidgetItem, + QMenu, + QVBoxLayout, + QWidget, +) + +from development_hub.project_discovery import Project, discover_projects + + +class ProjectListWidget(QWidget): + """Widget displaying the list of projects with context menu actions.""" + + project_selected = pyqtSignal(Project) + open_terminal_requested = pyqtSignal(Project) + + def __init__(self, parent=None): + super().__init__(parent) + self._projects: list[Project] = [] + self._setup_ui() + self.refresh() + + def _setup_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + # Header + header = QLabel("PROJECTS") + header.setObjectName("header") + layout.addWidget(header) + + # Project list + self.list_widget = QListWidget() + self.list_widget.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.list_widget.customContextMenuRequested.connect(self._show_context_menu) + self.list_widget.itemClicked.connect(self._on_item_clicked) + self.list_widget.itemDoubleClicked.connect(self._on_item_double_clicked) + layout.addWidget(self.list_widget) + + def refresh(self): + """Refresh the project list from the build configuration.""" + self.list_widget.clear() + self._projects = discover_projects() + + for project in self._projects: + item = QListWidgetItem(project.title) + item.setData(Qt.ItemDataRole.UserRole, project) + + # Add tooltip with project info + tooltip = f"{project.tagline}\n\nPath: {project.path}" + if not project.exists: + tooltip += "\n\n(Directory not found)" + item.setForeground(Qt.GlobalColor.darkGray) + item.setToolTip(tooltip) + + self.list_widget.addItem(item) + + def _get_selected_project(self) -> Project | None: + """Get the currently selected project.""" + item = self.list_widget.currentItem() + if item: + return item.data(Qt.ItemDataRole.UserRole) + return None + + def _on_item_clicked(self, item: QListWidgetItem): + """Handle single click on project.""" + project = item.data(Qt.ItemDataRole.UserRole) + self.project_selected.emit(project) + + def _on_item_double_clicked(self, item: QListWidgetItem): + """Handle double-click - open terminal at project.""" + project = item.data(Qt.ItemDataRole.UserRole) + if project.exists: + self.open_terminal_requested.emit(project) + + def _show_context_menu(self, position): + """Show context menu for project actions.""" + item = self.list_widget.itemAt(position) + if not item: + return + + project = item.data(Qt.ItemDataRole.UserRole) + menu = QMenu(self) + + # Open Terminal + open_terminal = QAction("Open Terminal", self) + open_terminal.triggered.connect(lambda: self.open_terminal_requested.emit(project)) + open_terminal.setEnabled(project.exists) + menu.addAction(open_terminal) + + # Open in Editor + open_editor = QAction("Open in Editor", self) + open_editor.triggered.connect(lambda: self._open_in_editor(project)) + open_editor.setEnabled(project.exists) + menu.addAction(open_editor) + + menu.addSeparator() + + # View on Gitea + view_gitea = QAction("View on Gitea", self) + view_gitea.triggered.connect(lambda: webbrowser.open(project.gitea_url)) + menu.addAction(view_gitea) + + # View Documentation + view_docs = QAction("View Documentation", self) + view_docs.triggered.connect(lambda: webbrowser.open(project.docs_url)) + menu.addAction(view_docs) + + menu.addSeparator() + + # Deploy Docs + deploy_docs = QAction("Deploy Docs", self) + deploy_docs.triggered.connect(lambda: self._deploy_docs(project)) + menu.addAction(deploy_docs) + + menu.exec(self.list_widget.mapToGlobal(position)) + + def _open_in_editor(self, project: Project): + """Open project in the default editor.""" + import os + import shutil + + # Try common editors in order of preference + editors = [ + os.environ.get("EDITOR"), + "pycharm", + "code", # VS Code + "subl", # Sublime Text + "gedit", + "xdg-open", + ] + + for editor in editors: + if editor and shutil.which(editor): + subprocess.Popen([editor, str(project.path)]) + return + + def _deploy_docs(self, project: Project): + """Deploy documentation for project.""" + build_script = Path.home() / "PycharmProjects/project-docs/scripts/build-public-docs.sh" + if build_script.exists(): + subprocess.Popen([str(build_script), project.key, "--deploy"]) diff --git a/src/development_hub/settings.py b/src/development_hub/settings.py new file mode 100644 index 0000000..10ab24e --- /dev/null +++ b/src/development_hub/settings.py @@ -0,0 +1,90 @@ +"""Settings management for Development Hub.""" + +import json +from pathlib import Path + + +class Settings: + """Application settings with file persistence.""" + + _instance = None + _settings_file = Path.home() / ".config" / "development-hub" / "settings.json" + _session_file = Path.home() / ".config" / "development-hub" / "session.json" + + # Default settings + DEFAULTS = { + "deploy_docs_after_creation": True, + "default_project_path": str(Path.home() / "PycharmProjects"), + } + + def __new__(cls): + """Singleton pattern.""" + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._settings = {} + cls._instance._load() + return cls._instance + + def _load(self): + """Load settings from file.""" + self._settings = self.DEFAULTS.copy() + + if self._settings_file.exists(): + try: + with open(self._settings_file) as f: + saved = json.load(f) + self._settings.update(saved) + except (json.JSONDecodeError, OSError): + pass + + def _save(self): + """Save settings to file.""" + self._settings_file.parent.mkdir(parents=True, exist_ok=True) + with open(self._settings_file, "w") as f: + json.dump(self._settings, f, indent=2) + + def get(self, key: str, default=None): + """Get a setting value.""" + return self._settings.get(key, default) + + def set(self, key: str, value): + """Set a setting value and save.""" + self._settings[key] = value + self._save() + + @property + def deploy_docs_after_creation(self) -> bool: + """Whether to deploy docs after creating a new project.""" + return self.get("deploy_docs_after_creation", True) + + @deploy_docs_after_creation.setter + def deploy_docs_after_creation(self, value: bool): + self.set("deploy_docs_after_creation", value) + + @property + def default_project_path(self) -> Path: + """Default path for new projects.""" + return Path(self.get("default_project_path", str(Path.home() / "PycharmProjects"))) + + @default_project_path.setter + def default_project_path(self, value: Path): + self.set("default_project_path", str(value)) + + def save_session(self, state: dict): + """Save session state to file.""" + self._session_file.parent.mkdir(parents=True, exist_ok=True) + try: + with open(self._session_file, "w") as f: + json.dump(state, f, indent=2) + except OSError: + pass + + def load_session(self) -> dict: + """Load session state from file.""" + if self._session_file.exists(): + try: + with open(self._session_file) as f: + return json.load(f) + except (json.JSONDecodeError, OSError): + pass + return {} diff --git a/src/development_hub/styles.py b/src/development_hub/styles.py new file mode 100644 index 0000000..4ffb97a --- /dev/null +++ b/src/development_hub/styles.py @@ -0,0 +1,214 @@ +"""Dark theme stylesheet for Development Hub.""" + +DARK_THEME = """ +QMainWindow { + background-color: #1e1e1e; +} + +QWidget { + background-color: #1e1e1e; + color: #d4d4d4; + font-family: "JetBrains Mono", "Fira Code", "Consolas", monospace; + font-size: 12px; +} + +QMenuBar { + background-color: #252526; + color: #cccccc; + border-bottom: 1px solid #3c3c3c; + padding: 4px; +} + +QMenuBar::item { + padding: 4px 10px; + background-color: transparent; +} + +QMenuBar::item:selected { + background-color: #094771; + border-radius: 3px; +} + +QMenu { + background-color: #252526; + border: 1px solid #3c3c3c; + padding: 4px; +} + +QMenu::item { + padding: 6px 24px 6px 12px; +} + +QMenu::item:selected { + background-color: #094771; +} + +QMenu::separator { + height: 1px; + background-color: #3c3c3c; + margin: 4px 8px; +} + +QListWidget { + background-color: #252526; + border: none; + outline: none; + padding: 4px; +} + +QListWidget::item { + padding: 8px 12px; + border-radius: 3px; + margin: 2px 4px; +} + +QListWidget::item:hover { + background-color: #2a2d2e; +} + +QListWidget::item:selected { + background-color: #094771; +} + +QSplitter::handle { + background-color: #3c3c3c; +} + +QSplitter::handle:horizontal { + width: 2px; +} + +QSplitter::handle:vertical { + height: 2px; +} + +QTabWidget::pane { + border: 1px solid #3c3c3c; + background-color: #1e1e1e; +} + +QTabBar::tab { + background-color: #2d2d2d; + color: #969696; + padding: 8px 16px; + border: none; + border-right: 1px solid #3c3c3c; +} + +QTabBar::tab:selected { + background-color: #1e1e1e; + color: #ffffff; + border-bottom: 2px solid #007acc; +} + +QTabBar::tab:hover:!selected { + background-color: #323232; +} + +QStatusBar { + background-color: #007acc; + color: #ffffff; + padding: 2px 8px; +} + +QLabel#header { + font-size: 11px; + font-weight: bold; + color: #858585; + padding: 8px 12px 4px 12px; + text-transform: uppercase; + letter-spacing: 1px; +} + +QScrollBar:vertical { + background-color: #1e1e1e; + width: 12px; + margin: 0; +} + +QScrollBar::handle:vertical { + background-color: #424242; + min-height: 20px; + border-radius: 3px; + margin: 2px; +} + +QScrollBar::handle:vertical:hover { + background-color: #525252; +} + +QScrollBar::add-line:vertical, +QScrollBar::sub-line:vertical { + height: 0; +} + +QScrollBar:horizontal { + background-color: #1e1e1e; + height: 12px; + margin: 0; +} + +QScrollBar::handle:horizontal { + background-color: #424242; + min-width: 20px; + border-radius: 3px; + margin: 2px; +} + +QScrollBar::handle:horizontal:hover { + background-color: #525252; +} + +QScrollBar::add-line:horizontal, +QScrollBar::sub-line:horizontal { + width: 0; +} + +QPushButton { + background-color: #0e639c; + color: #ffffff; + border: none; + padding: 6px 14px; + border-radius: 3px; +} + +QPushButton:hover { + background-color: #1177bb; +} + +QPushButton:pressed { + background-color: #094771; +} + +QPushButton:disabled { + background-color: #3c3c3c; + color: #6e6e6e; +} + +QLineEdit { + background-color: #3c3c3c; + border: 1px solid #3c3c3c; + border-radius: 3px; + padding: 6px 8px; + color: #cccccc; + selection-background-color: #094771; +} + +QLineEdit:focus { + border-color: #007acc; +} + +QTextEdit, QPlainTextEdit { + background-color: #1e1e1e; + border: none; + color: #d4d4d4; + selection-background-color: #264f78; +} + +QToolTip { + background-color: #252526; + color: #cccccc; + border: 1px solid #3c3c3c; + padding: 4px 8px; +} +""" diff --git a/src/development_hub/terminal_widget.py b/src/development_hub/terminal_widget.py new file mode 100644 index 0000000..071c8a6 --- /dev/null +++ b/src/development_hub/terminal_widget.py @@ -0,0 +1,786 @@ +"""PTY-based terminal widget with full terminal emulation using pyte.""" + +import fcntl +import os +import pty +import select +import struct +import subprocess +import termios +from pathlib import Path + +import pyte +from PyQt6.QtCore import QThread, pyqtSignal, Qt, QTimer +from PyQt6.QtGui import ( + QFont, + QFontMetrics, + QColor, + QPainter, + QKeyEvent, + QPalette, + QTextCharFormat, + QDragEnterEvent, + QDropEvent, +) +from PyQt6.QtWidgets import QWidget, QVBoxLayout, QScrollBar, QAbstractScrollArea + + +class PtyReaderThread(QThread): + """Thread that reads from PTY master and emits output.""" + + output_ready = pyqtSignal(bytes) + finished_signal = pyqtSignal() + + def __init__(self, master_fd: int): + super().__init__() + self.master_fd = master_fd + self._running = True + + def run(self): + """Read from PTY and emit output.""" + while self._running: + try: + readable, _, _ = select.select([self.master_fd], [], [], 0.1) + if readable: + try: + data = os.read(self.master_fd, 4096) + if data: + self.output_ready.emit(data) + else: + break + except OSError: + break + except (ValueError, OSError): + break + + self.finished_signal.emit() + + def stop(self): + """Stop the reader thread.""" + self._running = False + + +# Color mapping from pyte to Qt +PYTE_COLORS = { + "black": QColor("#1e1e1e"), + "red": QColor("#f44747"), + "green": QColor("#6a9955"), + "brown": QColor("#dcdcaa"), # yellow + "blue": QColor("#569cd6"), + "magenta": QColor("#c586c0"), + "cyan": QColor("#4ec9b0"), + "white": QColor("#d4d4d4"), + "default": QColor("#d4d4d4"), + # Bright colors + "brightblack": QColor("#808080"), + "brightred": QColor("#f14c4c"), + "brightgreen": QColor("#73c991"), + "brightbrown": QColor("#e2e210"), + "brightblue": QColor("#3794ff"), + "brightmagenta": QColor("#d670d6"), + "brightcyan": QColor("#29b8db"), + "brightwhite": QColor("#ffffff"), +} + +BG_COLORS = { + "black": QColor("#1e1e1e"), + "red": QColor("#5a1d1d"), + "green": QColor("#1d3d1d"), + "brown": QColor("#3d3d1d"), + "blue": QColor("#1d1d5a"), + "magenta": QColor("#3d1d3d"), + "cyan": QColor("#1d3d3d"), + "white": QColor("#3d3d3d"), + "default": QColor("#1e1e1e"), +} + + +class TerminalDisplay(QWidget): + """Terminal display widget that renders a pyte screen.""" + + key_pressed = pyqtSignal(bytes) + cursor_position_requested = pyqtSignal() + + def __init__(self, rows: int = 24, cols: int = 80, parent=None): + super().__init__(parent) + + # Create pyte screen and stream + self.screen = pyte.Screen(cols, rows) + self.stream = pyte.Stream(self.screen) + + # Set up display + self._setup_display() + + # Cursor state (no blinking for performance) + self._cursor_visible = True + + # Enable focus + self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) + + # Enable drag-drop + self.setAcceptDrops(True) + + # Dirty flag for efficient updates + self._dirty = True + + # Selection state + self._selection_start = None # (row, col) + self._selection_end = None # (row, col) + self._selecting = False + + def _setup_display(self): + """Configure the terminal display.""" + # Use monospace font - smaller size + self.font = QFont("Monospace", 10) + self.font.setStyleHint(QFont.StyleHint.Monospace) + self.font.setFixedPitch(True) + self.setFont(self.font) + + # Calculate character dimensions + fm = QFontMetrics(self.font) + self.char_width = fm.horizontalAdvance("M") + self.char_height = fm.lineSpacing() + + # Set background + palette = self.palette() + palette.setColor(QPalette.ColorRole.Window, QColor("#1e1e1e")) + self.setPalette(palette) + self.setAutoFillBackground(True) + + def resize_screen(self, rows: int, cols: int): + """Resize the pyte screen.""" + self.screen.resize(rows, cols) + self._dirty = True + self.update() + + def feed(self, data: bytes): + """Feed data to the terminal emulator.""" + try: + text = data.decode("utf-8", errors="replace") + except UnicodeDecodeError: + text = data.decode("latin-1", errors="replace") + + # Check for cursor position query (DSR) before feeding to pyte + if '\x1b[6n' in text: + self.cursor_position_requested.emit() + text = text.replace('\x1b[6n', '') + + if text: + self.stream.feed(text) + self._dirty = True + self.update() + + # Debug: print non-default attributes (disabled) + # self._debug_screen() + + def _debug_screen(self): + """Debug: Print cells with non-default attributes.""" + for row in range(min(5, self.screen.lines)): # Just first 5 rows + for col in range(min(80, self.screen.columns)): + char = self.screen.buffer[row][col] + if char.reverse or char.bg != "default" or (char.fg != "default" and char.data.strip()): + print(f"[{row},{col}] '{char.data}' fg={char.fg} bg={char.bg} rev={char.reverse} bold={char.bold}") + + def paintEvent(self, event): + """Render the terminal screen.""" + painter = QPainter(self) + painter.setFont(self.font) + + # Fill background + default_bg_color = QColor("#1e1e1e") + painter.fillRect(self.rect(), default_bg_color) + + fm = QFontMetrics(self.font) + ascent = fm.ascent() + + # Draw each row + for row in range(min(self.screen.lines, self.height() // self.char_height + 1)): + y_base = row * self.char_height + y_text = y_base + ascent + + col = 0 + while col < self.screen.columns: + char = self.screen.buffer[row][col] + x = col * self.char_width + + # Get colors + fg_color = self._get_fg_color(char) + bg_color = self._get_bg_color(char) + + # Draw background if not default (compare RGB values) + if bg_color.rgb() != default_bg_color.rgb(): + painter.fillRect(x, y_base, self.char_width, self.char_height, bg_color) + + # Draw character + if char.data and char.data != " ": + painter.setPen(fg_color) + painter.drawText(x, y_text, char.data) + + col += 1 + + + # Draw selection + self._draw_selection(painter) + + # Draw cursor (block cursor) + if self._cursor_visible and self.hasFocus(): + cx = self.screen.cursor.x + cy = self.screen.cursor.y + if 0 <= cx < self.screen.columns and 0 <= cy < self.screen.lines: + cursor_x = cx * self.char_width + cursor_y = cy * self.char_height + painter.fillRect( + cursor_x, cursor_y, + self.char_width, self.char_height, + QColor(200, 200, 200, 128) + ) + + def _color_to_qcolor(self, color, default_fg=True) -> QColor: + """Convert a pyte color to QColor. + + Args: + color: The color value from pyte (string, int, or tuple) + default_fg: If True, return default foreground; else default background + """ + default = QColor("#d4d4d4") if default_fg else QColor("#1e1e1e") + + if color == "default" or color is None: + return default + + # Handle string color names or hex colors + if isinstance(color, str): + # Check if it's a hex color (6 hex digits) - pyte returns 256/truecolor as hex + if len(color) == 6 and all(c in '0123456789abcdefABCDEF' for c in color): + return QColor(f"#{color}") + + # For named colors, use our palettes + if default_fg: + return PYTE_COLORS.get(color, default) + else: + return BG_COLORS.get(color, default) + + # Handle 256 color palette + if isinstance(color, int): + if color < 16: + # Standard 16 colors + fg_colors = [ + QColor("#1e1e1e"), QColor("#f44747"), QColor("#6a9955"), QColor("#dcdcaa"), + QColor("#569cd6"), QColor("#c586c0"), QColor("#4ec9b0"), QColor("#d4d4d4"), + QColor("#808080"), QColor("#f14c4c"), QColor("#73c991"), QColor("#e2e210"), + QColor("#3794ff"), QColor("#d670d6"), QColor("#29b8db"), QColor("#ffffff"), + ] + bg_colors = [ + QColor("#1e1e1e"), QColor("#5a1d1d"), QColor("#1d3d1d"), QColor("#3d3d1d"), + QColor("#1d1d5a"), QColor("#3d1d3d"), QColor("#1d3d3d"), QColor("#4a4a4a"), + QColor("#4d4d4d"), QColor("#8b3d3d"), QColor("#3d6b3d"), QColor("#6b6b3d"), + QColor("#3d3d8b"), QColor("#6b3d6b"), QColor("#3d6b6b"), QColor("#6a6a6a"), + ] + return fg_colors[color] if default_fg else bg_colors[color] + elif color < 232: + # 216 color cube + c = color - 16 + r = (c // 36) * 51 + g = ((c // 6) % 6) * 51 + b = (c % 6) * 51 + return QColor(r, g, b) + else: + # Grayscale (24 shades) + gray = (color - 232) * 10 + 8 + return QColor(gray, gray, gray) + + # Handle RGB tuple (truecolor) + if isinstance(color, tuple) and len(color) == 3: + return QColor(color[0], color[1], color[2]) + + return default + + def _get_fg_color(self, char) -> QColor: + """Get foreground color for a character.""" + if char.reverse: + # Reverse video: swap fg/bg + if char.bg == "default": + return QColor("#1e1e1e") + return self._color_to_qcolor(char.bg, default_fg=True) + + if char.fg == "default": + color = QColor("#d4d4d4") + else: + color = self._color_to_qcolor(char.fg, default_fg=True) + + # Brighten if bold + if char.bold and isinstance(char.fg, str) and char.fg != "default": + if not char.fg.startswith("bright"): + bright_name = "bright" + char.fg + if bright_name in PYTE_COLORS: + return PYTE_COLORS[bright_name] + + return color + + def _get_bg_color(self, char) -> QColor: + """Get background color for a character.""" + if char.reverse: + # Reverse video: swap fg/bg, use foreground as background + if char.fg == "default": + return QColor("#d4d4d4") # Light background for reverse + return self._color_to_qcolor(char.fg, default_fg=False) + + if char.bg == "default": + return QColor("#1e1e1e") + + return self._color_to_qcolor(char.bg, default_fg=False) + + def keyPressEvent(self, event: QKeyEvent): + """Handle keyboard input and send to PTY.""" + key = event.key() + modifiers = event.modifiers() + text = event.text() + + # Handle special key combinations + if modifiers == Qt.KeyboardModifier.ControlModifier: + if key == Qt.Key.Key_C: + self.key_pressed.emit(b'\x03') + return + elif key == Qt.Key.Key_D: + self.key_pressed.emit(b'\x04') + return + elif key == Qt.Key.Key_Z: + self.key_pressed.emit(b'\x1a') + return + elif key == Qt.Key.Key_L: + self.key_pressed.emit(b'\x0c') + return + elif key == Qt.Key.Key_A: + self.key_pressed.emit(b'\x01') + return + elif key == Qt.Key.Key_E: + self.key_pressed.emit(b'\x05') + return + elif key == Qt.Key.Key_K: + self.key_pressed.emit(b'\x0b') + return + elif key == Qt.Key.Key_U: + self.key_pressed.emit(b'\x15') + return + elif key == Qt.Key.Key_W: + self.key_pressed.emit(b'\x17') + return + + # Arrow keys and special keys + if key == Qt.Key.Key_Up: + self.key_pressed.emit(b'\x1b[A') + return + elif key == Qt.Key.Key_Down: + self.key_pressed.emit(b'\x1b[B') + return + elif key == Qt.Key.Key_Right: + self.key_pressed.emit(b'\x1b[C') + return + elif key == Qt.Key.Key_Left: + self.key_pressed.emit(b'\x1b[D') + return + elif key == Qt.Key.Key_Home: + self.key_pressed.emit(b'\x1b[H') + return + elif key == Qt.Key.Key_End: + self.key_pressed.emit(b'\x1b[F') + return + elif key == Qt.Key.Key_Delete: + self.key_pressed.emit(b'\x1b[3~') + return + elif key == Qt.Key.Key_Backspace: + self.key_pressed.emit(b'\x7f') + return + elif key == Qt.Key.Key_Tab: + self.key_pressed.emit(b'\t') + return + elif key == Qt.Key.Key_Return or key == Qt.Key.Key_Enter: + self.key_pressed.emit(b'\r') + return + elif key == Qt.Key.Key_Escape: + self.key_pressed.emit(b'\x1b') + return + elif key == Qt.Key.Key_PageUp: + self.key_pressed.emit(b'\x1b[5~') + return + elif key == Qt.Key.Key_PageDown: + self.key_pressed.emit(b'\x1b[6~') + return + elif key == Qt.Key.Key_Insert: + self.key_pressed.emit(b'\x1b[2~') + return + + # Function keys + if Qt.Key.Key_F1 <= key <= Qt.Key.Key_F12: + fn = key - Qt.Key.Key_F1 + 1 + if fn <= 4: + self.key_pressed.emit(f'\x1bO{chr(ord("P") + fn - 1)}'.encode()) + else: + codes = {5: 15, 6: 17, 7: 18, 8: 19, 9: 20, 10: 21, 11: 23, 12: 24} + self.key_pressed.emit(f'\x1b[{codes.get(fn, 15)}~'.encode()) + return + + # Regular text + if text: + self.key_pressed.emit(text.encode('utf-8')) + + def _pos_to_cell(self, pos) -> tuple[int, int]: + """Convert pixel position to (row, col).""" + col = max(0, min(pos.x() // self.char_width, self.screen.columns - 1)) + row = max(0, min(pos.y() // self.char_height, self.screen.lines - 1)) + return (row, col) + + def mousePressEvent(self, event): + """Handle mouse press for selection.""" + if event.button() == Qt.MouseButton.LeftButton: + self._selection_start = self._pos_to_cell(event.pos()) + self._selection_end = self._selection_start + self._selecting = True + self.update() + + def mouseMoveEvent(self, event): + """Handle mouse move for selection.""" + if self._selecting: + self._selection_end = self._pos_to_cell(event.pos()) + self.update() + + def mouseReleaseEvent(self, event): + """Handle mouse release.""" + if event.button() == Qt.MouseButton.LeftButton: + self._selecting = False + + def contextMenuEvent(self, event): + """Show context menu with copy/paste.""" + from PyQt6.QtWidgets import QMenu, QApplication + + menu = QMenu(self) + + copy_action = menu.addAction("Copy") + copy_action.triggered.connect(self._copy_selection) + copy_action.setEnabled(self._has_selection()) + + paste_action = menu.addAction("Paste") + paste_action.triggered.connect(self._paste_clipboard) + + menu.addSeparator() + + clear_action = menu.addAction("Clear Selection") + clear_action.triggered.connect(self._clear_selection) + + menu.exec(event.globalPos()) + + def _has_selection(self) -> bool: + """Check if there's an active selection.""" + return (self._selection_start is not None and + self._selection_end is not None and + self._selection_start != self._selection_end) + + def _get_selection_bounds(self) -> tuple[tuple[int, int], tuple[int, int]]: + """Get normalized selection bounds (start <= end).""" + if not self._has_selection(): + return None, None + + start = self._selection_start + end = self._selection_end + + # Normalize so start comes before end + if (start[0] > end[0]) or (start[0] == end[0] and start[1] > end[1]): + start, end = end, start + + return start, end + + def _copy_selection(self): + """Copy selected text to clipboard.""" + from PyQt6.QtWidgets import QApplication + + start, end = self._get_selection_bounds() + if start is None: + return + + lines = [] + for row in range(start[0], end[0] + 1): + line_start = start[1] if row == start[0] else 0 + line_end = end[1] + 1 if row == end[0] else self.screen.columns + + line = "" + for col in range(line_start, line_end): + char = self.screen.buffer[row][col] + line += char.data if char.data else " " + + lines.append(line.rstrip()) + + text = "\n".join(lines) + QApplication.clipboard().setText(text) + + def _paste_clipboard(self): + """Paste clipboard content as keyboard input.""" + from PyQt6.QtWidgets import QApplication + + text = QApplication.clipboard().text() + if text: + self.key_pressed.emit(text.encode('utf-8')) + + def _clear_selection(self): + """Clear the current selection.""" + self._selection_start = None + self._selection_end = None + self.update() + + def _draw_selection(self, painter): + """Draw selection highlight.""" + start, end = self._get_selection_bounds() + if start is None: + return + + selection_color = QColor(70, 130, 180, 100) # Steel blue, semi-transparent + + for row in range(start[0], end[0] + 1): + line_start = start[1] if row == start[0] else 0 + line_end = end[1] + 1 if row == end[0] else self.screen.columns + + x = line_start * self.char_width + y = row * self.char_height + width = (line_end - line_start) * self.char_width + + painter.fillRect(x, y, width, self.char_height, selection_color) + + def dragEnterEvent(self, event: QDragEnterEvent): + """Accept drag events that contain file URLs.""" + if event.mimeData().hasUrls(): + event.acceptProposedAction() + else: + event.ignore() + + def dragMoveEvent(self, event): + """Accept drag move events.""" + if event.mimeData().hasUrls(): + event.acceptProposedAction() + + def dropEvent(self, event: QDropEvent): + """Handle file/folder drops.""" + urls = event.mimeData().urls() + if not urls: + return + + # Get the first dropped item's local path + path = urls[0].toLocalFile() + if not path: + return + + # Determine what to do based on the path type + path_obj = Path(path) + + if path_obj.is_dir(): + # Directory → cd to it + self._inject_text(f'cd "{path}"\n') + elif path_obj.is_file() and os.access(path, os.X_OK): + # Executable file → run it + self._inject_text(f'"{path}"\n') + else: + # Regular file → insert quoted path (no newline, user can add args) + self._inject_text(f'"{path}" ') + + event.acceptProposedAction() + + def _inject_text(self, text: str): + """Inject text as if user typed it.""" + self.key_pressed.emit(text.encode('utf-8')) + + +class TerminalWidget(QWidget): + """Full terminal widget with PTY support using pyte emulation.""" + + closed = pyqtSignal() + + def __init__(self, cwd: Path | None = None, parent=None): + super().__init__(parent) + self.cwd = cwd or Path.home() + self.master_fd = None + self.slave_fd = None + self.process = None + self.reader_thread = None + + self._setup_ui() + self._start_shell() + + def _setup_ui(self): + """Set up the terminal UI.""" + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + # Start with reasonable default size + self.display = TerminalDisplay(rows=24, cols=80) + self.display.key_pressed.connect(self._on_key_pressed) + self.display.cursor_position_requested.connect(self._report_cursor_position) + self.display.setSizePolicy( + self.display.sizePolicy().horizontalPolicy(), + self.display.sizePolicy().verticalPolicy() + ) + layout.addWidget(self.display) + + def _start_shell(self): + """Start the shell process with PTY.""" + # Create PTY pair + self.master_fd, self.slave_fd = pty.openpty() + + # Get terminal size + rows = self.display.screen.lines + cols = self.display.screen.columns + + # Set terminal size + winsize = struct.pack('HHHH', rows, cols, 0, 0) + fcntl.ioctl(self.slave_fd, termios.TIOCSWINSZ, winsize) + + # Get shell + shell = os.environ.get('SHELL', '/bin/bash') + + # Environment + env = os.environ.copy() + env['TERM'] = 'xterm-256color' + env['COLORTERM'] = 'truecolor' + env['COLUMNS'] = str(cols) + env['LINES'] = str(rows) + + self.process = subprocess.Popen( + [shell, '-i'], + stdin=self.slave_fd, + stdout=self.slave_fd, + stderr=self.slave_fd, + cwd=str(self.cwd), + env=env, + start_new_session=True, + ) + + os.close(self.slave_fd) + self.slave_fd = None + + # Make master non-blocking + flags = fcntl.fcntl(self.master_fd, fcntl.F_GETFL) + fcntl.fcntl(self.master_fd, fcntl.F_SETFL, flags | os.O_NONBLOCK) + + # Start reader thread + self.reader_thread = PtyReaderThread(self.master_fd) + self.reader_thread.output_ready.connect(self._on_output) + self.reader_thread.finished_signal.connect(self._on_shell_exit) + self.reader_thread.start() + + def resizeEvent(self, event): + """Handle resize events to update PTY size.""" + super().resizeEvent(event) + QTimer.singleShot(50, self._update_terminal_size) + + def _update_terminal_size(self): + """Update terminal size based on widget size.""" + if self.master_fd is None: + return + + # Use the display's calculated char dimensions + char_width = self.display.char_width + char_height = self.display.char_height + + if char_width <= 0 or char_height <= 0: + return + + width = self.display.width() + height = self.display.height() + + if width <= 0 or height <= 0: + return + + cols = max(width // char_width, 20) + rows = max(height // char_height, 5) + + # Clamp to reasonable values + cols = min(cols, 500) + rows = min(rows, 200) + + # Update pyte screen if size changed + if rows != self.display.screen.lines or cols != self.display.screen.columns: + self.display.resize_screen(rows, cols) + + # Update PTY size + try: + winsize = struct.pack('HHHH', rows, cols, width, height) + fcntl.ioctl(self.master_fd, termios.TIOCSWINSZ, winsize) + except OSError: + pass + + def _on_key_pressed(self, data: bytes): + """Handle key press from display.""" + if self.master_fd is not None: + try: + os.write(self.master_fd, data) + except OSError: + pass + + def _report_cursor_position(self): + """Respond to cursor position query (DSR).""" + if self.master_fd is None: + return + + # Get cursor position from pyte screen + row = self.display.screen.cursor.y + 1 # 1-based + col = self.display.screen.cursor.x + 1 # 1-based + + # Send CPR (Cursor Position Report): \x1b[row;colR + response = f'\x1b[{row};{col}R'.encode('utf-8') + try: + os.write(self.master_fd, response) + except OSError: + pass + + def _on_output(self, data: bytes): + """Handle output from PTY.""" + self.display.feed(data) + + def _on_shell_exit(self): + """Handle shell process exit.""" + self.display.feed(b'\r\n[Process exited]\r\n') + self.closed.emit() + + def inject_text(self, text: str): + """Inject text as if user typed it.""" + if self.master_fd is not None: + try: + os.write(self.master_fd, text.encode('utf-8')) + except OSError: + pass + + def send_signal(self, sig: int): + """Send a signal to the shell process.""" + if self.process: + try: + os.killpg(os.getpgid(self.process.pid), sig) + except (OSError, ProcessLookupError): + pass + + def terminate(self): + """Terminate the shell and clean up.""" + if self.reader_thread: + self.reader_thread.stop() + self.reader_thread.wait(1000) + + if self.process: + try: + self.process.terminate() + self.process.wait(timeout=1) + except subprocess.TimeoutExpired: + self.process.kill() + except OSError: + pass + + if self.master_fd is not None: + try: + os.close(self.master_fd) + except OSError: + pass + self.master_fd = None + + def closeEvent(self, event): + """Handle widget close.""" + self.terminate() + super().closeEvent(event) + + def setFocus(self): + """Set focus to the display widget.""" + self.display.setFocus() diff --git a/src/development_hub/workspace.py b/src/development_hub/workspace.py new file mode 100644 index 0000000..9597242 --- /dev/null +++ b/src/development_hub/workspace.py @@ -0,0 +1,626 @@ +"""Workspace widget for managing splittable pane layouts with tabs.""" + +from pathlib import Path +from weakref import ref + +from PyQt6.QtCore import Qt, pyqtSignal, QEvent, QTimer +from PyQt6.QtWidgets import ( + QWidget, + QVBoxLayout, + QSplitter, + QTabWidget, + QLabel, + QFrame, + QApplication, +) + +from development_hub.terminal_widget import TerminalWidget + + +# Styles for focused/unfocused panes +PANE_FOCUSED_STYLE = """ + PaneWidget { + border: 2px solid #4a9eff; + border-radius: 3px; + } +""" + +PANE_UNFOCUSED_STYLE = """ + PaneWidget { + border: 2px solid transparent; + border-radius: 3px; + } +""" + + +class PaneWidget(QFrame): + """A pane containing a tab widget with terminals.""" + + clicked = pyqtSignal(object) # Emits self when clicked + empty = pyqtSignal(object) # Emits self when last tab closed + + def __init__(self, parent=None): + super().__init__(parent) + self._is_focused = False + self._setup_ui() + + def _setup_ui(self): + """Set up the pane UI.""" + layout = QVBoxLayout(self) + layout.setContentsMargins(2, 2, 2, 2) + layout.setSpacing(0) + + self.tab_widget = QTabWidget() + self.tab_widget.setTabsClosable(True) + self.tab_widget.setMovable(True) + self.tab_widget.tabCloseRequested.connect(self._close_tab) + layout.addWidget(self.tab_widget) + + # Set initial unfocused style + self.setStyleSheet(PANE_UNFOCUSED_STYLE) + + def set_focused(self, focused: bool): + """Set the focus state and update visual styling.""" + self._is_focused = focused + if focused: + self.setStyleSheet(PANE_FOCUSED_STYLE) + else: + self.setStyleSheet(PANE_UNFOCUSED_STYLE) + + def is_focused(self) -> bool: + """Check if this pane is focused.""" + return self._is_focused + + def add_welcome_tab(self): + """Add a welcome tab with instructions.""" + welcome = QWidget() + layout = QVBoxLayout(welcome) + layout.setContentsMargins(40, 40, 40, 40) + + label = QLabel( + "

Development Hub

" + "

Central project orchestration for your development ecosystem.

" + "

Getting Started

" + "" + "

Keyboard Shortcuts

" + "" + ) + label.setWordWrap(True) + label.setAlignment(Qt.AlignmentFlag.AlignTop) + layout.addWidget(label) + layout.addStretch() + + self.tab_widget.addTab(welcome, "Welcome") + + def add_terminal(self, cwd: Path, title: str) -> TerminalWidget: + """Add a new terminal tab to this pane.""" + terminal = TerminalWidget(cwd=cwd) + + # Use weak reference to avoid preventing garbage collection + weak_self = ref(self) + def on_closed(): + pane = weak_self() + if pane is not None: + pane._on_terminal_closed(terminal) + terminal.closed.connect(on_closed) + + idx = self.tab_widget.addTab(terminal, title) + self.tab_widget.setCurrentIndex(idx) + terminal.setFocus() + return terminal + + def _close_tab(self, index: int): + """Close tab at index.""" + widget = self.tab_widget.widget(index) + self.tab_widget.removeTab(index) + + if isinstance(widget, TerminalWidget): + widget.terminate() + + widget.deleteLater() + + # Check if pane is now empty + if self.tab_widget.count() == 0: + self.empty.emit(self) + + def _on_terminal_closed(self, terminal: TerminalWidget): + """Handle terminal process exit.""" + # Check if tab_widget still exists + if not hasattr(self, 'tab_widget') or self.tab_widget is None: + return + try: + for i in range(self.tab_widget.count()): + if self.tab_widget.widget(i) == terminal: + current_title = self.tab_widget.tabText(i) + if not current_title.endswith(" (exited)"): + self.tab_widget.setTabText(i, f"{current_title} (exited)") + break + except RuntimeError: + # Widget was deleted + pass + + def close_current_tab(self): + """Close the currently selected tab.""" + idx = self.tab_widget.currentIndex() + if idx >= 0: + self._close_tab(idx) + + def take_current_tab(self) -> tuple[QWidget, str] | None: + """Remove and return the current tab widget and its title.""" + idx = self.tab_widget.currentIndex() + if idx < 0: + return None + + title = self.tab_widget.tabText(idx) + widget = self.tab_widget.widget(idx) + self.tab_widget.removeTab(idx) + + # Check if pane is now empty + if self.tab_widget.count() == 0: + self.empty.emit(self) + + return (widget, title) + + def add_widget_tab(self, widget: QWidget, title: str): + """Add an existing widget as a tab.""" + idx = self.tab_widget.addTab(widget, title) + self.tab_widget.setCurrentIndex(idx) + + def has_tabs(self) -> bool: + """Check if pane has any tabs.""" + return self.tab_widget.count() > 0 + + def tab_count(self) -> int: + """Get number of tabs in this pane.""" + return self.tab_widget.count() + + def get_current_terminal(self) -> TerminalWidget | None: + """Get the current terminal widget.""" + widget = self.tab_widget.currentWidget() + if isinstance(widget, TerminalWidget): + return widget + return None + + def terminate_all(self): + """Terminate all terminals in this pane.""" + for i in range(self.tab_widget.count()): + widget = self.tab_widget.widget(i) + if isinstance(widget, TerminalWidget): + widget.terminate() + + def setFocus(self): + """Set focus to the current terminal.""" + terminal = self.get_current_terminal() + if terminal: + terminal.setFocus() + + def get_session_state(self) -> dict: + """Get the session state for persistence.""" + tabs = [] + for i in range(self.tab_widget.count()): + widget = self.tab_widget.widget(i) + title = self.tab_widget.tabText(i) + + if isinstance(widget, TerminalWidget): + tabs.append({ + "type": "terminal", + "title": title, + "cwd": str(widget.cwd), + }) + # Skip welcome tab and other non-terminal widgets + + return { + "tabs": tabs, + "current_tab": self.tab_widget.currentIndex(), + } + + def restore_tabs(self, state: dict): + """Restore tabs from session state.""" + tabs = state.get("tabs", []) + current_tab = state.get("current_tab", 0) + + for tab_info in tabs: + if tab_info.get("type") == "terminal": + cwd = Path(tab_info.get("cwd", str(Path.home()))) + title = tab_info.get("title", "Terminal") + self.add_terminal(cwd, title) + + # Restore current tab selection + if 0 <= current_tab < self.tab_widget.count(): + self.tab_widget.setCurrentIndex(current_tab) + + +class WorkspaceManager(QWidget): + """Manages splittable panes, each with their own tab bar.""" + + pane_count_changed = pyqtSignal(int) + + def __init__(self, parent=None): + super().__init__(parent) + self._active_pane = None + self._setup_ui() + + # Install application-wide event filter for focus tracking + QApplication.instance().installEventFilter(self) + + def _setup_ui(self): + """Set up the workspace with initial pane.""" + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + # Create initial pane with welcome tab + self._root_widget = self._create_pane() + self._root_widget.add_welcome_tab() + layout.addWidget(self._root_widget) + self._active_pane = self._root_widget + self._active_pane.set_focused(True) + + def _create_pane(self) -> PaneWidget: + """Create a new pane widget.""" + pane = PaneWidget() + pane.empty.connect(self._on_pane_empty) + return pane + + def eventFilter(self, obj, event): + """Application-wide event filter to track which pane is clicked.""" + if event.type() == QEvent.Type.MouseButtonPress: + # Find which pane (if any) contains the clicked widget + widget = obj + while widget is not None: + if isinstance(widget, PaneWidget): + self._set_active_pane(widget) + break + widget = widget.parent() + return super().eventFilter(obj, event) + + def _set_active_pane(self, pane: PaneWidget): + """Set the active pane and update styling.""" + if pane == self._active_pane: + return + + if self._active_pane is not None: + self._active_pane.set_focused(False) + + self._active_pane = pane + pane.set_focused(True) + + def _on_pane_empty(self, pane: PaneWidget): + """Handle pane becoming empty.""" + panes = self.find_panes() + if len(panes) <= 1: + # Last pane - don't remove, just leave empty + return + + self._remove_pane(pane) + + def _remove_pane(self, pane: PaneWidget): + """Remove a pane from the workspace.""" + parent = pane.parent() + + pane.terminate_all() + + if parent == self: + # This is the root - shouldn't remove + return + + if isinstance(parent, QSplitter): + # Remove from splitter + pane.setParent(None) + pane.deleteLater() + + # If splitter has only one child left, replace splitter with that child + if parent.count() == 1: + remaining = parent.widget(0) + grandparent = parent.parent() + + if grandparent == self: + # Parent splitter is root + layout = self.layout() + layout.removeWidget(parent) + remaining.setParent(None) + layout.addWidget(remaining) + self._root_widget = remaining + parent.deleteLater() + + elif isinstance(grandparent, QSplitter): + # Nested splitter + index = grandparent.indexOf(parent) + remaining.setParent(None) + grandparent.replaceWidget(index, remaining) + parent.deleteLater() + + # Update active pane with proper focus styling + panes = self.find_panes() + if panes: + self._active_pane = panes[0] + self._active_pane.set_focused(True) + self._active_pane.setFocus() + + self.pane_count_changed.emit(len(self.find_panes())) + + def get_active_pane(self) -> PaneWidget | None: + """Get the currently active pane.""" + if self._active_pane and self._active_pane.isVisible(): + return self._active_pane + + # Fallback to first pane + panes = self.find_panes() + if panes: + self._active_pane = panes[0] + return self._active_pane + return None + + def find_panes(self) -> list[PaneWidget]: + """Find all pane widgets in the workspace.""" + panes = [] + self._collect_panes(self._root_widget, panes) + return panes + + def _collect_panes(self, widget, panes: list): + """Recursively collect panes from widget tree.""" + if isinstance(widget, PaneWidget): + panes.append(widget) + elif isinstance(widget, QSplitter): + for i in range(widget.count()): + self._collect_panes(widget.widget(i), panes) + + def add_terminal(self, cwd: Path, title: str) -> TerminalWidget: + """Add a terminal to the active pane.""" + pane = self.get_active_pane() + if pane: + return pane.add_terminal(cwd, title) + return None + + def split_horizontal(self): + """Split the active pane horizontally (left/right).""" + self._split(Qt.Orientation.Horizontal) + + def split_vertical(self): + """Split the active pane vertically (top/bottom).""" + self._split(Qt.Orientation.Vertical) + + def _split(self, orientation: Qt.Orientation): + """Split the active pane with given orientation.""" + current_pane = self.get_active_pane() + if not current_pane: + return + + # Get current size before splitting + if orientation == Qt.Orientation.Horizontal: + total_size = current_pane.width() + else: + total_size = current_pane.height() + half_size = max(total_size // 2, 100) + + # Create new pane + new_pane = self._create_pane() + + # If current pane has 2+ tabs, move the focused tab to new pane + tab_to_move = None + if current_pane.tab_count() >= 2: + tab_to_move = current_pane.take_current_tab() + + # Find parent of current pane + parent = current_pane.parent() + splitter_to_equalize = None + + if parent == self: + # Current is the root widget - create splitter as new root + layout = self.layout() + layout.removeWidget(current_pane) + + splitter = QSplitter(orientation) + splitter.addWidget(current_pane) + splitter.addWidget(new_pane) + splitter_to_equalize = splitter + + layout.addWidget(splitter) + self._root_widget = splitter + + elif isinstance(parent, QSplitter): + # Current is in a splitter + index = parent.indexOf(current_pane) + + if parent.orientation() == orientation: + # Same orientation - just add to existing splitter + parent.insertWidget(index + 1, new_pane) + splitter_to_equalize = parent + else: + # Different orientation - need nested splitter + new_splitter = QSplitter(orientation) + parent.replaceWidget(index, new_splitter) + new_splitter.addWidget(current_pane) + new_splitter.addWidget(new_pane) + splitter_to_equalize = new_splitter + + # If we moved a tab, add it to the new pane + if tab_to_move: + widget, title = tab_to_move + new_pane.add_widget_tab(widget, title) + + # Update focus to new pane + self._set_active_pane(new_pane) + new_pane.setFocus() + + # Equalize sizes after layout is processed + if splitter_to_equalize: + QTimer.singleShot(0, lambda s=splitter_to_equalize: self._equalize_splitter(s)) + + self.pane_count_changed.emit(len(self.find_panes())) + + def _equalize_splitter(self, splitter: QSplitter): + """Set equal sizes for all children in a splitter.""" + count = splitter.count() + if count == 0: + return + + if splitter.orientation() == Qt.Orientation.Horizontal: + total = splitter.width() + else: + total = splitter.height() + + size_each = total // count + splitter.setSizes([size_each] * count) + + def close_current_tab(self): + """Close the current tab in the active pane.""" + pane = self.get_active_pane() + if pane: + pane.close_current_tab() + + def close_active_pane(self): + """Close the currently active pane.""" + pane = self.get_active_pane() + if pane: + panes = self.find_panes() + if len(panes) > 1: + self._remove_pane(pane) + + def total_tab_count(self) -> int: + """Get total number of tabs across all panes.""" + return sum(pane.tab_count() for pane in self.find_panes()) + + def terminate_all(self): + """Terminate all terminals in all panes.""" + for pane in self.find_panes(): + pane.terminate_all() + + def focus_next_pane(self): + """Focus the next pane.""" + panes = self.find_panes() + if len(panes) <= 1: + return + + current = self.get_active_pane() + if current in panes: + idx = panes.index(current) + next_idx = (idx + 1) % len(panes) + next_pane = panes[next_idx] + self._set_active_pane(next_pane) + next_pane.setFocus() + + def focus_previous_pane(self): + """Focus the previous pane.""" + panes = self.find_panes() + if len(panes) <= 1: + return + + current = self.get_active_pane() + if current in panes: + idx = panes.index(current) + prev_idx = (idx - 1) % len(panes) + prev_pane = panes[prev_idx] + self._set_active_pane(prev_pane) + prev_pane.setFocus() + + def get_session_state(self) -> dict: + """Get the complete session state for persistence.""" + return { + "layout": self._serialize_widget(self._root_widget), + } + + def _serialize_widget(self, widget) -> dict: + """Recursively serialize a widget tree.""" + if isinstance(widget, PaneWidget): + return { + "type": "pane", + "state": widget.get_session_state(), + } + elif isinstance(widget, QSplitter): + children = [] + for i in range(widget.count()): + children.append(self._serialize_widget(widget.widget(i))) + + return { + "type": "splitter", + "orientation": "horizontal" if widget.orientation() == Qt.Orientation.Horizontal else "vertical", + "sizes": widget.sizes(), + "children": children, + } + return {} + + def restore_session(self, state: dict): + """Restore the workspace from session state.""" + layout = state.get("layout") + if not layout: + return + + # Check if there are any tabs to restore + if not self._has_tabs_in_layout(layout): + return + + # Clear existing layout + old_root = self._root_widget + self.layout().removeWidget(old_root) + + # Build new layout + new_root = self._deserialize_widget(layout) + if new_root: + self.layout().addWidget(new_root) + self._root_widget = new_root + + # Clean up old root + if isinstance(old_root, PaneWidget): + old_root.terminate_all() + old_root.deleteLater() + + # Set first pane as active + panes = self.find_panes() + if panes: + self._active_pane = panes[0] + self._active_pane.set_focused(True) + + def _has_tabs_in_layout(self, layout: dict) -> bool: + """Check if a layout has any tabs to restore.""" + if layout.get("type") == "pane": + tabs = layout.get("state", {}).get("tabs", []) + return len(tabs) > 0 + elif layout.get("type") == "splitter": + for child in layout.get("children", []): + if self._has_tabs_in_layout(child): + return True + return False + + def _deserialize_widget(self, data: dict) -> QWidget | None: + """Recursively deserialize a widget tree.""" + widget_type = data.get("type") + + if widget_type == "pane": + pane = self._create_pane() + pane.restore_tabs(data.get("state", {})) + return pane + + elif widget_type == "splitter": + orientation = ( + Qt.Orientation.Horizontal + if data.get("orientation") == "horizontal" + else Qt.Orientation.Vertical + ) + splitter = QSplitter(orientation) + + for child_data in data.get("children", []): + child = self._deserialize_widget(child_data) + if child: + splitter.addWidget(child) + + # Restore sizes after adding all children + sizes = data.get("sizes", []) + if sizes and len(sizes) == splitter.count(): + QTimer.singleShot(0, lambda s=splitter, sz=sizes: s.setSizes(sz)) + + return splitter + + return None