Add Development Hub PyQt6 GUI application

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 <noreply@anthropic.com>
This commit is contained in:
rob 2026-01-06 01:25:18 -04:00
parent be7b848640
commit d570681d86
12 changed files with 2676 additions and 0 deletions

30
pyproject.toml Normal file
View File

@ -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"]

View File

@ -0,0 +1,3 @@
"""Development Hub - Central project orchestration GUI."""
__version__ = "0.1.0"

View File

@ -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())

View File

@ -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())

View File

@ -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()

View File

@ -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",
"<h3>Development Hub</h3>"
"<p>Version 0.1.0</p>"
"<p>Central project orchestration for multi-project development.</p>"
"<p>Part of Rob's development ecosystem.</p>"
"<h3>Keyboard Shortcuts</h3>"
"<ul>"
"<li><b>Ctrl+Shift+T</b> - New terminal tab</li>"
"<li><b>Ctrl+Shift+W</b> - Close current tab</li>"
"<li><b>Ctrl+Shift+D</b> - Split pane horizontal</li>"
"<li><b>Ctrl+Shift+E</b> - Split pane vertical</li>"
"<li><b>Ctrl+Alt+Left/Right</b> - Switch panes</li>"
"<li><b>Ctrl+B</b> - Toggle project panel</li>"
"<li><b>F5</b> - Refresh project list</li>"
"</ul>"
)
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)

View File

@ -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

View File

@ -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"])

View File

@ -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 {}

View File

@ -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;
}
"""

View File

@ -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()

View File

@ -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(
"<h2>Development Hub</h2>"
"<p>Central project orchestration for your development ecosystem.</p>"
"<h3>Getting Started</h3>"
"<ul>"
"<li>Double-click a project to open a terminal</li>"
"<li>Right-click for more actions (Gitea, docs, deploy)</li>"
"<li>Ctrl+N to create a new project</li>"
"</ul>"
"<h3>Keyboard Shortcuts</h3>"
"<ul>"
"<li><b>Ctrl+Shift+T</b> - New terminal tab</li>"
"<li><b>Ctrl+Shift+W</b> - Close current tab</li>"
"<li><b>Ctrl+Shift+D</b> - Split pane horizontal</li>"
"<li><b>Ctrl+Shift+E</b> - Split pane vertical</li>"
"<li><b>Ctrl+Alt+Left/Right</b> - Switch panes</li>"
"<li><b>Ctrl+B</b> - Toggle project panel</li>"
"<li><b>F5</b> - Refresh project list</li>"
"</ul>"
)
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