feat: dynamic tabbed workspace with multi-file and multi-terminal support
Replaces the fixed 3-tab workspace (Editor/Diff/Terminal) with a VS Code-style dynamic tab system. Users can now open multiple files, terminals, and diffs in closeable tabs with type icons, modified indicators, and file deduplication. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+6
-7
@@ -534,7 +534,7 @@ class ClideApp(App[None]):
|
||||
def action_toggle_terminal(self) -> None:
|
||||
"""Toggle terminal (shows workspace with terminal tab)."""
|
||||
workspace = self.query_one(WorkspacePanel)
|
||||
if self.workspace_visible and workspace.active_tab == "terminal":
|
||||
if self.workspace_visible and workspace.get_active_tab_type() == "terminal":
|
||||
self.workspace_visible = False
|
||||
else:
|
||||
workspace.show_terminal()
|
||||
@@ -568,7 +568,7 @@ class ClideApp(App[None]):
|
||||
"""Focus editor."""
|
||||
self.workspace_visible = True
|
||||
workspace = self.query_one(WorkspacePanel)
|
||||
workspace.focus_tab("editor")
|
||||
workspace.focus_last_editor()
|
||||
|
||||
def action_focus_terminal(self) -> None:
|
||||
"""Focus terminal."""
|
||||
@@ -583,9 +583,9 @@ class ClideApp(App[None]):
|
||||
sidebar.focus()
|
||||
|
||||
def action_close_tab(self) -> None:
|
||||
"""Close current tab/editor."""
|
||||
# TODO: Implement tab closing
|
||||
pass
|
||||
"""Close current tab in workspace."""
|
||||
workspace = self.query_one(WorkspacePanel)
|
||||
workspace.close_tab()
|
||||
|
||||
def action_open_git(self) -> None:
|
||||
"""Open git panel."""
|
||||
@@ -614,8 +614,7 @@ class ClideApp(App[None]):
|
||||
try:
|
||||
workspace = self.query_one(WorkspacePanel)
|
||||
if workspace.has_unsaved_changes():
|
||||
workspace._action_save()
|
||||
# FileSaved message will be emitted by EditorPane if successful
|
||||
workspace.save_active_editor()
|
||||
else:
|
||||
self.notify("No unsaved changes", severity="warning")
|
||||
except Exception as e:
|
||||
|
||||
@@ -14,6 +14,7 @@ from clide.models.git import (
|
||||
from clide.models.problems import Problem, ProblemsState, ProblemsSummary, Severity
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition, ThemeMetadata
|
||||
from clide.models.todos import TodoItem, TodosState, TodosSummary, TodoType
|
||||
from clide.models.workspace import TAB_ICONS, TabInfo, TabType
|
||||
|
||||
__all__ = [
|
||||
# Config
|
||||
@@ -51,4 +52,8 @@ __all__ = [
|
||||
"ThemeColors",
|
||||
"ThemeDefinition",
|
||||
"ThemeMetadata",
|
||||
# Workspace
|
||||
"TabInfo",
|
||||
"TabType",
|
||||
"TAB_ICONS",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Workspace tab models."""
|
||||
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class TabType(str, Enum):
|
||||
"""Types of workspace tabs."""
|
||||
|
||||
EDITOR = "editor"
|
||||
TERMINAL = "terminal"
|
||||
DIFF = "diff"
|
||||
|
||||
|
||||
# Nerd Font icons for each tab type
|
||||
TAB_ICONS: dict[TabType, str] = {
|
||||
TabType.EDITOR: "\uf15c", # nf-fa-file_text_o
|
||||
TabType.TERMINAL: "\uf120", # nf-fa-terminal
|
||||
TabType.DIFF: "\uf440", # nf-oct-diff
|
||||
}
|
||||
|
||||
|
||||
class TabInfo(BaseModel):
|
||||
"""Metadata for a workspace tab."""
|
||||
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
tab_id: str
|
||||
tab_type: TabType
|
||||
label: str
|
||||
file_path: Path | None = None
|
||||
is_proposal: bool = False
|
||||
diff_file_path: str | None = None
|
||||
@@ -78,7 +78,7 @@ class DiffPane(Vertical):
|
||||
else:
|
||||
yield Static("No diff loaded", classes="diff-header")
|
||||
|
||||
yield RichLog(id="diff-log", highlight=True, markup=True, classes="diff-content")
|
||||
yield RichLog(highlight=True, markup=True, classes="diff-content")
|
||||
|
||||
if self._is_proposal:
|
||||
with Horizontal(classes="diff-actions"):
|
||||
@@ -109,7 +109,7 @@ class DiffPane(Vertical):
|
||||
|
||||
def _render_diff(self) -> None:
|
||||
"""Render the diff content."""
|
||||
log = self.query_one("#diff-log", RichLog)
|
||||
log = self.query_one(RichLog)
|
||||
log.clear()
|
||||
|
||||
if not self._diff:
|
||||
@@ -131,7 +131,7 @@ class DiffPane(Vertical):
|
||||
def clear(self) -> None:
|
||||
"""Clear the diff viewer."""
|
||||
self._diff = None
|
||||
log = self.query_one("#diff-log", RichLog)
|
||||
log = self.query_one(RichLog)
|
||||
log.clear()
|
||||
|
||||
header = self.query_one(".diff-header", Static)
|
||||
|
||||
@@ -117,7 +117,7 @@ class EditorPane(Vertical):
|
||||
try:
|
||||
import tree_sitter_typescript as tst
|
||||
|
||||
textarea = self.query_one("#editor-textarea", TextArea)
|
||||
textarea = self.query_one(TextArea)
|
||||
|
||||
# Register TypeScript
|
||||
try:
|
||||
@@ -149,24 +149,22 @@ class EditorPane(Vertical):
|
||||
yield TextArea(
|
||||
self._buffer.content,
|
||||
language=self._buffer.language,
|
||||
id="editor-textarea",
|
||||
show_line_numbers=True,
|
||||
)
|
||||
yield Static(
|
||||
self._get_status_text(),
|
||||
classes="editor-status",
|
||||
id="editor-status",
|
||||
)
|
||||
else:
|
||||
yield Static("No file open", classes="file-tab")
|
||||
yield TextArea(id="editor-textarea", show_line_numbers=True)
|
||||
yield Static("", classes="editor-status", id="editor-status")
|
||||
yield TextArea(show_line_numbers=True)
|
||||
yield Static("", classes="editor-status")
|
||||
|
||||
def load_buffer(self, buffer: FileBuffer) -> None: # noqa: ARG002
|
||||
"""Load a file buffer into the editor."""
|
||||
self._buffer = buffer
|
||||
|
||||
textarea = self.query_one("#editor-textarea", TextArea)
|
||||
textarea = self.query_one(TextArea)
|
||||
|
||||
# Register TypeScript if needed
|
||||
if buffer.language in ("typescript", "tsx"):
|
||||
@@ -194,7 +192,7 @@ class EditorPane(Vertical):
|
||||
|
||||
def get_content(self) -> str:
|
||||
"""Get current editor content."""
|
||||
textarea = self.query_one("#editor-textarea", TextArea)
|
||||
textarea = self.query_one(TextArea)
|
||||
return textarea.text
|
||||
|
||||
def _get_status_text(self) -> str:
|
||||
@@ -210,7 +208,7 @@ class EditorPane(Vertical):
|
||||
|
||||
def _update_status(self) -> None:
|
||||
"""Update status bar."""
|
||||
status = self.query_one("#editor-status", Static)
|
||||
status = self.query_one(".editor-status", Static)
|
||||
status.update(self._get_status_text())
|
||||
|
||||
def on_text_area_changed(self, event: TextArea.Changed) -> None:
|
||||
@@ -265,7 +263,7 @@ class EditorPane(Vertical):
|
||||
self.load_buffer(buffer)
|
||||
|
||||
if goto_line is not None:
|
||||
textarea = self.query_one("#editor-textarea", TextArea)
|
||||
textarea = self.query_one(TextArea)
|
||||
textarea.cursor_location = (goto_line - 1, 0)
|
||||
|
||||
def save(self) -> bool:
|
||||
|
||||
@@ -50,7 +50,7 @@ class TerminalPane(Vertical):
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Static(f"Terminal - {self._cwd}", classes="terminal-header")
|
||||
self._terminal = TerminalDisplay(id="terminal-display")
|
||||
self._terminal = TerminalDisplay()
|
||||
yield self._terminal
|
||||
|
||||
def on_mount(self) -> None:
|
||||
@@ -80,6 +80,10 @@ class TerminalPane(Vertical):
|
||||
if self._terminal:
|
||||
self._terminal.stop()
|
||||
|
||||
def on_unmount(self) -> None:
|
||||
"""Clean up PTY when widget is destroyed."""
|
||||
self.stop()
|
||||
|
||||
def restart(self) -> None:
|
||||
"""Restart the shell."""
|
||||
if self._terminal:
|
||||
|
||||
+452
-228
@@ -1,27 +1,112 @@
|
||||
"""Workspace panel with Editor, Diff, and Terminal tabs."""
|
||||
"""Workspace panel with dynamic tabbed Editor, Diff, and Terminal panes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from textual import events
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Horizontal, Vertical
|
||||
from textual.containers import Container, Horizontal, Vertical
|
||||
from textual.message import Message
|
||||
from textual.reactive import reactive
|
||||
from textual.widgets import ContentSwitcher, Tab, Tabs
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Static, Tab, Tabs
|
||||
|
||||
from clide.models.diff import DiffContent
|
||||
from clide.models.workspace import TAB_ICONS, TabInfo, TabType
|
||||
from clide.widgets.components.action_bar import ActionBar, ActionButton
|
||||
from clide.widgets.components.diff_pane import DiffPane
|
||||
from clide.widgets.components.editor_pane import EditorPane
|
||||
from clide.widgets.components.terminal_pane import TerminalPane
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from clide.models.diff import DiffContent
|
||||
|
||||
|
||||
class ClosableTab(Tab):
|
||||
"""Tab with a type icon prefix and close button."""
|
||||
|
||||
class CloseClicked(Message):
|
||||
"""Posted when the close region of a tab is clicked."""
|
||||
|
||||
def __init__(self, tab_id: str) -> None:
|
||||
self.tab_id = tab_id
|
||||
super().__init__()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
label: str,
|
||||
*,
|
||||
tab_type: TabType | None = None,
|
||||
closable: bool = True,
|
||||
id: str | None = None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
self._base_label = label
|
||||
self._tab_type = tab_type
|
||||
self._closable = closable
|
||||
display_label = self._build_label(label, tab_type, closable)
|
||||
super().__init__(display_label, id=id, **kwargs)
|
||||
|
||||
@staticmethod
|
||||
def _build_label(label: str, tab_type: TabType | None, closable: bool) -> str:
|
||||
"""Build the display label with icon prefix and close indicator."""
|
||||
parts: list[str] = []
|
||||
if tab_type:
|
||||
icon = TAB_ICONS.get(tab_type, "")
|
||||
if icon:
|
||||
parts.append(icon)
|
||||
parts.append(label)
|
||||
if closable:
|
||||
parts.append("×")
|
||||
return " ".join(parts)
|
||||
|
||||
def update_label(self, label: str) -> None:
|
||||
"""Update the tab label, preserving icon and close button."""
|
||||
self._base_label = label
|
||||
self.label = self._build_label(label, self._tab_type, self._closable)
|
||||
|
||||
def _on_click(self, event: events.Click) -> None:
|
||||
"""Detect click on the close region (rightmost 2 chars)."""
|
||||
if self._closable and event.x >= self.size.width - 3:
|
||||
event.stop()
|
||||
self.post_message(self.CloseClicked(self.id or ""))
|
||||
else:
|
||||
super()._on_click()
|
||||
|
||||
|
||||
class NewTabButton(Static):
|
||||
"""A + button for creating new tabs."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
NewTabButton {
|
||||
width: 3;
|
||||
height: 1;
|
||||
padding: 0 1;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
NewTabButton:hover {
|
||||
color: $text;
|
||||
background: $surface;
|
||||
}
|
||||
"""
|
||||
|
||||
class Clicked(Message):
|
||||
"""Posted when the new tab button is clicked."""
|
||||
|
||||
def __init__(self, **kwargs) -> None:
|
||||
super().__init__("+", **kwargs)
|
||||
|
||||
def on_click(self) -> None:
|
||||
self.post_message(self.Clicked())
|
||||
|
||||
|
||||
class WorkspacePanel(Vertical):
|
||||
"""Center workspace panel with Editor, Diff, and Terminal tabs.
|
||||
"""Center workspace panel with dynamic tabbed panes.
|
||||
|
||||
Hidden by default. Shows when:
|
||||
- File is opened
|
||||
- Diff is displayed
|
||||
- Terminal is activated
|
||||
Supports multiple editor, terminal, and diff tabs. Hidden by default.
|
||||
Shows when a file is opened, diff is displayed, or terminal is activated.
|
||||
"""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
@@ -62,18 +147,18 @@ class WorkspacePanel(Vertical):
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
WorkspacePanel ContentSwitcher {
|
||||
WorkspacePanel #workspace-content {
|
||||
height: 1fr;
|
||||
}
|
||||
|
||||
WorkspacePanel #pane-editor,
|
||||
WorkspacePanel #pane-diff,
|
||||
WorkspacePanel #pane-terminal {
|
||||
WorkspacePanel #workspace-content > * {
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
"""
|
||||
|
||||
# -- Messages --
|
||||
|
||||
class FileSaved(Message):
|
||||
"""Emitted when a file is saved."""
|
||||
|
||||
@@ -98,23 +183,16 @@ class WorkspacePanel(Vertical):
|
||||
class CloseRequested(Message):
|
||||
"""Emitted when workspace should be hidden."""
|
||||
|
||||
pass
|
||||
|
||||
# Reactive state - persisted when hidden
|
||||
visible: reactive[bool] = reactive(False)
|
||||
active_tab: reactive[str] = reactive("editor")
|
||||
maximized: reactive[bool] = reactive(False)
|
||||
|
||||
# Messages for app-level actions
|
||||
class MaximizeRequested(Message):
|
||||
"""Emitted when workspace should be maximized."""
|
||||
|
||||
pass
|
||||
|
||||
class RestoreRequested(Message):
|
||||
"""Emitted when workspace should be restored from maximized."""
|
||||
|
||||
pass
|
||||
# -- Reactive state --
|
||||
|
||||
visible: reactive[bool] = reactive(False)
|
||||
maximized: reactive[bool] = reactive(False)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -126,287 +204,433 @@ class WorkspacePanel(Vertical):
|
||||
self.id = "panel-workspace"
|
||||
self._action_bar: ActionBar | None = None
|
||||
|
||||
# Tab management
|
||||
self._tab_counter: int = 0
|
||||
self._tab_registry: dict[str, TabInfo] = {}
|
||||
self._file_to_tab: dict[Path, str] = {} # resolved path → tab_id
|
||||
self._terminal_count: int = 0
|
||||
self._pane_widgets: dict[str, Widget] = {} # tab_id → pane widget
|
||||
|
||||
# -- Compose --
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
# Header row with tabs and action bar
|
||||
with Horizontal(id="workspace-header"):
|
||||
yield Tabs(
|
||||
Tab("Editor", id="tab-editor"),
|
||||
Tab("Diff", id="tab-diff"),
|
||||
Tab("Terminal", id="tab-terminal"),
|
||||
id="workspace-tabs",
|
||||
)
|
||||
yield Tabs(id="workspace-tabs")
|
||||
yield NewTabButton(id="new-tab-btn")
|
||||
self._action_bar = ActionBar(id="workspace-action-bar")
|
||||
yield self._action_bar
|
||||
|
||||
# Content area
|
||||
with ContentSwitcher(id="workspace-content", initial="pane-editor"):
|
||||
with Vertical(id="pane-editor"):
|
||||
yield EditorPane(id="editor-pane")
|
||||
with Vertical(id="pane-diff"):
|
||||
yield DiffPane(id="diff-pane")
|
||||
with Vertical(id="pane-terminal"):
|
||||
yield TerminalPane(cwd=self._workdir, id="terminal-pane")
|
||||
yield Container(id="workspace-content")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Set initial visibility and configure action bar."""
|
||||
self._update_visibility()
|
||||
self._setup_action_bar()
|
||||
|
||||
# -- Action bar --
|
||||
|
||||
def _setup_action_bar(self) -> None:
|
||||
"""Set up the action bar with standard buttons."""
|
||||
"""Set up the action bar with window control buttons only."""
|
||||
if self._action_bar is None:
|
||||
return
|
||||
|
||||
# Register standard buttons using simple ASCII icons
|
||||
# Save button (for editor)
|
||||
self._action_bar.register_button(ActionButton(id="minimize", icon="_", tooltip="Minimize"))
|
||||
self._action_bar.register_button(ActionButton(id="maximize", icon="^", tooltip="Maximize"))
|
||||
self._action_bar.register_button(
|
||||
ActionButton(
|
||||
id="save",
|
||||
icon="[S]",
|
||||
tooltip="Save",
|
||||
)
|
||||
ActionButton(id="restore", icon="v", tooltip="Restore", visible=False)
|
||||
)
|
||||
|
||||
# Add separator
|
||||
self._action_bar.add_separator()
|
||||
|
||||
# Close button
|
||||
self._action_bar.register_button(
|
||||
ActionButton(
|
||||
id="close",
|
||||
icon="x",
|
||||
tooltip="Close",
|
||||
)
|
||||
ActionButton(id="close", icon="x", tooltip="Close workspace")
|
||||
)
|
||||
|
||||
# Minimize button
|
||||
self._action_bar.register_button(
|
||||
ActionButton(
|
||||
id="minimize",
|
||||
icon="_",
|
||||
tooltip="Minimize",
|
||||
)
|
||||
)
|
||||
|
||||
# Maximize button
|
||||
self._action_bar.register_button(
|
||||
ActionButton(
|
||||
id="maximize",
|
||||
icon="^",
|
||||
tooltip="Maximize",
|
||||
)
|
||||
)
|
||||
|
||||
# Restore button (hidden by default)
|
||||
self._action_bar.register_button(
|
||||
ActionButton(
|
||||
id="restore",
|
||||
icon="v",
|
||||
tooltip="Restore",
|
||||
visible=False,
|
||||
)
|
||||
)
|
||||
|
||||
# Update button visibility based on current tab
|
||||
self._update_action_bar_for_tab(self.active_tab)
|
||||
|
||||
def _update_action_bar_for_tab(self, tab: str) -> None:
|
||||
"""Update action bar buttons based on active tab."""
|
||||
if self._action_bar is None:
|
||||
return
|
||||
|
||||
# Save button only visible for editor
|
||||
self._action_bar.set_button_visible("save", tab == "editor")
|
||||
|
||||
# Update save button enabled state based on editor modified state
|
||||
if tab == "editor":
|
||||
try:
|
||||
editor = self.query_one("#editor-pane", EditorPane)
|
||||
self._action_bar.set_button_enabled("save", editor.modified)
|
||||
except Exception:
|
||||
self._action_bar.set_button_enabled("save", False)
|
||||
|
||||
def watch_maximized(self, maximized: bool) -> None:
|
||||
"""Handle maximize state changes."""
|
||||
if self._action_bar is None:
|
||||
return
|
||||
|
||||
# Toggle maximize/restore button visibility
|
||||
self._action_bar.set_button_visible("maximize", not maximized)
|
||||
self._action_bar.set_button_visible("restore", maximized)
|
||||
|
||||
# Update CSS class
|
||||
if maximized:
|
||||
self.add_class("maximized")
|
||||
else:
|
||||
self.remove_class("maximized")
|
||||
|
||||
def on_action_bar_button_pressed(self, event: ActionBar.ButtonPressed) -> None:
|
||||
"""Handle action bar button presses."""
|
||||
button_id = event.button_id
|
||||
actions = {
|
||||
"close": self._action_close,
|
||||
"minimize": self._action_minimize,
|
||||
"maximize": self._action_maximize,
|
||||
"restore": self._action_restore,
|
||||
}
|
||||
action = actions.get(event.button_id)
|
||||
if action:
|
||||
action()
|
||||
|
||||
if button_id == "save":
|
||||
self._action_save()
|
||||
elif button_id == "close":
|
||||
self._action_close()
|
||||
elif button_id == "minimize":
|
||||
self._action_minimize()
|
||||
elif button_id == "maximize":
|
||||
self._action_maximize()
|
||||
elif button_id == "restore":
|
||||
self._action_restore()
|
||||
# -- Tab management internals --
|
||||
|
||||
def _action_save(self) -> None:
|
||||
"""Save the current file in editor."""
|
||||
try:
|
||||
editor = self.query_one("#editor-pane", EditorPane)
|
||||
editor.save()
|
||||
except Exception as e:
|
||||
self.app.notify(f"Save failed: {e}", severity="error")
|
||||
def _generate_tab_id(self, tab_type: TabType) -> str:
|
||||
self._tab_counter += 1
|
||||
return f"ws-{tab_type.value}-{self._tab_counter}"
|
||||
|
||||
def _action_close(self) -> None:
|
||||
"""Close the workspace panel."""
|
||||
self.post_message(self.CloseRequested())
|
||||
self.hide()
|
||||
def _add_tab(
|
||||
self,
|
||||
tab_type: TabType,
|
||||
label: str,
|
||||
pane_widget: Widget,
|
||||
*,
|
||||
file_path: Path | None = None,
|
||||
is_proposal: bool = False,
|
||||
diff_file_path: str | None = None,
|
||||
) -> str:
|
||||
"""Create a new tab and mount its pane widget."""
|
||||
tab_id = self._generate_tab_id(tab_type)
|
||||
|
||||
def _action_minimize(self) -> None:
|
||||
"""Minimize (hide) the workspace panel."""
|
||||
self.hide()
|
||||
# Register metadata
|
||||
info = TabInfo(
|
||||
tab_id=tab_id,
|
||||
tab_type=tab_type,
|
||||
label=label,
|
||||
file_path=file_path,
|
||||
is_proposal=is_proposal,
|
||||
diff_file_path=diff_file_path,
|
||||
)
|
||||
self._tab_registry[tab_id] = info
|
||||
self._pane_widgets[tab_id] = pane_widget
|
||||
|
||||
def _action_maximize(self) -> None:
|
||||
"""Maximize the workspace panel."""
|
||||
self.maximized = True
|
||||
self.post_message(self.MaximizeRequested())
|
||||
if file_path:
|
||||
self._file_to_tab[file_path.resolve()] = tab_id
|
||||
|
||||
def _action_restore(self) -> None:
|
||||
"""Restore from maximized state."""
|
||||
self.maximized = False
|
||||
self.post_message(self.RestoreRequested())
|
||||
# Create the tab widget
|
||||
tab = ClosableTab(label, tab_type=tab_type, id=tab_id)
|
||||
tabs = self.query_one("#workspace-tabs", Tabs)
|
||||
tabs.add_tab(tab)
|
||||
|
||||
def on_tabs_tab_activated(self, event: Tabs.TabActivated) -> None:
|
||||
"""Handle tab switches to update action bar and content."""
|
||||
# Extract tab name from tab id (e.g., "tab-editor" -> "editor")
|
||||
tab_id = event.tab.id
|
||||
if tab_id and tab_id.startswith("tab-"):
|
||||
tab_name = tab_id.replace("tab-", "")
|
||||
self.active_tab = tab_name
|
||||
self._update_action_bar_for_tab(tab_name)
|
||||
# Mount the pane (hidden by default)
|
||||
pane_widget.display = False
|
||||
content = self.query_one("#workspace-content", Container)
|
||||
content.mount(pane_widget)
|
||||
|
||||
# Switch content
|
||||
# Activate this tab
|
||||
tabs.active = tab_id
|
||||
return tab_id
|
||||
|
||||
def _remove_tab(self, tab_id: str) -> None:
|
||||
"""Remove a tab and destroy its pane widget."""
|
||||
if tab_id not in self._tab_registry:
|
||||
return
|
||||
|
||||
info = self._tab_registry.pop(tab_id)
|
||||
pane = self._pane_widgets.pop(tab_id, None)
|
||||
|
||||
# Clean up file index
|
||||
if info.file_path:
|
||||
resolved = info.file_path.resolve()
|
||||
if resolved in self._file_to_tab:
|
||||
del self._file_to_tab[resolved]
|
||||
|
||||
# Stop terminal PTY before removal
|
||||
if info.tab_type == TabType.TERMINAL and pane:
|
||||
try:
|
||||
content = self.query_one("#workspace-content", ContentSwitcher)
|
||||
content.current = f"pane-{tab_name}"
|
||||
terminal = pane if isinstance(pane, TerminalPane) else None
|
||||
if terminal:
|
||||
terminal.stop()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Remove the tab from the tab bar
|
||||
try:
|
||||
tabs = self.query_one("#workspace-tabs", Tabs)
|
||||
tabs.remove_tab(tab_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Remove and destroy the pane widget
|
||||
if pane:
|
||||
try:
|
||||
pane.remove()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# If no tabs remain, hide workspace
|
||||
if not self._tab_registry:
|
||||
self.hide()
|
||||
self.post_message(self.CloseRequested())
|
||||
|
||||
def _activate_tab(self, tab_id: str) -> None:
|
||||
"""Activate a specific tab."""
|
||||
if tab_id not in self._tab_registry:
|
||||
return
|
||||
try:
|
||||
tabs = self.query_one("#workspace-tabs", Tabs)
|
||||
tabs.active = tab_id
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _get_pane(self, tab_id: str) -> Widget | None:
|
||||
"""Get the pane widget for a tab."""
|
||||
return self._pane_widgets.get(tab_id)
|
||||
|
||||
# -- Tab events --
|
||||
|
||||
def on_tabs_tab_activated(self, event: Tabs.TabActivated) -> None:
|
||||
"""Handle tab switches: show active pane, hide others."""
|
||||
tab_id = event.tab.id
|
||||
if not tab_id or tab_id not in self._tab_registry:
|
||||
return
|
||||
|
||||
# Show only the active pane
|
||||
for tid, pane in self._pane_widgets.items():
|
||||
pane.display = tid == tab_id
|
||||
|
||||
# Focus terminal if it's a terminal tab
|
||||
info = self._tab_registry[tab_id]
|
||||
if info.tab_type == TabType.TERMINAL:
|
||||
pane = self._pane_widgets.get(tab_id)
|
||||
if isinstance(pane, TerminalPane):
|
||||
pane.focus_terminal()
|
||||
|
||||
def on_closable_tab_close_clicked(self, event: ClosableTab.CloseClicked) -> None:
|
||||
"""Handle tab close button clicks."""
|
||||
self._remove_tab(event.tab_id)
|
||||
|
||||
def on_new_tab_button_clicked(self, _event: NewTabButton.Clicked) -> None:
|
||||
"""Handle + button: create a new terminal tab."""
|
||||
self.new_terminal()
|
||||
|
||||
# -- Visibility --
|
||||
|
||||
def watch_visible(self, visible: bool) -> None:
|
||||
"""Handle visibility changes - hide, don't destroy."""
|
||||
self._update_visibility()
|
||||
|
||||
def _update_visibility(self) -> None:
|
||||
"""Update display based on visibility state."""
|
||||
if self.visible:
|
||||
self.remove_class("hidden")
|
||||
else:
|
||||
self.add_class("hidden")
|
||||
|
||||
def watch_maximized(self, maximized: bool) -> None:
|
||||
"""Handle maximize state changes."""
|
||||
if self._action_bar:
|
||||
self._action_bar.set_button_visible("maximize", not maximized)
|
||||
self._action_bar.set_button_visible("restore", maximized)
|
||||
if maximized:
|
||||
self.add_class("maximized")
|
||||
else:
|
||||
self.remove_class("maximized")
|
||||
|
||||
# -- Window actions --
|
||||
|
||||
def _action_close(self) -> None:
|
||||
self.post_message(self.CloseRequested())
|
||||
self.hide()
|
||||
|
||||
def _action_minimize(self) -> None:
|
||||
self.hide()
|
||||
|
||||
def _action_maximize(self) -> None:
|
||||
self.maximized = True
|
||||
self.post_message(self.MaximizeRequested())
|
||||
|
||||
def _action_restore(self) -> None:
|
||||
self.maximized = False
|
||||
self.post_message(self.RestoreRequested())
|
||||
|
||||
# -- Public API --
|
||||
|
||||
def show(self, tab: str | None = None) -> None:
|
||||
"""Show workspace, optionally focusing a specific tab."""
|
||||
"""Show workspace, optionally activating a tab by ID."""
|
||||
self.visible = True
|
||||
if tab:
|
||||
self.focus_tab(tab)
|
||||
if tab and tab in self._tab_registry:
|
||||
self._activate_tab(tab)
|
||||
|
||||
def hide(self) -> None:
|
||||
"""Hide workspace (state is preserved)."""
|
||||
"""Hide workspace (all state preserved)."""
|
||||
self.visible = False
|
||||
|
||||
def toggle(self) -> None:
|
||||
"""Toggle workspace visibility."""
|
||||
self.visible = not self.visible
|
||||
|
||||
def focus_tab(self, tab_id: str) -> None:
|
||||
"""Focus a specific tab."""
|
||||
def open_file(self, path: Path, line: int | None = None) -> str:
|
||||
"""Open a file in an editor tab. Reuses existing tab for same path."""
|
||||
self.visible = True
|
||||
|
||||
resolved = path.resolve()
|
||||
if resolved in self._file_to_tab:
|
||||
tab_id = self._file_to_tab[resolved]
|
||||
self._activate_tab(tab_id)
|
||||
if line is not None:
|
||||
pane = self._pane_widgets.get(tab_id)
|
||||
if isinstance(pane, EditorPane):
|
||||
from textual.widgets import TextArea
|
||||
|
||||
try:
|
||||
textarea = pane.query_one(TextArea)
|
||||
textarea.cursor_location = (line - 1, 0)
|
||||
except Exception:
|
||||
pass
|
||||
return tab_id
|
||||
|
||||
editor = EditorPane()
|
||||
tab_id = self._add_tab(
|
||||
TabType.EDITOR,
|
||||
label=path.name,
|
||||
pane_widget=editor,
|
||||
file_path=resolved,
|
||||
)
|
||||
self.call_after_refresh(lambda: editor.load_file(path, goto_line=line))
|
||||
return tab_id
|
||||
|
||||
def show_diff(self, diff: DiffContent, is_proposal: bool = False) -> str:
|
||||
"""Open a diff in a new tab."""
|
||||
self.visible = True
|
||||
|
||||
diff_pane = DiffPane(diff=diff, is_proposal=is_proposal)
|
||||
label = f"Diff: {Path(diff.file_path).name}"
|
||||
tab_id = self._add_tab(
|
||||
TabType.DIFF,
|
||||
label=label,
|
||||
pane_widget=diff_pane,
|
||||
is_proposal=is_proposal,
|
||||
diff_file_path=diff.file_path,
|
||||
)
|
||||
return tab_id
|
||||
|
||||
def show_terminal(self) -> str:
|
||||
"""Show existing terminal or create first one."""
|
||||
self.visible = True
|
||||
|
||||
# Find an existing terminal tab
|
||||
for tab_id, info in self._tab_registry.items():
|
||||
if info.tab_type == TabType.TERMINAL:
|
||||
self._activate_tab(tab_id)
|
||||
return tab_id
|
||||
|
||||
return self.new_terminal()
|
||||
|
||||
def new_terminal(self) -> str:
|
||||
"""Create a new terminal tab."""
|
||||
self.visible = True
|
||||
self._terminal_count += 1
|
||||
|
||||
terminal = TerminalPane(cwd=self._workdir)
|
||||
label = f"Terminal {self._terminal_count}" if self._terminal_count > 1 else "Terminal"
|
||||
tab_id = self._add_tab(
|
||||
TabType.TERMINAL,
|
||||
label=label,
|
||||
pane_widget=terminal,
|
||||
)
|
||||
return tab_id
|
||||
|
||||
def close_tab(self, tab_id: str | None = None) -> None:
|
||||
"""Close a tab. If tab_id is None, close the active tab."""
|
||||
if tab_id is None:
|
||||
try:
|
||||
tabs = self.query_one("#workspace-tabs", Tabs)
|
||||
tab_id = tabs.active
|
||||
except Exception:
|
||||
return
|
||||
if tab_id:
|
||||
self._remove_tab(tab_id)
|
||||
|
||||
def get_active_tab_type(self) -> str | None:
|
||||
"""Get the type of the active tab."""
|
||||
try:
|
||||
# Activate the tab
|
||||
tabs = self.query_one("#workspace-tabs", Tabs)
|
||||
tabs.active = f"tab-{tab_id}"
|
||||
|
||||
# Switch content
|
||||
content = self.query_one("#workspace-content", ContentSwitcher)
|
||||
content.current = f"pane-{tab_id}"
|
||||
|
||||
self.active_tab = tab_id
|
||||
self._update_action_bar_for_tab(tab_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Editor methods
|
||||
def open_file(self, path: Path, line: int | None = None) -> None:
|
||||
"""Open a file in the editor tab."""
|
||||
self.show("editor")
|
||||
try:
|
||||
editor = self.query_one("#editor-pane", EditorPane)
|
||||
editor.load_file(path, goto_line=line)
|
||||
active = tabs.active
|
||||
if active and active in self._tab_registry:
|
||||
return self._tab_registry[active].tab_type.value
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def get_current_file(self) -> Path | None:
|
||||
"""Get the currently open file."""
|
||||
"""Get the file in the active editor tab."""
|
||||
try:
|
||||
editor = self.query_one("#editor-pane", EditorPane)
|
||||
return editor.current_file
|
||||
tabs = self.query_one("#workspace-tabs", Tabs)
|
||||
active = tabs.active
|
||||
if active and active in self._tab_registry:
|
||||
info = self._tab_registry[active]
|
||||
if info.tab_type == TabType.EDITOR:
|
||||
return info.file_path
|
||||
except Exception:
|
||||
return None
|
||||
pass
|
||||
return None
|
||||
|
||||
def has_unsaved_changes(self) -> bool:
|
||||
"""Check if editor has unsaved changes."""
|
||||
try:
|
||||
editor = self.query_one("#editor-pane", EditorPane)
|
||||
return editor.modified
|
||||
except Exception:
|
||||
return False
|
||||
"""Check if any editor tab has unsaved changes."""
|
||||
for tab_id, info in self._tab_registry.items():
|
||||
if info.tab_type == TabType.EDITOR:
|
||||
pane = self._pane_widgets.get(tab_id)
|
||||
if isinstance(pane, EditorPane) and pane.modified:
|
||||
return True
|
||||
return False
|
||||
|
||||
# Diff methods
|
||||
def show_diff(
|
||||
self,
|
||||
diff: DiffContent,
|
||||
is_proposal: bool = False,
|
||||
) -> None:
|
||||
"""Show a diff in the diff tab."""
|
||||
self.show("diff")
|
||||
def save_active_editor(self) -> None:
|
||||
"""Save the active editor tab."""
|
||||
try:
|
||||
diff_pane = self.query_one("#diff-pane", DiffPane)
|
||||
diff_pane.load_diff(diff, is_proposal)
|
||||
tabs = self.query_one("#workspace-tabs", Tabs)
|
||||
active = tabs.active
|
||||
if active and active in self._tab_registry:
|
||||
info = self._tab_registry[active]
|
||||
if info.tab_type == TabType.EDITOR:
|
||||
pane = self._pane_widgets.get(active)
|
||||
if isinstance(pane, EditorPane):
|
||||
pane.save()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def focus_last_editor(self) -> None:
|
||||
"""Focus the most recent editor tab."""
|
||||
for tab_id in reversed(list(self._tab_registry)):
|
||||
info = self._tab_registry[tab_id]
|
||||
if info.tab_type == TabType.EDITOR:
|
||||
self._activate_tab(tab_id)
|
||||
return
|
||||
|
||||
def focus_tab(self, tab_id: str) -> None:
|
||||
"""Focus a specific tab by ID. Legacy compat."""
|
||||
self._activate_tab(tab_id)
|
||||
|
||||
def clear_diff(self) -> None:
|
||||
"""Clear the diff view."""
|
||||
try:
|
||||
diff_pane = self.query_one("#diff-pane", DiffPane)
|
||||
diff_pane.clear()
|
||||
except Exception:
|
||||
pass
|
||||
"""Close all non-proposal diff tabs."""
|
||||
to_remove = [
|
||||
tab_id
|
||||
for tab_id, info in self._tab_registry.items()
|
||||
if info.tab_type == TabType.DIFF and not info.is_proposal
|
||||
]
|
||||
for tab_id in to_remove:
|
||||
self._remove_tab(tab_id)
|
||||
|
||||
# Terminal methods
|
||||
def show_terminal(self) -> None:
|
||||
"""Show and focus the terminal tab."""
|
||||
self.show("terminal")
|
||||
try:
|
||||
terminal = self.query_one("#terminal-pane", TerminalPane)
|
||||
terminal.focus_terminal()
|
||||
except Exception:
|
||||
pass
|
||||
# -- Event forwarding --
|
||||
|
||||
# Event forwarding
|
||||
def on_editor_pane_file_saved(self, event: EditorPane.FileSaved) -> None:
|
||||
"""Forward file save event."""
|
||||
"""Forward file save and update tab label."""
|
||||
self.post_message(self.FileSaved(event.path))
|
||||
# Update the tab label (remove modified indicator)
|
||||
resolved = event.path.resolve()
|
||||
if resolved in self._file_to_tab:
|
||||
tab_id = self._file_to_tab[resolved]
|
||||
try:
|
||||
tab = self.query_one(f"#{tab_id}", ClosableTab)
|
||||
tab.update_label(event.path.name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def on_editor_pane_content_changed(self, event: EditorPane.ContentChanged) -> None:
|
||||
"""Update tab label with modified indicator when content changes."""
|
||||
resolved = event.path.resolve()
|
||||
if resolved in self._file_to_tab:
|
||||
tab_id = self._file_to_tab[resolved]
|
||||
try:
|
||||
tab = self.query_one(f"#{tab_id}", ClosableTab)
|
||||
tab.update_label(f"● {event.path.name}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def on_diff_pane_accept_clicked(self, event: DiffPane.AcceptClicked) -> None:
|
||||
"""Forward diff accept event."""
|
||||
"""Forward diff accept and close the diff tab."""
|
||||
self.post_message(self.DiffAccepted(event.file_path))
|
||||
# Find and close the diff tab
|
||||
for tab_id, info in list(self._tab_registry.items()):
|
||||
if info.tab_type == TabType.DIFF and info.diff_file_path == event.file_path:
|
||||
self._remove_tab(tab_id)
|
||||
break
|
||||
|
||||
def on_diff_pane_reject_clicked(self, event: DiffPane.RejectClicked) -> None:
|
||||
"""Forward diff reject event."""
|
||||
"""Forward diff reject and close the diff tab."""
|
||||
self.post_message(self.DiffRejected(event.file_path))
|
||||
for tab_id, info in list(self._tab_registry.items()):
|
||||
if info.tab_type == TabType.DIFF and info.diff_file_path == event.file_path:
|
||||
self._remove_tab(tab_id)
|
||||
break
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 36 KiB |
+36
-37
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 36 KiB |
+36
-37
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 36 KiB |
@@ -59,7 +59,7 @@ class TestWorkspacePanel:
|
||||
panel = WorkspacePanel(workdir=tmp_path)
|
||||
assert panel._workdir == tmp_path
|
||||
assert panel.visible is False
|
||||
assert panel.active_tab == "editor"
|
||||
assert panel.get_active_tab_type() is None
|
||||
assert panel.id == "panel-workspace"
|
||||
|
||||
def test_default_workdir(self):
|
||||
@@ -109,13 +109,6 @@ class TestWorkspacePanel:
|
||||
msg = WorkspacePanel.DiffRejected("test.py")
|
||||
assert msg.file_path == "test.py"
|
||||
|
||||
def test_command_submitted_message(self):
|
||||
"""Test CommandSubmitted message."""
|
||||
from clide.widgets.panels.workspace import WorkspacePanel
|
||||
|
||||
msg = WorkspacePanel.CommandSubmitted("ls -la")
|
||||
assert msg.command == "ls -la"
|
||||
|
||||
def test_close_requested_message(self):
|
||||
"""Test CloseRequested message."""
|
||||
from clide.widgets.panels.workspace import WorkspacePanel
|
||||
|
||||
@@ -383,8 +383,7 @@ class TestTerminalPane:
|
||||
|
||||
pane = TerminalPane(cwd=tmp_path)
|
||||
assert pane.cwd == tmp_path
|
||||
assert pane._history == []
|
||||
assert pane._history_index == 0
|
||||
assert pane._terminal is None
|
||||
|
||||
def test_default_cwd(self):
|
||||
"""Test default cwd is current directory."""
|
||||
@@ -401,13 +400,6 @@ class TestTerminalPane:
|
||||
pane._cwd = tmp_path
|
||||
assert pane.cwd == tmp_path
|
||||
|
||||
def test_command_submitted_message(self):
|
||||
"""Test CommandSubmitted message."""
|
||||
from clide.widgets.components.terminal_pane import TerminalPane
|
||||
|
||||
msg = TerminalPane.CommandSubmitted("ls -la")
|
||||
assert msg.command == "ls -la"
|
||||
|
||||
|
||||
class TestProblemsView:
|
||||
"""Tests for ProblemsView component."""
|
||||
|
||||
Reference in New Issue
Block a user