Pull ttyd/zellij/clide-launcher scripts into deploy/ so the web access layer lives with the project. Zellij is configured in locked mode with a bare layout so it only provides session persistence without intercepting keys or showing UI. Remove Ctrl+G goto-line binding from Clide to avoid conflict with Zellij's unlock key. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
862 lines
30 KiB
Python
862 lines
30 KiB
Python
"""Main Textual Application for Clide."""
|
|
|
|
from pathlib import Path
|
|
from typing import ClassVar
|
|
|
|
from textual import work
|
|
from textual.app import App, ComposeResult
|
|
from textual.binding import Binding
|
|
from textual.containers import Horizontal, Vertical
|
|
from textual.reactive import reactive
|
|
from textual.widgets import Footer, Header
|
|
from textual.worker import Worker, WorkerState
|
|
|
|
from clide.controllers.diff import DiffController
|
|
from clide.controllers.editor import EditorController
|
|
from clide.controllers.git import GitController
|
|
from clide.controllers.jira import JiraController
|
|
from clide.controllers.problems import ProblemsController
|
|
from clide.controllers.todos import TodosController
|
|
from clide.extensions.manager import ExtensionManager
|
|
from clide.models.config import ClideSettings
|
|
from clide.services.claude_events import (
|
|
ClaudeEvent,
|
|
FileEditEvent,
|
|
FileReadEvent,
|
|
FileWriteEvent,
|
|
setup_event_parsing,
|
|
)
|
|
from clide.services.file_watcher import FileEvent, FileEventMessage, setup_file_watching
|
|
from clide.services.settings_service import get_settings_service
|
|
from clide.services.syntax_service import register_languages
|
|
from clide.themes.registry import get_all_themes, get_theme
|
|
from clide.widgets.panels.claude import ClaudePanel
|
|
from clide.widgets.panels.context import ContextPanel
|
|
from clide.widgets.panels.sidebar import SidebarPanel
|
|
from clide.widgets.panels.workspace import WorkspacePanel
|
|
|
|
|
|
class ClideApp(App[None]):
|
|
"""Clide TUI Application - Claude Code IDE.
|
|
|
|
Panel architecture:
|
|
- Sidebar (left): Files, Git, Tree tabs + branch status
|
|
- Center: Workspace (Editor/Diff/Terminal) + Claude panel
|
|
- Context (right): Problems, TODOs, Jira tabs
|
|
|
|
Workspace is hidden by default. Claude takes full height when
|
|
workspace is hidden, 40% when visible.
|
|
"""
|
|
|
|
TITLE = "Clide"
|
|
SUB_TITLE = "Claude Code IDE"
|
|
|
|
CSS: ClassVar[str] = """
|
|
/* Main layout */
|
|
Screen {
|
|
layout: horizontal;
|
|
}
|
|
|
|
#main-container {
|
|
width: 100%;
|
|
height: 100%;
|
|
layout: horizontal;
|
|
}
|
|
|
|
#center-column {
|
|
width: 1fr;
|
|
height: 100%;
|
|
layout: vertical;
|
|
}
|
|
|
|
/* Panel styling */
|
|
SidebarPanel {
|
|
width: 20%;
|
|
min-width: 25;
|
|
max-width: 50;
|
|
}
|
|
|
|
ContextPanel {
|
|
width: 25%;
|
|
min-width: 30;
|
|
max-width: 50;
|
|
}
|
|
|
|
/* Workspace + Claude layout */
|
|
WorkspacePanel {
|
|
height: 60%;
|
|
}
|
|
|
|
WorkspacePanel.hidden {
|
|
display: none;
|
|
}
|
|
|
|
ClaudePanel {
|
|
height: 100%;
|
|
}
|
|
|
|
ClaudePanel.with-workspace {
|
|
height: 40%;
|
|
}
|
|
|
|
/* Compact mode - applied when .compact class is on #main-container */
|
|
#main-container.compact SidebarPanel {
|
|
display: none;
|
|
}
|
|
|
|
#main-container.compact ContextPanel {
|
|
display: none;
|
|
}
|
|
|
|
/* Fullscreen mode */
|
|
.fullscreen {
|
|
dock: top;
|
|
width: 100%;
|
|
height: 100%;
|
|
layer: fullscreen;
|
|
}
|
|
|
|
/* Global button styling - outlined look */
|
|
Button {
|
|
background: transparent;
|
|
border: solid $secondary;
|
|
color: $foreground;
|
|
margin: 0 1;
|
|
}
|
|
|
|
Button:hover {
|
|
background: $secondary 20%;
|
|
border: solid $secondary;
|
|
}
|
|
|
|
Button:focus {
|
|
border: solid $primary;
|
|
}
|
|
|
|
Button.-primary {
|
|
border: solid $primary;
|
|
color: $primary;
|
|
}
|
|
|
|
Button.-primary:hover {
|
|
background: $primary 20%;
|
|
}
|
|
"""
|
|
|
|
# Keybindings
|
|
# OS-native shortcuts (Ctrl on Windows/Linux, Cmd on Mac mapped to ctrl in terminal)
|
|
# Alt-based shortcuts for actions that shouldn't interfere with input fields
|
|
# Note: priority=True ensures bindings work even when widgets have focus
|
|
BINDINGS: ClassVar[list[Binding]] = [
|
|
# Global
|
|
Binding("alt+q", "quit", "Quit", priority=True),
|
|
Binding("alt+p", "command_palette", "Commands"),
|
|
Binding("alt+o", "quick_open", "Quick Open"),
|
|
Binding("alt+b", "toggle_sidebar", "Sidebar", priority=True),
|
|
Binding("alt+shift+b", "toggle_context", "Context", priority=True),
|
|
Binding("alt+`", "toggle_terminal", "Terminal", priority=True),
|
|
Binding("alt+c", "toggle_compact", "Compact", priority=True),
|
|
Binding("f11", "toggle_fullscreen", "Fullscreen"),
|
|
Binding("escape", "escape", "Escape", show=False),
|
|
# Navigation
|
|
Binding("alt+1", "focus_claude", "Claude", show=False, priority=True),
|
|
Binding("alt+2", "focus_editor", "Editor", show=False, priority=True),
|
|
Binding("alt+3", "focus_terminal", "Terminal", show=False, priority=True),
|
|
Binding("alt+0", "focus_sidebar", "Sidebar", show=False, priority=True),
|
|
Binding("alt+w", "close_tab", "Close Tab", show=False),
|
|
Binding("ctrl+w", "close_tab", "Close Tab", show=False),
|
|
# Git
|
|
Binding("alt+g", "open_git", "Git", show=False, priority=True),
|
|
# Problems
|
|
Binding("alt+m", "open_problems", "Problems", show=False, priority=True),
|
|
Binding("f8", "next_problem", "Next Problem", show=False),
|
|
Binding("shift+f8", "prev_problem", "Prev Problem", show=False),
|
|
# Editor - OS-native shortcuts with priority
|
|
Binding("ctrl+s", "save_file", "Save", priority=True),
|
|
Binding("alt+s", "save_file", "Save", show=False, priority=True),
|
|
Binding("ctrl+z", "undo", "Undo", show=False, priority=True),
|
|
# Theme
|
|
Binding("alt+t", "select_theme", "Theme", priority=True),
|
|
]
|
|
|
|
# Reactive state
|
|
current_file: reactive[Path | None] = reactive(None)
|
|
workspace_visible: reactive[bool] = reactive(False)
|
|
compact_mode: reactive[bool] = reactive(False)
|
|
fullscreen_panel: reactive[str | None] = reactive(None)
|
|
|
|
def __init__(
|
|
self,
|
|
workdir: Path | None = None,
|
|
settings: ClideSettings | None = None,
|
|
test_mode: bool = False,
|
|
) -> None:
|
|
super().__init__()
|
|
self.workdir = workdir or Path.cwd()
|
|
self.settings = settings or ClideSettings()
|
|
self._test_mode = test_mode
|
|
|
|
# User settings persistence
|
|
self._settings_service = get_settings_service()
|
|
self._user_settings = self._settings_service.load()
|
|
|
|
# Extension manager
|
|
self.extension_manager = ExtensionManager()
|
|
|
|
# Controllers
|
|
self.git_controller = GitController(self.workdir)
|
|
self.editor_controller = EditorController()
|
|
self.diff_controller = DiffController(self.workdir)
|
|
self.problems_controller = ProblemsController(self.workdir)
|
|
self.todos_controller = TodosController(self.workdir)
|
|
# Use settings parameter if jira_enabled explicitly set, otherwise user settings
|
|
jira_enabled = self.settings.jira_enabled or self._user_settings.jira_enabled
|
|
self.jira_controller = JiraController(
|
|
enabled=jira_enabled,
|
|
)
|
|
|
|
# Register themes
|
|
self._register_themes()
|
|
|
|
# Register additional syntax highlighting languages
|
|
register_languages()
|
|
|
|
def _register_themes(self) -> None:
|
|
"""Register all themes with Textual."""
|
|
for theme_meta in get_all_themes():
|
|
theme_def = get_theme(theme_meta.name)
|
|
if theme_def:
|
|
self.register_theme(theme_def.to_textual_theme())
|
|
|
|
# Set initial theme: settings parameter takes precedence over user settings
|
|
# Use settings.theme if different from default, otherwise user settings
|
|
if self.settings.theme != "summer-night":
|
|
self.theme = self.settings.theme
|
|
else:
|
|
self.theme = self._user_settings.theme
|
|
|
|
def set_theme(self, theme_name: str, *, save: bool = True) -> None:
|
|
"""Set the application theme.
|
|
|
|
Args:
|
|
theme_name: Name of the theme to apply
|
|
save: Whether to persist the setting (default: True)
|
|
"""
|
|
self.theme = theme_name
|
|
if save:
|
|
self._settings_service.set("theme", theme_name)
|
|
self.notify(f"Theme set to: {theme_name}", severity="information")
|
|
|
|
def save_user_settings(self) -> None:
|
|
"""Save current user settings to disk."""
|
|
self._settings_service.update(
|
|
theme=self.theme,
|
|
compact_mode=self.compact_mode,
|
|
sidebar_visible=self.query_one(SidebarPanel).display,
|
|
context_visible=self.query_one(ContextPanel).display,
|
|
)
|
|
|
|
def compose(self) -> ComposeResult:
|
|
"""Create the main layout."""
|
|
yield Header()
|
|
|
|
with Horizontal(id="main-container"):
|
|
# Left sidebar
|
|
yield SidebarPanel(workdir=self.workdir)
|
|
|
|
# Center column with workspace and claude
|
|
with Vertical(id="center-column"):
|
|
yield WorkspacePanel(workdir=self.workdir)
|
|
yield ClaudePanel(
|
|
workdir=self.workdir,
|
|
auto_start=not self._test_mode,
|
|
)
|
|
|
|
# Right context panel
|
|
yield ContextPanel(
|
|
jira_enabled=self.settings.jira_enabled,
|
|
project_path=self.workdir,
|
|
)
|
|
|
|
yield Footer()
|
|
|
|
async def on_mount(self) -> None:
|
|
"""Initialize application on mount."""
|
|
# Apply saved user settings
|
|
self._apply_user_settings()
|
|
|
|
# Load extensions
|
|
self.extension_manager.load_extensions()
|
|
await self.extension_manager.trigger_app_startup(self)
|
|
|
|
# Set up file watching for real-time sync
|
|
self._setup_file_watching()
|
|
|
|
# Set up Claude event parsing for IDE integration
|
|
self._setup_claude_events()
|
|
|
|
# Initial data refresh
|
|
await self._refresh_git()
|
|
await self._refresh_problems()
|
|
await self._refresh_todos()
|
|
if self._user_settings.jira_enabled:
|
|
await self._refresh_jira()
|
|
|
|
# Focus Claude panel
|
|
self.action_focus_claude()
|
|
|
|
def _setup_file_watching(self) -> None:
|
|
"""Set up file system watching for real-time updates."""
|
|
self._file_watcher = setup_file_watching(
|
|
self.workdir,
|
|
handlers=[self._on_file_changed],
|
|
)
|
|
|
|
def _setup_claude_events(self) -> None:
|
|
"""Set up Claude event parsing for IDE integration."""
|
|
setup_event_parsing(self._on_claude_event)
|
|
|
|
def _on_file_changed(self, event: FileEvent) -> None:
|
|
"""Handle file system changes by posting a FileEventMessage.
|
|
|
|
This bridges the watchdog callback to Textual's message system,
|
|
allowing widgets to subscribe to file events via on_file_event_message.
|
|
|
|
Note: This is called from the watchdog thread, so we use call_from_thread
|
|
to safely execute on the main thread. The FileEvent (Pydantic model) is
|
|
thread-safe, but we create the Message on the main thread to avoid any
|
|
potential Textual threading issues.
|
|
"""
|
|
|
|
def post_file_event():
|
|
self.post_message(FileEventMessage(event))
|
|
|
|
self.call_from_thread(post_file_event)
|
|
|
|
async def on_file_event_message(self, message: FileEventMessage) -> None:
|
|
"""Handle file event messages from the file watcher.
|
|
|
|
This is the central handler for all file system events.
|
|
The App coordinates updates to child widgets since Textual
|
|
messages bubble up (not down to children).
|
|
"""
|
|
event = message.event
|
|
|
|
# Ignore files in .clide directory (settings, etc.)
|
|
if ".clide" in str(event.path):
|
|
return
|
|
|
|
# Trigger extension hooks
|
|
self.extension_manager.trigger_file_changed(event)
|
|
|
|
# Debounce: skip if we refreshed recently (within 1 second)
|
|
import time
|
|
|
|
now = time.time()
|
|
if hasattr(self, "_last_file_refresh") and now - self._last_file_refresh < 1.0:
|
|
return
|
|
self._last_file_refresh = now
|
|
|
|
# Refresh file tree for created/deleted/moved files
|
|
if event.event_type in ("created", "deleted", "moved"):
|
|
try:
|
|
sidebar = self.query_one(SidebarPanel)
|
|
sidebar.refresh_files()
|
|
except Exception:
|
|
pass
|
|
|
|
# Use call_later to avoid blocking the event loop during refreshes
|
|
# This ensures UI responsiveness isn't affected by heavy git operations
|
|
if event.event_type in ("created", "modified", "deleted", "moved"):
|
|
self.call_later(self._async_refresh_after_file_change, event)
|
|
|
|
async def _async_refresh_after_file_change(self, event: FileEvent) -> None:
|
|
"""Perform async refreshes after a file change without blocking UI."""
|
|
# Refresh git status for all file changes
|
|
await self._refresh_git()
|
|
|
|
# Only refresh problems/todos for Python/text files
|
|
if event.path.suffix in (".py", ".pyi", ".txt", ".md", ".rst"):
|
|
if event.event_type in ("created", "modified"):
|
|
await self._refresh_problems()
|
|
await self._refresh_todos()
|
|
|
|
def _on_claude_event(self, event: ClaudeEvent) -> None:
|
|
"""Handle Claude Code events for IDE integration.
|
|
|
|
When Claude reads/edits files, we can update the UI accordingly.
|
|
"""
|
|
# Schedule handling on the main thread
|
|
self.call_later(self._handle_claude_event, event)
|
|
|
|
async def _handle_claude_event(self, event: ClaudeEvent) -> None:
|
|
"""Async handler for Claude events."""
|
|
if isinstance(event, FileReadEvent):
|
|
# Claude read a file - highlight in sidebar file tree
|
|
try:
|
|
sidebar = self.query_one(SidebarPanel)
|
|
sidebar.highlight_file(event.path)
|
|
except Exception:
|
|
pass
|
|
|
|
# Trigger extension hook
|
|
self.extension_manager.trigger_claude_event("file_read", {"path": str(event.path)})
|
|
|
|
elif isinstance(event, FileEditEvent):
|
|
# Claude edited a file - notify user
|
|
# Note: Git refresh is handled by FileEventMessage from the file watcher
|
|
self.notify(f"Claude edited: {event.path.name}", severity="information")
|
|
|
|
# Trigger extension hook
|
|
self.extension_manager.trigger_claude_event("file_edit", {"path": str(event.path)})
|
|
|
|
elif isinstance(event, FileWriteEvent):
|
|
# Claude created/wrote a file - notify user
|
|
# Note: Git refresh and file tree refresh are handled by
|
|
# FileEventMessage from the file watcher (event-driven)
|
|
self.notify(f"Claude wrote: {event.path.name}", severity="information")
|
|
|
|
# Trigger extension hook
|
|
self.extension_manager.trigger_claude_event("file_write", {"path": str(event.path)})
|
|
|
|
def _apply_user_settings(self) -> None:
|
|
"""Apply saved user settings on startup."""
|
|
# Panel visibility
|
|
sidebar = self.query_one(SidebarPanel)
|
|
sidebar.visible = self._user_settings.sidebar_visible
|
|
|
|
context = self.query_one(ContextPanel)
|
|
context.visible = self._user_settings.context_visible
|
|
|
|
# Compact mode
|
|
self.compact_mode = self._user_settings.compact_mode
|
|
|
|
# Re-apply theme after mount (Textual needs this for proper initialization)
|
|
if self._user_settings.theme:
|
|
self.theme = self._user_settings.theme
|
|
|
|
# Reactive watchers
|
|
def watch_workspace_visible(self, visible: bool) -> None:
|
|
"""Update panels when workspace visibility changes."""
|
|
workspace = self.query_one(WorkspacePanel)
|
|
claude = self.query_one(ClaudePanel)
|
|
|
|
workspace.visible = visible
|
|
claude.workspace_visible = visible
|
|
|
|
def watch_compact_mode(self, compact: bool) -> None:
|
|
"""Toggle compact mode class."""
|
|
container = self.query_one("#main-container")
|
|
if compact:
|
|
container.add_class("compact")
|
|
else:
|
|
container.remove_class("compact")
|
|
|
|
# Data refresh methods
|
|
async def _refresh_git(self) -> None:
|
|
"""Refresh git status."""
|
|
status = await self.git_controller.get_status()
|
|
if status:
|
|
sidebar = self.query_one(SidebarPanel)
|
|
sidebar.update_git_status(status.staged, status.unstaged)
|
|
sidebar.current_branch = status.branch
|
|
|
|
branches = await self.git_controller.get_branches()
|
|
if branches:
|
|
sidebar = self.query_one(SidebarPanel)
|
|
sidebar.update_branches([b.name for b in branches])
|
|
|
|
commits = await self.git_controller.get_log(limit=50)
|
|
if commits:
|
|
sidebar = self.query_one(SidebarPanel)
|
|
sidebar.update_git_graph(commits)
|
|
|
|
async def _refresh_problems(self) -> None:
|
|
"""Refresh linter problems."""
|
|
problems = await self.problems_controller.run_all()
|
|
context = self.query_one(ContextPanel)
|
|
context.update_problems(problems)
|
|
|
|
async def _refresh_todos(self) -> None:
|
|
"""Refresh TODOs."""
|
|
code_todos, project_todos = await self.todos_controller.scan()
|
|
context = self.query_one(ContextPanel)
|
|
context.update_todos(code_todos, project_todos)
|
|
|
|
async def _refresh_jira(self) -> None:
|
|
"""Refresh Jira content."""
|
|
context = self.query_one(ContextPanel)
|
|
context.set_jira_loading()
|
|
content = await self.jira_controller.get_content()
|
|
if content:
|
|
context.update_jira(content)
|
|
else:
|
|
context.set_jira_error("Failed to load Jira content")
|
|
|
|
# Action methods
|
|
def action_toggle_sidebar(self) -> None:
|
|
"""Toggle sidebar visibility."""
|
|
sidebar = self.query_one(SidebarPanel)
|
|
sidebar.visible = not sidebar.visible
|
|
self._settings_service.set("sidebar_visible", sidebar.visible)
|
|
|
|
def action_toggle_context(self) -> None:
|
|
"""Toggle context panel visibility."""
|
|
context = self.query_one(ContextPanel)
|
|
context.visible = not context.visible
|
|
self._settings_service.set("context_visible", context.visible)
|
|
|
|
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":
|
|
self.workspace_visible = False
|
|
else:
|
|
workspace.show_terminal()
|
|
self.workspace_visible = True
|
|
|
|
def action_toggle_compact(self) -> None:
|
|
"""Toggle compact mode."""
|
|
self.compact_mode = not self.compact_mode
|
|
self._settings_service.set("compact_mode", self.compact_mode)
|
|
|
|
def action_toggle_fullscreen(self) -> None:
|
|
"""Toggle fullscreen for focused panel."""
|
|
# TODO: Implement fullscreen toggle
|
|
pass
|
|
|
|
def action_escape(self) -> None:
|
|
"""Handle escape key."""
|
|
if self.fullscreen_panel:
|
|
self.fullscreen_panel = None
|
|
elif self.workspace_visible:
|
|
workspace = self.query_one(WorkspacePanel)
|
|
if not workspace.has_unsaved_changes():
|
|
self.workspace_visible = False
|
|
|
|
def action_focus_claude(self) -> None:
|
|
"""Focus Claude panel."""
|
|
claude = self.query_one(ClaudePanel)
|
|
claude.focus_terminal()
|
|
|
|
def action_focus_editor(self) -> None:
|
|
"""Focus editor."""
|
|
self.workspace_visible = True
|
|
workspace = self.query_one(WorkspacePanel)
|
|
workspace.focus_tab("editor")
|
|
|
|
def action_focus_terminal(self) -> None:
|
|
"""Focus terminal."""
|
|
self.workspace_visible = True
|
|
workspace = self.query_one(WorkspacePanel)
|
|
workspace.show_terminal()
|
|
|
|
def action_focus_sidebar(self) -> None:
|
|
"""Focus sidebar."""
|
|
sidebar = self.query_one(SidebarPanel)
|
|
sidebar.visible = True
|
|
sidebar.focus()
|
|
|
|
def action_close_tab(self) -> None:
|
|
"""Close current tab/editor."""
|
|
# TODO: Implement tab closing
|
|
pass
|
|
|
|
def action_open_git(self) -> None:
|
|
"""Open git panel."""
|
|
sidebar = self.query_one(SidebarPanel)
|
|
sidebar.visible = True
|
|
sidebar.focus_tab("sidebar-git")
|
|
|
|
def action_open_problems(self) -> None:
|
|
"""Open problems panel."""
|
|
context = self.query_one(ContextPanel)
|
|
context.visible = True
|
|
context.focus_problems()
|
|
|
|
def action_next_problem(self) -> None:
|
|
"""Go to next problem."""
|
|
# TODO: Implement problem navigation
|
|
pass
|
|
|
|
def action_prev_problem(self) -> None:
|
|
"""Go to previous problem."""
|
|
# TODO: Implement problem navigation
|
|
pass
|
|
|
|
def action_save_file(self) -> None:
|
|
"""Save current file in editor."""
|
|
try:
|
|
workspace = self.query_one(WorkspacePanel)
|
|
if workspace.has_unsaved_changes():
|
|
workspace._action_save()
|
|
# FileSaved message will be emitted by EditorPane if successful
|
|
else:
|
|
self.notify("No unsaved changes", severity="warning")
|
|
except Exception as e:
|
|
self.notify(f"Save failed: {e}", severity="error")
|
|
|
|
def action_undo(self) -> None:
|
|
"""Undo last action in focused widget."""
|
|
# Undo is handled by the focused widget (TextArea has built-in undo)
|
|
# This action provides feedback if no undo is available
|
|
focused = self.focused
|
|
if focused and hasattr(focused, "undo"):
|
|
focused.undo()
|
|
else:
|
|
self.notify("Undo not available", severity="warning")
|
|
|
|
|
|
def action_quick_open(self) -> None:
|
|
"""Quick file open."""
|
|
# TODO: Implement quick open
|
|
pass
|
|
|
|
async def action_select_theme(self) -> None:
|
|
"""Open theme selector."""
|
|
from clide.themes.registry import get_all_themes
|
|
|
|
# Get all available themes
|
|
themes = get_all_themes()
|
|
theme_names = [t.name for t in themes]
|
|
|
|
# Use Textual's built-in selection if available, otherwise cycle
|
|
# For now, simple cycle through themes
|
|
current_idx = theme_names.index(self.theme) if self.theme in theme_names else 0
|
|
next_idx = (current_idx + 1) % len(theme_names)
|
|
self.set_theme(theme_names[next_idx])
|
|
|
|
# Event handlers for panel messages
|
|
async def on_sidebar_panel_file_selected(
|
|
self,
|
|
event: SidebarPanel.FileSelected,
|
|
) -> None:
|
|
"""Handle file selection from sidebar."""
|
|
self.current_file = event.path
|
|
self.workspace_visible = True
|
|
workspace = self.query_one(WorkspacePanel)
|
|
workspace.open_file(event.path)
|
|
|
|
async def on_sidebar_panel_git_file_selected(
|
|
self,
|
|
event: SidebarPanel.GitFileSelected,
|
|
) -> None:
|
|
"""Handle git file selection - show diff."""
|
|
diff = await self.diff_controller.get_file_diff(
|
|
str(event.path),
|
|
staged=event.staged,
|
|
)
|
|
if diff:
|
|
self.workspace_visible = True
|
|
workspace = self.query_one(WorkspacePanel)
|
|
workspace.show_diff(diff)
|
|
|
|
async def on_sidebar_panel_branch_changed(
|
|
self,
|
|
event: SidebarPanel.BranchChanged,
|
|
) -> None:
|
|
"""Handle branch change."""
|
|
success = await self.git_controller.checkout_branch(event.branch)
|
|
if success:
|
|
await self._refresh_git()
|
|
|
|
async def on_context_panel_problem_clicked(
|
|
self,
|
|
event: ContextPanel.ProblemClicked,
|
|
) -> None:
|
|
"""Handle problem click - open file at line."""
|
|
problem = event.problem
|
|
self.workspace_visible = True
|
|
workspace = self.query_one(WorkspacePanel)
|
|
workspace.open_file(problem.file_path, line=problem.line)
|
|
|
|
async def on_context_panel_todo_clicked(
|
|
self,
|
|
event: ContextPanel.TodoClicked,
|
|
) -> None:
|
|
"""Handle TODO click - open file at line."""
|
|
item = event.item
|
|
self.workspace_visible = True
|
|
workspace = self.query_one(WorkspacePanel)
|
|
workspace.open_file(item.file_path, line=item.line)
|
|
|
|
async def on_context_panel_project_todo_clicked(
|
|
self,
|
|
event: ContextPanel.ProjectTodoClicked,
|
|
) -> None:
|
|
"""Handle project TODO click - open TODO.md at line."""
|
|
item = event.item
|
|
todo_path = self.workdir / "TODO.md"
|
|
if todo_path.exists():
|
|
self.workspace_visible = True
|
|
workspace = self.query_one(WorkspacePanel)
|
|
workspace.open_file(todo_path, line=item.line)
|
|
|
|
async def on_context_panel_todo_md_created(
|
|
self,
|
|
event: ContextPanel.TodoMdCreated,
|
|
) -> None:
|
|
"""Handle TODO.md creation - refresh TODOs and open file."""
|
|
# Refresh TODOs to pick up the new file
|
|
await self._refresh_todos()
|
|
# Open the new file in editor
|
|
self.workspace_visible = True
|
|
workspace = self.query_one(WorkspacePanel)
|
|
workspace.open_file(event.path)
|
|
self.notify("Created TODO.md")
|
|
|
|
async def on_context_panel_jira_refresh_requested(
|
|
self,
|
|
_event: ContextPanel.JiraRefreshRequested,
|
|
) -> None:
|
|
"""Handle Jira refresh request."""
|
|
await self._refresh_jira()
|
|
|
|
async def on_workspace_panel_file_saved(
|
|
self,
|
|
event: WorkspacePanel.FileSaved,
|
|
) -> None:
|
|
"""Handle file save - refresh problems and git."""
|
|
self.notify(f"Saved: {event.path.name}", severity="information")
|
|
await self._refresh_git()
|
|
await self._refresh_problems()
|
|
|
|
async def on_workspace_panel_diff_accepted(
|
|
self,
|
|
event: WorkspacePanel.DiffAccepted,
|
|
) -> None:
|
|
"""Handle diff accept."""
|
|
await self.diff_controller.accept_proposal(event.file_path)
|
|
await self._refresh_git()
|
|
await self._refresh_problems()
|
|
|
|
async def on_workspace_panel_diff_rejected(
|
|
self,
|
|
event: WorkspacePanel.DiffRejected,
|
|
) -> None:
|
|
"""Handle diff reject."""
|
|
await self.diff_controller.reject_proposal(event.file_path)
|
|
workspace = self.query_one(WorkspacePanel)
|
|
workspace.clear_diff()
|
|
|
|
async def on_workspace_panel_command_submitted(
|
|
self,
|
|
event: WorkspacePanel.CommandSubmitted,
|
|
) -> None:
|
|
"""Handle terminal command - run and show output."""
|
|
from clide.services.process_service import ProcessService
|
|
|
|
workspace = self.query_one(WorkspacePanel)
|
|
result = await ProcessService.run_async(
|
|
event.command,
|
|
cwd=self.workdir,
|
|
shell=True,
|
|
)
|
|
if result.stdout:
|
|
workspace.write_terminal_output(result.stdout)
|
|
if result.stderr:
|
|
workspace.write_terminal_error(result.stderr)
|
|
|
|
# Refresh after command
|
|
await self._refresh_git()
|
|
|
|
def on_claude_panel_claude_started(
|
|
self,
|
|
_event: ClaudePanel.ClaudeStarted,
|
|
) -> None:
|
|
"""Handle Claude Code started."""
|
|
self.notify("Claude Code started", severity="information")
|
|
|
|
def on_claude_panel_claude_exited(
|
|
self,
|
|
event: ClaudePanel.ClaudeExited,
|
|
) -> None:
|
|
"""Handle Claude Code exited."""
|
|
if event.return_code != 0:
|
|
self.notify(f"Claude Code exited with code {event.return_code}", severity="warning")
|
|
|
|
def on_workspace_panel_maximize_requested(
|
|
self,
|
|
_event: WorkspacePanel.MaximizeRequested,
|
|
) -> None:
|
|
"""Handle workspace maximize request - hide sidebars."""
|
|
# Hide sidebars when workspace is maximized
|
|
sidebar = self.query_one(SidebarPanel)
|
|
context = self.query_one(ContextPanel)
|
|
sidebar.display = False
|
|
context.display = False
|
|
|
|
# Hide Claude panel
|
|
claude = self.query_one(ClaudePanel)
|
|
claude.display = False
|
|
|
|
def on_workspace_panel_restore_requested(
|
|
self,
|
|
_event: WorkspacePanel.RestoreRequested,
|
|
) -> None:
|
|
"""Handle workspace restore request - show sidebars."""
|
|
# Restore sidebars based on saved visibility settings
|
|
sidebar = self.query_one(SidebarPanel)
|
|
context = self.query_one(ContextPanel)
|
|
sidebar.display = self._user_settings.sidebar_visible
|
|
context.display = self._user_settings.context_visible
|
|
|
|
# Show Claude panel
|
|
claude = self.query_one(ClaudePanel)
|
|
claude.display = True
|
|
|
|
def on_workspace_panel_close_requested(
|
|
self,
|
|
_event: WorkspacePanel.CloseRequested,
|
|
) -> None:
|
|
"""Handle workspace close request."""
|
|
self.workspace_visible = False
|
|
|
|
def on_sidebar_panel_claude_command_requested(
|
|
self,
|
|
event: SidebarPanel.ClaudeCommandRequested,
|
|
) -> None:
|
|
"""Handle Claude command request from git panel.
|
|
|
|
Sends skill commands (e.g., /commit) to Claude terminal.
|
|
Ensures the specific skill is installed before sending.
|
|
"""
|
|
from clide.services.skill_installer import get_skill_installer
|
|
|
|
# Extract skill name from command (e.g., "/commit" -> "commit")
|
|
skill_name = event.command.lstrip("/").split()[0]
|
|
|
|
installer = get_skill_installer(project_dir=self.workdir)
|
|
|
|
# Quick check if already installed
|
|
if installer.is_installed(skill_name):
|
|
self._send_claude_command(event.command)
|
|
return
|
|
|
|
# Need to install - show notification and do in background
|
|
self.notify(f"Installing {skill_name} skill...", timeout=10)
|
|
self._install_skill_and_run(skill_name, event.command)
|
|
|
|
@work(thread=True)
|
|
def _install_skill_and_run(self, skill_name: str, command: str) -> tuple[str, str]:
|
|
"""Install skill in background thread and return command to run."""
|
|
self.git_controller._ensure_skill(skill_name)
|
|
return (skill_name, command)
|
|
|
|
def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
|
|
"""Handle worker completion."""
|
|
if event.state == WorkerState.SUCCESS:
|
|
# Check if this was a skill installation worker
|
|
if event.worker.name == "_install_skill_and_run":
|
|
result = event.worker.result
|
|
if result:
|
|
skill_name, command = result
|
|
self._send_claude_command(command)
|
|
self.notify(f"{skill_name} skill installed!", severity="information", timeout=3)
|
|
|
|
def _send_claude_command(self, command: str) -> None:
|
|
"""Send a command to Claude terminal."""
|
|
claude = self.query_one(ClaudePanel)
|
|
claude.send_input(command)
|
|
self.action_focus_claude()
|