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