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:
|
def action_toggle_terminal(self) -> None:
|
||||||
"""Toggle terminal (shows workspace with terminal tab)."""
|
"""Toggle terminal (shows workspace with terminal tab)."""
|
||||||
workspace = self.query_one(WorkspacePanel)
|
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
|
self.workspace_visible = False
|
||||||
else:
|
else:
|
||||||
workspace.show_terminal()
|
workspace.show_terminal()
|
||||||
@@ -568,7 +568,7 @@ class ClideApp(App[None]):
|
|||||||
"""Focus editor."""
|
"""Focus editor."""
|
||||||
self.workspace_visible = True
|
self.workspace_visible = True
|
||||||
workspace = self.query_one(WorkspacePanel)
|
workspace = self.query_one(WorkspacePanel)
|
||||||
workspace.focus_tab("editor")
|
workspace.focus_last_editor()
|
||||||
|
|
||||||
def action_focus_terminal(self) -> None:
|
def action_focus_terminal(self) -> None:
|
||||||
"""Focus terminal."""
|
"""Focus terminal."""
|
||||||
@@ -583,9 +583,9 @@ class ClideApp(App[None]):
|
|||||||
sidebar.focus()
|
sidebar.focus()
|
||||||
|
|
||||||
def action_close_tab(self) -> None:
|
def action_close_tab(self) -> None:
|
||||||
"""Close current tab/editor."""
|
"""Close current tab in workspace."""
|
||||||
# TODO: Implement tab closing
|
workspace = self.query_one(WorkspacePanel)
|
||||||
pass
|
workspace.close_tab()
|
||||||
|
|
||||||
def action_open_git(self) -> None:
|
def action_open_git(self) -> None:
|
||||||
"""Open git panel."""
|
"""Open git panel."""
|
||||||
@@ -614,8 +614,7 @@ class ClideApp(App[None]):
|
|||||||
try:
|
try:
|
||||||
workspace = self.query_one(WorkspacePanel)
|
workspace = self.query_one(WorkspacePanel)
|
||||||
if workspace.has_unsaved_changes():
|
if workspace.has_unsaved_changes():
|
||||||
workspace._action_save()
|
workspace.save_active_editor()
|
||||||
# FileSaved message will be emitted by EditorPane if successful
|
|
||||||
else:
|
else:
|
||||||
self.notify("No unsaved changes", severity="warning")
|
self.notify("No unsaved changes", severity="warning")
|
||||||
except Exception as e:
|
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.problems import Problem, ProblemsState, ProblemsSummary, Severity
|
||||||
from clide.models.theme import ThemeColors, ThemeDefinition, ThemeMetadata
|
from clide.models.theme import ThemeColors, ThemeDefinition, ThemeMetadata
|
||||||
from clide.models.todos import TodoItem, TodosState, TodosSummary, TodoType
|
from clide.models.todos import TodoItem, TodosState, TodosSummary, TodoType
|
||||||
|
from clide.models.workspace import TAB_ICONS, TabInfo, TabType
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# Config
|
# Config
|
||||||
@@ -51,4 +52,8 @@ __all__ = [
|
|||||||
"ThemeColors",
|
"ThemeColors",
|
||||||
"ThemeDefinition",
|
"ThemeDefinition",
|
||||||
"ThemeMetadata",
|
"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:
|
else:
|
||||||
yield Static("No diff loaded", classes="diff-header")
|
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:
|
if self._is_proposal:
|
||||||
with Horizontal(classes="diff-actions"):
|
with Horizontal(classes="diff-actions"):
|
||||||
@@ -109,7 +109,7 @@ class DiffPane(Vertical):
|
|||||||
|
|
||||||
def _render_diff(self) -> None:
|
def _render_diff(self) -> None:
|
||||||
"""Render the diff content."""
|
"""Render the diff content."""
|
||||||
log = self.query_one("#diff-log", RichLog)
|
log = self.query_one(RichLog)
|
||||||
log.clear()
|
log.clear()
|
||||||
|
|
||||||
if not self._diff:
|
if not self._diff:
|
||||||
@@ -131,7 +131,7 @@ class DiffPane(Vertical):
|
|||||||
def clear(self) -> None:
|
def clear(self) -> None:
|
||||||
"""Clear the diff viewer."""
|
"""Clear the diff viewer."""
|
||||||
self._diff = None
|
self._diff = None
|
||||||
log = self.query_one("#diff-log", RichLog)
|
log = self.query_one(RichLog)
|
||||||
log.clear()
|
log.clear()
|
||||||
|
|
||||||
header = self.query_one(".diff-header", Static)
|
header = self.query_one(".diff-header", Static)
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ class EditorPane(Vertical):
|
|||||||
try:
|
try:
|
||||||
import tree_sitter_typescript as tst
|
import tree_sitter_typescript as tst
|
||||||
|
|
||||||
textarea = self.query_one("#editor-textarea", TextArea)
|
textarea = self.query_one(TextArea)
|
||||||
|
|
||||||
# Register TypeScript
|
# Register TypeScript
|
||||||
try:
|
try:
|
||||||
@@ -149,24 +149,22 @@ class EditorPane(Vertical):
|
|||||||
yield TextArea(
|
yield TextArea(
|
||||||
self._buffer.content,
|
self._buffer.content,
|
||||||
language=self._buffer.language,
|
language=self._buffer.language,
|
||||||
id="editor-textarea",
|
|
||||||
show_line_numbers=True,
|
show_line_numbers=True,
|
||||||
)
|
)
|
||||||
yield Static(
|
yield Static(
|
||||||
self._get_status_text(),
|
self._get_status_text(),
|
||||||
classes="editor-status",
|
classes="editor-status",
|
||||||
id="editor-status",
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
yield Static("No file open", classes="file-tab")
|
yield Static("No file open", classes="file-tab")
|
||||||
yield TextArea(id="editor-textarea", show_line_numbers=True)
|
yield TextArea(show_line_numbers=True)
|
||||||
yield Static("", classes="editor-status", id="editor-status")
|
yield Static("", classes="editor-status")
|
||||||
|
|
||||||
def load_buffer(self, buffer: FileBuffer) -> None: # noqa: ARG002
|
def load_buffer(self, buffer: FileBuffer) -> None: # noqa: ARG002
|
||||||
"""Load a file buffer into the editor."""
|
"""Load a file buffer into the editor."""
|
||||||
self._buffer = buffer
|
self._buffer = buffer
|
||||||
|
|
||||||
textarea = self.query_one("#editor-textarea", TextArea)
|
textarea = self.query_one(TextArea)
|
||||||
|
|
||||||
# Register TypeScript if needed
|
# Register TypeScript if needed
|
||||||
if buffer.language in ("typescript", "tsx"):
|
if buffer.language in ("typescript", "tsx"):
|
||||||
@@ -194,7 +192,7 @@ class EditorPane(Vertical):
|
|||||||
|
|
||||||
def get_content(self) -> str:
|
def get_content(self) -> str:
|
||||||
"""Get current editor content."""
|
"""Get current editor content."""
|
||||||
textarea = self.query_one("#editor-textarea", TextArea)
|
textarea = self.query_one(TextArea)
|
||||||
return textarea.text
|
return textarea.text
|
||||||
|
|
||||||
def _get_status_text(self) -> str:
|
def _get_status_text(self) -> str:
|
||||||
@@ -210,7 +208,7 @@ class EditorPane(Vertical):
|
|||||||
|
|
||||||
def _update_status(self) -> None:
|
def _update_status(self) -> None:
|
||||||
"""Update status bar."""
|
"""Update status bar."""
|
||||||
status = self.query_one("#editor-status", Static)
|
status = self.query_one(".editor-status", Static)
|
||||||
status.update(self._get_status_text())
|
status.update(self._get_status_text())
|
||||||
|
|
||||||
def on_text_area_changed(self, event: TextArea.Changed) -> None:
|
def on_text_area_changed(self, event: TextArea.Changed) -> None:
|
||||||
@@ -265,7 +263,7 @@ class EditorPane(Vertical):
|
|||||||
self.load_buffer(buffer)
|
self.load_buffer(buffer)
|
||||||
|
|
||||||
if goto_line is not None:
|
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)
|
textarea.cursor_location = (goto_line - 1, 0)
|
||||||
|
|
||||||
def save(self) -> bool:
|
def save(self) -> bool:
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ class TerminalPane(Vertical):
|
|||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
yield Static(f"Terminal - {self._cwd}", classes="terminal-header")
|
yield Static(f"Terminal - {self._cwd}", classes="terminal-header")
|
||||||
self._terminal = TerminalDisplay(id="terminal-display")
|
self._terminal = TerminalDisplay()
|
||||||
yield self._terminal
|
yield self._terminal
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
@@ -80,6 +80,10 @@ class TerminalPane(Vertical):
|
|||||||
if self._terminal:
|
if self._terminal:
|
||||||
self._terminal.stop()
|
self._terminal.stop()
|
||||||
|
|
||||||
|
def on_unmount(self) -> None:
|
||||||
|
"""Clean up PTY when widget is destroyed."""
|
||||||
|
self.stop()
|
||||||
|
|
||||||
def restart(self) -> None:
|
def restart(self) -> None:
|
||||||
"""Restart the shell."""
|
"""Restart the shell."""
|
||||||
if self._terminal:
|
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 pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from textual import events
|
||||||
from textual.app import ComposeResult
|
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.message import Message
|
||||||
from textual.reactive import reactive
|
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.action_bar import ActionBar, ActionButton
|
||||||
from clide.widgets.components.diff_pane import DiffPane
|
from clide.widgets.components.diff_pane import DiffPane
|
||||||
from clide.widgets.components.editor_pane import EditorPane
|
from clide.widgets.components.editor_pane import EditorPane
|
||||||
from clide.widgets.components.terminal_pane import TerminalPane
|
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):
|
class WorkspacePanel(Vertical):
|
||||||
"""Center workspace panel with Editor, Diff, and Terminal tabs.
|
"""Center workspace panel with dynamic tabbed panes.
|
||||||
|
|
||||||
Hidden by default. Shows when:
|
Supports multiple editor, terminal, and diff tabs. Hidden by default.
|
||||||
- File is opened
|
Shows when a file is opened, diff is displayed, or terminal is activated.
|
||||||
- Diff is displayed
|
|
||||||
- Terminal is activated
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
DEFAULT_CSS = """
|
DEFAULT_CSS = """
|
||||||
@@ -62,18 +147,18 @@ class WorkspacePanel(Vertical):
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
WorkspacePanel ContentSwitcher {
|
WorkspacePanel #workspace-content {
|
||||||
height: 1fr;
|
height: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
WorkspacePanel #pane-editor,
|
WorkspacePanel #workspace-content > * {
|
||||||
WorkspacePanel #pane-diff,
|
|
||||||
WorkspacePanel #pane-terminal {
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# -- Messages --
|
||||||
|
|
||||||
class FileSaved(Message):
|
class FileSaved(Message):
|
||||||
"""Emitted when a file is saved."""
|
"""Emitted when a file is saved."""
|
||||||
|
|
||||||
@@ -98,23 +183,16 @@ class WorkspacePanel(Vertical):
|
|||||||
class CloseRequested(Message):
|
class CloseRequested(Message):
|
||||||
"""Emitted when workspace should be hidden."""
|
"""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):
|
class MaximizeRequested(Message):
|
||||||
"""Emitted when workspace should be maximized."""
|
"""Emitted when workspace should be maximized."""
|
||||||
|
|
||||||
pass
|
|
||||||
|
|
||||||
class RestoreRequested(Message):
|
class RestoreRequested(Message):
|
||||||
"""Emitted when workspace should be restored from maximized."""
|
"""Emitted when workspace should be restored from maximized."""
|
||||||
|
|
||||||
pass
|
# -- Reactive state --
|
||||||
|
|
||||||
|
visible: reactive[bool] = reactive(False)
|
||||||
|
maximized: reactive[bool] = reactive(False)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -126,287 +204,433 @@ class WorkspacePanel(Vertical):
|
|||||||
self.id = "panel-workspace"
|
self.id = "panel-workspace"
|
||||||
self._action_bar: ActionBar | None = None
|
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:
|
def compose(self) -> ComposeResult:
|
||||||
# Header row with tabs and action bar
|
|
||||||
with Horizontal(id="workspace-header"):
|
with Horizontal(id="workspace-header"):
|
||||||
yield Tabs(
|
yield Tabs(id="workspace-tabs")
|
||||||
Tab("Editor", id="tab-editor"),
|
yield NewTabButton(id="new-tab-btn")
|
||||||
Tab("Diff", id="tab-diff"),
|
|
||||||
Tab("Terminal", id="tab-terminal"),
|
|
||||||
id="workspace-tabs",
|
|
||||||
)
|
|
||||||
self._action_bar = ActionBar(id="workspace-action-bar")
|
self._action_bar = ActionBar(id="workspace-action-bar")
|
||||||
yield self._action_bar
|
yield self._action_bar
|
||||||
|
|
||||||
# Content area
|
yield Container(id="workspace-content")
|
||||||
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")
|
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
"""Set initial visibility and configure action bar."""
|
"""Set initial visibility and configure action bar."""
|
||||||
self._update_visibility()
|
self._update_visibility()
|
||||||
self._setup_action_bar()
|
self._setup_action_bar()
|
||||||
|
|
||||||
|
# -- Action bar --
|
||||||
|
|
||||||
def _setup_action_bar(self) -> None:
|
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:
|
if self._action_bar is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Register standard buttons using simple ASCII icons
|
self._action_bar.register_button(ActionButton(id="minimize", icon="_", tooltip="Minimize"))
|
||||||
# Save button (for editor)
|
self._action_bar.register_button(ActionButton(id="maximize", icon="^", tooltip="Maximize"))
|
||||||
self._action_bar.register_button(
|
self._action_bar.register_button(
|
||||||
ActionButton(
|
ActionButton(id="restore", icon="v", tooltip="Restore", visible=False)
|
||||||
id="save",
|
|
||||||
icon="[S]",
|
|
||||||
tooltip="Save",
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add separator
|
|
||||||
self._action_bar.add_separator()
|
|
||||||
|
|
||||||
# Close button
|
|
||||||
self._action_bar.register_button(
|
self._action_bar.register_button(
|
||||||
ActionButton(
|
ActionButton(id="close", icon="x", tooltip="Close workspace")
|
||||||
id="close",
|
|
||||||
icon="x",
|
|
||||||
tooltip="Close",
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# 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:
|
def on_action_bar_button_pressed(self, event: ActionBar.ButtonPressed) -> None:
|
||||||
"""Handle action bar button presses."""
|
"""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":
|
# -- Tab management internals --
|
||||||
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()
|
|
||||||
|
|
||||||
def _action_save(self) -> None:
|
def _generate_tab_id(self, tab_type: TabType) -> str:
|
||||||
"""Save the current file in editor."""
|
self._tab_counter += 1
|
||||||
try:
|
return f"ws-{tab_type.value}-{self._tab_counter}"
|
||||||
editor = self.query_one("#editor-pane", EditorPane)
|
|
||||||
editor.save()
|
|
||||||
except Exception as e:
|
|
||||||
self.app.notify(f"Save failed: {e}", severity="error")
|
|
||||||
|
|
||||||
def _action_close(self) -> None:
|
def _add_tab(
|
||||||
"""Close the workspace panel."""
|
self,
|
||||||
self.post_message(self.CloseRequested())
|
tab_type: TabType,
|
||||||
self.hide()
|
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:
|
# Register metadata
|
||||||
"""Minimize (hide) the workspace panel."""
|
info = TabInfo(
|
||||||
self.hide()
|
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:
|
if file_path:
|
||||||
"""Maximize the workspace panel."""
|
self._file_to_tab[file_path.resolve()] = tab_id
|
||||||
self.maximized = True
|
|
||||||
self.post_message(self.MaximizeRequested())
|
|
||||||
|
|
||||||
def _action_restore(self) -> None:
|
# Create the tab widget
|
||||||
"""Restore from maximized state."""
|
tab = ClosableTab(label, tab_type=tab_type, id=tab_id)
|
||||||
self.maximized = False
|
tabs = self.query_one("#workspace-tabs", Tabs)
|
||||||
self.post_message(self.RestoreRequested())
|
tabs.add_tab(tab)
|
||||||
|
|
||||||
def on_tabs_tab_activated(self, event: Tabs.TabActivated) -> None:
|
# Mount the pane (hidden by default)
|
||||||
"""Handle tab switches to update action bar and content."""
|
pane_widget.display = False
|
||||||
# Extract tab name from tab id (e.g., "tab-editor" -> "editor")
|
content = self.query_one("#workspace-content", Container)
|
||||||
tab_id = event.tab.id
|
content.mount(pane_widget)
|
||||||
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)
|
|
||||||
|
|
||||||
# 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:
|
try:
|
||||||
content = self.query_one("#workspace-content", ContentSwitcher)
|
terminal = pane if isinstance(pane, TerminalPane) else None
|
||||||
content.current = f"pane-{tab_name}"
|
if terminal:
|
||||||
|
terminal.stop()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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:
|
def watch_visible(self, visible: bool) -> None:
|
||||||
"""Handle visibility changes - hide, don't destroy."""
|
"""Handle visibility changes - hide, don't destroy."""
|
||||||
self._update_visibility()
|
self._update_visibility()
|
||||||
|
|
||||||
def _update_visibility(self) -> None:
|
def _update_visibility(self) -> None:
|
||||||
"""Update display based on visibility state."""
|
|
||||||
if self.visible:
|
if self.visible:
|
||||||
self.remove_class("hidden")
|
self.remove_class("hidden")
|
||||||
else:
|
else:
|
||||||
self.add_class("hidden")
|
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:
|
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
|
self.visible = True
|
||||||
if tab:
|
if tab and tab in self._tab_registry:
|
||||||
self.focus_tab(tab)
|
self._activate_tab(tab)
|
||||||
|
|
||||||
def hide(self) -> None:
|
def hide(self) -> None:
|
||||||
"""Hide workspace (state is preserved)."""
|
"""Hide workspace (all state preserved)."""
|
||||||
self.visible = False
|
self.visible = False
|
||||||
|
|
||||||
def toggle(self) -> None:
|
def toggle(self) -> None:
|
||||||
"""Toggle workspace visibility."""
|
"""Toggle workspace visibility."""
|
||||||
self.visible = not self.visible
|
self.visible = not self.visible
|
||||||
|
|
||||||
def focus_tab(self, tab_id: str) -> None:
|
def open_file(self, path: Path, line: int | None = None) -> str:
|
||||||
"""Focus a specific tab."""
|
"""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:
|
try:
|
||||||
# Activate the tab
|
|
||||||
tabs = self.query_one("#workspace-tabs", Tabs)
|
tabs = self.query_one("#workspace-tabs", Tabs)
|
||||||
tabs.active = f"tab-{tab_id}"
|
active = tabs.active
|
||||||
|
if active and active in self._tab_registry:
|
||||||
# Switch content
|
return self._tab_registry[active].tab_type.value
|
||||||
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)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
def get_current_file(self) -> Path | None:
|
def get_current_file(self) -> Path | None:
|
||||||
"""Get the currently open file."""
|
"""Get the file in the active editor tab."""
|
||||||
try:
|
try:
|
||||||
editor = self.query_one("#editor-pane", EditorPane)
|
tabs = self.query_one("#workspace-tabs", Tabs)
|
||||||
return editor.current_file
|
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:
|
except Exception:
|
||||||
return None
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
def has_unsaved_changes(self) -> bool:
|
def has_unsaved_changes(self) -> bool:
|
||||||
"""Check if editor has unsaved changes."""
|
"""Check if any editor tab has unsaved changes."""
|
||||||
try:
|
for tab_id, info in self._tab_registry.items():
|
||||||
editor = self.query_one("#editor-pane", EditorPane)
|
if info.tab_type == TabType.EDITOR:
|
||||||
return editor.modified
|
pane = self._pane_widgets.get(tab_id)
|
||||||
except Exception:
|
if isinstance(pane, EditorPane) and pane.modified:
|
||||||
return False
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
# Diff methods
|
def save_active_editor(self) -> None:
|
||||||
def show_diff(
|
"""Save the active editor tab."""
|
||||||
self,
|
|
||||||
diff: DiffContent,
|
|
||||||
is_proposal: bool = False,
|
|
||||||
) -> None:
|
|
||||||
"""Show a diff in the diff tab."""
|
|
||||||
self.show("diff")
|
|
||||||
try:
|
try:
|
||||||
diff_pane = self.query_one("#diff-pane", DiffPane)
|
tabs = self.query_one("#workspace-tabs", Tabs)
|
||||||
diff_pane.load_diff(diff, is_proposal)
|
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:
|
except Exception:
|
||||||
pass
|
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:
|
def clear_diff(self) -> None:
|
||||||
"""Clear the diff view."""
|
"""Close all non-proposal diff tabs."""
|
||||||
try:
|
to_remove = [
|
||||||
diff_pane = self.query_one("#diff-pane", DiffPane)
|
tab_id
|
||||||
diff_pane.clear()
|
for tab_id, info in self._tab_registry.items()
|
||||||
except Exception:
|
if info.tab_type == TabType.DIFF and not info.is_proposal
|
||||||
pass
|
]
|
||||||
|
for tab_id in to_remove:
|
||||||
|
self._remove_tab(tab_id)
|
||||||
|
|
||||||
# Terminal methods
|
# -- Event forwarding --
|
||||||
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
|
|
||||||
def on_editor_pane_file_saved(self, event: EditorPane.FileSaved) -> None:
|
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))
|
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:
|
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))
|
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:
|
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))
|
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)
|
panel = WorkspacePanel(workdir=tmp_path)
|
||||||
assert panel._workdir == tmp_path
|
assert panel._workdir == tmp_path
|
||||||
assert panel.visible is False
|
assert panel.visible is False
|
||||||
assert panel.active_tab == "editor"
|
assert panel.get_active_tab_type() is None
|
||||||
assert panel.id == "panel-workspace"
|
assert panel.id == "panel-workspace"
|
||||||
|
|
||||||
def test_default_workdir(self):
|
def test_default_workdir(self):
|
||||||
@@ -109,13 +109,6 @@ class TestWorkspacePanel:
|
|||||||
msg = WorkspacePanel.DiffRejected("test.py")
|
msg = WorkspacePanel.DiffRejected("test.py")
|
||||||
assert msg.file_path == "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):
|
def test_close_requested_message(self):
|
||||||
"""Test CloseRequested message."""
|
"""Test CloseRequested message."""
|
||||||
from clide.widgets.panels.workspace import WorkspacePanel
|
from clide.widgets.panels.workspace import WorkspacePanel
|
||||||
|
|||||||
@@ -383,8 +383,7 @@ class TestTerminalPane:
|
|||||||
|
|
||||||
pane = TerminalPane(cwd=tmp_path)
|
pane = TerminalPane(cwd=tmp_path)
|
||||||
assert pane.cwd == tmp_path
|
assert pane.cwd == tmp_path
|
||||||
assert pane._history == []
|
assert pane._terminal is None
|
||||||
assert pane._history_index == 0
|
|
||||||
|
|
||||||
def test_default_cwd(self):
|
def test_default_cwd(self):
|
||||||
"""Test default cwd is current directory."""
|
"""Test default cwd is current directory."""
|
||||||
@@ -401,13 +400,6 @@ class TestTerminalPane:
|
|||||||
pane._cwd = tmp_path
|
pane._cwd = tmp_path
|
||||||
assert 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:
|
class TestProblemsView:
|
||||||
"""Tests for ProblemsView component."""
|
"""Tests for ProblemsView component."""
|
||||||
|
|||||||
Reference in New Issue
Block a user