development-hub/src/development_hub/workspace.py

804 lines
29 KiB
Python

"""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
from development_hub.widgets.action_menu import ActionMenu
from development_hub.views.dashboard import ProjectDashboard
from development_hub.views.global_dashboard import GlobalDashboard
from development_hub.project_discovery import Project
# 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
terminal_requested = pyqtSignal() # Request new terminal
standup_requested = pyqtSignal() # Request daily standup dialog
launch_discussion_requested = pyqtSignal() # Request launch discussion action
update_docs_requested = pyqtSignal() # Request docs update
settings_requested = pyqtSignal() # Request settings dialog
close_requested = pyqtSignal(object) # Emits self when close pane requested
def __init__(self, parent=None):
super().__init__(parent)
self._is_focused = False
self._current_project: Project | None = None
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)
# Add action menu as corner widget
self.action_menu = ActionMenu()
self.action_menu.dashboard_requested.connect(self._on_dashboard_clicked)
self.action_menu.global_dashboard_requested.connect(self._on_global_dashboard_clicked)
self.action_menu.terminal_requested.connect(self.terminal_requested.emit)
self.action_menu.launch_discussion_requested.connect(self.launch_discussion_requested.emit)
self.action_menu.standup_requested.connect(self.standup_requested.emit)
self.action_menu.update_docs_requested.connect(self.update_docs_requested.emit)
self.action_menu.settings_requested.connect(self.settings_requested.emit)
self.action_menu.close_pane_requested.connect(lambda: self.close_requested.emit(self))
self.tab_widget.setCornerWidget(self.action_menu, Qt.Corner.TopRightCorner)
# 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 its dashboard</li>"
"<li>Right-click for more actions (terminal, Gitea, docs, deploy)</li>"
"<li>Use the ☰ menu for context-aware actions</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 add_dashboard(self, project: Project) -> ProjectDashboard:
"""Add a project dashboard tab to this pane."""
dashboard = ProjectDashboard(project)
idx = self.tab_widget.addTab(dashboard, f"{project.title}")
self.tab_widget.setCurrentIndex(idx)
self._current_project = project
self.action_menu.set_project_context(project.key)
return dashboard
def add_global_dashboard(self) -> GlobalDashboard:
"""Add the global dashboard tab to this pane."""
dashboard = GlobalDashboard()
dashboard.project_selected.connect(self._on_global_project_selected)
dashboard.standup_requested.connect(self.standup_requested.emit)
idx = self.tab_widget.addTab(dashboard, "Dashboard")
self.tab_widget.setCurrentIndex(idx)
self._current_project = None
self.action_menu.set_project_context(None)
return dashboard
def set_current_project(self, project: Project | None):
"""Set the current project context for this pane."""
self._current_project = project
self.action_menu.set_project_context(project.key if project else None)
def get_current_project(self) -> Project | None:
"""Get the current project context."""
return self._current_project
def _on_dashboard_clicked(self):
"""Handle dashboard action from menu."""
if self._current_project:
# Check if dashboard already exists for this project
for i in range(self.tab_widget.count()):
widget = self.tab_widget.widget(i)
if isinstance(widget, ProjectDashboard) and widget.project == self._current_project:
self.tab_widget.setCurrentIndex(i)
return
self.add_dashboard(self._current_project)
else:
# Open global dashboard
self._on_global_dashboard_clicked()
def _on_global_dashboard_clicked(self):
"""Handle global dashboard action from menu."""
# Check if global dashboard already exists
for i in range(self.tab_widget.count()):
widget = self.tab_widget.widget(i)
if isinstance(widget, GlobalDashboard):
self.tab_widget.setCurrentIndex(i)
return
self.add_global_dashboard()
def _on_global_project_selected(self, project_key: str):
"""Handle project selection from global dashboard."""
# Find the project by key
from development_hub.project_discovery import discover_projects
for project in discover_projects():
if project.key == project_key:
self.add_dashboard(project)
return
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()
# If pane is now empty, add welcome tab instead of emitting empty
if self.tab_widget.count() == 0:
self.add_welcome_tab()
self._current_project = None
self.action_menu.set_project_context(None)
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),
})
elif isinstance(widget, ProjectDashboard):
tabs.append({
"type": "dashboard",
"project_key": widget.project.key,
"state": widget.get_state(),
})
elif isinstance(widget, GlobalDashboard):
tabs.append({
"type": "global_dashboard",
})
# Skip welcome tab and other non-saveable widgets
return {
"tabs": tabs,
"current_tab": self.tab_widget.currentIndex(),
}
def restore_tabs(self, state: dict):
"""Restore tabs from session state."""
from development_hub.project_discovery import discover_projects
tabs = state.get("tabs", [])
current_tab = state.get("current_tab", 0)
# Cache discovered projects for dashboard restoration
projects_by_key = {p.key: p for p in discover_projects()}
for tab_info in tabs:
tab_type = tab_info.get("type")
if tab_type == "terminal":
cwd = Path(tab_info.get("cwd", str(Path.home())))
title = tab_info.get("title", "Terminal")
self.add_terminal(cwd, title)
elif tab_type == "dashboard":
project_key = tab_info.get("project_key")
if project_key and project_key in projects_by_key:
project = projects_by_key[project_key]
dashboard = self.add_dashboard(project)
# Restore dashboard state
if dashboard and "state" in tab_info:
dashboard.restore_state(tab_info["state"])
elif tab_type == "global_dashboard":
self.add_global_dashboard()
# 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)
terminal_requested = pyqtSignal() # Request new terminal (no project context)
standup_requested = pyqtSignal() # Request daily standup dialog
launch_discussion_requested = pyqtSignal() # Request launch discussion action
update_docs_requested = pyqtSignal(str) # Request docs update with project key
settings_requested = pyqtSignal() # Request settings dialog
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)
pane.close_requested.connect(self._close_pane)
pane.terminal_requested.connect(self.terminal_requested.emit)
pane.standup_requested.connect(self.standup_requested.emit)
pane.launch_discussion_requested.connect(self.launch_discussion_requested.emit)
pane.update_docs_requested.connect(self._on_update_docs_requested)
pane.settings_requested.connect(self.settings_requested.emit)
return pane
def _on_update_docs_requested(self):
"""Handle update docs request from a pane."""
pane = self.get_active_pane()
if pane:
project = pane.get_current_project()
if project:
self.update_docs_requested.emit(project.key)
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 add_dashboard(self, project: Project) -> ProjectDashboard | None:
"""Add a project dashboard to the active pane."""
pane = self.get_active_pane()
if pane:
return pane.add_dashboard(project)
return None
def add_global_dashboard(self) -> GlobalDashboard | None:
"""Add the global dashboard to the active pane."""
pane = self.get_active_pane()
if pane:
return pane.add_global_dashboard()
return None
def set_project_context(self, project: Project | None):
"""Set the project context for the active pane."""
pane = self.get_active_pane()
if pane:
pane.set_current_project(project)
def get_active_dashboard(self) -> ProjectDashboard | None:
"""Get the currently active dashboard widget, if any.
Returns:
The active ProjectDashboard or None if current tab is not a dashboard
"""
pane = self.get_active_pane()
if pane:
widget = pane.tab_widget.currentWidget()
if isinstance(widget, ProjectDashboard):
return widget
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_pane(self, pane: PaneWidget):
"""Close a specific pane."""
panes = self.find_panes()
if len(panes) > 1:
self._remove_pane(pane)
self.pane_count_changed.emit(len(self.find_panes()))
def close_active_pane(self):
"""Close the currently active pane."""
pane = self.get_active_pane()
if pane:
self._close_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", {}))
# If pane is empty after restore, add welcome tab
if pane.tab_count() == 0:
pane.add_welcome_tab()
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