From a14ee82883921ea5f9ad17ea8d3ab24baec96368 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Tue, 24 Feb 2026 14:33:47 +0100 Subject: [PATCH] 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 --- clide/app.py | 13 +- clide/models/__init__.py | 5 + clide/models/workspace.py | 35 + clide/widgets/components/diff_pane.py | 6 +- clide/widgets/components/editor_pane.py | 16 +- clide/widgets/components/terminal_pane.py | 6 +- clide/widgets/panels/workspace.py | 680 ++++++++++++------ .../test_initial_layout.svg | 73 +- .../test_layout_without_left_panel.svg | 73 +- .../test_layout_without_right_panel.svg | 73 +- tests/unit/test_panels.py | 9 +- tests/unit/test_widgets.py | 10 +- 12 files changed, 623 insertions(+), 376 deletions(-) create mode 100644 clide/models/workspace.py diff --git a/clide/app.py b/clide/app.py index c488f1d3..b5a23bef 100644 --- a/clide/app.py +++ b/clide/app.py @@ -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: diff --git a/clide/models/__init__.py b/clide/models/__init__.py index 5669aeaf..97dcb029 100644 --- a/clide/models/__init__.py +++ b/clide/models/__init__.py @@ -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", ] diff --git a/clide/models/workspace.py b/clide/models/workspace.py new file mode 100644 index 00000000..a1a6991e --- /dev/null +++ b/clide/models/workspace.py @@ -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 diff --git a/clide/widgets/components/diff_pane.py b/clide/widgets/components/diff_pane.py index b9ce4af5..45b45cd9 100644 --- a/clide/widgets/components/diff_pane.py +++ b/clide/widgets/components/diff_pane.py @@ -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) diff --git a/clide/widgets/components/editor_pane.py b/clide/widgets/components/editor_pane.py index 024f0313..6bd6b712 100644 --- a/clide/widgets/components/editor_pane.py +++ b/clide/widgets/components/editor_pane.py @@ -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: diff --git a/clide/widgets/components/terminal_pane.py b/clide/widgets/components/terminal_pane.py index f0355eb4..f26e8c5b 100644 --- a/clide/widgets/components/terminal_pane.py +++ b/clide/widgets/components/terminal_pane.py @@ -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: diff --git a/clide/widgets/panels/workspace.py b/clide/widgets/panels/workspace.py index 8aa3f8a5..3925c23a 100644 --- a/clide/widgets/panels/workspace.py +++ b/clide/widgets/panels/workspace.py @@ -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 diff --git a/tests/snapshots/__snapshots__/test_app_snapshots/test_initial_layout.svg b/tests/snapshots/__snapshots__/test_app_snapshots/test_initial_layout.svg index 6af352f6..75b39253 100644 --- a/tests/snapshots/__snapshots__/test_app_snapshots/test_initial_layout.svg +++ b/tests/snapshots/__snapshots__/test_app_snapshots/test_initial_layout.svg @@ -39,14 +39,13 @@ .terminal-r5 { fill: #494f59 } .terminal-r6 { fill: #00a3d2 } .terminal-r7 { fill: #58d1eb;font-weight: bold } -.terminal-r8 { fill: #00ff00 } -.terminal-r9 { fill: #5b616b } -.terminal-r10 { fill: #4d9fb3 } -.terminal-r11 { fill: #a8aeba } -.terminal-r12 { fill: #cf8447 } -.terminal-r13 { fill: #393e48 } -.terminal-r14 { fill: #afb1b5 } -.terminal-r15 { fill: #fa5f8b;font-weight: bold } +.terminal-r8 { fill: #5b616b } +.terminal-r9 { fill: #4d9fb3 } +.terminal-r10 { fill: #a8aeba } +.terminal-r11 { fill: #393e48 } +.terminal-r12 { fill: #0a4e66 } +.terminal-r13 { fill: #afb1b5 } +.terminal-r14 { fill: #fa5f8b;font-weight: bold } @@ -180,35 +179,35 @@ - + Clide — Claude Code IDE -FilesGitTreeJiraTODOs (87)Problems (3 -━━━━━╺━━━━━━━━━━━━━━━━━━━━━━╺━━━━━━━━━━━━━━━━━━━━━━━━ +FilesGitTree +━━━━━╺━━━━━━━━━━━━━━━━━━ ▾ clide -├── ▸ .claude -├── ▸ .venv -├── ▸ clideJira integration is -├── ▸ docsdisabled. -├── ▸ logs -├── ▸ testsEnable it in settings with -├── ◦ .coverageCLIDE_JIRA_ENABLED=true -├── ◦ .gitignore -├── ◦ .pre-commit-config. -├── ◦ CHANGELOG.md -├── ◦ claude-authentik-cr -├── ◦ CLAUDE.md -├── ◦ Makefile -├── ◦ pyproject.toml -├── ◦ README.md -└── ◦ TODO.md - - - - - - - +├── ▸ .claude +├── ▸ .config +├── ▸ .gitea +├── ▸ .venv +├── ▸ clide +├── ▸ deploy +├── ▸ dist +├── ▸ docs +├── ▸ logs +├── ▸ packaging +├── ▸ scripts +├── ▸ src +├── ▸ tests +├── ◦ .gitignore +├── ◦ .gitmodules +├── ◦ .pre-commit-config. +├── ◦ CHANGELOG.md +├── ◦ CLAUDE.md +├── ◦ clide.spec +├── ◦ Makefile +├── ◦ pyproject.toml +├── ◦ README.md +└── ◦ TODO.md @@ -219,9 +218,9 @@ - -mainunstage - alt+q Quit  alt+p Commands  alt+o Quick Open  alt+b Sidebar  alt+shift+b Context  alt+` Terminal  alt+c Compact  f11 Fu + +mainunstage + alt+q Quit  alt+p Commands  alt+o Quick Open  alt+b Sidebar  alt+shift+b Context  alt+` Terminal  alt+c Compact  f11 Fu diff --git a/tests/snapshots/__snapshots__/test_app_snapshots/test_layout_without_left_panel.svg b/tests/snapshots/__snapshots__/test_app_snapshots/test_layout_without_left_panel.svg index 6af352f6..75b39253 100644 --- a/tests/snapshots/__snapshots__/test_app_snapshots/test_layout_without_left_panel.svg +++ b/tests/snapshots/__snapshots__/test_app_snapshots/test_layout_without_left_panel.svg @@ -39,14 +39,13 @@ .terminal-r5 { fill: #494f59 } .terminal-r6 { fill: #00a3d2 } .terminal-r7 { fill: #58d1eb;font-weight: bold } -.terminal-r8 { fill: #00ff00 } -.terminal-r9 { fill: #5b616b } -.terminal-r10 { fill: #4d9fb3 } -.terminal-r11 { fill: #a8aeba } -.terminal-r12 { fill: #cf8447 } -.terminal-r13 { fill: #393e48 } -.terminal-r14 { fill: #afb1b5 } -.terminal-r15 { fill: #fa5f8b;font-weight: bold } +.terminal-r8 { fill: #5b616b } +.terminal-r9 { fill: #4d9fb3 } +.terminal-r10 { fill: #a8aeba } +.terminal-r11 { fill: #393e48 } +.terminal-r12 { fill: #0a4e66 } +.terminal-r13 { fill: #afb1b5 } +.terminal-r14 { fill: #fa5f8b;font-weight: bold } @@ -180,35 +179,35 @@ - + Clide — Claude Code IDE -FilesGitTreeJiraTODOs (87)Problems (3 -━━━━━╺━━━━━━━━━━━━━━━━━━━━━━╺━━━━━━━━━━━━━━━━━━━━━━━━ +FilesGitTree +━━━━━╺━━━━━━━━━━━━━━━━━━ ▾ clide -├── ▸ .claude -├── ▸ .venv -├── ▸ clideJira integration is -├── ▸ docsdisabled. -├── ▸ logs -├── ▸ testsEnable it in settings with -├── ◦ .coverageCLIDE_JIRA_ENABLED=true -├── ◦ .gitignore -├── ◦ .pre-commit-config. -├── ◦ CHANGELOG.md -├── ◦ claude-authentik-cr -├── ◦ CLAUDE.md -├── ◦ Makefile -├── ◦ pyproject.toml -├── ◦ README.md -└── ◦ TODO.md - - - - - - - +├── ▸ .claude +├── ▸ .config +├── ▸ .gitea +├── ▸ .venv +├── ▸ clide +├── ▸ deploy +├── ▸ dist +├── ▸ docs +├── ▸ logs +├── ▸ packaging +├── ▸ scripts +├── ▸ src +├── ▸ tests +├── ◦ .gitignore +├── ◦ .gitmodules +├── ◦ .pre-commit-config. +├── ◦ CHANGELOG.md +├── ◦ CLAUDE.md +├── ◦ clide.spec +├── ◦ Makefile +├── ◦ pyproject.toml +├── ◦ README.md +└── ◦ TODO.md @@ -219,9 +218,9 @@ - -mainunstage - alt+q Quit  alt+p Commands  alt+o Quick Open  alt+b Sidebar  alt+shift+b Context  alt+` Terminal  alt+c Compact  f11 Fu + +mainunstage + alt+q Quit  alt+p Commands  alt+o Quick Open  alt+b Sidebar  alt+shift+b Context  alt+` Terminal  alt+c Compact  f11 Fu diff --git a/tests/snapshots/__snapshots__/test_app_snapshots/test_layout_without_right_panel.svg b/tests/snapshots/__snapshots__/test_app_snapshots/test_layout_without_right_panel.svg index 6af352f6..75b39253 100644 --- a/tests/snapshots/__snapshots__/test_app_snapshots/test_layout_without_right_panel.svg +++ b/tests/snapshots/__snapshots__/test_app_snapshots/test_layout_without_right_panel.svg @@ -39,14 +39,13 @@ .terminal-r5 { fill: #494f59 } .terminal-r6 { fill: #00a3d2 } .terminal-r7 { fill: #58d1eb;font-weight: bold } -.terminal-r8 { fill: #00ff00 } -.terminal-r9 { fill: #5b616b } -.terminal-r10 { fill: #4d9fb3 } -.terminal-r11 { fill: #a8aeba } -.terminal-r12 { fill: #cf8447 } -.terminal-r13 { fill: #393e48 } -.terminal-r14 { fill: #afb1b5 } -.terminal-r15 { fill: #fa5f8b;font-weight: bold } +.terminal-r8 { fill: #5b616b } +.terminal-r9 { fill: #4d9fb3 } +.terminal-r10 { fill: #a8aeba } +.terminal-r11 { fill: #393e48 } +.terminal-r12 { fill: #0a4e66 } +.terminal-r13 { fill: #afb1b5 } +.terminal-r14 { fill: #fa5f8b;font-weight: bold } @@ -180,35 +179,35 @@ - + Clide — Claude Code IDE -FilesGitTreeJiraTODOs (87)Problems (3 -━━━━━╺━━━━━━━━━━━━━━━━━━━━━━╺━━━━━━━━━━━━━━━━━━━━━━━━ +FilesGitTree +━━━━━╺━━━━━━━━━━━━━━━━━━ ▾ clide -├── ▸ .claude -├── ▸ .venv -├── ▸ clideJira integration is -├── ▸ docsdisabled. -├── ▸ logs -├── ▸ testsEnable it in settings with -├── ◦ .coverageCLIDE_JIRA_ENABLED=true -├── ◦ .gitignore -├── ◦ .pre-commit-config. -├── ◦ CHANGELOG.md -├── ◦ claude-authentik-cr -├── ◦ CLAUDE.md -├── ◦ Makefile -├── ◦ pyproject.toml -├── ◦ README.md -└── ◦ TODO.md - - - - - - - +├── ▸ .claude +├── ▸ .config +├── ▸ .gitea +├── ▸ .venv +├── ▸ clide +├── ▸ deploy +├── ▸ dist +├── ▸ docs +├── ▸ logs +├── ▸ packaging +├── ▸ scripts +├── ▸ src +├── ▸ tests +├── ◦ .gitignore +├── ◦ .gitmodules +├── ◦ .pre-commit-config. +├── ◦ CHANGELOG.md +├── ◦ CLAUDE.md +├── ◦ clide.spec +├── ◦ Makefile +├── ◦ pyproject.toml +├── ◦ README.md +└── ◦ TODO.md @@ -219,9 +218,9 @@ - -mainunstage - alt+q Quit  alt+p Commands  alt+o Quick Open  alt+b Sidebar  alt+shift+b Context  alt+` Terminal  alt+c Compact  f11 Fu + +mainunstage + alt+q Quit  alt+p Commands  alt+o Quick Open  alt+b Sidebar  alt+shift+b Context  alt+` Terminal  alt+c Compact  f11 Fu diff --git a/tests/unit/test_panels.py b/tests/unit/test_panels.py index 499339f1..9b5ef71e 100644 --- a/tests/unit/test_panels.py +++ b/tests/unit/test_panels.py @@ -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 diff --git a/tests/unit/test_widgets.py b/tests/unit/test_widgets.py index a8a48fc2..6cf74c97 100644 --- a/tests/unit/test_widgets.py +++ b/tests/unit/test_widgets.py @@ -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."""