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:
2026-02-24 14:33:47 +01:00
co-authored by Claude Opus 4.6
parent cd949e4d9c
commit a14ee82883
12 changed files with 623 additions and 376 deletions
+6 -7
View File
@@ -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:
+5
View File
@@ -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",
]
+35
View File
@@ -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
+3 -3
View File
@@ -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)
+7 -9
View File
@@ -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:
+5 -1
View File
@@ -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
View File
@@ -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

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 36 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 36 KiB

+1 -8
View File
@@ -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
+1 -9
View File
@@ -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."""