804 lines
29 KiB
Python
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
|