Restructure project and add user settings persistence
- Move src/clide/ to clide/ (flatten directory structure) - Add SettingsService for persistent user preferences - Settings stored in ~/.clide/settings.json - Persist theme, panel visibility, and compact mode state - Fix terminal rendering glitch with throttled refresh (~60fps) - Update pyproject.toml, Makefile, and tests for new structure Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
6b7c3ecf9b
commit
1ed21d2a0f
@@ -0,0 +1,3 @@
|
||||
"""Clide - A TUI CLI IDE for Claude Code CLI."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Entry point for python -m clide."""
|
||||
|
||||
from clide.cli import app
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
+558
@@ -0,0 +1,558 @@
|
||||
"""Main Textual Application for Clide."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import ClassVar
|
||||
|
||||
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 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.settings_service import SettingsService, get_settings_service
|
||||
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;
|
||||
}
|
||||
|
||||
ContextPanel {
|
||||
width: 25%;
|
||||
min-width: 30;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
"""
|
||||
|
||||
# VSCode-style keybindings
|
||||
BINDINGS: ClassVar[list[Binding]] = [
|
||||
# Global
|
||||
Binding("ctrl+q", "quit", "Quit"),
|
||||
Binding("ctrl+shift+p", "command_palette", "Commands"),
|
||||
Binding("ctrl+p", "quick_open", "Quick Open"),
|
||||
Binding("ctrl+b", "toggle_sidebar", "Toggle Sidebar"),
|
||||
Binding("ctrl+shift+b", "toggle_context", "Toggle Context"),
|
||||
Binding("ctrl+`", "toggle_terminal", "Toggle Terminal"),
|
||||
Binding("ctrl+shift+c", "toggle_compact", "Compact Mode"),
|
||||
Binding("f11", "toggle_fullscreen", "Fullscreen"),
|
||||
Binding("escape", "escape", "Escape", show=False),
|
||||
# Navigation
|
||||
Binding("ctrl+1", "focus_claude", "Focus Claude", show=False),
|
||||
Binding("ctrl+2", "focus_editor", "Focus Editor", show=False),
|
||||
Binding("ctrl+3", "focus_terminal", "Focus Terminal", show=False),
|
||||
Binding("ctrl+0", "focus_sidebar", "Focus Sidebar", show=False),
|
||||
Binding("ctrl+w", "close_tab", "Close Tab", show=False),
|
||||
# Git
|
||||
Binding("ctrl+shift+g", "open_git", "Git", show=False),
|
||||
# Problems
|
||||
Binding("ctrl+shift+m", "open_problems", "Problems", show=False),
|
||||
Binding("f8", "next_problem", "Next Problem", show=False),
|
||||
Binding("shift+f8", "prev_problem", "Prev Problem", show=False),
|
||||
# Editor
|
||||
Binding("ctrl+s", "save_file", "Save", show=False),
|
||||
Binding("ctrl+g", "goto_line", "Go to Line", show=False),
|
||||
# Theme
|
||||
Binding("ctrl+k ctrl+t", "select_theme", "Select Theme", show=False),
|
||||
]
|
||||
|
||||
# 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)
|
||||
self.jira_controller = JiraController(
|
||||
enabled=self._user_settings.jira_enabled,
|
||||
)
|
||||
|
||||
# Register themes
|
||||
self._register_themes()
|
||||
|
||||
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 from user settings
|
||||
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)
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
# 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 _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
|
||||
|
||||
# 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."""
|
||||
todos = await self.todos_controller.scan()
|
||||
context = self.query_one(ContextPanel)
|
||||
context.update_todos(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."""
|
||||
# EditorPane handles save internally
|
||||
pass
|
||||
|
||||
def action_goto_line(self) -> None:
|
||||
"""Go to line dialog."""
|
||||
# TODO: Implement go to line
|
||||
pass
|
||||
|
||||
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_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."""
|
||||
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")
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Typer CLI entry point for Clide."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
import typer
|
||||
|
||||
from clide import __version__
|
||||
|
||||
app = typer.Typer(
|
||||
name="clide",
|
||||
help="A TUI CLI IDE for Claude Code CLI",
|
||||
add_completion=True,
|
||||
no_args_is_help=False,
|
||||
)
|
||||
|
||||
|
||||
def version_callback(value: bool) -> None:
|
||||
"""Print version and exit."""
|
||||
if value:
|
||||
typer.echo(f"clide {__version__}")
|
||||
raise typer.Exit()
|
||||
|
||||
|
||||
@app.callback(invoke_without_command=True)
|
||||
def main(
|
||||
ctx: typer.Context,
|
||||
_version: Annotated[
|
||||
bool | None,
|
||||
typer.Option("--version", "-v", callback=version_callback, is_eager=True),
|
||||
] = None,
|
||||
workdir: Annotated[
|
||||
Path | None,
|
||||
typer.Option("--workdir", "-w", help="Working directory to open"),
|
||||
] = None,
|
||||
) -> None:
|
||||
"""Launch Clide TUI application."""
|
||||
if ctx.invoked_subcommand is None:
|
||||
from clide.app import ClideApp
|
||||
|
||||
app_instance = ClideApp(workdir=workdir)
|
||||
app_instance.run()
|
||||
|
||||
|
||||
@app.command()
|
||||
def config() -> None:
|
||||
"""Open configuration editor."""
|
||||
typer.echo("Configuration editor not yet implemented")
|
||||
|
||||
|
||||
@app.command()
|
||||
def extensions() -> None:
|
||||
"""List installed extensions."""
|
||||
typer.echo("Extension manager not yet implemented")
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Domain controllers for Clide."""
|
||||
|
||||
from clide.controllers.base import ControllerMixin, controller
|
||||
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
|
||||
|
||||
__all__ = [
|
||||
"controller",
|
||||
"ControllerMixin",
|
||||
"GitController",
|
||||
"EditorController",
|
||||
"DiffController",
|
||||
"ProblemsController",
|
||||
"TodosController",
|
||||
"JiraController",
|
||||
]
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Base controller utilities using decorator pattern."""
|
||||
|
||||
from functools import wraps
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from textual.message import Message
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from textual.app import App
|
||||
|
||||
|
||||
def controller[T](cls: type[T]) -> type[T]:
|
||||
"""Decorator to add controller capabilities to a class.
|
||||
|
||||
Adds:
|
||||
- _app attribute for parent application reference
|
||||
- set_app() method to set the application
|
||||
- post_message() method to emit messages
|
||||
- initialize() and shutdown() lifecycle hooks (if not defined)
|
||||
|
||||
Usage:
|
||||
@controller
|
||||
class GitController:
|
||||
def __init__(self, workdir: Path) -> None:
|
||||
self.workdir = workdir
|
||||
|
||||
async def get_status(self) -> GitStatus:
|
||||
...
|
||||
"""
|
||||
original_init = cls.__init__
|
||||
|
||||
@wraps(original_init)
|
||||
def new_init(self, *args, **kwargs):
|
||||
self._app = None
|
||||
original_init(self, *args, **kwargs)
|
||||
|
||||
cls.__init__ = new_init
|
||||
|
||||
def set_app(self, app: "App[object]") -> None:
|
||||
"""Set the parent application."""
|
||||
self._app = app
|
||||
|
||||
def post_message(self, message: Message) -> None:
|
||||
"""Post a message to the application's message queue."""
|
||||
if self._app:
|
||||
self._app.post_message(message)
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""Initialize the controller. Called after app mount."""
|
||||
pass
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
"""Clean up resources. Called before app exit."""
|
||||
pass
|
||||
|
||||
# Only add methods if they don't exist
|
||||
if not hasattr(cls, "set_app"):
|
||||
cls.set_app = set_app
|
||||
if not hasattr(cls, "post_message"):
|
||||
cls.post_message = post_message
|
||||
if not hasattr(cls, "initialize"):
|
||||
cls.initialize = initialize
|
||||
if not hasattr(cls, "shutdown"):
|
||||
cls.shutdown = shutdown
|
||||
|
||||
return cls
|
||||
|
||||
|
||||
class ControllerMixin:
|
||||
"""Mixin alternative for controller capabilities.
|
||||
|
||||
Use this if you prefer inheritance over decorators.
|
||||
|
||||
Usage:
|
||||
class GitController(ControllerMixin):
|
||||
def __init__(self, workdir: Path) -> None:
|
||||
self.workdir = workdir
|
||||
"""
|
||||
|
||||
_app: "App[object] | None" = None
|
||||
|
||||
def set_app(self, app: "App[object]") -> None:
|
||||
"""Set the parent application."""
|
||||
self._app = app
|
||||
|
||||
@property
|
||||
def app(self) -> "App[object] | None":
|
||||
"""Get the parent application."""
|
||||
return self._app
|
||||
|
||||
def post_message(self, message: Message) -> None:
|
||||
"""Post a message to the application's message queue."""
|
||||
if self._app:
|
||||
self._app.post_message(message)
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""Initialize the controller. Called after app mount."""
|
||||
pass
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
"""Clean up resources. Called before app exit."""
|
||||
pass
|
||||
@@ -0,0 +1,330 @@
|
||||
"""Diff controller for viewing and managing diffs."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from textual.message import Message
|
||||
|
||||
from clide.controllers.base import controller
|
||||
from clide.models.diff import ChangeType, DiffContent, DiffHunk, DiffLine, DiffViewState
|
||||
from clide.services.git_service import GitService
|
||||
|
||||
|
||||
@controller
|
||||
class DiffController:
|
||||
"""Controller for diff viewing and Claude-proposed changes."""
|
||||
|
||||
class DiffLoaded(Message):
|
||||
"""Emitted when a diff is loaded."""
|
||||
|
||||
def __init__(self, diff: DiffContent) -> None:
|
||||
self.diff = diff
|
||||
super().__init__()
|
||||
|
||||
class HunkAccepted(Message):
|
||||
"""Emitted when a hunk is accepted."""
|
||||
|
||||
def __init__(self, hunk_index: int) -> None:
|
||||
self.hunk_index = hunk_index
|
||||
super().__init__()
|
||||
|
||||
class HunkRejected(Message):
|
||||
"""Emitted when a hunk is rejected."""
|
||||
|
||||
def __init__(self, hunk_index: int) -> None:
|
||||
self.hunk_index = hunk_index
|
||||
super().__init__()
|
||||
|
||||
class AllChangesAccepted(Message):
|
||||
"""Emitted when all changes are accepted."""
|
||||
pass
|
||||
|
||||
class AllChangesRejected(Message):
|
||||
"""Emitted when all changes are rejected."""
|
||||
pass
|
||||
|
||||
def __init__(self, repo_path: Path) -> None:
|
||||
self._git_service = GitService(repo_path)
|
||||
self._state = DiffViewState()
|
||||
|
||||
@property
|
||||
def state(self) -> DiffViewState:
|
||||
"""Get diff view state."""
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def diff(self) -> DiffContent | None:
|
||||
"""Get current diff content."""
|
||||
return self._state.diff
|
||||
|
||||
@property
|
||||
def is_proposal(self) -> bool:
|
||||
"""Check if current diff is a Claude proposal."""
|
||||
return self._state.is_proposal
|
||||
|
||||
async def load_git_diff(self, path: str, staged: bool = False) -> DiffContent | None:
|
||||
"""Load diff from git.
|
||||
|
||||
Args:
|
||||
path: File path
|
||||
staged: Whether to load staged diff
|
||||
|
||||
Returns:
|
||||
DiffContent or None if no diff
|
||||
"""
|
||||
diff_text = await self._git_service.get_diff(path, staged)
|
||||
if not diff_text:
|
||||
return None
|
||||
|
||||
diff = self._parse_diff(path, diff_text)
|
||||
self._state.diff = diff
|
||||
self._state.is_proposal = False
|
||||
self._state.accepted_hunks = set()
|
||||
self._state.rejected_hunks = set()
|
||||
|
||||
return diff
|
||||
|
||||
def load_proposal(self, path: str, old_content: str, new_content: str) -> DiffContent:
|
||||
"""Load a Claude-proposed change as a diff.
|
||||
|
||||
Args:
|
||||
path: File path
|
||||
old_content: Original content
|
||||
new_content: Proposed content
|
||||
|
||||
Returns:
|
||||
DiffContent of the proposal
|
||||
"""
|
||||
diff = self._create_diff_from_content(path, old_content, new_content)
|
||||
self._state.diff = diff
|
||||
self._state.is_proposal = True
|
||||
self._state.accepted_hunks = set()
|
||||
self._state.rejected_hunks = set()
|
||||
|
||||
return diff
|
||||
|
||||
def accept_hunk(self, index: int) -> None:
|
||||
"""Accept a specific hunk.
|
||||
|
||||
Args:
|
||||
index: Hunk index
|
||||
"""
|
||||
self._state.accepted_hunks.add(index)
|
||||
self._state.rejected_hunks.discard(index)
|
||||
|
||||
def reject_hunk(self, index: int) -> None:
|
||||
"""Reject a specific hunk.
|
||||
|
||||
Args:
|
||||
index: Hunk index
|
||||
"""
|
||||
self._state.rejected_hunks.add(index)
|
||||
self._state.accepted_hunks.discard(index)
|
||||
|
||||
def accept_all(self) -> None:
|
||||
"""Accept all hunks."""
|
||||
if self._state.diff:
|
||||
for i in range(len(self._state.diff.hunks)):
|
||||
self._state.accepted_hunks.add(i)
|
||||
self._state.rejected_hunks.clear()
|
||||
|
||||
def reject_all(self) -> None:
|
||||
"""Reject all hunks."""
|
||||
if self._state.diff:
|
||||
for i in range(len(self._state.diff.hunks)):
|
||||
self._state.rejected_hunks.add(i)
|
||||
self._state.accepted_hunks.clear()
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear current diff."""
|
||||
self._state.diff = None
|
||||
self._state.is_proposal = False
|
||||
self._state.accepted_hunks = set()
|
||||
self._state.rejected_hunks = set()
|
||||
|
||||
def toggle_side_by_side(self) -> bool:
|
||||
"""Toggle side-by-side view.
|
||||
|
||||
Returns:
|
||||
New side_by_side value
|
||||
"""
|
||||
self._state.side_by_side = not self._state.side_by_side
|
||||
return self._state.side_by_side
|
||||
|
||||
def _parse_diff(self, path: str, diff_text: str) -> DiffContent:
|
||||
"""Parse git diff output into DiffContent."""
|
||||
hunks: list[DiffHunk] = []
|
||||
current_hunk_lines: list[DiffLine] = []
|
||||
current_header = ""
|
||||
old_start = old_count = new_start = new_count = 0
|
||||
|
||||
for line in diff_text.split("\n"):
|
||||
if line.startswith("@@"):
|
||||
# Save previous hunk
|
||||
if current_hunk_lines:
|
||||
hunks.append(DiffHunk(
|
||||
header=current_header,
|
||||
old_start=old_start,
|
||||
old_count=old_count,
|
||||
new_start=new_start,
|
||||
new_count=new_count,
|
||||
lines=tuple(current_hunk_lines),
|
||||
))
|
||||
current_hunk_lines = []
|
||||
|
||||
# Parse hunk header
|
||||
current_header = line
|
||||
# Format: @@ -old_start,old_count +new_start,new_count @@
|
||||
import re
|
||||
match = re.match(r"@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@", line)
|
||||
if match:
|
||||
old_start = int(match.group(1))
|
||||
old_count = int(match.group(2)) if match.group(2) else 1
|
||||
new_start = int(match.group(3))
|
||||
new_count = int(match.group(4)) if match.group(4) else 1
|
||||
|
||||
elif line.startswith("+") and not line.startswith("+++"):
|
||||
current_hunk_lines.append(DiffLine(
|
||||
change_type=ChangeType.ADDED,
|
||||
content=line[1:],
|
||||
new_line_num=new_start + len([ln for ln in current_hunk_lines if ln.change_type != ChangeType.REMOVED]),
|
||||
))
|
||||
elif line.startswith("-") and not line.startswith("---"):
|
||||
current_hunk_lines.append(DiffLine(
|
||||
change_type=ChangeType.REMOVED,
|
||||
content=line[1:],
|
||||
old_line_num=old_start + len([ln for ln in current_hunk_lines if ln.change_type != ChangeType.ADDED]),
|
||||
))
|
||||
elif line.startswith(" "):
|
||||
old_num = old_start + len([ln for ln in current_hunk_lines if ln.change_type != ChangeType.ADDED])
|
||||
new_num = new_start + len([ln for ln in current_hunk_lines if ln.change_type != ChangeType.REMOVED])
|
||||
current_hunk_lines.append(DiffLine(
|
||||
change_type=ChangeType.CONTEXT,
|
||||
content=line[1:],
|
||||
old_line_num=old_num,
|
||||
new_line_num=new_num,
|
||||
))
|
||||
|
||||
# Save last hunk
|
||||
if current_hunk_lines:
|
||||
hunks.append(DiffHunk(
|
||||
header=current_header,
|
||||
old_start=old_start,
|
||||
old_count=old_count,
|
||||
new_start=new_start,
|
||||
new_count=new_count,
|
||||
lines=tuple(current_hunk_lines),
|
||||
))
|
||||
|
||||
return DiffContent(
|
||||
file_path=path,
|
||||
hunks=tuple(hunks),
|
||||
)
|
||||
|
||||
async def get_file_diff(self, path: str, staged: bool = False) -> DiffContent | None:
|
||||
"""Get diff for a file (alias for load_git_diff).
|
||||
|
||||
Args:
|
||||
path: File path
|
||||
staged: Whether to get staged diff
|
||||
|
||||
Returns:
|
||||
DiffContent or None
|
||||
"""
|
||||
return await self.load_git_diff(path, staged)
|
||||
|
||||
async def accept_proposal(self, _file_path: str) -> bool:
|
||||
"""Accept a proposed change and apply it.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
if not self._state.diff or not self._state.is_proposal:
|
||||
return False
|
||||
|
||||
self.accept_all()
|
||||
# TODO: Apply the changes to the file
|
||||
self.clear()
|
||||
return True
|
||||
|
||||
async def reject_proposal(self, _file_path: str) -> bool:
|
||||
"""Reject a proposed change.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
self.reject_all()
|
||||
self.clear()
|
||||
return True
|
||||
|
||||
def _create_diff_from_content(self, path: str, old: str, new: str) -> DiffContent:
|
||||
"""Create diff from old and new content."""
|
||||
import difflib
|
||||
|
||||
old_lines = old.splitlines(keepends=True)
|
||||
new_lines = new.splitlines(keepends=True)
|
||||
|
||||
diff_lines: list[DiffLine] = []
|
||||
old_num = new_num = 1
|
||||
|
||||
for tag, i1, i2, j1, j2 in difflib.SequenceMatcher(None, old_lines, new_lines).get_opcodes():
|
||||
if tag == "equal":
|
||||
for line in old_lines[i1:i2]:
|
||||
diff_lines.append(DiffLine(
|
||||
change_type=ChangeType.CONTEXT,
|
||||
content=line.rstrip("\n"),
|
||||
old_line_num=old_num,
|
||||
new_line_num=new_num,
|
||||
))
|
||||
old_num += 1
|
||||
new_num += 1
|
||||
elif tag == "delete":
|
||||
for line in old_lines[i1:i2]:
|
||||
diff_lines.append(DiffLine(
|
||||
change_type=ChangeType.REMOVED,
|
||||
content=line.rstrip("\n"),
|
||||
old_line_num=old_num,
|
||||
))
|
||||
old_num += 1
|
||||
elif tag == "insert":
|
||||
for line in new_lines[j1:j2]:
|
||||
diff_lines.append(DiffLine(
|
||||
change_type=ChangeType.ADDED,
|
||||
content=line.rstrip("\n"),
|
||||
new_line_num=new_num,
|
||||
))
|
||||
new_num += 1
|
||||
elif tag == "replace":
|
||||
for line in old_lines[i1:i2]:
|
||||
diff_lines.append(DiffLine(
|
||||
change_type=ChangeType.REMOVED,
|
||||
content=line.rstrip("\n"),
|
||||
old_line_num=old_num,
|
||||
))
|
||||
old_num += 1
|
||||
for line in new_lines[j1:j2]:
|
||||
diff_lines.append(DiffLine(
|
||||
change_type=ChangeType.ADDED,
|
||||
content=line.rstrip("\n"),
|
||||
new_line_num=new_num,
|
||||
))
|
||||
new_num += 1
|
||||
|
||||
hunk = DiffHunk(
|
||||
header="@@ -1 +1 @@",
|
||||
old_start=1,
|
||||
old_count=len(old_lines),
|
||||
new_start=1,
|
||||
new_count=len(new_lines),
|
||||
lines=tuple(diff_lines),
|
||||
)
|
||||
|
||||
return DiffContent(
|
||||
file_path=path,
|
||||
hunks=(hunk,),
|
||||
)
|
||||
@@ -0,0 +1,215 @@
|
||||
"""Editor controller for managing open files."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from textual.message import Message
|
||||
|
||||
from clide.controllers.base import controller
|
||||
from clide.models.editor import CursorPosition, EditorState, FileBuffer
|
||||
|
||||
|
||||
@controller
|
||||
class EditorController:
|
||||
"""Controller for editor state and file operations."""
|
||||
|
||||
class FileOpened(Message):
|
||||
"""Emitted when a file is opened."""
|
||||
|
||||
def __init__(self, buffer: FileBuffer) -> None:
|
||||
self.buffer = buffer
|
||||
super().__init__()
|
||||
|
||||
class FileClosed(Message):
|
||||
"""Emitted when a file is closed."""
|
||||
|
||||
def __init__(self, path: Path) -> None:
|
||||
self.path = path
|
||||
super().__init__()
|
||||
|
||||
class FileSaved(Message):
|
||||
"""Emitted when a file is saved."""
|
||||
|
||||
def __init__(self, path: Path) -> None:
|
||||
self.path = path
|
||||
super().__init__()
|
||||
|
||||
class FileModified(Message):
|
||||
"""Emitted when file content changes."""
|
||||
|
||||
def __init__(self, path: Path, is_modified: bool) -> None:
|
||||
self.path = path
|
||||
self.is_modified = is_modified
|
||||
super().__init__()
|
||||
|
||||
class ActiveBufferChanged(Message):
|
||||
"""Emitted when active buffer changes."""
|
||||
|
||||
def __init__(self, buffer: FileBuffer | None) -> None:
|
||||
self.buffer = buffer
|
||||
super().__init__()
|
||||
|
||||
def __init__(self, project_path: Path | None = None) -> None:
|
||||
self._project_path = project_path or Path.cwd()
|
||||
self._state = EditorState()
|
||||
|
||||
@property
|
||||
def state(self) -> EditorState:
|
||||
"""Get editor state."""
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def active_buffer(self) -> FileBuffer | None:
|
||||
"""Get currently active buffer."""
|
||||
return self._state.active_buffer
|
||||
|
||||
@property
|
||||
def open_files(self) -> list[FileBuffer]:
|
||||
"""Get list of open file buffers."""
|
||||
return self._state.buffers
|
||||
|
||||
@property
|
||||
def has_unsaved_changes(self) -> bool:
|
||||
"""Check if any buffer has unsaved changes."""
|
||||
return any(b.is_modified for b in self._state.buffers)
|
||||
|
||||
async def open_file(self, path: Path, line: int | None = None) -> FileBuffer:
|
||||
"""Open a file in the editor.
|
||||
|
||||
Args:
|
||||
path: File path to open
|
||||
line: Optional line number to jump to
|
||||
|
||||
Returns:
|
||||
FileBuffer for the opened file
|
||||
"""
|
||||
# Check if already open
|
||||
existing = self._state.get_buffer_by_path(path)
|
||||
if existing:
|
||||
self._set_active_buffer(existing)
|
||||
if line:
|
||||
existing.cursor = CursorPosition(line=line - 1, column=0)
|
||||
return existing
|
||||
|
||||
# Read file content
|
||||
content = await self._service.read_file(path)
|
||||
language = await self._service.get_language(path)
|
||||
|
||||
cursor = CursorPosition(line=line - 1 if line else 0, column=0)
|
||||
|
||||
buffer = FileBuffer(
|
||||
path=path,
|
||||
content=content,
|
||||
language=language,
|
||||
cursor=cursor,
|
||||
)
|
||||
|
||||
self._state.buffers.append(buffer)
|
||||
self._set_active_buffer(buffer)
|
||||
|
||||
# Add to recent files
|
||||
if path not in self._state.recent_files:
|
||||
self._state.recent_files.insert(0, path)
|
||||
self._state.recent_files = self._state.recent_files[:20]
|
||||
|
||||
return buffer
|
||||
|
||||
async def close_file(self, path: Path) -> bool:
|
||||
"""Close a file buffer.
|
||||
|
||||
Args:
|
||||
path: File path to close
|
||||
|
||||
Returns:
|
||||
True if closed (may be False if unsaved and user cancels)
|
||||
"""
|
||||
buffer = self._state.get_buffer_by_path(path)
|
||||
if not buffer:
|
||||
return True
|
||||
|
||||
# Remove from buffers
|
||||
self._state.buffers.remove(buffer)
|
||||
|
||||
# Update active buffer
|
||||
if self._state.active_buffer_index is not None and self._state.active_buffer_index >= len(self._state.buffers):
|
||||
self._state.active_buffer_index = (
|
||||
len(self._state.buffers) - 1 if self._state.buffers else None
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
async def save_file(self, path: Path | None = None) -> bool:
|
||||
"""Save a file.
|
||||
|
||||
Args:
|
||||
path: File path (defaults to active buffer)
|
||||
|
||||
Returns:
|
||||
True if saved successfully
|
||||
"""
|
||||
if path:
|
||||
buffer = self._state.get_buffer_by_path(path)
|
||||
else:
|
||||
buffer = self.active_buffer
|
||||
|
||||
if not buffer:
|
||||
return False
|
||||
|
||||
await self._service.write_file(buffer.path, buffer.content)
|
||||
buffer.is_modified = False
|
||||
|
||||
return True
|
||||
|
||||
async def save_all(self) -> int:
|
||||
"""Save all modified buffers.
|
||||
|
||||
Returns:
|
||||
Number of files saved
|
||||
"""
|
||||
count = 0
|
||||
for buffer in self._state.buffers:
|
||||
if buffer.is_modified:
|
||||
await self._service.write_file(buffer.path, buffer.content)
|
||||
buffer.is_modified = False
|
||||
count += 1
|
||||
return count
|
||||
|
||||
def update_content(self, path: Path, content: str) -> None:
|
||||
"""Update buffer content.
|
||||
|
||||
Args:
|
||||
path: File path
|
||||
content: New content
|
||||
"""
|
||||
buffer = self._state.get_buffer_by_path(path)
|
||||
if buffer:
|
||||
buffer.content = content
|
||||
buffer.is_modified = True
|
||||
|
||||
def update_cursor(self, path: Path, line: int, column: int) -> None:
|
||||
"""Update cursor position.
|
||||
|
||||
Args:
|
||||
path: File path
|
||||
line: Line number (0-indexed)
|
||||
column: Column number (0-indexed)
|
||||
"""
|
||||
buffer = self._state.get_buffer_by_path(path)
|
||||
if buffer:
|
||||
buffer.cursor = CursorPosition(line=line, column=column)
|
||||
|
||||
def set_active_by_index(self, index: int) -> None:
|
||||
"""Set active buffer by index.
|
||||
|
||||
Args:
|
||||
index: Buffer index
|
||||
"""
|
||||
if 0 <= index < len(self._state.buffers):
|
||||
self._state.active_buffer_index = index
|
||||
|
||||
def _set_active_buffer(self, buffer: FileBuffer) -> None:
|
||||
"""Set active buffer."""
|
||||
try:
|
||||
index = self._state.buffers.index(buffer)
|
||||
self._state.active_buffer_index = index
|
||||
except ValueError:
|
||||
pass
|
||||
@@ -0,0 +1,201 @@
|
||||
"""Git controller for managing git operations."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from textual.message import Message
|
||||
|
||||
from clide.controllers.base import controller
|
||||
from clide.models.git import GitBranch, GitCommit, GitStatus
|
||||
from clide.services.git_service import GitService
|
||||
|
||||
|
||||
@controller
|
||||
class GitController:
|
||||
"""Controller for git operations."""
|
||||
|
||||
class StatusUpdated(Message):
|
||||
"""Emitted when git status changes."""
|
||||
|
||||
def __init__(self, status: GitStatus) -> None:
|
||||
self.status = status
|
||||
super().__init__()
|
||||
|
||||
class BranchesUpdated(Message):
|
||||
"""Emitted when branches list changes."""
|
||||
|
||||
def __init__(self, branches: list[GitBranch]) -> None:
|
||||
self.branches = branches
|
||||
super().__init__()
|
||||
|
||||
class LogUpdated(Message):
|
||||
"""Emitted when commit log is refreshed."""
|
||||
|
||||
def __init__(self, commits: list[GitCommit]) -> None:
|
||||
self.commits = commits
|
||||
super().__init__()
|
||||
|
||||
class FileStaged(Message):
|
||||
"""Emitted when a file is staged."""
|
||||
|
||||
def __init__(self, path: str) -> None:
|
||||
self.path = path
|
||||
super().__init__()
|
||||
|
||||
class FileUnstaged(Message):
|
||||
"""Emitted when a file is unstaged."""
|
||||
|
||||
def __init__(self, path: str) -> None:
|
||||
self.path = path
|
||||
super().__init__()
|
||||
|
||||
def __init__(self, repo_path: Path) -> None:
|
||||
self._service = GitService(repo_path)
|
||||
self._status: GitStatus | None = None
|
||||
self._branches: list[GitBranch] = []
|
||||
self._commits: list[GitCommit] = []
|
||||
|
||||
@property
|
||||
def status(self) -> GitStatus | None:
|
||||
"""Get current git status."""
|
||||
return self._status
|
||||
|
||||
@property
|
||||
def branches(self) -> list[GitBranch]:
|
||||
"""Get list of branches."""
|
||||
return self._branches
|
||||
|
||||
@property
|
||||
def commits(self) -> list[GitCommit]:
|
||||
"""Get commit log."""
|
||||
return self._commits
|
||||
|
||||
@property
|
||||
def current_branch(self) -> str:
|
||||
"""Get current branch name."""
|
||||
return self._status.branch if self._status else "unknown"
|
||||
|
||||
async def refresh_status(self) -> GitStatus:
|
||||
"""Refresh git status.
|
||||
|
||||
Returns:
|
||||
Updated GitStatus
|
||||
"""
|
||||
self._status = await self._service.get_status()
|
||||
return self._status
|
||||
|
||||
async def get_status(self) -> GitStatus | None:
|
||||
"""Get git status (refreshes if needed)."""
|
||||
return await self.refresh_status()
|
||||
|
||||
async def refresh_branches(self) -> list[GitBranch]:
|
||||
"""Refresh branches list.
|
||||
|
||||
Returns:
|
||||
Updated list of branches
|
||||
"""
|
||||
self._branches = await self._service.get_branches()
|
||||
return self._branches
|
||||
|
||||
async def get_branches(self) -> list[GitBranch]:
|
||||
"""Get branches list (refreshes if needed)."""
|
||||
return await self.refresh_branches()
|
||||
|
||||
async def refresh_log(self, max_count: int = 50) -> list[GitCommit]:
|
||||
"""Refresh commit log.
|
||||
|
||||
Args:
|
||||
max_count: Maximum commits to fetch
|
||||
|
||||
Returns:
|
||||
Updated list of commits
|
||||
"""
|
||||
self._commits = await self._service.get_log(max_count)
|
||||
return self._commits
|
||||
|
||||
async def get_log(self, limit: int = 50) -> list[GitCommit]:
|
||||
"""Get commit log (refreshes if needed)."""
|
||||
return await self.refresh_log(limit)
|
||||
|
||||
async def stage_file(self, path: str) -> bool:
|
||||
"""Stage a file.
|
||||
|
||||
Args:
|
||||
path: File path to stage
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
success = await self._service.stage_file(path)
|
||||
if success:
|
||||
await self.refresh_status()
|
||||
return success
|
||||
|
||||
async def unstage_file(self, path: str) -> bool:
|
||||
"""Unstage a file.
|
||||
|
||||
Args:
|
||||
path: File path to unstage
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
success = await self._service.unstage_file(path)
|
||||
if success:
|
||||
await self.refresh_status()
|
||||
return success
|
||||
|
||||
async def discard_changes(self, path: str) -> bool:
|
||||
"""Discard changes to a file.
|
||||
|
||||
Args:
|
||||
path: File path
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
success = await self._service.discard_changes(path)
|
||||
if success:
|
||||
await self.refresh_status()
|
||||
return success
|
||||
|
||||
async def checkout_branch(self, branch: str) -> bool:
|
||||
"""Checkout a branch.
|
||||
|
||||
Args:
|
||||
branch: Branch name
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
success = await self._service.checkout_branch(branch)
|
||||
if success:
|
||||
await self.refresh_status()
|
||||
await self.refresh_branches()
|
||||
return success
|
||||
|
||||
async def create_branch(self, name: str) -> bool:
|
||||
"""Create and checkout a new branch.
|
||||
|
||||
Args:
|
||||
name: New branch name
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
success = await self._service.create_branch(name)
|
||||
if success:
|
||||
await self.refresh_status()
|
||||
await self.refresh_branches()
|
||||
return success
|
||||
|
||||
async def get_file_diff(self, path: str, staged: bool = False) -> str:
|
||||
"""Get diff for a file.
|
||||
|
||||
Args:
|
||||
path: File path
|
||||
staged: Whether to get staged diff
|
||||
|
||||
Returns:
|
||||
Diff string
|
||||
"""
|
||||
return await self._service.get_diff(path, staged)
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Jira controller for Jira CLI integration."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from textual.message import Message
|
||||
|
||||
from clide.controllers.base import controller
|
||||
from clide.services.process_service import ProcessService
|
||||
|
||||
|
||||
@controller
|
||||
class JiraController:
|
||||
"""Controller for Jira CLI integration."""
|
||||
|
||||
class JiraOutputUpdated(Message):
|
||||
"""Emitted when Jira output is updated."""
|
||||
|
||||
def __init__(self, output: str) -> None:
|
||||
self.output = output
|
||||
super().__init__()
|
||||
|
||||
class JiraError(Message):
|
||||
"""Emitted when Jira command fails."""
|
||||
|
||||
def __init__(self, error: str) -> None:
|
||||
self.error = error
|
||||
super().__init__()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
project_path: Path | None = None,
|
||||
jira_cli_path: str = "jira",
|
||||
enabled: bool = False,
|
||||
) -> None:
|
||||
self._project_path = project_path or Path.cwd()
|
||||
self._jira_cli = jira_cli_path
|
||||
self._enabled = enabled
|
||||
self._last_output: str = ""
|
||||
self._last_error: str = ""
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
"""Check if Jira integration is enabled."""
|
||||
return self._enabled
|
||||
|
||||
@property
|
||||
def last_output(self) -> str:
|
||||
"""Get last Jira output."""
|
||||
return self._last_output
|
||||
|
||||
@property
|
||||
def last_error(self) -> str:
|
||||
"""Get last error message."""
|
||||
return self._last_error
|
||||
|
||||
def enable(self) -> None:
|
||||
"""Enable Jira integration."""
|
||||
self._enabled = True
|
||||
|
||||
def disable(self) -> None:
|
||||
"""Disable Jira integration."""
|
||||
self._enabled = False
|
||||
|
||||
async def run_command(self, *args: str) -> str:
|
||||
"""Run a Jira CLI command.
|
||||
|
||||
Args:
|
||||
*args: Command arguments
|
||||
|
||||
Returns:
|
||||
Command output
|
||||
"""
|
||||
if not self._enabled:
|
||||
return "Jira integration is disabled"
|
||||
|
||||
process = ProcessService(cwd=self._project_path)
|
||||
result = await process.run(self._jira_cli, *args)
|
||||
|
||||
if result.success:
|
||||
self._last_output = result.stdout
|
||||
self._last_error = ""
|
||||
return result.stdout
|
||||
else:
|
||||
self._last_error = result.stderr
|
||||
return f"Error: {result.stderr}"
|
||||
|
||||
async def list_issues(self, project: str | None = None) -> str:
|
||||
"""List Jira issues.
|
||||
|
||||
Args:
|
||||
project: Optional project key
|
||||
|
||||
Returns:
|
||||
Formatted issue list
|
||||
"""
|
||||
args = ["issue", "list"]
|
||||
if project:
|
||||
args.extend(["--project", project])
|
||||
|
||||
return await self.run_command(*args)
|
||||
|
||||
async def get_issue(self, issue_key: str) -> str:
|
||||
"""Get a specific issue.
|
||||
|
||||
Args:
|
||||
issue_key: Issue key (e.g., PROJ-123)
|
||||
|
||||
Returns:
|
||||
Issue details
|
||||
"""
|
||||
return await self.run_command("issue", "view", issue_key)
|
||||
|
||||
async def get_my_issues(self) -> str:
|
||||
"""Get issues assigned to current user.
|
||||
|
||||
Returns:
|
||||
Formatted issue list
|
||||
"""
|
||||
return await self.run_command("issue", "list", "--assignee", "@me")
|
||||
|
||||
async def get_sprint_issues(self) -> str:
|
||||
"""Get issues in current sprint.
|
||||
|
||||
Returns:
|
||||
Formatted issue list
|
||||
"""
|
||||
return await self.run_command("sprint", "list", "--current")
|
||||
|
||||
async def refresh(self) -> str:
|
||||
"""Refresh Jira data (get my issues).
|
||||
|
||||
Returns:
|
||||
Updated output
|
||||
"""
|
||||
return await self.get_my_issues()
|
||||
|
||||
async def check_available(self) -> bool:
|
||||
"""Check if Jira CLI is available.
|
||||
|
||||
Returns:
|
||||
True if available
|
||||
"""
|
||||
process = ProcessService(cwd=self._project_path)
|
||||
result = await process.run(self._jira_cli, "--version")
|
||||
return result.success
|
||||
|
||||
async def get_content(self) -> str | None:
|
||||
"""Get Jira content for display.
|
||||
|
||||
Returns:
|
||||
Markdown content or None
|
||||
"""
|
||||
if not self._enabled:
|
||||
return None
|
||||
try:
|
||||
return await self.get_my_issues()
|
||||
except Exception:
|
||||
return None
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Problems controller for linter integration."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from textual.message import Message
|
||||
|
||||
from clide.controllers.base import controller
|
||||
from clide.models.problems import Problem, ProblemsState, ProblemsSummary, Severity
|
||||
from clide.services.linter_service import LinterService
|
||||
|
||||
|
||||
@controller
|
||||
class ProblemsController:
|
||||
"""Controller for problems/diagnostics from linters."""
|
||||
|
||||
class ProblemsUpdated(Message):
|
||||
"""Emitted when problems list is updated."""
|
||||
|
||||
def __init__(self, problems: list[Problem], summary: ProblemsSummary) -> None:
|
||||
self.problems = problems
|
||||
self.summary = summary
|
||||
super().__init__()
|
||||
|
||||
class ProblemSelected(Message):
|
||||
"""Emitted when a problem is selected."""
|
||||
|
||||
def __init__(self, problem: Problem) -> None:
|
||||
self.problem = problem
|
||||
super().__init__()
|
||||
|
||||
def __init__(self, project_path: Path, linters: list[str] | None = None) -> None:
|
||||
self._service = LinterService(project_path)
|
||||
self._linters = linters or ["ruff"]
|
||||
self._state = ProblemsState()
|
||||
|
||||
@property
|
||||
def state(self) -> ProblemsState:
|
||||
"""Get problems state."""
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def problems(self) -> list[Problem]:
|
||||
"""Get list of problems."""
|
||||
return self._state.problems
|
||||
|
||||
@property
|
||||
def summary(self) -> ProblemsSummary:
|
||||
"""Get problems summary."""
|
||||
return self._state.summary
|
||||
|
||||
@property
|
||||
def error_count(self) -> int:
|
||||
"""Get error count."""
|
||||
return self._state.summary.errors
|
||||
|
||||
@property
|
||||
def warning_count(self) -> int:
|
||||
"""Get warning count."""
|
||||
return self._state.summary.warnings
|
||||
|
||||
async def refresh(self) -> tuple[list[Problem], ProblemsSummary]:
|
||||
"""Refresh problems from all linters.
|
||||
|
||||
Returns:
|
||||
Tuple of (problems, summary)
|
||||
"""
|
||||
problems, summary = await self._service.run_all(self._linters)
|
||||
|
||||
self._state.problems = problems
|
||||
self._state.summary = summary
|
||||
|
||||
return problems, summary
|
||||
|
||||
def filter_by_severity(self, severity: Severity | None) -> list[Problem]:
|
||||
"""Filter problems by severity.
|
||||
|
||||
Args:
|
||||
severity: Severity to filter by, or None for all
|
||||
|
||||
Returns:
|
||||
Filtered list of problems
|
||||
"""
|
||||
self._state.filter_severity = severity
|
||||
|
||||
if severity is None:
|
||||
return self._state.problems
|
||||
|
||||
return [p for p in self._state.problems if p.severity == severity]
|
||||
|
||||
def filter_by_source(self, source: str | None) -> list[Problem]:
|
||||
"""Filter problems by source linter.
|
||||
|
||||
Args:
|
||||
source: Source to filter by, or None for all
|
||||
|
||||
Returns:
|
||||
Filtered list of problems
|
||||
"""
|
||||
self._state.filter_source = source
|
||||
|
||||
if source is None:
|
||||
return self._state.problems
|
||||
|
||||
return [p for p in self._state.problems if p.source == source]
|
||||
|
||||
def get_problems_for_file(self, path: Path) -> list[Problem]:
|
||||
"""Get problems for a specific file.
|
||||
|
||||
Args:
|
||||
path: File path
|
||||
|
||||
Returns:
|
||||
List of problems for that file
|
||||
"""
|
||||
return self._state.problems_for_file(path)
|
||||
|
||||
def select_problem(self, index: int) -> Problem | None:
|
||||
"""Select a problem by index.
|
||||
|
||||
Args:
|
||||
index: Problem index
|
||||
|
||||
Returns:
|
||||
Selected problem or None
|
||||
"""
|
||||
if 0 <= index < len(self._state.problems):
|
||||
self._state.selected_index = index
|
||||
return self._state.problems[index]
|
||||
return None
|
||||
|
||||
def next_problem(self) -> Problem | None:
|
||||
"""Select next problem.
|
||||
|
||||
Returns:
|
||||
Next problem or None
|
||||
"""
|
||||
if not self._state.problems:
|
||||
return None
|
||||
|
||||
if self._state.selected_index is None:
|
||||
self._state.selected_index = 0
|
||||
else:
|
||||
self._state.selected_index = (
|
||||
self._state.selected_index + 1
|
||||
) % len(self._state.problems)
|
||||
|
||||
return self._state.problems[self._state.selected_index]
|
||||
|
||||
def prev_problem(self) -> Problem | None:
|
||||
"""Select previous problem.
|
||||
|
||||
Returns:
|
||||
Previous problem or None
|
||||
"""
|
||||
if not self._state.problems:
|
||||
return None
|
||||
|
||||
if self._state.selected_index is None:
|
||||
self._state.selected_index = len(self._state.problems) - 1
|
||||
else:
|
||||
self._state.selected_index = (
|
||||
self._state.selected_index - 1
|
||||
) % len(self._state.problems)
|
||||
|
||||
return self._state.problems[self._state.selected_index]
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all problems."""
|
||||
self._state.problems = []
|
||||
self._state.summary = ProblemsSummary()
|
||||
self._state.selected_index = None
|
||||
|
||||
async def run_all(self) -> list[Problem]:
|
||||
"""Run all linters and return problems.
|
||||
|
||||
Returns:
|
||||
List of problems
|
||||
"""
|
||||
problems, _ = await self.refresh()
|
||||
return problems
|
||||
@@ -0,0 +1,138 @@
|
||||
"""TODOs controller for tracking TODO comments."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from textual.message import Message
|
||||
|
||||
from clide.controllers.base import controller
|
||||
from clide.models.todos import TodoItem, TodosState, TodosSummary, TodoType
|
||||
from clide.services.todo_scanner import TodoScanner
|
||||
|
||||
|
||||
@controller
|
||||
class TodosController:
|
||||
"""Controller for TODO/FIXME comment tracking."""
|
||||
|
||||
class TodosUpdated(Message):
|
||||
"""Emitted when TODOs list is updated."""
|
||||
|
||||
def __init__(self, items: list[TodoItem], summary: TodosSummary) -> None:
|
||||
self.items = items
|
||||
self.summary = summary
|
||||
super().__init__()
|
||||
|
||||
class TodoSelected(Message):
|
||||
"""Emitted when a TODO is selected."""
|
||||
|
||||
def __init__(self, item: TodoItem) -> None:
|
||||
self.item = item
|
||||
super().__init__()
|
||||
|
||||
def __init__(self, project_path: Path) -> None:
|
||||
self._scanner = TodoScanner(project_path)
|
||||
self._state = TodosState()
|
||||
|
||||
@property
|
||||
def state(self) -> TodosState:
|
||||
"""Get TODOs state."""
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def items(self) -> list[TodoItem]:
|
||||
"""Get list of TODO items."""
|
||||
return self._state.items
|
||||
|
||||
@property
|
||||
def summary(self) -> TodosSummary:
|
||||
"""Get TODOs summary."""
|
||||
return self._state.summary
|
||||
|
||||
@property
|
||||
def total_count(self) -> int:
|
||||
"""Get total TODO count."""
|
||||
return self._state.summary.total
|
||||
|
||||
async def refresh(self) -> tuple[list[TodoItem], TodosSummary]:
|
||||
"""Refresh TODOs from project.
|
||||
|
||||
Returns:
|
||||
Tuple of (items, summary)
|
||||
"""
|
||||
items, summary = await self._scanner.scan()
|
||||
|
||||
self._state.items = items
|
||||
self._state.summary = summary
|
||||
|
||||
return items, summary
|
||||
|
||||
def filter_by_type(self, todo_type: TodoType | None) -> list[TodoItem]:
|
||||
"""Filter TODOs by type.
|
||||
|
||||
Args:
|
||||
todo_type: Type to filter by, or None for all
|
||||
|
||||
Returns:
|
||||
Filtered list of TODOs
|
||||
"""
|
||||
self._state.filter_type = todo_type
|
||||
|
||||
if todo_type is None:
|
||||
return self._state.items
|
||||
|
||||
return self._state.items_by_type(todo_type)
|
||||
|
||||
def get_items_for_file(self, path: Path) -> list[TodoItem]:
|
||||
"""Get TODOs for a specific file.
|
||||
|
||||
Args:
|
||||
path: File path
|
||||
|
||||
Returns:
|
||||
List of TODOs for that file
|
||||
"""
|
||||
return self._state.items_for_file(path)
|
||||
|
||||
def select_item(self, index: int) -> TodoItem | None:
|
||||
"""Select a TODO by index.
|
||||
|
||||
Args:
|
||||
index: Item index
|
||||
|
||||
Returns:
|
||||
Selected item or None
|
||||
"""
|
||||
if 0 <= index < len(self._state.items):
|
||||
self._state.selected_index = index
|
||||
return self._state.items[index]
|
||||
return None
|
||||
|
||||
def toggle_group_by_file(self) -> bool:
|
||||
"""Toggle grouping by file.
|
||||
|
||||
Returns:
|
||||
New group_by_file value
|
||||
"""
|
||||
self._state.group_by_file = not self._state.group_by_file
|
||||
return self._state.group_by_file
|
||||
|
||||
def get_grouped_items(self) -> dict[Path, list[TodoItem]]:
|
||||
"""Get TODOs grouped by file.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping file paths to TODO lists
|
||||
"""
|
||||
grouped: dict[Path, list[TodoItem]] = {}
|
||||
for item in self._state.items:
|
||||
if item.file_path not in grouped:
|
||||
grouped[item.file_path] = []
|
||||
grouped[item.file_path].append(item)
|
||||
return grouped
|
||||
|
||||
async def scan(self) -> list[TodoItem]:
|
||||
"""Scan for TODOs and return items.
|
||||
|
||||
Returns:
|
||||
List of TODO items
|
||||
"""
|
||||
items, _ = await self.refresh()
|
||||
return items
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Extension system for Clide using pluggy."""
|
||||
|
||||
from clide.extensions.hookspecs import ClideHookSpec, hookimpl, hookspec
|
||||
from clide.extensions.manager import ExtensionManager
|
||||
|
||||
__all__ = ["ClideHookSpec", "ExtensionManager", "hookimpl", "hookspec"]
|
||||
@@ -0,0 +1 @@
|
||||
"""Built-in extensions for Clide."""
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Pluggy hook specifications for Clide extensions."""
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pluggy
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from textual.app import App
|
||||
from textual.widget import Widget
|
||||
|
||||
hookspec = pluggy.HookspecMarker("clide")
|
||||
hookimpl = pluggy.HookimplMarker("clide")
|
||||
|
||||
|
||||
class ClideHookSpec:
|
||||
"""Hook specifications for Clide extensions.
|
||||
|
||||
Extensions implement these hooks to extend functionality.
|
||||
"""
|
||||
|
||||
@hookspec
|
||||
def clide_register_panel(self) -> dict[str, Any] | None:
|
||||
"""Register a custom panel for the UI.
|
||||
|
||||
Returns:
|
||||
Dictionary with panel configuration:
|
||||
- name: Panel identifier
|
||||
- widget: Widget class to instantiate
|
||||
- position: "left", "right", or "bottom"
|
||||
- keybinding: Optional keyboard shortcut
|
||||
"""
|
||||
|
||||
@hookspec
|
||||
def clide_register_commands(self) -> list[dict[str, Any]] | None:
|
||||
"""Register custom commands for the command palette.
|
||||
|
||||
Returns:
|
||||
List of command dictionaries:
|
||||
- name: Command display name
|
||||
- callback: Async callable to execute
|
||||
- description: Help text
|
||||
"""
|
||||
|
||||
@hookspec
|
||||
def clide_on_app_startup(self, app: "App[object]") -> None:
|
||||
"""Called when the application starts.
|
||||
|
||||
Args:
|
||||
app: The Clide application instance
|
||||
"""
|
||||
|
||||
@hookspec
|
||||
def clide_on_app_shutdown(self, app: "App[object]") -> None:
|
||||
"""Called when the application is shutting down.
|
||||
|
||||
Args:
|
||||
app: The Clide application instance
|
||||
"""
|
||||
|
||||
@hookspec
|
||||
def clide_on_file_open(self, path: str) -> None:
|
||||
"""Called when a file is opened in the file browser.
|
||||
|
||||
Args:
|
||||
path: Absolute path to the opened file
|
||||
"""
|
||||
|
||||
@hookspec
|
||||
def clide_modify_widget(self, widget: "Widget") -> "Widget":
|
||||
"""Modify a widget before it's mounted.
|
||||
|
||||
Args:
|
||||
widget: The widget about to be mounted
|
||||
|
||||
Returns:
|
||||
The modified (or original) widget
|
||||
"""
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Extension manager for loading and managing Clide extensions."""
|
||||
|
||||
from importlib.metadata import entry_points
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pluggy
|
||||
|
||||
from clide.extensions.hookspecs import ClideHookSpec
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from textual.app import App
|
||||
|
||||
EXTENSION_NAMESPACE = "clide.extensions"
|
||||
|
||||
|
||||
class ExtensionManager:
|
||||
"""Manages loading and lifecycle of Clide extensions."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._pm = pluggy.PluginManager("clide")
|
||||
self._pm.add_hookspecs(ClideHookSpec)
|
||||
self._loaded: list[str] = []
|
||||
|
||||
@property
|
||||
def hook(self) -> pluggy.HookRelay:
|
||||
"""Access the hook relay for calling hooks."""
|
||||
return self._pm.hook
|
||||
|
||||
def load_extensions(self) -> None:
|
||||
"""Load all extensions from entry points."""
|
||||
eps = entry_points(group=EXTENSION_NAMESPACE)
|
||||
for ep in eps:
|
||||
try:
|
||||
plugin = ep.load()
|
||||
self._pm.register(plugin, name=ep.name)
|
||||
self._loaded.append(ep.name)
|
||||
except Exception as e:
|
||||
# Log but don't crash on extension load failure
|
||||
print(f"Failed to load extension {ep.name}: {e}")
|
||||
|
||||
def register_plugin(self, plugin: object, name: str) -> None:
|
||||
"""Manually register a plugin instance.
|
||||
|
||||
Args:
|
||||
plugin: Plugin object with hookimpl methods
|
||||
name: Unique name for the plugin
|
||||
"""
|
||||
self._pm.register(plugin, name=name)
|
||||
self._loaded.append(name)
|
||||
|
||||
def unregister_plugin(self, name: str) -> None:
|
||||
"""Unregister a plugin by name.
|
||||
|
||||
Args:
|
||||
name: Name of the plugin to unregister
|
||||
"""
|
||||
plugin = self._pm.get_plugin(name)
|
||||
if plugin:
|
||||
self._pm.unregister(plugin)
|
||||
self._loaded.remove(name)
|
||||
|
||||
def list_extensions(self) -> list[str]:
|
||||
"""Get list of loaded extension names."""
|
||||
return self._loaded.copy()
|
||||
|
||||
async def trigger_app_startup(self, app: "App[object]") -> None:
|
||||
"""Trigger startup hooks for all extensions.
|
||||
|
||||
Args:
|
||||
app: The Clide application instance
|
||||
"""
|
||||
self.hook.clide_on_app_startup(app=app)
|
||||
|
||||
async def trigger_app_shutdown(self, app: "App[object]") -> None:
|
||||
"""Trigger shutdown hooks for all extensions.
|
||||
|
||||
Args:
|
||||
app: The Clide application instance
|
||||
"""
|
||||
self.hook.clide_on_app_shutdown(app=app)
|
||||
@@ -0,0 +1 @@
|
||||
"""Shared utility functions and helpers for Clide."""
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Pydantic models for Clide."""
|
||||
|
||||
from clide.models.config import ClideSettings, PanelConfig
|
||||
from clide.models.diff import ChangeType, DiffContent, DiffHunk, DiffLine, DiffViewState
|
||||
from clide.models.editor import CursorPosition, EditorState, FileBuffer, Selection
|
||||
from clide.models.git import (
|
||||
ChangeStatus,
|
||||
GitBranch,
|
||||
GitChange,
|
||||
GitCommit,
|
||||
GitGraph,
|
||||
GitStatus,
|
||||
)
|
||||
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
|
||||
|
||||
__all__ = [
|
||||
# Config
|
||||
"ClideSettings",
|
||||
"PanelConfig",
|
||||
# Git
|
||||
"ChangeStatus",
|
||||
"GitBranch",
|
||||
"GitChange",
|
||||
"GitCommit",
|
||||
"GitGraph",
|
||||
"GitStatus",
|
||||
# Editor
|
||||
"CursorPosition",
|
||||
"EditorState",
|
||||
"FileBuffer",
|
||||
"Selection",
|
||||
# Diff
|
||||
"ChangeType",
|
||||
"DiffContent",
|
||||
"DiffHunk",
|
||||
"DiffLine",
|
||||
"DiffViewState",
|
||||
# Problems
|
||||
"Problem",
|
||||
"ProblemsSummary",
|
||||
"ProblemsState",
|
||||
"Severity",
|
||||
# Todos
|
||||
"TodoItem",
|
||||
"TodosSummary",
|
||||
"TodosState",
|
||||
"TodoType",
|
||||
# Theme
|
||||
"ThemeColors",
|
||||
"ThemeDefinition",
|
||||
"ThemeMetadata",
|
||||
]
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Configuration models using Pydantic Settings."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class ClideSettings(BaseSettings):
|
||||
"""Main application settings loaded from environment and config files."""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_prefix="CLIDE_",
|
||||
env_file=".config/.env",
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
# Appearance
|
||||
theme: str = "summer-night" # Default to Summer Night
|
||||
|
||||
# Paths
|
||||
claude_path: str = "claude"
|
||||
default_workdir: Path = Path.cwd()
|
||||
jira_cli_path: str = "jira"
|
||||
|
||||
# Behavior
|
||||
auto_save: bool = True
|
||||
confirm_exit: bool = True
|
||||
|
||||
# Integrations
|
||||
jira_enabled: bool = False
|
||||
confluence_enabled: bool = False
|
||||
imagin_enabled: bool = False
|
||||
|
||||
# Linters
|
||||
linters: list[str] = ["ruff"]
|
||||
|
||||
|
||||
class PanelConfig(BaseModel):
|
||||
"""Panel visibility and layout configuration."""
|
||||
|
||||
model_config = ConfigDict(strict=True, frozen=True)
|
||||
|
||||
sidebar_visible: bool = True
|
||||
context_visible: bool = True
|
||||
workspace_visible: bool = False # Hidden by default
|
||||
sidebar_width_percent: int = 20
|
||||
context_width_percent: int = 25
|
||||
|
||||
|
||||
class KeybindingsConfig(BaseModel):
|
||||
"""Keybinding configuration."""
|
||||
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
# Global
|
||||
command_palette: str = "ctrl+shift+p"
|
||||
quick_open: str = "ctrl+p"
|
||||
toggle_sidebar: str = "ctrl+b"
|
||||
toggle_context: str = "ctrl+shift+b"
|
||||
toggle_terminal: str = "ctrl+`"
|
||||
toggle_compact: str = "ctrl+shift+c"
|
||||
fullscreen: str = "f11"
|
||||
|
||||
# Navigation
|
||||
focus_claude: str = "ctrl+1"
|
||||
focus_editor: str = "ctrl+2"
|
||||
focus_terminal: str = "ctrl+3"
|
||||
focus_sidebar: str = "ctrl+0"
|
||||
next_tab: str = "ctrl+tab"
|
||||
prev_tab: str = "ctrl+shift+tab"
|
||||
close_tab: str = "ctrl+w"
|
||||
|
||||
# Git
|
||||
git_panel: str = "ctrl+shift+g"
|
||||
stage_file: str = "ctrl+enter"
|
||||
unstage_file: str = "ctrl+backspace"
|
||||
|
||||
# Search
|
||||
find_in_file: str = "ctrl+f"
|
||||
find_in_project: str = "ctrl+shift+f"
|
||||
problems_panel: str = "ctrl+shift+m"
|
||||
next_problem: str = "f8"
|
||||
prev_problem: str = "shift+f8"
|
||||
|
||||
# Editor
|
||||
save: str = "ctrl+s"
|
||||
undo: str = "ctrl+z"
|
||||
redo: str = "ctrl+shift+z"
|
||||
go_to_line: str = "ctrl+g"
|
||||
|
||||
# Theme
|
||||
select_theme: str = "ctrl+k ctrl+t"
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Diff-related Pydantic models."""
|
||||
|
||||
from enum import Enum
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class ChangeType(str, Enum):
|
||||
"""Type of change in a diff line."""
|
||||
|
||||
ADDED = "added"
|
||||
REMOVED = "removed"
|
||||
CONTEXT = "context"
|
||||
HEADER = "header"
|
||||
|
||||
|
||||
class DiffLine(BaseModel):
|
||||
"""A single line in a diff."""
|
||||
|
||||
model_config = ConfigDict(strict=True, frozen=True)
|
||||
|
||||
change_type: ChangeType
|
||||
content: str
|
||||
old_line_num: int | None = None
|
||||
new_line_num: int | None = None
|
||||
|
||||
|
||||
class DiffHunk(BaseModel):
|
||||
"""A hunk (section) of a diff."""
|
||||
|
||||
model_config = ConfigDict(strict=True, frozen=True)
|
||||
|
||||
header: str
|
||||
old_start: int
|
||||
old_count: int
|
||||
new_start: int
|
||||
new_count: int
|
||||
lines: tuple[DiffLine, ...]
|
||||
|
||||
|
||||
class DiffContent(BaseModel):
|
||||
"""Complete diff for a file."""
|
||||
|
||||
model_config = ConfigDict(strict=True, frozen=True)
|
||||
|
||||
file_path: str
|
||||
old_path: str | None = None # For renames
|
||||
hunks: tuple[DiffHunk, ...]
|
||||
is_binary: bool = False
|
||||
is_new_file: bool = False
|
||||
is_deleted: bool = False
|
||||
|
||||
|
||||
class DiffViewState(BaseModel):
|
||||
"""State of the diff viewer."""
|
||||
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
diff: DiffContent | None = None
|
||||
scroll_offset: int = 0
|
||||
selected_hunk_index: int | None = None
|
||||
side_by_side: bool = True
|
||||
# For Claude-proposed changes
|
||||
is_proposal: bool = False
|
||||
accepted_hunks: set[int] = set()
|
||||
rejected_hunks: set[int] = set()
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Editor-related Pydantic models."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class CursorPosition(BaseModel):
|
||||
"""Cursor position in editor."""
|
||||
|
||||
model_config = ConfigDict(strict=True, frozen=True)
|
||||
|
||||
line: int
|
||||
column: int
|
||||
|
||||
|
||||
class Selection(BaseModel):
|
||||
"""Text selection range."""
|
||||
|
||||
model_config = ConfigDict(strict=True, frozen=True)
|
||||
|
||||
start: CursorPosition
|
||||
end: CursorPosition
|
||||
|
||||
|
||||
class FileBuffer(BaseModel):
|
||||
"""A file buffer in the editor."""
|
||||
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
path: Path
|
||||
content: str
|
||||
language: str | None = None
|
||||
is_modified: bool = False
|
||||
cursor: CursorPosition = CursorPosition(line=0, column=0)
|
||||
selection: Selection | None = None
|
||||
scroll_offset: int = 0
|
||||
|
||||
@property
|
||||
def filename(self) -> str:
|
||||
"""Get the filename from path."""
|
||||
return self.path.name
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
"""Get display name with modification indicator."""
|
||||
prefix = "● " if self.is_modified else ""
|
||||
return f"{prefix}{self.filename}"
|
||||
|
||||
|
||||
class EditorState(BaseModel):
|
||||
"""State of the editor panel."""
|
||||
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
buffers: list[FileBuffer] = []
|
||||
active_buffer_index: int | None = None
|
||||
recent_files: list[Path] = []
|
||||
|
||||
@property
|
||||
def active_buffer(self) -> FileBuffer | None:
|
||||
"""Get currently active buffer."""
|
||||
if self.active_buffer_index is not None and self.buffers:
|
||||
return self.buffers[self.active_buffer_index]
|
||||
return None
|
||||
|
||||
def get_buffer_by_path(self, path: Path) -> FileBuffer | None:
|
||||
"""Find a buffer by its file path."""
|
||||
for buffer in self.buffers:
|
||||
if buffer.path == path:
|
||||
return buffer
|
||||
return None
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Git-related Pydantic models."""
|
||||
|
||||
from enum import Enum
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class ChangeStatus(str, Enum):
|
||||
"""Git file change status."""
|
||||
|
||||
ADDED = "added"
|
||||
MODIFIED = "modified"
|
||||
DELETED = "deleted"
|
||||
RENAMED = "renamed"
|
||||
COPIED = "copied"
|
||||
UNTRACKED = "untracked"
|
||||
IGNORED = "ignored"
|
||||
UNMERGED = "unmerged"
|
||||
|
||||
|
||||
class GitChange(BaseModel):
|
||||
"""A single file change in git."""
|
||||
|
||||
model_config = ConfigDict(strict=True, frozen=True)
|
||||
|
||||
path: str
|
||||
status: ChangeStatus
|
||||
staged: bool
|
||||
old_path: str | None = None # For renames
|
||||
|
||||
|
||||
class GitStatus(BaseModel):
|
||||
"""Current git repository status."""
|
||||
|
||||
model_config = ConfigDict(strict=True, frozen=True)
|
||||
|
||||
branch: str
|
||||
ahead: int = 0
|
||||
behind: int = 0
|
||||
staged: tuple[GitChange, ...]
|
||||
unstaged: tuple[GitChange, ...]
|
||||
untracked: tuple[str, ...] = ()
|
||||
has_conflicts: bool = False
|
||||
|
||||
|
||||
class GitBranch(BaseModel):
|
||||
"""Git branch information."""
|
||||
|
||||
model_config = ConfigDict(strict=True, frozen=True)
|
||||
|
||||
name: str
|
||||
is_current: bool = False
|
||||
is_remote: bool = False
|
||||
tracking: str | None = None
|
||||
commit_hash: str | None = None
|
||||
commit_message: str | None = None
|
||||
|
||||
|
||||
class GitCommit(BaseModel):
|
||||
"""Git commit information for graph view."""
|
||||
|
||||
model_config = ConfigDict(strict=True, frozen=True)
|
||||
|
||||
hash: str
|
||||
short_hash: str
|
||||
message: str
|
||||
author: str
|
||||
date: str
|
||||
is_merge: bool = False
|
||||
refs: tuple[str, ...] = () # branch names, tags
|
||||
parents: tuple[str, ...] = ()
|
||||
|
||||
|
||||
class GitGraph(BaseModel):
|
||||
"""Git log graph data."""
|
||||
|
||||
model_config = ConfigDict(strict=True, frozen=True)
|
||||
|
||||
commits: tuple[GitCommit, ...]
|
||||
branches: tuple[GitBranch, ...]
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Problems (linter errors) Pydantic models."""
|
||||
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class Severity(str, Enum):
|
||||
"""Problem severity level."""
|
||||
|
||||
ERROR = "error"
|
||||
WARNING = "warning"
|
||||
INFO = "info"
|
||||
HINT = "hint"
|
||||
|
||||
|
||||
class Problem(BaseModel):
|
||||
"""A single linter problem/diagnostic."""
|
||||
|
||||
model_config = ConfigDict(strict=True, frozen=True)
|
||||
|
||||
file_path: Path
|
||||
line: int
|
||||
column: int
|
||||
end_line: int | None = None
|
||||
end_column: int | None = None
|
||||
severity: Severity
|
||||
message: str
|
||||
source: str # e.g., "ruff", "mypy", "eslint"
|
||||
code: str | None = None # e.g., "E501", "W0612"
|
||||
|
||||
@property
|
||||
def location(self) -> str:
|
||||
"""Human-readable location string."""
|
||||
return f"{self.file_path}:{self.line}:{self.column}"
|
||||
|
||||
@property
|
||||
def severity_icon(self) -> str:
|
||||
"""Icon for severity level."""
|
||||
icons = {
|
||||
Severity.ERROR: "✖",
|
||||
Severity.WARNING: "⚠",
|
||||
Severity.INFO: "ℹ",
|
||||
Severity.HINT: "💡",
|
||||
}
|
||||
return icons[self.severity]
|
||||
|
||||
|
||||
class ProblemsSummary(BaseModel):
|
||||
"""Summary of problems in the workspace."""
|
||||
|
||||
model_config = ConfigDict(strict=True, frozen=True)
|
||||
|
||||
errors: int = 0
|
||||
warnings: int = 0
|
||||
infos: int = 0
|
||||
hints: int = 0
|
||||
|
||||
@property
|
||||
def total(self) -> int:
|
||||
"""Total number of problems."""
|
||||
return self.errors + self.warnings + self.infos + self.hints
|
||||
|
||||
@property
|
||||
def display_text(self) -> str:
|
||||
"""Text for tab badge."""
|
||||
if self.errors:
|
||||
return f"⚠ {self.errors}"
|
||||
if self.warnings:
|
||||
return f"⚠ {self.warnings}"
|
||||
return f"✓ {self.total}"
|
||||
|
||||
|
||||
class ProblemsState(BaseModel):
|
||||
"""State of the problems panel."""
|
||||
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
problems: list[Problem] = []
|
||||
summary: ProblemsSummary = ProblemsSummary()
|
||||
filter_severity: Severity | None = None
|
||||
filter_source: str | None = None
|
||||
selected_index: int | None = None
|
||||
|
||||
def problems_for_file(self, path: Path) -> list[Problem]:
|
||||
"""Get problems for a specific file."""
|
||||
return [p for p in self.problems if p.file_path == path]
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Theme-related Pydantic models."""
|
||||
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, field_validator
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from textual.theme import Theme
|
||||
|
||||
|
||||
def validate_hex_color(value: str) -> str:
|
||||
"""Validate hex color format."""
|
||||
if not re.match(r"^#[0-9A-Fa-f]{6}$", value):
|
||||
raise ValueError(f"Invalid hex color: {value}")
|
||||
return value.lower()
|
||||
|
||||
|
||||
class ThemeColors(BaseModel):
|
||||
"""Color definitions for a theme."""
|
||||
|
||||
model_config = ConfigDict(strict=True, frozen=True)
|
||||
|
||||
# Core colors
|
||||
primary: str
|
||||
secondary: str
|
||||
accent: str
|
||||
|
||||
# Backgrounds
|
||||
background: str
|
||||
surface: str
|
||||
panel: str
|
||||
|
||||
# Text
|
||||
foreground: str
|
||||
|
||||
# Status
|
||||
success: str
|
||||
warning: str
|
||||
error: str
|
||||
|
||||
@field_validator("*", mode="before")
|
||||
@classmethod
|
||||
def validate_colors(cls, v: str) -> str:
|
||||
"""Validate all color fields are valid hex."""
|
||||
return validate_hex_color(v)
|
||||
|
||||
|
||||
class ThemeDefinition(BaseModel):
|
||||
"""Complete theme definition."""
|
||||
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
name: str # e.g., "summer-night"
|
||||
display_name: str # e.g., "Summer Night"
|
||||
dark: bool # True for dark themes
|
||||
colors: ThemeColors
|
||||
|
||||
def to_textual_colors(self) -> dict[str, str]:
|
||||
"""Convert to Textual theme color dict."""
|
||||
return {
|
||||
"primary": self.colors.primary,
|
||||
"secondary": self.colors.secondary,
|
||||
"accent": self.colors.accent,
|
||||
"background": self.colors.background,
|
||||
"surface": self.colors.surface,
|
||||
"panel": self.colors.panel,
|
||||
"foreground": self.colors.foreground,
|
||||
"success": self.colors.success,
|
||||
"warning": self.colors.warning,
|
||||
"error": self.colors.error,
|
||||
}
|
||||
|
||||
def to_textual_theme(self) -> "Theme":
|
||||
"""Convert to a Textual Theme object."""
|
||||
from textual.theme import Theme
|
||||
|
||||
return Theme(
|
||||
name=self.name,
|
||||
primary=self.colors.primary,
|
||||
secondary=self.colors.secondary,
|
||||
accent=self.colors.accent,
|
||||
background=self.colors.background,
|
||||
surface=self.colors.surface,
|
||||
panel=self.colors.panel,
|
||||
foreground=self.colors.foreground,
|
||||
success=self.colors.success,
|
||||
warning=self.colors.warning,
|
||||
error=self.colors.error,
|
||||
dark=self.dark,
|
||||
)
|
||||
|
||||
|
||||
class ThemeMetadata(BaseModel):
|
||||
"""Theme metadata for listing themes."""
|
||||
|
||||
model_config = ConfigDict(strict=True, frozen=True)
|
||||
|
||||
name: str
|
||||
display_name: str
|
||||
dark: bool
|
||||
category: str = "custom" # e.g., "core", "popular", "seasonal", "custom"
|
||||
@@ -0,0 +1,93 @@
|
||||
"""TODO comments Pydantic models."""
|
||||
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class TodoType(str, Enum):
|
||||
"""Type of TODO comment."""
|
||||
|
||||
TODO = "TODO"
|
||||
FIXME = "FIXME"
|
||||
HACK = "HACK"
|
||||
XXX = "XXX"
|
||||
NOTE = "NOTE"
|
||||
BUG = "BUG"
|
||||
OPTIMIZE = "OPTIMIZE"
|
||||
REVIEW = "REVIEW"
|
||||
|
||||
|
||||
class TodoItem(BaseModel):
|
||||
"""A single TODO comment found in code."""
|
||||
|
||||
model_config = ConfigDict(strict=True, frozen=True)
|
||||
|
||||
file_path: Path
|
||||
line: int
|
||||
column: int
|
||||
todo_type: TodoType
|
||||
text: str
|
||||
context_line: str # The full line containing the TODO
|
||||
|
||||
@property
|
||||
def location(self) -> str:
|
||||
"""Human-readable location string."""
|
||||
return f"{self.file_path}:{self.line}"
|
||||
|
||||
@property
|
||||
def type_icon(self) -> str:
|
||||
"""Icon for TODO type."""
|
||||
icons = {
|
||||
TodoType.TODO: "☐",
|
||||
TodoType.FIXME: "🔧",
|
||||
TodoType.HACK: "⚡",
|
||||
TodoType.XXX: "❗",
|
||||
TodoType.NOTE: "📝",
|
||||
TodoType.BUG: "🐛",
|
||||
TodoType.OPTIMIZE: "⚡",
|
||||
TodoType.REVIEW: "👀",
|
||||
}
|
||||
return icons[self.todo_type]
|
||||
|
||||
|
||||
class TodosSummary(BaseModel):
|
||||
"""Summary of TODOs in the workspace."""
|
||||
|
||||
model_config = ConfigDict(strict=True, frozen=True)
|
||||
|
||||
todo_count: int = 0
|
||||
fixme_count: int = 0
|
||||
hack_count: int = 0
|
||||
other_count: int = 0
|
||||
|
||||
@property
|
||||
def total(self) -> int:
|
||||
"""Total number of TODOs."""
|
||||
return self.todo_count + self.fixme_count + self.hack_count + self.other_count
|
||||
|
||||
@property
|
||||
def display_text(self) -> str:
|
||||
"""Text for tab badge."""
|
||||
return f"✓{self.total}"
|
||||
|
||||
|
||||
class TodosState(BaseModel):
|
||||
"""State of the TODOs panel."""
|
||||
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
items: list[TodoItem] = []
|
||||
summary: TodosSummary = TodosSummary()
|
||||
filter_type: TodoType | None = None
|
||||
selected_index: int | None = None
|
||||
group_by_file: bool = True
|
||||
|
||||
def items_for_file(self, path: Path) -> list[TodoItem]:
|
||||
"""Get TODO items for a specific file."""
|
||||
return [item for item in self.items if item.file_path == path]
|
||||
|
||||
def items_by_type(self, todo_type: TodoType) -> list[TodoItem]:
|
||||
"""Get TODO items of a specific type."""
|
||||
return [item for item in self.items if item.todo_type == todo_type]
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Business logic services for Clide."""
|
||||
|
||||
from clide.services.git_service import GitService
|
||||
from clide.services.linter_service import LinterService
|
||||
from clide.services.process_service import ProcessService
|
||||
from clide.services.settings_service import SettingsService, UserSettings, get_settings_service
|
||||
from clide.services.todo_scanner import TodoScanner
|
||||
|
||||
__all__ = [
|
||||
"GitService",
|
||||
"LinterService",
|
||||
"ProcessService",
|
||||
"SettingsService",
|
||||
"TodoScanner",
|
||||
"UserSettings",
|
||||
"get_settings_service",
|
||||
]
|
||||
@@ -0,0 +1,124 @@
|
||||
"""File operations service."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class FileService:
|
||||
"""Service for file I/O operations."""
|
||||
|
||||
def __init__(self, project_path: Path) -> None:
|
||||
self.project_path = project_path
|
||||
|
||||
async def read_file(self, path: Path) -> str:
|
||||
"""Read file contents.
|
||||
|
||||
Args:
|
||||
path: Path to file (relative or absolute)
|
||||
|
||||
Returns:
|
||||
File contents as string
|
||||
"""
|
||||
full_path = self._resolve_path(path)
|
||||
return full_path.read_text(encoding="utf-8")
|
||||
|
||||
async def write_file(self, path: Path, content: str) -> None:
|
||||
"""Write content to file.
|
||||
|
||||
Args:
|
||||
path: Path to file
|
||||
content: Content to write
|
||||
"""
|
||||
full_path = self._resolve_path(path)
|
||||
full_path.write_text(content, encoding="utf-8")
|
||||
|
||||
async def file_exists(self, path: Path) -> bool:
|
||||
"""Check if file exists.
|
||||
|
||||
Args:
|
||||
path: Path to check
|
||||
|
||||
Returns:
|
||||
True if file exists
|
||||
"""
|
||||
full_path = self._resolve_path(path)
|
||||
return full_path.exists() and full_path.is_file()
|
||||
|
||||
async def get_language(self, path: Path) -> str | None:
|
||||
"""Detect language from file extension.
|
||||
|
||||
Args:
|
||||
path: File path
|
||||
|
||||
Returns:
|
||||
Language identifier or None
|
||||
"""
|
||||
extension_map = {
|
||||
".py": "python",
|
||||
".js": "javascript",
|
||||
".ts": "typescript",
|
||||
".jsx": "jsx",
|
||||
".tsx": "tsx",
|
||||
".html": "html",
|
||||
".css": "css",
|
||||
".scss": "scss",
|
||||
".json": "json",
|
||||
".yaml": "yaml",
|
||||
".yml": "yaml",
|
||||
".toml": "toml",
|
||||
".md": "markdown",
|
||||
".rs": "rust",
|
||||
".go": "go",
|
||||
".java": "java",
|
||||
".c": "c",
|
||||
".cpp": "cpp",
|
||||
".h": "c",
|
||||
".hpp": "cpp",
|
||||
".rb": "ruby",
|
||||
".php": "php",
|
||||
".sh": "bash",
|
||||
".bash": "bash",
|
||||
".sql": "sql",
|
||||
".xml": "xml",
|
||||
".vue": "vue",
|
||||
".svelte": "svelte",
|
||||
}
|
||||
return extension_map.get(path.suffix.lower())
|
||||
|
||||
def _resolve_path(self, path: Path) -> Path:
|
||||
"""Resolve path relative to project root.
|
||||
|
||||
Args:
|
||||
path: Path to resolve
|
||||
|
||||
Returns:
|
||||
Absolute path
|
||||
"""
|
||||
if path.is_absolute():
|
||||
return path
|
||||
return self.project_path / path
|
||||
|
||||
def list_directory(self, path: Path | None = None) -> list[Path]:
|
||||
"""List directory contents.
|
||||
|
||||
Args:
|
||||
path: Directory path (defaults to project root)
|
||||
|
||||
Returns:
|
||||
List of paths in directory
|
||||
"""
|
||||
dir_path = self._resolve_path(path) if path else self.project_path
|
||||
if not dir_path.is_dir():
|
||||
return []
|
||||
|
||||
entries = []
|
||||
for entry in sorted(dir_path.iterdir()):
|
||||
# Skip hidden files and common excludes
|
||||
if entry.name.startswith("."):
|
||||
continue
|
||||
if entry.name in ("__pycache__", "node_modules", ".git"):
|
||||
continue
|
||||
entries.append(entry)
|
||||
|
||||
# Sort: directories first, then files
|
||||
entries.sort(key=lambda p: (not p.is_dir(), p.name.lower()))
|
||||
return entries
|
||||
@@ -0,0 +1,256 @@
|
||||
"""Git operations service."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from clide.models.git import (
|
||||
ChangeStatus,
|
||||
GitBranch,
|
||||
GitChange,
|
||||
GitCommit,
|
||||
GitStatus,
|
||||
)
|
||||
from clide.services.process_service import ProcessService
|
||||
|
||||
|
||||
class GitService:
|
||||
"""Service for git operations."""
|
||||
|
||||
def __init__(self, repo_path: Path) -> None:
|
||||
self.repo_path = repo_path
|
||||
self._process = ProcessService(cwd=repo_path)
|
||||
|
||||
async def get_status(self) -> GitStatus:
|
||||
"""Get current git status.
|
||||
|
||||
Returns:
|
||||
GitStatus with staged/unstaged changes
|
||||
"""
|
||||
# Get porcelain status
|
||||
result = await self._process.run("git", "status", "--porcelain", "-z")
|
||||
|
||||
staged: list[GitChange] = []
|
||||
unstaged: list[GitChange] = []
|
||||
untracked: list[str] = []
|
||||
|
||||
if result.success and result.stdout:
|
||||
entries = result.stdout.split("\0")
|
||||
for entry in entries:
|
||||
if not entry or len(entry) < 3:
|
||||
continue
|
||||
|
||||
index_status = entry[0]
|
||||
worktree_status = entry[1]
|
||||
path = entry[3:]
|
||||
|
||||
# Parse status
|
||||
if index_status == "?":
|
||||
untracked.append(path)
|
||||
else:
|
||||
if index_status != " ":
|
||||
staged.append(GitChange(
|
||||
path=path,
|
||||
status=self._parse_status(index_status),
|
||||
staged=True,
|
||||
))
|
||||
if worktree_status != " ":
|
||||
unstaged.append(GitChange(
|
||||
path=path,
|
||||
status=self._parse_status(worktree_status),
|
||||
staged=False,
|
||||
))
|
||||
|
||||
# Get current branch
|
||||
branch_result = await self._process.run(
|
||||
"git", "branch", "--show-current"
|
||||
)
|
||||
branch = branch_result.stdout.strip() if branch_result.success else "HEAD"
|
||||
|
||||
# Get ahead/behind
|
||||
ahead, behind = await self._get_ahead_behind(branch)
|
||||
|
||||
return GitStatus(
|
||||
branch=branch,
|
||||
ahead=ahead,
|
||||
behind=behind,
|
||||
staged=tuple(staged),
|
||||
unstaged=tuple(unstaged),
|
||||
untracked=tuple(untracked),
|
||||
)
|
||||
|
||||
async def _get_ahead_behind(self, branch: str) -> tuple[int, int]:
|
||||
"""Get commits ahead/behind upstream."""
|
||||
result = await self._process.run(
|
||||
"git", "rev-list", "--left-right", "--count",
|
||||
f"{branch}...@{{upstream}}"
|
||||
)
|
||||
if result.success:
|
||||
parts = result.stdout.strip().split()
|
||||
if len(parts) == 2:
|
||||
return int(parts[0]), int(parts[1])
|
||||
return 0, 0
|
||||
|
||||
def _parse_status(self, char: str) -> ChangeStatus:
|
||||
"""Parse git status character to ChangeStatus."""
|
||||
mapping = {
|
||||
"A": ChangeStatus.ADDED,
|
||||
"M": ChangeStatus.MODIFIED,
|
||||
"D": ChangeStatus.DELETED,
|
||||
"R": ChangeStatus.RENAMED,
|
||||
"C": ChangeStatus.COPIED,
|
||||
"?": ChangeStatus.UNTRACKED,
|
||||
"!": ChangeStatus.IGNORED,
|
||||
"U": ChangeStatus.UNMERGED,
|
||||
}
|
||||
return mapping.get(char, ChangeStatus.MODIFIED)
|
||||
|
||||
async def stage_file(self, path: str) -> bool:
|
||||
"""Stage a file.
|
||||
|
||||
Args:
|
||||
path: File path to stage
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
result = await self._process.run("git", "add", path)
|
||||
return result.success
|
||||
|
||||
async def unstage_file(self, path: str) -> bool:
|
||||
"""Unstage a file.
|
||||
|
||||
Args:
|
||||
path: File path to unstage
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
result = await self._process.run("git", "restore", "--staged", path)
|
||||
return result.success
|
||||
|
||||
async def discard_changes(self, path: str) -> bool:
|
||||
"""Discard changes to a file.
|
||||
|
||||
Args:
|
||||
path: File path to discard
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
result = await self._process.run("git", "restore", path)
|
||||
return result.success
|
||||
|
||||
async def get_branches(self) -> list[GitBranch]:
|
||||
"""Get list of branches.
|
||||
|
||||
Returns:
|
||||
List of GitBranch objects
|
||||
"""
|
||||
result = await self._process.run(
|
||||
"git", "branch", "-a", "--format",
|
||||
"%(HEAD)%(refname:short)|%(upstream:short)|%(objectname:short)|%(subject)"
|
||||
)
|
||||
|
||||
branches: list[GitBranch] = []
|
||||
if result.success:
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
if not line:
|
||||
continue
|
||||
is_current = line.startswith("*")
|
||||
parts = line[1:].split("|")
|
||||
if len(parts) >= 4:
|
||||
name = parts[0].strip()
|
||||
branches.append(GitBranch(
|
||||
name=name,
|
||||
is_current=is_current,
|
||||
is_remote=name.startswith("remotes/"),
|
||||
tracking=parts[1] or None,
|
||||
commit_hash=parts[2],
|
||||
commit_message=parts[3],
|
||||
))
|
||||
|
||||
return branches
|
||||
|
||||
async def checkout_branch(self, branch: str) -> bool:
|
||||
"""Checkout a branch.
|
||||
|
||||
Args:
|
||||
branch: Branch name to checkout
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
result = await self._process.run("git", "checkout", branch)
|
||||
return result.success
|
||||
|
||||
async def create_branch(self, name: str, start_point: str | None = None) -> bool:
|
||||
"""Create a new branch.
|
||||
|
||||
Args:
|
||||
name: New branch name
|
||||
start_point: Optional starting commit/branch
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
args = ["git", "checkout", "-b", name]
|
||||
if start_point:
|
||||
args.append(start_point)
|
||||
result = await self._process.run(*args)
|
||||
return result.success
|
||||
|
||||
async def get_diff(self, path: str, staged: bool = False) -> str:
|
||||
"""Get diff for a file.
|
||||
|
||||
Args:
|
||||
path: File path
|
||||
staged: Whether to get staged diff
|
||||
|
||||
Returns:
|
||||
Diff output string
|
||||
"""
|
||||
args = ["git", "diff"]
|
||||
if staged:
|
||||
args.append("--cached")
|
||||
args.append("--")
|
||||
args.append(path)
|
||||
|
||||
result = await self._process.run(*args)
|
||||
return result.stdout if result.success else ""
|
||||
|
||||
async def get_log(self, max_count: int = 50) -> list[GitCommit]:
|
||||
"""Get commit log.
|
||||
|
||||
Args:
|
||||
max_count: Maximum number of commits to return
|
||||
|
||||
Returns:
|
||||
List of GitCommit objects
|
||||
"""
|
||||
result = await self._process.run(
|
||||
"git", "log",
|
||||
f"--max-count={max_count}",
|
||||
"--format=%H|%h|%s|%an|%ar|%P|%D",
|
||||
"--all",
|
||||
)
|
||||
|
||||
commits: list[GitCommit] = []
|
||||
if result.success:
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split("|")
|
||||
if len(parts) >= 7:
|
||||
parents = tuple(parts[5].split()) if parts[5] else ()
|
||||
refs = tuple(r.strip() for r in parts[6].split(",")) if parts[6] else ()
|
||||
commits.append(GitCommit(
|
||||
hash=parts[0],
|
||||
short_hash=parts[1],
|
||||
message=parts[2],
|
||||
author=parts[3],
|
||||
date=parts[4],
|
||||
is_merge=len(parents) > 1,
|
||||
parents=parents,
|
||||
refs=refs,
|
||||
))
|
||||
|
||||
return commits
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Linter integration service."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from clide.models.problems import Problem, ProblemsSummary, Severity
|
||||
from clide.services.process_service import ProcessService
|
||||
|
||||
|
||||
class LinterService:
|
||||
"""Service for running linters and parsing output."""
|
||||
|
||||
def __init__(self, project_path: Path) -> None:
|
||||
self.project_path = project_path
|
||||
self._process = ProcessService(cwd=project_path)
|
||||
|
||||
async def run_ruff(self) -> list[Problem]:
|
||||
"""Run ruff linter.
|
||||
|
||||
Returns:
|
||||
List of problems found
|
||||
"""
|
||||
result = await self._process.run(
|
||||
"ruff", "check", "--output-format=json", "."
|
||||
)
|
||||
|
||||
problems: list[Problem] = []
|
||||
if result.stdout:
|
||||
try:
|
||||
data = json.loads(result.stdout)
|
||||
for item in data:
|
||||
severity = self._ruff_severity(item.get("code", ""))
|
||||
problems.append(Problem(
|
||||
file_path=Path(item["filename"]),
|
||||
line=item["location"]["row"],
|
||||
column=item["location"]["column"],
|
||||
end_line=item.get("end_location", {}).get("row"),
|
||||
end_column=item.get("end_location", {}).get("column"),
|
||||
severity=severity,
|
||||
message=item["message"],
|
||||
source="ruff",
|
||||
code=item.get("code"),
|
||||
))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
return problems
|
||||
|
||||
def _ruff_severity(self, code: str) -> Severity:
|
||||
"""Map ruff code to severity."""
|
||||
if code.startswith("E") or code.startswith("F"):
|
||||
return Severity.ERROR
|
||||
if code.startswith("W"):
|
||||
return Severity.WARNING
|
||||
return Severity.INFO
|
||||
|
||||
async def run_mypy(self) -> list[Problem]:
|
||||
"""Run mypy type checker.
|
||||
|
||||
Returns:
|
||||
List of problems found
|
||||
"""
|
||||
result = await self._process.run(
|
||||
"mypy", "--output=json", "."
|
||||
)
|
||||
|
||||
problems: list[Problem] = []
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
data = json.loads(line)
|
||||
severity = self._mypy_severity(data.get("severity", "error"))
|
||||
problems.append(Problem(
|
||||
file_path=Path(data["file"]),
|
||||
line=data["line"],
|
||||
column=data.get("column", 1),
|
||||
severity=severity,
|
||||
message=data["message"],
|
||||
source="mypy",
|
||||
code=data.get("code"),
|
||||
))
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
continue
|
||||
|
||||
return problems
|
||||
|
||||
def _mypy_severity(self, severity: str) -> Severity:
|
||||
"""Map mypy severity to Severity enum."""
|
||||
mapping = {
|
||||
"error": Severity.ERROR,
|
||||
"warning": Severity.WARNING,
|
||||
"note": Severity.INFO,
|
||||
}
|
||||
return mapping.get(severity, Severity.ERROR)
|
||||
|
||||
async def run_all(self, linters: list[str]) -> tuple[list[Problem], ProblemsSummary]:
|
||||
"""Run all configured linters.
|
||||
|
||||
Args:
|
||||
linters: List of linter names to run
|
||||
|
||||
Returns:
|
||||
Tuple of (problems list, summary)
|
||||
"""
|
||||
all_problems: list[Problem] = []
|
||||
|
||||
for linter in linters:
|
||||
if linter == "ruff":
|
||||
all_problems.extend(await self.run_ruff())
|
||||
elif linter == "mypy":
|
||||
all_problems.extend(await self.run_mypy())
|
||||
|
||||
# Create summary
|
||||
errors = sum(1 for p in all_problems if p.severity == Severity.ERROR)
|
||||
warnings = sum(1 for p in all_problems if p.severity == Severity.WARNING)
|
||||
infos = sum(1 for p in all_problems if p.severity == Severity.INFO)
|
||||
hints = sum(1 for p in all_problems if p.severity == Severity.HINT)
|
||||
|
||||
summary = ProblemsSummary(
|
||||
errors=errors,
|
||||
warnings=warnings,
|
||||
infos=infos,
|
||||
hints=hints,
|
||||
)
|
||||
|
||||
return all_problems, summary
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Generic subprocess management service."""
|
||||
|
||||
import asyncio
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass
|
||||
class CommandResult:
|
||||
"""Result of a command execution."""
|
||||
|
||||
returncode: int
|
||||
stdout: str
|
||||
stderr: str
|
||||
|
||||
@property
|
||||
def success(self) -> bool:
|
||||
"""Check if command succeeded."""
|
||||
return self.returncode == 0
|
||||
|
||||
|
||||
class ProcessService:
|
||||
"""Service for running subprocess commands."""
|
||||
|
||||
def __init__(self, cwd: Path | None = None) -> None:
|
||||
self.cwd = cwd or Path.cwd()
|
||||
|
||||
async def run(
|
||||
self,
|
||||
*args: str,
|
||||
cwd: Path | None = None,
|
||||
timeout: float | None = 30.0,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> CommandResult:
|
||||
"""Run a command asynchronously.
|
||||
|
||||
Args:
|
||||
*args: Command and arguments
|
||||
cwd: Working directory (defaults to service cwd)
|
||||
timeout: Timeout in seconds
|
||||
env: Environment variables to add
|
||||
|
||||
Returns:
|
||||
CommandResult with stdout, stderr, and returncode
|
||||
"""
|
||||
working_dir = cwd or self.cwd
|
||||
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*args,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=working_dir,
|
||||
env=env,
|
||||
)
|
||||
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
process.communicate(),
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
return CommandResult(
|
||||
returncode=process.returncode or 0,
|
||||
stdout=stdout.decode("utf-8", errors="replace"),
|
||||
stderr=stderr.decode("utf-8", errors="replace"),
|
||||
)
|
||||
except TimeoutError:
|
||||
process.kill()
|
||||
return CommandResult(
|
||||
returncode=-1,
|
||||
stdout="",
|
||||
stderr="Command timed out",
|
||||
)
|
||||
except Exception as e:
|
||||
return CommandResult(
|
||||
returncode=-1,
|
||||
stdout="",
|
||||
stderr=str(e),
|
||||
)
|
||||
|
||||
def run_sync(
|
||||
self,
|
||||
*args: str,
|
||||
cwd: Path | None = None,
|
||||
timeout: float | None = 30.0,
|
||||
) -> CommandResult:
|
||||
"""Run a command synchronously (for use in threads).
|
||||
|
||||
Args:
|
||||
*args: Command and arguments
|
||||
cwd: Working directory
|
||||
timeout: Timeout in seconds
|
||||
|
||||
Returns:
|
||||
CommandResult with stdout, stderr, and returncode
|
||||
"""
|
||||
working_dir = cwd or self.cwd
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
args,
|
||||
capture_output=True,
|
||||
cwd=working_dir,
|
||||
timeout=timeout,
|
||||
text=True,
|
||||
)
|
||||
return CommandResult(
|
||||
returncode=result.returncode,
|
||||
stdout=result.stdout,
|
||||
stderr=result.stderr,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return CommandResult(
|
||||
returncode=-1,
|
||||
stdout="",
|
||||
stderr="Command timed out",
|
||||
)
|
||||
except Exception as e:
|
||||
return CommandResult(
|
||||
returncode=-1,
|
||||
stdout="",
|
||||
stderr=str(e),
|
||||
)
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Settings persistence service for user preferences."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class UserSettings(BaseModel):
|
||||
"""User settings that persist across sessions.
|
||||
|
||||
Stored in ~/.clide/settings.json
|
||||
"""
|
||||
|
||||
# Appearance
|
||||
theme: str = "summer-night"
|
||||
|
||||
# Panel state
|
||||
sidebar_visible: bool = True
|
||||
context_visible: bool = True
|
||||
|
||||
# Window
|
||||
compact_mode: bool = False
|
||||
|
||||
# Behavior
|
||||
auto_save: bool = True
|
||||
confirm_exit: bool = True
|
||||
|
||||
# Integrations
|
||||
jira_enabled: bool = False
|
||||
|
||||
|
||||
class SettingsService:
|
||||
"""Service for loading and saving user settings.
|
||||
|
||||
Settings are stored in ~/.clide/settings.json
|
||||
"""
|
||||
|
||||
def __init__(self, settings_dir: Path | None = None) -> None:
|
||||
"""Initialize the settings service.
|
||||
|
||||
Args:
|
||||
settings_dir: Override the settings directory (default: ~/.clide)
|
||||
"""
|
||||
self._settings_dir = settings_dir or Path.home() / ".clide"
|
||||
self._settings_file = self._settings_dir / "settings.json"
|
||||
self._settings: UserSettings | None = None
|
||||
|
||||
@property
|
||||
def settings_dir(self) -> Path:
|
||||
"""Get the settings directory path."""
|
||||
return self._settings_dir
|
||||
|
||||
@property
|
||||
def settings_file(self) -> Path:
|
||||
"""Get the settings file path."""
|
||||
return self._settings_file
|
||||
|
||||
def load(self) -> UserSettings:
|
||||
"""Load settings from disk, creating defaults if needed.
|
||||
|
||||
Returns:
|
||||
The loaded or default UserSettings
|
||||
"""
|
||||
if self._settings is not None:
|
||||
return self._settings
|
||||
|
||||
if self._settings_file.exists():
|
||||
try:
|
||||
data = json.loads(self._settings_file.read_text())
|
||||
self._settings = UserSettings.model_validate(data)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
# Invalid JSON or schema, use defaults
|
||||
self._settings = UserSettings()
|
||||
else:
|
||||
self._settings = UserSettings()
|
||||
|
||||
return self._settings
|
||||
|
||||
def save(self) -> None:
|
||||
"""Save current settings to disk."""
|
||||
if self._settings is None:
|
||||
return
|
||||
|
||||
# Ensure directory exists
|
||||
self._settings_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Write settings as formatted JSON
|
||||
data = self._settings.model_dump(mode="json")
|
||||
self._settings_file.write_text(
|
||||
json.dumps(data, indent=2, sort_keys=True) + "\n"
|
||||
)
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
"""Get a setting value.
|
||||
|
||||
Args:
|
||||
key: The setting key (attribute name)
|
||||
default: Default value if key doesn't exist
|
||||
|
||||
Returns:
|
||||
The setting value or default
|
||||
"""
|
||||
settings = self.load()
|
||||
return getattr(settings, key, default)
|
||||
|
||||
def set(self, key: str, value: Any, *, save: bool = True) -> None:
|
||||
"""Set a setting value.
|
||||
|
||||
Args:
|
||||
key: The setting key (attribute name)
|
||||
value: The value to set
|
||||
save: Whether to save immediately (default: True)
|
||||
"""
|
||||
settings = self.load()
|
||||
if hasattr(settings, key):
|
||||
# Create new settings with updated value
|
||||
data = settings.model_dump()
|
||||
data[key] = value
|
||||
self._settings = UserSettings.model_validate(data)
|
||||
|
||||
if save:
|
||||
self.save()
|
||||
|
||||
def update(self, **kwargs: Any) -> None:
|
||||
"""Update multiple settings at once.
|
||||
|
||||
Args:
|
||||
**kwargs: Key-value pairs to update
|
||||
"""
|
||||
settings = self.load()
|
||||
data = settings.model_dump()
|
||||
|
||||
for key, value in kwargs.items():
|
||||
if hasattr(settings, key):
|
||||
data[key] = value
|
||||
|
||||
self._settings = UserSettings.model_validate(data)
|
||||
self.save()
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset settings to defaults."""
|
||||
self._settings = UserSettings()
|
||||
self.save()
|
||||
|
||||
|
||||
# Global instance for convenience
|
||||
_settings_service: SettingsService | None = None
|
||||
|
||||
|
||||
def get_settings_service() -> SettingsService:
|
||||
"""Get the global settings service instance."""
|
||||
global _settings_service
|
||||
if _settings_service is None:
|
||||
_settings_service = SettingsService()
|
||||
return _settings_service
|
||||
@@ -0,0 +1,151 @@
|
||||
"""TODO comment scanner service."""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from clide.models.todos import TodoItem, TodosSummary, TodoType
|
||||
from clide.services.process_service import ProcessService
|
||||
|
||||
|
||||
class TodoScanner:
|
||||
"""Service for scanning TODO/FIXME comments in code."""
|
||||
|
||||
# Pattern to match TODO-style comments
|
||||
TODO_PATTERN = re.compile(
|
||||
r"(?:#|//|/\*|\*|<!--)\s*(TODO|FIXME|HACK|XXX|NOTE|BUG|OPTIMIZE|REVIEW)\s*:?\s*(.+?)(?:\*/|-->)?$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# File extensions to scan
|
||||
SCAN_EXTENSIONS = {
|
||||
".py", ".js", ".ts", ".jsx", ".tsx", ".java", ".c", ".cpp", ".h",
|
||||
".go", ".rs", ".rb", ".php", ".css", ".scss", ".html", ".vue",
|
||||
".svelte", ".md", ".sh", ".bash", ".yaml", ".yml", ".toml",
|
||||
}
|
||||
|
||||
def __init__(self, project_path: Path) -> None:
|
||||
self.project_path = project_path
|
||||
self._process = ProcessService(cwd=project_path)
|
||||
|
||||
async def scan(self) -> tuple[list[TodoItem], TodosSummary]:
|
||||
"""Scan project for TODO comments.
|
||||
|
||||
Returns:
|
||||
Tuple of (todo items, summary)
|
||||
"""
|
||||
items: list[TodoItem] = []
|
||||
|
||||
# Use ripgrep if available for speed
|
||||
result = await self._process.run(
|
||||
"rg", "--line-number", "--no-heading",
|
||||
"-e", r"\b(TODO|FIXME|HACK|XXX|NOTE|BUG|OPTIMIZE|REVIEW)\b",
|
||||
"--type-add", "code:*.py",
|
||||
"--type-add", "code:*.js",
|
||||
"--type-add", "code:*.ts",
|
||||
"--type", "code",
|
||||
".",
|
||||
)
|
||||
|
||||
if result.success:
|
||||
items = self._parse_ripgrep_output(result.stdout)
|
||||
else:
|
||||
# Fallback to Python-based scanning
|
||||
items = await self._scan_with_python()
|
||||
|
||||
# Create summary
|
||||
todo_count = sum(1 for i in items if i.todo_type == TodoType.TODO)
|
||||
fixme_count = sum(1 for i in items if i.todo_type == TodoType.FIXME)
|
||||
hack_count = sum(1 for i in items if i.todo_type == TodoType.HACK)
|
||||
other_count = len(items) - todo_count - fixme_count - hack_count
|
||||
|
||||
summary = TodosSummary(
|
||||
todo_count=todo_count,
|
||||
fixme_count=fixme_count,
|
||||
hack_count=hack_count,
|
||||
other_count=other_count,
|
||||
)
|
||||
|
||||
return items, summary
|
||||
|
||||
def _parse_ripgrep_output(self, output: str) -> list[TodoItem]:
|
||||
"""Parse ripgrep output into TodoItems."""
|
||||
items: list[TodoItem] = []
|
||||
|
||||
for line in output.strip().split("\n"):
|
||||
if not line:
|
||||
continue
|
||||
|
||||
# Format: path:line:content
|
||||
parts = line.split(":", 2)
|
||||
if len(parts) < 3:
|
||||
continue
|
||||
|
||||
file_path = Path(parts[0])
|
||||
try:
|
||||
line_num = int(parts[1])
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
content = parts[2]
|
||||
|
||||
# Parse the TODO type and text
|
||||
match = self.TODO_PATTERN.search(content)
|
||||
if match:
|
||||
todo_type_str = match.group(1).upper()
|
||||
todo_text = match.group(2).strip()
|
||||
|
||||
try:
|
||||
todo_type = TodoType(todo_type_str)
|
||||
except ValueError:
|
||||
todo_type = TodoType.TODO
|
||||
|
||||
items.append(TodoItem(
|
||||
file_path=file_path,
|
||||
line=line_num,
|
||||
column=content.find(todo_type_str) + 1,
|
||||
todo_type=todo_type,
|
||||
text=todo_text,
|
||||
context_line=content.strip(),
|
||||
))
|
||||
|
||||
return items
|
||||
|
||||
async def _scan_with_python(self) -> list[TodoItem]:
|
||||
"""Fallback Python-based scanning."""
|
||||
items: list[TodoItem] = []
|
||||
|
||||
for ext in self.SCAN_EXTENSIONS:
|
||||
for file_path in self.project_path.rglob(f"*{ext}"):
|
||||
# Skip hidden directories and common excludes
|
||||
if any(part.startswith(".") for part in file_path.parts):
|
||||
continue
|
||||
if "node_modules" in file_path.parts:
|
||||
continue
|
||||
if "__pycache__" in file_path.parts:
|
||||
continue
|
||||
|
||||
try:
|
||||
content = file_path.read_text(encoding="utf-8", errors="ignore")
|
||||
for line_num, line in enumerate(content.split("\n"), 1):
|
||||
match = self.TODO_PATTERN.search(line)
|
||||
if match:
|
||||
todo_type_str = match.group(1).upper()
|
||||
todo_text = match.group(2).strip()
|
||||
|
||||
try:
|
||||
todo_type = TodoType(todo_type_str)
|
||||
except ValueError:
|
||||
todo_type = TodoType.TODO
|
||||
|
||||
items.append(TodoItem(
|
||||
file_path=file_path.relative_to(self.project_path),
|
||||
line=line_num,
|
||||
column=line.find(todo_type_str) + 1,
|
||||
todo_type=todo_type,
|
||||
text=todo_text,
|
||||
context_line=line.strip(),
|
||||
))
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
return items
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Theme system for Clide."""
|
||||
|
||||
from clide.themes.registry import (
|
||||
DEFAULT_THEME,
|
||||
get_all_themes,
|
||||
get_theme,
|
||||
get_themes_by_category,
|
||||
register_theme,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"get_theme",
|
||||
"get_all_themes",
|
||||
"get_themes_by_category",
|
||||
"register_theme",
|
||||
"DEFAULT_THEME",
|
||||
]
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Built-in themes for Clide."""
|
||||
|
||||
from clide.themes.builtin import (
|
||||
all_hallows_eve,
|
||||
christmas,
|
||||
dark_autumn,
|
||||
dracula,
|
||||
fall,
|
||||
gamma,
|
||||
gruvbox_dark,
|
||||
gruvbox_light,
|
||||
hacker_style,
|
||||
halloween,
|
||||
houston,
|
||||
monokai_winter,
|
||||
nord,
|
||||
one_dark,
|
||||
one_dark_pro,
|
||||
one_dark_teal,
|
||||
one_light,
|
||||
pro_hacker,
|
||||
santa_baby,
|
||||
summer_day,
|
||||
summer_night,
|
||||
winter_is_coming,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"summer_night",
|
||||
"summer_day",
|
||||
"one_dark",
|
||||
"one_dark_pro",
|
||||
"one_light",
|
||||
"dracula",
|
||||
"nord",
|
||||
"gruvbox_dark",
|
||||
"gruvbox_light",
|
||||
"one_dark_teal",
|
||||
"gamma",
|
||||
"winter_is_coming",
|
||||
"monokai_winter",
|
||||
"fall",
|
||||
"dark_autumn",
|
||||
"all_hallows_eve",
|
||||
"halloween",
|
||||
"christmas",
|
||||
"santa_baby",
|
||||
"pro_hacker",
|
||||
"hacker_style",
|
||||
"houston",
|
||||
]
|
||||
@@ -0,0 +1,21 @@
|
||||
"""All Hallows' Eve Plus theme - Halloween."""
|
||||
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition
|
||||
|
||||
theme = ThemeDefinition(
|
||||
name="all-hallows-eve",
|
||||
display_name="All Hallows' Eve",
|
||||
dark=True,
|
||||
colors=ThemeColors(
|
||||
primary="#ff7518", # Pumpkin orange
|
||||
secondary="#9932cc", # Dark orchid
|
||||
accent="#ff6347",
|
||||
background="#1a0a1a",
|
||||
surface="#2d1a2d",
|
||||
panel="#401a40",
|
||||
foreground="#dda0dd",
|
||||
success="#32cd32",
|
||||
warning="#ff7518",
|
||||
error="#dc143c",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Christmas theme."""
|
||||
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition
|
||||
|
||||
theme = ThemeDefinition(
|
||||
name="christmas",
|
||||
display_name="Christmas",
|
||||
dark=True,
|
||||
colors=ThemeColors(
|
||||
primary="#ff0000", # Christmas red
|
||||
secondary="#228b22", # Forest green
|
||||
accent="#ffd700", # Gold
|
||||
background="#0a1a0a",
|
||||
surface="#1a2a1a",
|
||||
panel="#2a3a2a",
|
||||
foreground="#f0f0f0",
|
||||
success="#228b22",
|
||||
warning="#ffd700",
|
||||
error="#ff0000",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Dark Autumn Frost theme."""
|
||||
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition
|
||||
|
||||
theme = ThemeDefinition(
|
||||
name="dark-autumn",
|
||||
display_name="Dark Autumn Frost",
|
||||
dark=True,
|
||||
colors=ThemeColors(
|
||||
primary="#c49a6c",
|
||||
secondary="#8b7355",
|
||||
accent="#a0522d",
|
||||
background="#1c1410",
|
||||
surface="#2a1f18",
|
||||
panel="#382a20",
|
||||
foreground="#d2b48c",
|
||||
success="#6b8e23",
|
||||
warning="#b8860b",
|
||||
error="#8b0000",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Dracula theme."""
|
||||
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition
|
||||
|
||||
theme = ThemeDefinition(
|
||||
name="dracula",
|
||||
display_name="Dracula",
|
||||
dark=True,
|
||||
colors=ThemeColors(
|
||||
primary="#bd93f9",
|
||||
secondary="#8be9fd",
|
||||
accent="#ff79c6",
|
||||
background="#282a36",
|
||||
surface="#21222c",
|
||||
panel="#343746",
|
||||
foreground="#f8f8f2",
|
||||
success="#50fa7b",
|
||||
warning="#ffb86c",
|
||||
error="#ff5555",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Fall theme - Autumn colors."""
|
||||
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition
|
||||
|
||||
theme = ThemeDefinition(
|
||||
name="fall",
|
||||
display_name="Fall",
|
||||
dark=True,
|
||||
colors=ThemeColors(
|
||||
primary="#e9967a",
|
||||
secondary="#daa520",
|
||||
accent="#cd853f",
|
||||
background="#2d1f1f",
|
||||
surface="#3d2929",
|
||||
panel="#4d3333",
|
||||
foreground="#f5deb3",
|
||||
success="#8fbc8f",
|
||||
warning="#daa520",
|
||||
error="#cd5c5c",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Gamma theme - GitKraken Gamma style."""
|
||||
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition
|
||||
|
||||
theme = ThemeDefinition(
|
||||
name="gamma",
|
||||
display_name="Gamma",
|
||||
dark=True,
|
||||
colors=ThemeColors(
|
||||
primary="#00d4aa",
|
||||
secondary="#7c3aed",
|
||||
accent="#f472b6",
|
||||
background="#0f172a",
|
||||
surface="#1e293b",
|
||||
panel="#334155",
|
||||
foreground="#e2e8f0",
|
||||
success="#22c55e",
|
||||
warning="#f59e0b",
|
||||
error="#ef4444",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Gruvbox Dark theme - Retro groove color scheme."""
|
||||
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition
|
||||
|
||||
theme = ThemeDefinition(
|
||||
name="gruvbox-dark",
|
||||
display_name="Gruvbox Dark",
|
||||
dark=True,
|
||||
colors=ThemeColors(
|
||||
primary="#83a598",
|
||||
secondary="#8ec07c",
|
||||
accent="#d3869b",
|
||||
background="#282828",
|
||||
surface="#3c3836",
|
||||
panel="#504945",
|
||||
foreground="#ebdbb2",
|
||||
success="#b8bb26",
|
||||
warning="#fabd2f",
|
||||
error="#fb4934",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Gruvbox Light theme."""
|
||||
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition
|
||||
|
||||
theme = ThemeDefinition(
|
||||
name="gruvbox-light",
|
||||
display_name="Gruvbox Light",
|
||||
dark=False,
|
||||
colors=ThemeColors(
|
||||
primary="#076678",
|
||||
secondary="#427b58",
|
||||
accent="#8f3f71",
|
||||
background="#fbf1c7",
|
||||
surface="#ebdbb2",
|
||||
panel="#d5c4a1",
|
||||
foreground="#3c3836",
|
||||
success="#79740e",
|
||||
warning="#b57614",
|
||||
error="#9d0006",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Hacker Style theme - Matrix inspired."""
|
||||
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition
|
||||
|
||||
theme = ThemeDefinition(
|
||||
name="hacker-style",
|
||||
display_name="Hacker Style",
|
||||
dark=True,
|
||||
colors=ThemeColors(
|
||||
primary="#20c20e",
|
||||
secondary="#33ff33",
|
||||
accent="#66ff66",
|
||||
background="#0c0c0c",
|
||||
surface="#121212",
|
||||
panel="#1a1a1a",
|
||||
foreground="#33ff33",
|
||||
success="#20c20e",
|
||||
warning="#c0c020",
|
||||
error="#c02020",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Halloween theme."""
|
||||
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition
|
||||
|
||||
theme = ThemeDefinition(
|
||||
name="halloween",
|
||||
display_name="Halloween",
|
||||
dark=True,
|
||||
colors=ThemeColors(
|
||||
primary="#ff6600",
|
||||
secondary="#8a2be2",
|
||||
accent="#ff4500",
|
||||
background="#0d0d0d",
|
||||
surface="#1a1a1a",
|
||||
panel="#262626",
|
||||
foreground="#e6e6e6",
|
||||
success="#00ff00",
|
||||
warning="#ff6600",
|
||||
error="#ff0000",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Houston theme."""
|
||||
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition
|
||||
|
||||
theme = ThemeDefinition(
|
||||
name="houston",
|
||||
display_name="Houston",
|
||||
dark=True,
|
||||
colors=ThemeColors(
|
||||
primary="#ff6f00",
|
||||
secondary="#00bcd4",
|
||||
accent="#ff4081",
|
||||
background="#17212b",
|
||||
surface="#232e3c",
|
||||
panel="#2e3a48",
|
||||
foreground="#eeffff",
|
||||
success="#4caf50",
|
||||
warning="#ff9800",
|
||||
error="#f44336",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Monokai Winter Night theme."""
|
||||
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition
|
||||
|
||||
theme = ThemeDefinition(
|
||||
name="monokai-winter",
|
||||
display_name="Monokai Winter Night",
|
||||
dark=True,
|
||||
colors=ThemeColors(
|
||||
primary="#66d9ef",
|
||||
secondary="#a6e22e",
|
||||
accent="#f92672",
|
||||
background="#1a1a2e",
|
||||
surface="#16213e",
|
||||
panel="#0f3460",
|
||||
foreground="#f8f8f2",
|
||||
success="#a6e22e",
|
||||
warning="#e6db74",
|
||||
error="#f92672",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Nord theme - Arctic, north-bluish color palette."""
|
||||
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition
|
||||
|
||||
theme = ThemeDefinition(
|
||||
name="nord",
|
||||
display_name="Nord",
|
||||
dark=True,
|
||||
colors=ThemeColors(
|
||||
primary="#88c0d0",
|
||||
secondary="#81a1c1",
|
||||
accent="#b48ead",
|
||||
background="#2e3440",
|
||||
surface="#3b4252",
|
||||
panel="#434c5e",
|
||||
foreground="#eceff4",
|
||||
success="#a3be8c",
|
||||
warning="#ebcb8b",
|
||||
error="#bf616a",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Atom One Dark theme."""
|
||||
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition
|
||||
|
||||
theme = ThemeDefinition(
|
||||
name="one-dark",
|
||||
display_name="One Dark",
|
||||
dark=True,
|
||||
colors=ThemeColors(
|
||||
primary="#61afef",
|
||||
secondary="#56b6c2",
|
||||
accent="#c678dd",
|
||||
background="#282c34",
|
||||
surface="#21252b",
|
||||
panel="#2c313a",
|
||||
foreground="#abb2bf",
|
||||
success="#98c379",
|
||||
warning="#e5c07b",
|
||||
error="#e06c75",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""One Dark Pro theme."""
|
||||
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition
|
||||
|
||||
theme = ThemeDefinition(
|
||||
name="one-dark-pro",
|
||||
display_name="One Dark Pro",
|
||||
dark=True,
|
||||
colors=ThemeColors(
|
||||
primary="#61afef",
|
||||
secondary="#56b6c2",
|
||||
accent="#c678dd",
|
||||
background="#282c34",
|
||||
surface="#1e2227",
|
||||
panel="#333842",
|
||||
foreground="#abb2bf",
|
||||
success="#98c379",
|
||||
warning="#d19a66",
|
||||
error="#e06c75",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""One Dark Teal theme - GitKraken signature teal accent."""
|
||||
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition
|
||||
|
||||
theme = ThemeDefinition(
|
||||
name="one-dark-teal",
|
||||
display_name="One Dark Teal",
|
||||
dark=True,
|
||||
colors=ThemeColors(
|
||||
primary="#2acf9f", # GitKraken teal
|
||||
secondary="#61afef",
|
||||
accent="#c678dd",
|
||||
background="#282c34",
|
||||
surface="#21252b",
|
||||
panel="#2c313a",
|
||||
foreground="#abb2bf",
|
||||
success="#2acf9f",
|
||||
warning="#e5c07b",
|
||||
error="#e06c75",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Atom One Light theme."""
|
||||
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition
|
||||
|
||||
theme = ThemeDefinition(
|
||||
name="one-light",
|
||||
display_name="One Light",
|
||||
dark=False,
|
||||
colors=ThemeColors(
|
||||
primary="#4078f2",
|
||||
secondary="#0184bc",
|
||||
accent="#a626a4",
|
||||
background="#fafafa",
|
||||
surface="#f0f0f0",
|
||||
panel="#e5e5e6",
|
||||
foreground="#383a42",
|
||||
success="#50a14f",
|
||||
warning="#c18401",
|
||||
error="#e45649",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Pro Hacker theme - Green on black."""
|
||||
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition
|
||||
|
||||
theme = ThemeDefinition(
|
||||
name="pro-hacker",
|
||||
display_name="Pro Hacker",
|
||||
dark=True,
|
||||
colors=ThemeColors(
|
||||
primary="#00ff00",
|
||||
secondary="#00cc00",
|
||||
accent="#00ff88",
|
||||
background="#000000",
|
||||
surface="#0a0a0a",
|
||||
panel="#141414",
|
||||
foreground="#00ff00",
|
||||
success="#00ff00",
|
||||
warning="#ffff00",
|
||||
error="#ff0000",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Santa Baby theme - Light Christmas theme."""
|
||||
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition
|
||||
|
||||
theme = ThemeDefinition(
|
||||
name="santa-baby",
|
||||
display_name="Santa Baby",
|
||||
dark=False,
|
||||
colors=ThemeColors(
|
||||
primary="#c41e3a", # Cardinal red
|
||||
secondary="#228b22", # Forest green
|
||||
accent="#b8860b", # Dark goldenrod
|
||||
background="#fff8f0",
|
||||
surface="#f0e8e0",
|
||||
panel="#e0d8d0",
|
||||
foreground="#2f1f1f",
|
||||
success="#228b22",
|
||||
warning="#daa520",
|
||||
error="#c41e3a",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Summer Day theme - Light variant of Summer Night.
|
||||
|
||||
Inverted lightness scale with adjusted accent hues for readability.
|
||||
"""
|
||||
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition
|
||||
|
||||
theme = ThemeDefinition(
|
||||
name="summer-day",
|
||||
display_name="Summer Day",
|
||||
dark=False,
|
||||
colors=ThemeColors(
|
||||
primary="#0088b0",
|
||||
secondary="#008a99",
|
||||
accent="#d03060",
|
||||
background="#f5f7fa",
|
||||
surface="#e8ebf0",
|
||||
panel="#dde1e8",
|
||||
foreground="#21262f",
|
||||
success="#008a7a",
|
||||
warning="#b06830",
|
||||
error="#c04048",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Summer Night theme - Default dark theme.
|
||||
|
||||
Based on jackw01/summer-night-vscode-theme.
|
||||
Vibrant colors with HCL-based monochrome scale.
|
||||
"""
|
||||
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition
|
||||
|
||||
# Monochrome scale (HCL equidistant lightness)
|
||||
# mono_1: #e2e8f5 - Lightest text
|
||||
# mono_2: #c4c9d6 - Secondary text
|
||||
# mono_3: #a6abb8 - Muted text
|
||||
# mono_4: #898e9a - Comments
|
||||
# mono_5: #6d727e - Subtle
|
||||
# mono_6: #525762 - Borders
|
||||
# mono_7: #393e48 - Surface
|
||||
# mono_8: #21262f - Background
|
||||
|
||||
# Accent colors (HCL analogous scales)
|
||||
# cyan: #00a3d2 - Primary accent
|
||||
# teal: #00a9b9 - Links
|
||||
# pink: #fa5f8b - Keywords
|
||||
# yellow: #d3ab58 - Strings
|
||||
# red: #f06c6f - Errors
|
||||
# orange: #d08447 - Warnings
|
||||
# coral: #e17954 - Functions
|
||||
# green: #00ab9a - Success
|
||||
|
||||
theme = ThemeDefinition(
|
||||
name="summer-night",
|
||||
display_name="Summer Night",
|
||||
dark=True,
|
||||
colors=ThemeColors(
|
||||
primary="#00a3d2",
|
||||
secondary="#00a9b9",
|
||||
accent="#fa5f8b",
|
||||
background="#21262f",
|
||||
surface="#393e48",
|
||||
panel="#292e38",
|
||||
foreground="#e2e8f5",
|
||||
success="#00ab9a",
|
||||
warning="#d08447",
|
||||
error="#f06c6f",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Winter Is Coming theme - Bluish, icy vibe."""
|
||||
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition
|
||||
|
||||
theme = ThemeDefinition(
|
||||
name="winter-is-coming",
|
||||
display_name="Winter Is Coming",
|
||||
dark=True,
|
||||
colors=ThemeColors(
|
||||
primary="#89ddff",
|
||||
secondary="#82aaff",
|
||||
accent="#c792ea",
|
||||
background="#011627",
|
||||
surface="#0d293e",
|
||||
panel="#1d3b53",
|
||||
foreground="#d6deeb",
|
||||
success="#22da6e",
|
||||
warning="#ecc48d",
|
||||
error="#ef5350",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Custom theme loader for user-defined themes."""
|
||||
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition
|
||||
from clide.themes.registry import register_theme
|
||||
|
||||
|
||||
def load_custom_themes(themes_dir: Path) -> list[str]:
|
||||
"""Load custom themes from a directory.
|
||||
|
||||
Args:
|
||||
themes_dir: Directory containing .toml theme files
|
||||
|
||||
Returns:
|
||||
List of loaded theme names
|
||||
"""
|
||||
loaded = []
|
||||
|
||||
if not themes_dir.exists():
|
||||
return loaded
|
||||
|
||||
for theme_file in themes_dir.glob("*.toml"):
|
||||
try:
|
||||
theme = load_theme_file(theme_file)
|
||||
if theme:
|
||||
register_theme(theme, "custom")
|
||||
loaded.append(theme.name)
|
||||
except Exception as e:
|
||||
# Log but don't crash on bad theme files
|
||||
print(f"Failed to load theme {theme_file}: {e}")
|
||||
|
||||
return loaded
|
||||
|
||||
|
||||
def load_theme_file(path: Path) -> ThemeDefinition | None:
|
||||
"""Load a single theme from a TOML file.
|
||||
|
||||
Args:
|
||||
path: Path to the theme TOML file
|
||||
|
||||
Returns:
|
||||
Theme definition or None if invalid
|
||||
|
||||
Example TOML format:
|
||||
name = "my-theme"
|
||||
display_name = "My Custom Theme"
|
||||
dark = true
|
||||
|
||||
[colors]
|
||||
primary = "#007acc"
|
||||
secondary = "#3c3c3c"
|
||||
accent = "#0e639c"
|
||||
background = "#1e1e1e"
|
||||
surface = "#252526"
|
||||
panel = "#2d2d2d"
|
||||
foreground = "#d4d4d4"
|
||||
success = "#4ec9b0"
|
||||
warning = "#dcdcaa"
|
||||
error = "#f14c4c"
|
||||
"""
|
||||
with open(path, "rb") as f:
|
||||
data = tomllib.load(f)
|
||||
|
||||
# Validate required fields
|
||||
required = ["name", "display_name", "dark", "colors"]
|
||||
for field in required:
|
||||
if field not in data:
|
||||
raise ValueError(f"Missing required field: {field}")
|
||||
|
||||
colors_data = data["colors"]
|
||||
color_fields = [
|
||||
"primary", "secondary", "accent", "background", "surface",
|
||||
"panel", "foreground", "success", "warning", "error"
|
||||
]
|
||||
for field in color_fields:
|
||||
if field not in colors_data:
|
||||
raise ValueError(f"Missing color field: {field}")
|
||||
|
||||
colors = ThemeColors(**colors_data)
|
||||
return ThemeDefinition(
|
||||
name=data["name"],
|
||||
display_name=data["display_name"],
|
||||
dark=data["dark"],
|
||||
colors=colors,
|
||||
)
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Theme registry for managing available themes."""
|
||||
|
||||
|
||||
from clide.models.theme import ThemeDefinition, ThemeMetadata
|
||||
|
||||
# Theme registry
|
||||
_themes: dict[str, ThemeDefinition] = {}
|
||||
_theme_metadata: dict[str, ThemeMetadata] = {}
|
||||
|
||||
DEFAULT_THEME = "summer-night"
|
||||
|
||||
|
||||
def register_theme(
|
||||
theme: ThemeDefinition,
|
||||
category: str = "custom",
|
||||
) -> None:
|
||||
"""Register a theme in the registry.
|
||||
|
||||
Args:
|
||||
theme: Theme definition to register
|
||||
category: Theme category (core, popular, seasonal, custom)
|
||||
"""
|
||||
_themes[theme.name] = theme
|
||||
_theme_metadata[theme.name] = ThemeMetadata(
|
||||
name=theme.name,
|
||||
display_name=theme.display_name,
|
||||
dark=theme.dark,
|
||||
category=category,
|
||||
)
|
||||
|
||||
|
||||
def get_theme(name: str) -> ThemeDefinition | None:
|
||||
"""Get a theme by name.
|
||||
|
||||
Args:
|
||||
name: Theme name
|
||||
|
||||
Returns:
|
||||
Theme definition or None if not found
|
||||
"""
|
||||
return _themes.get(name)
|
||||
|
||||
|
||||
def get_all_themes() -> list[ThemeMetadata]:
|
||||
"""Get metadata for all registered themes.
|
||||
|
||||
Returns:
|
||||
List of theme metadata sorted by category then name
|
||||
"""
|
||||
themes = list(_theme_metadata.values())
|
||||
# Sort: core first, then alphabetically by category, then by name
|
||||
category_order = {"core": 0, "popular": 1, "gitkraken": 2, "seasonal": 3, "hacker": 4, "custom": 5}
|
||||
themes.sort(key=lambda t: (category_order.get(t.category, 99), t.name))
|
||||
return themes
|
||||
|
||||
|
||||
def get_themes_by_category(category: str) -> list[ThemeMetadata]:
|
||||
"""Get themes filtered by category.
|
||||
|
||||
Args:
|
||||
category: Category to filter by
|
||||
|
||||
Returns:
|
||||
List of theme metadata in that category
|
||||
"""
|
||||
return [t for t in _theme_metadata.values() if t.category == category]
|
||||
|
||||
|
||||
def _load_builtin_themes() -> None:
|
||||
"""Load all built-in themes."""
|
||||
# Import here to avoid circular imports
|
||||
from clide.themes.builtin import (
|
||||
all_hallows_eve,
|
||||
christmas,
|
||||
dark_autumn,
|
||||
dracula,
|
||||
fall,
|
||||
gamma,
|
||||
gruvbox_dark,
|
||||
gruvbox_light,
|
||||
hacker_style,
|
||||
halloween,
|
||||
houston,
|
||||
monokai_winter,
|
||||
nord,
|
||||
one_dark,
|
||||
one_dark_pro,
|
||||
one_dark_teal,
|
||||
one_light,
|
||||
pro_hacker,
|
||||
santa_baby,
|
||||
summer_day,
|
||||
summer_night,
|
||||
winter_is_coming,
|
||||
)
|
||||
|
||||
# Core themes
|
||||
register_theme(summer_night.theme, "core")
|
||||
register_theme(summer_day.theme, "core")
|
||||
|
||||
# Popular themes
|
||||
register_theme(one_dark.theme, "popular")
|
||||
register_theme(one_dark_pro.theme, "popular")
|
||||
register_theme(one_light.theme, "popular")
|
||||
register_theme(dracula.theme, "popular")
|
||||
register_theme(nord.theme, "popular")
|
||||
register_theme(gruvbox_dark.theme, "popular")
|
||||
register_theme(gruvbox_light.theme, "popular")
|
||||
|
||||
# GitKraken style
|
||||
register_theme(one_dark_teal.theme, "gitkraken")
|
||||
register_theme(gamma.theme, "gitkraken")
|
||||
|
||||
# Seasonal - Winter
|
||||
register_theme(winter_is_coming.theme, "seasonal")
|
||||
register_theme(monokai_winter.theme, "seasonal")
|
||||
|
||||
# Seasonal - Fall
|
||||
register_theme(fall.theme, "seasonal")
|
||||
register_theme(dark_autumn.theme, "seasonal")
|
||||
|
||||
# Seasonal - Halloween
|
||||
register_theme(all_hallows_eve.theme, "seasonal")
|
||||
register_theme(halloween.theme, "seasonal")
|
||||
|
||||
# Seasonal - Christmas
|
||||
register_theme(christmas.theme, "seasonal")
|
||||
register_theme(santa_baby.theme, "seasonal")
|
||||
|
||||
# Hacker style
|
||||
register_theme(pro_hacker.theme, "hacker")
|
||||
register_theme(hacker_style.theme, "hacker")
|
||||
|
||||
# Bonus
|
||||
register_theme(houston.theme, "popular")
|
||||
|
||||
|
||||
# Load built-in themes on module import
|
||||
_load_builtin_themes()
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Textual widgets for Clide UI."""
|
||||
|
||||
from clide.widgets.components.branch_status import BranchStatus
|
||||
from clide.widgets.components.diff_pane import DiffPane
|
||||
from clide.widgets.components.editor_pane import EditorPane
|
||||
from clide.widgets.components.files_view import FilesView
|
||||
from clide.widgets.components.git_changes import GitChangesView
|
||||
from clide.widgets.components.git_graph import GitGraphView
|
||||
from clide.widgets.components.jira_view import JiraView
|
||||
from clide.widgets.components.problems_view import ProblemsView
|
||||
from clide.widgets.components.terminal_pane import TerminalPane
|
||||
from clide.widgets.components.todos_view import TodosView
|
||||
|
||||
__all__ = [
|
||||
"BranchStatus",
|
||||
"DiffPane",
|
||||
"EditorPane",
|
||||
"FilesView",
|
||||
"GitChangesView",
|
||||
"GitGraphView",
|
||||
"JiraView",
|
||||
"ProblemsView",
|
||||
"TerminalPane",
|
||||
"TodosView",
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Reusable UI components for Clide."""
|
||||
|
||||
from clide.widgets.components.branch_status import BranchStatus
|
||||
from clide.widgets.components.diff_pane import DiffPane
|
||||
from clide.widgets.components.editor_pane import EditorPane
|
||||
from clide.widgets.components.files_view import FilesView
|
||||
from clide.widgets.components.git_changes import GitChangesView
|
||||
from clide.widgets.components.git_graph import GitGraphView
|
||||
from clide.widgets.components.jira_view import JiraView
|
||||
from clide.widgets.components.problems_view import ProblemsView
|
||||
from clide.widgets.components.terminal_pane import TerminalPane
|
||||
from clide.widgets.components.todos_view import TodosView
|
||||
|
||||
__all__ = [
|
||||
"BranchStatus",
|
||||
"DiffPane",
|
||||
"EditorPane",
|
||||
"FilesView",
|
||||
"GitChangesView",
|
||||
"GitGraphView",
|
||||
"JiraView",
|
||||
"ProblemsView",
|
||||
"TerminalPane",
|
||||
"TodosView",
|
||||
]
|
||||
@@ -0,0 +1,161 @@
|
||||
"""Branch status bar component."""
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Horizontal, Vertical
|
||||
from textual.message import Message
|
||||
from textual.widgets import Button, Label, ListItem, ListView, Static
|
||||
|
||||
from clide.models.git import GitBranch
|
||||
|
||||
|
||||
class BranchStatus(Vertical):
|
||||
"""Branch status bar with popout branch selector."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
BranchStatus {
|
||||
height: auto;
|
||||
dock: bottom;
|
||||
}
|
||||
|
||||
BranchStatus .status-bar {
|
||||
height: 1;
|
||||
background: $surface;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
BranchStatus .branch-icon {
|
||||
width: 2;
|
||||
}
|
||||
|
||||
BranchStatus .branch-name {
|
||||
width: 1fr;
|
||||
}
|
||||
|
||||
BranchStatus .popout {
|
||||
display: none;
|
||||
height: auto;
|
||||
max-height: 15;
|
||||
background: $panel;
|
||||
border: solid $primary;
|
||||
layer: popout;
|
||||
}
|
||||
|
||||
BranchStatus .popout.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
BranchStatus .popout-header {
|
||||
background: $surface;
|
||||
padding: 0 1;
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
BranchStatus .popout-actions {
|
||||
height: 1;
|
||||
padding: 0 1;
|
||||
}
|
||||
"""
|
||||
|
||||
class BranchChanged(Message):
|
||||
"""Emitted when branch is changed."""
|
||||
|
||||
def __init__(self, branch: str) -> None:
|
||||
self.branch = branch
|
||||
super().__init__()
|
||||
|
||||
# Alias for backwards compatibility
|
||||
BranchChangeRequested = BranchChanged
|
||||
|
||||
class NewBranchRequested(Message):
|
||||
"""Emitted when new branch creation is requested."""
|
||||
pass
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
current_branch: str = "main",
|
||||
branches: list[GitBranch] | None = None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._current = current_branch
|
||||
self._branches = branches or []
|
||||
self._popout_visible = False
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Horizontal(classes="status-bar"):
|
||||
yield Static("⎇", classes="branch-icon")
|
||||
yield Static(self._current, classes="branch-name", id="branch-name")
|
||||
yield Static("▾", classes="toggle-icon")
|
||||
|
||||
with Vertical(classes="popout", id="branch-popout"):
|
||||
yield Label("Recent branches", classes="popout-header")
|
||||
yield ListView(
|
||||
*[ListItem(Label(b.name)) for b in self._branches[:5]],
|
||||
id="branch-list",
|
||||
)
|
||||
with Horizontal(classes="popout-actions"):
|
||||
yield Button("Checkout", id="btn-checkout", variant="primary")
|
||||
yield Button("New", id="btn-new")
|
||||
|
||||
@property
|
||||
def branch(self) -> str:
|
||||
"""Get current branch."""
|
||||
return self._current
|
||||
|
||||
@branch.setter
|
||||
def branch(self, value: str) -> None:
|
||||
"""Set current branch."""
|
||||
self._current = value
|
||||
try:
|
||||
self.query_one("#branch-name", Static).update(value)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def update_branch(self, branch: str) -> None:
|
||||
"""Update current branch display."""
|
||||
self.branch = branch
|
||||
|
||||
def update_branches(self, branches: list[GitBranch] | list[str]) -> None:
|
||||
"""Update branches list."""
|
||||
self._branches = branches # type: ignore
|
||||
try:
|
||||
branch_list = self.query_one("#branch-list", ListView)
|
||||
branch_list.clear()
|
||||
for branch in branches[:5]:
|
||||
if isinstance(branch, str):
|
||||
name = branch
|
||||
is_current = name == self._current
|
||||
else:
|
||||
name = branch.name
|
||||
is_current = branch.is_current
|
||||
marker = "● " if is_current else "○ "
|
||||
branch_list.append(ListItem(Label(f"{marker}{name}")))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def toggle_popout(self) -> None:
|
||||
"""Toggle popout visibility."""
|
||||
self._popout_visible = not self._popout_visible
|
||||
popout = self.query_one("#branch-popout")
|
||||
if self._popout_visible:
|
||||
popout.add_class("visible")
|
||||
else:
|
||||
popout.remove_class("visible")
|
||||
|
||||
def on_click(self) -> None:
|
||||
"""Handle click on status bar."""
|
||||
self.toggle_popout()
|
||||
|
||||
def on_button_pressed(self, _event: Button.Pressed) -> None:
|
||||
"""Handle button presses."""
|
||||
if event.button.id == "btn-checkout":
|
||||
branch_list = self.query_one("#branch-list", ListView)
|
||||
if branch_list.highlighted_child:
|
||||
# Get selected branch name
|
||||
label = branch_list.highlighted_child.query_one(Label)
|
||||
branch = label.renderable.plain.lstrip("● ○ ")
|
||||
self.post_message(self.BranchChangeRequested(branch))
|
||||
self.toggle_popout()
|
||||
elif event.button.id == "btn-new":
|
||||
self.post_message(self.NewBranchRequested())
|
||||
self.toggle_popout()
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Diff pane component for viewing diffs."""
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Horizontal, Vertical
|
||||
from textual.message import Message
|
||||
from textual.widgets import Button, RichLog, Static
|
||||
|
||||
from clide.models.diff import ChangeType, DiffContent
|
||||
|
||||
|
||||
class DiffPane(Vertical):
|
||||
"""Diff viewer pane with accept/reject for proposals."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
DiffPane {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
DiffPane .diff-header {
|
||||
height: 1;
|
||||
background: $surface;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
DiffPane .diff-content {
|
||||
height: 1fr;
|
||||
}
|
||||
|
||||
DiffPane .diff-actions {
|
||||
height: auto;
|
||||
padding: 1;
|
||||
background: $panel;
|
||||
}
|
||||
|
||||
DiffPane .added {
|
||||
background: #1e3a1e;
|
||||
color: #4ec9b0;
|
||||
}
|
||||
|
||||
DiffPane .removed {
|
||||
background: #3a1e1e;
|
||||
color: #f14c4c;
|
||||
}
|
||||
|
||||
DiffPane .hunk-header {
|
||||
color: $accent;
|
||||
text-style: bold;
|
||||
}
|
||||
"""
|
||||
|
||||
class AcceptClicked(Message):
|
||||
"""Emitted when accept is clicked."""
|
||||
|
||||
def __init__(self, file_path: str) -> None:
|
||||
self.file_path = file_path
|
||||
super().__init__()
|
||||
|
||||
class RejectClicked(Message):
|
||||
"""Emitted when reject is clicked."""
|
||||
|
||||
def __init__(self, file_path: str) -> None:
|
||||
self.file_path = file_path
|
||||
super().__init__()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
diff: DiffContent | None = None,
|
||||
is_proposal: bool = False,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._diff = diff
|
||||
self._is_proposal = is_proposal
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
if self._diff:
|
||||
yield Static(f"Diff: {self._diff.file_path}", classes="diff-header")
|
||||
else:
|
||||
yield Static("No diff loaded", classes="diff-header")
|
||||
|
||||
yield RichLog(id="diff-log", highlight=True, markup=True, classes="diff-content")
|
||||
|
||||
if self._is_proposal:
|
||||
with Horizontal(classes="diff-actions"):
|
||||
yield Button("Accept", id="btn-accept", variant="success")
|
||||
yield Button("Reject", id="btn-reject", variant="error")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Render diff on mount."""
|
||||
self._render_diff()
|
||||
|
||||
def load_diff(self, diff: DiffContent, is_proposal: bool = False) -> None:
|
||||
"""Load a diff into the viewer."""
|
||||
self._diff = diff
|
||||
self._is_proposal = is_proposal
|
||||
|
||||
# Update header
|
||||
header = self.query_one(".diff-header", Static)
|
||||
header.update(f"Diff: {diff.file_path}")
|
||||
|
||||
# Show/hide action buttons
|
||||
try:
|
||||
actions = self.query_one(".diff-actions")
|
||||
actions.display = is_proposal
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self._render_diff()
|
||||
|
||||
def _render_diff(self) -> None:
|
||||
"""Render the diff content."""
|
||||
log = self.query_one("#diff-log", RichLog)
|
||||
log.clear()
|
||||
|
||||
if not self._diff:
|
||||
log.write("[dim]No diff to display[/]")
|
||||
return
|
||||
|
||||
for hunk in self._diff.hunks:
|
||||
# Hunk header
|
||||
log.write(f"[hunk-header]{hunk.header}[/]")
|
||||
|
||||
for line in hunk.lines:
|
||||
if line.change_type == ChangeType.ADDED:
|
||||
log.write(f"[green]+{line.content}[/]")
|
||||
elif line.change_type == ChangeType.REMOVED:
|
||||
log.write(f"[red]-{line.content}[/]")
|
||||
else:
|
||||
log.write(f" {line.content}")
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear the diff viewer."""
|
||||
self._diff = None
|
||||
log = self.query_one("#diff-log", RichLog)
|
||||
log.clear()
|
||||
|
||||
header = self.query_one(".diff-header", Static)
|
||||
header.update("No diff loaded")
|
||||
|
||||
def on_button_pressed(self, _event: Button.Pressed) -> None:
|
||||
"""Handle button presses."""
|
||||
if not self._diff:
|
||||
return
|
||||
|
||||
if event.button.id == "btn-accept":
|
||||
self.post_message(self.AcceptClicked(self._diff.file_path))
|
||||
elif event.button.id == "btn-reject":
|
||||
self.post_message(self.RejectClicked(self._diff.file_path))
|
||||
@@ -0,0 +1,199 @@
|
||||
"""Editor pane component with syntax highlighting."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Vertical
|
||||
from textual.message import Message
|
||||
from textual.widgets import Static, TextArea
|
||||
|
||||
from clide.models.editor import CursorPosition, FileBuffer
|
||||
|
||||
|
||||
class EditorPane(Vertical):
|
||||
"""Editor pane with TextArea and status bar."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
EditorPane {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
EditorPane TextArea {
|
||||
height: 1fr;
|
||||
}
|
||||
|
||||
EditorPane .editor-status {
|
||||
height: 1;
|
||||
background: $surface;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
EditorPane .file-tab {
|
||||
height: 1;
|
||||
background: $panel;
|
||||
}
|
||||
|
||||
EditorPane .modified {
|
||||
color: $warning;
|
||||
}
|
||||
"""
|
||||
|
||||
class ContentChanged(Message):
|
||||
"""Emitted when content changes."""
|
||||
|
||||
def __init__(self, path: Path, content: str) -> None:
|
||||
self.path = path
|
||||
self.content = content
|
||||
super().__init__()
|
||||
|
||||
class CursorMoved(Message):
|
||||
"""Emitted when cursor moves."""
|
||||
|
||||
def __init__(self, path: Path, line: int, column: int) -> None:
|
||||
self.path = path
|
||||
self.line = line
|
||||
self.column = column
|
||||
super().__init__()
|
||||
|
||||
class SaveRequested(Message):
|
||||
"""Emitted when save is requested."""
|
||||
|
||||
def __init__(self, path: Path) -> None:
|
||||
self.path = path
|
||||
super().__init__()
|
||||
|
||||
class FileSaved(Message):
|
||||
"""Emitted when file is saved."""
|
||||
|
||||
def __init__(self, path: Path) -> None:
|
||||
self.path = path
|
||||
super().__init__()
|
||||
|
||||
def __init__(self, buffer: FileBuffer | None = None, **kwargs) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._buffer = buffer
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
if self._buffer:
|
||||
yield Static(self._buffer.display_name, classes="file-tab")
|
||||
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")
|
||||
|
||||
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.load_text(buffer.content)
|
||||
textarea.language = buffer.language
|
||||
|
||||
# Update tab
|
||||
tab = self.query_one(".file-tab", Static)
|
||||
tab.update(buffer.display_name)
|
||||
|
||||
# Update status
|
||||
self._update_status()
|
||||
|
||||
# Set cursor position
|
||||
if buffer.cursor:
|
||||
textarea.cursor_location = (buffer.cursor.line, buffer.cursor.column)
|
||||
|
||||
def get_content(self) -> str:
|
||||
"""Get current editor content."""
|
||||
textarea = self.query_one("#editor-textarea", TextArea)
|
||||
return textarea.text
|
||||
|
||||
def _get_status_text(self) -> str:
|
||||
"""Generate status bar text."""
|
||||
if not self._buffer:
|
||||
return ""
|
||||
|
||||
line = self._buffer.cursor.line + 1 if self._buffer.cursor else 1
|
||||
col = self._buffer.cursor.column + 1 if self._buffer.cursor else 1
|
||||
lang = self._buffer.language or "plain text"
|
||||
|
||||
return f"Ln {line}, Col {col} | {lang}"
|
||||
|
||||
def _update_status(self) -> None:
|
||||
"""Update status bar."""
|
||||
status = self.query_one("#editor-status", Static)
|
||||
status.update(self._get_status_text())
|
||||
|
||||
def on_text_area_changed(self, event: TextArea.Changed) -> None:
|
||||
"""Handle text changes."""
|
||||
if self._buffer:
|
||||
self._buffer.content = event.text_area.text
|
||||
self._buffer.is_modified = True
|
||||
|
||||
# Update tab to show modified indicator
|
||||
tab = self.query_one(".file-tab", Static)
|
||||
tab.update(self._buffer.display_name)
|
||||
|
||||
self.post_message(self.ContentChanged(self._buffer.path, event.text_area.text))
|
||||
|
||||
def on_text_area_selection_changed(self, event: TextArea.SelectionChanged) -> None:
|
||||
"""Handle cursor movement."""
|
||||
if self._buffer:
|
||||
line, col = event.selection.end
|
||||
# CursorPosition is frozen, so create new one
|
||||
self._buffer.cursor = CursorPosition(line=line, column=col)
|
||||
self._update_status()
|
||||
self.post_message(self.CursorMoved(self._buffer.path, line, col))
|
||||
|
||||
@property
|
||||
def current_file(self) -> Path | None:
|
||||
"""Get currently open file path."""
|
||||
return self._buffer.path if self._buffer else None
|
||||
|
||||
@property
|
||||
def modified(self) -> bool:
|
||||
"""Check if buffer has unsaved changes."""
|
||||
return self._buffer.is_modified if self._buffer else False
|
||||
|
||||
def load_file(self, path: Path, goto_line: int | None = None) -> None:
|
||||
"""Load a file from disk into the editor."""
|
||||
from clide.services.file_service import FileService
|
||||
|
||||
content = FileService.read_file(path)
|
||||
language = FileService.detect_language(path)
|
||||
|
||||
buffer = FileBuffer(
|
||||
path=path,
|
||||
content=content,
|
||||
language=language,
|
||||
is_modified=False,
|
||||
)
|
||||
self.load_buffer(buffer)
|
||||
|
||||
if goto_line is not None:
|
||||
textarea = self.query_one("#editor-textarea", TextArea)
|
||||
textarea.cursor_location = (goto_line - 1, 0)
|
||||
|
||||
def save(self) -> bool:
|
||||
"""Save current buffer to disk."""
|
||||
if not self._buffer:
|
||||
return False
|
||||
|
||||
from clide.services.file_service import FileService
|
||||
|
||||
success = FileService.write_file(self._buffer.path, self._buffer.content)
|
||||
if success:
|
||||
self._buffer.is_modified = False
|
||||
# Update tab
|
||||
tab = self.query_one(".file-tab", Static)
|
||||
tab.update(self._buffer.display_name)
|
||||
self.post_message(self.FileSaved(self._buffer.path))
|
||||
return success
|
||||
@@ -0,0 +1,87 @@
|
||||
"""File browser component using DirectoryTree."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from rich.text import Text
|
||||
from textual.message import Message
|
||||
from textual.widgets import DirectoryTree
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from rich.style import Style
|
||||
from textual.widgets._directory_tree import DirEntry
|
||||
from textual.widgets._tree import TreeNode
|
||||
|
||||
|
||||
# Minimal Unicode icons (works with any font)
|
||||
ICON_FOLDER_OPEN = "▾"
|
||||
ICON_FOLDER_CLOSED = "▸"
|
||||
ICON_FILE = "◦"
|
||||
|
||||
|
||||
class FilesView(DirectoryTree):
|
||||
"""File browser widget wrapping DirectoryTree."""
|
||||
|
||||
class FileSelected(Message):
|
||||
"""Emitted when a file is selected."""
|
||||
|
||||
def __init__(self, node: TreeNode[DirEntry], path: Path) -> None:
|
||||
self.node = node
|
||||
self.path = path
|
||||
super().__init__()
|
||||
|
||||
class DirectorySelected(Message):
|
||||
"""Emitted when a directory is selected."""
|
||||
|
||||
def __init__(self, node: TreeNode[DirEntry], path: Path) -> None:
|
||||
self.node = node
|
||||
self.path = path
|
||||
super().__init__()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path: Path,
|
||||
*,
|
||||
name: str | None = None,
|
||||
id: str | None = None,
|
||||
classes: str | None = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
path,
|
||||
name=name,
|
||||
id=id,
|
||||
classes=classes,
|
||||
)
|
||||
|
||||
def render_label(
|
||||
self, node: TreeNode[DirEntry], base_style: Style, style: Style
|
||||
) -> Text:
|
||||
"""Render a label with minimal Unicode icons."""
|
||||
path = node.data.path
|
||||
|
||||
if path.is_dir():
|
||||
icon = ICON_FOLDER_OPEN if node.is_expanded else ICON_FOLDER_CLOSED
|
||||
icon_style = "bold cyan"
|
||||
else:
|
||||
icon = ICON_FILE
|
||||
icon_style = "dim"
|
||||
|
||||
label = Text()
|
||||
label.append(f"{icon} ", style=icon_style)
|
||||
label.append(path.name, style=style)
|
||||
return label
|
||||
|
||||
def filter_paths(self, paths: list[Path]) -> list[Path]:
|
||||
"""Filter out hidden and ignored paths."""
|
||||
return [
|
||||
p for p in paths
|
||||
if not p.name.startswith(".")
|
||||
and p.name not in ("__pycache__", "node_modules", ".git", ".venv", "venv")
|
||||
]
|
||||
|
||||
|
||||
def refresh_tree(self) -> None:
|
||||
"""Refresh the directory tree."""
|
||||
self.reload()
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Git changes view component."""
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Vertical
|
||||
from textual.message import Message
|
||||
from textual.widgets import Label, ListItem, ListView, Static
|
||||
|
||||
from clide.models.git import ChangeStatus, GitChange
|
||||
|
||||
|
||||
class GitChangeItem(ListItem):
|
||||
"""A single git change item."""
|
||||
|
||||
STATUS_ICONS = {
|
||||
ChangeStatus.ADDED: "+",
|
||||
ChangeStatus.MODIFIED: "~",
|
||||
ChangeStatus.DELETED: "-",
|
||||
ChangeStatus.RENAMED: "→",
|
||||
ChangeStatus.UNTRACKED: "?",
|
||||
ChangeStatus.COPIED: "C",
|
||||
ChangeStatus.UNMERGED: "!",
|
||||
ChangeStatus.IGNORED: "I",
|
||||
}
|
||||
|
||||
def __init__(self, change: GitChange) -> None:
|
||||
super().__init__()
|
||||
self.change = change
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
icon = self.STATUS_ICONS.get(self.change.status, "?")
|
||||
status_class = self.change.status.value
|
||||
yield Static(
|
||||
f"[{status_class}]{icon}[/] {self.change.path}",
|
||||
markup=True,
|
||||
)
|
||||
|
||||
|
||||
class GitChangesView(Vertical):
|
||||
"""View for staged and unstaged git changes."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
GitChangesView {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
GitChangesView .section-header {
|
||||
background: $surface;
|
||||
padding: 0 1;
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
GitChangesView ListView {
|
||||
height: auto;
|
||||
max-height: 50%;
|
||||
}
|
||||
|
||||
GitChangesView .added { color: $success; }
|
||||
GitChangesView .modified { color: $warning; }
|
||||
GitChangesView .deleted { color: $error; }
|
||||
GitChangesView .untracked { color: $accent; }
|
||||
"""
|
||||
|
||||
class FileClicked(Message):
|
||||
"""Emitted when a file is clicked."""
|
||||
|
||||
def __init__(self, change: GitChange) -> None:
|
||||
self.change = change
|
||||
super().__init__()
|
||||
|
||||
class StageRequested(Message):
|
||||
"""Emitted when staging is requested."""
|
||||
|
||||
def __init__(self, path: str) -> None:
|
||||
self.path = path
|
||||
super().__init__()
|
||||
|
||||
class UnstageRequested(Message):
|
||||
"""Emitted when unstaging is requested."""
|
||||
|
||||
def __init__(self, path: str) -> None:
|
||||
self.path = path
|
||||
super().__init__()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
staged: list[GitChange] | None = None,
|
||||
unstaged: list[GitChange] | None = None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._staged = staged or []
|
||||
self._unstaged = unstaged or []
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Label("Staged Changes", classes="section-header")
|
||||
yield ListView(
|
||||
*[GitChangeItem(c) for c in self._staged],
|
||||
id="staged-list",
|
||||
)
|
||||
yield Label("Changes", classes="section-header")
|
||||
yield ListView(
|
||||
*[GitChangeItem(c) for c in self._unstaged],
|
||||
id="unstaged-list",
|
||||
)
|
||||
|
||||
def update_changes(
|
||||
self,
|
||||
staged: list[GitChange],
|
||||
unstaged: list[GitChange],
|
||||
) -> None:
|
||||
"""Update the changes lists."""
|
||||
self._staged = staged
|
||||
self._unstaged = unstaged
|
||||
|
||||
staged_list = self.query_one("#staged-list", ListView)
|
||||
unstaged_list = self.query_one("#unstaged-list", ListView)
|
||||
|
||||
staged_list.clear()
|
||||
for change in staged:
|
||||
staged_list.append(GitChangeItem(change))
|
||||
|
||||
unstaged_list.clear()
|
||||
for change in unstaged:
|
||||
unstaged_list.append(GitChangeItem(change))
|
||||
|
||||
def on_list_view_selected(self, event: ListView.Selected) -> None:
|
||||
"""Handle item selection."""
|
||||
if isinstance(event.item, GitChangeItem):
|
||||
self.post_message(self.FileClicked(event.item.change))
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Git graph visualization component."""
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Vertical
|
||||
from textual.message import Message
|
||||
from textual.widgets import RichLog
|
||||
|
||||
from clide.models.git import GitCommit
|
||||
|
||||
|
||||
class GitGraphView(Vertical):
|
||||
"""View for git commit graph visualization."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
GitGraphView {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
GitGraphView RichLog {
|
||||
height: 100%;
|
||||
scrollbar-size: 1 1;
|
||||
}
|
||||
|
||||
GitGraphView .commit-line {
|
||||
height: auto;
|
||||
}
|
||||
"""
|
||||
|
||||
# Graph drawing characters
|
||||
COMMIT = "●"
|
||||
MERGE = "◆"
|
||||
LINE = "│"
|
||||
BRANCH = "├"
|
||||
JOIN = "┴"
|
||||
|
||||
class CommitSelected(Message):
|
||||
"""Emitted when a commit is selected."""
|
||||
|
||||
def __init__(self, commit: GitCommit) -> None:
|
||||
self.commit = commit
|
||||
super().__init__()
|
||||
|
||||
def __init__(self, commits: list[GitCommit] | None = None, **kwargs) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._commits = commits or []
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield RichLog(id="graph-log", highlight=True, markup=True)
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Render initial graph."""
|
||||
self._render_graph()
|
||||
|
||||
def update_commits(self, commits: list[GitCommit]) -> None:
|
||||
"""Update the commit list."""
|
||||
self._commits = commits
|
||||
self._render_graph()
|
||||
|
||||
def _render_graph(self) -> None:
|
||||
"""Render the commit graph."""
|
||||
log = self.query_one("#graph-log", RichLog)
|
||||
log.clear()
|
||||
|
||||
for commit in self._commits:
|
||||
line = self._format_commit_line(commit)
|
||||
log.write(line)
|
||||
|
||||
def _format_commit_line(self, commit: GitCommit) -> str:
|
||||
"""Format a single commit line."""
|
||||
# Choose commit symbol
|
||||
symbol = self.MERGE if commit.is_merge else self.COMMIT
|
||||
|
||||
# Format refs (branches, tags)
|
||||
refs_str = ""
|
||||
if commit.refs:
|
||||
refs = ", ".join(commit.refs)
|
||||
refs_str = f" [bold cyan]({refs})[/]"
|
||||
|
||||
# Truncate message
|
||||
message = commit.message[:50]
|
||||
if len(commit.message) > 50:
|
||||
message += "..."
|
||||
|
||||
return (
|
||||
f"[bold yellow]{symbol}[/] "
|
||||
f"[dim]{commit.short_hash}[/]"
|
||||
f"{refs_str} "
|
||||
f"{message} "
|
||||
f"[dim]- {commit.author}, {commit.date}[/]"
|
||||
)
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Jira view component for Jira CLI output."""
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Vertical
|
||||
from textual.message import Message
|
||||
from textual.widgets import Button, Markdown, Static
|
||||
|
||||
|
||||
class JiraView(Vertical):
|
||||
"""View for Jira CLI output."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
JiraView {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
JiraView .jira-header {
|
||||
height: 1;
|
||||
background: $surface;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
JiraView Markdown {
|
||||
height: 1fr;
|
||||
padding: 1;
|
||||
}
|
||||
|
||||
JiraView .jira-actions {
|
||||
height: auto;
|
||||
padding: 0 1;
|
||||
background: $panel;
|
||||
}
|
||||
|
||||
JiraView .disabled-message {
|
||||
padding: 2;
|
||||
text-align: center;
|
||||
color: $warning;
|
||||
}
|
||||
"""
|
||||
|
||||
class RefreshRequested(Message):
|
||||
"""Emitted when refresh is requested."""
|
||||
pass
|
||||
|
||||
class IssueClicked(Message):
|
||||
"""Emitted when an issue is clicked."""
|
||||
|
||||
def __init__(self, issue_key: str) -> None:
|
||||
self.issue_key = issue_key
|
||||
super().__init__()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
content: str = "",
|
||||
enabled: bool = True,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._content = content
|
||||
self._enabled = enabled
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Static("Jira", classes="jira-header")
|
||||
|
||||
if self._enabled:
|
||||
yield Markdown(self._content or "*Loading...*", id="jira-content")
|
||||
yield Button("↻ Refresh", id="btn-refresh", classes="jira-actions")
|
||||
else:
|
||||
yield Static(
|
||||
"Jira integration is disabled.\n\n"
|
||||
"Enable it in settings with CLIDE_JIRA_ENABLED=true",
|
||||
classes="disabled-message",
|
||||
)
|
||||
|
||||
def update_content(self, content: str) -> None:
|
||||
"""Update Jira output content."""
|
||||
self._content = content
|
||||
try:
|
||||
markdown = self.query_one("#jira-content", Markdown)
|
||||
markdown.update(content)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def set_loading(self) -> None:
|
||||
"""Show loading state."""
|
||||
self.update_content("*Loading...*")
|
||||
|
||||
def set_error(self, error: str) -> None:
|
||||
"""Show error state."""
|
||||
self.update_content(f"**Error:** {error}")
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
"""Handle button presses."""
|
||||
if event.button.id == "btn-refresh":
|
||||
self.set_loading()
|
||||
self.post_message(self.RefreshRequested())
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Problems view component for linter errors."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Vertical
|
||||
from textual.message import Message
|
||||
from textual.widgets import ListItem, ListView, Static
|
||||
|
||||
from clide.models.problems import Problem
|
||||
|
||||
|
||||
class ProblemItem(ListItem):
|
||||
"""A single problem item."""
|
||||
|
||||
def __init__(self, problem: Problem) -> None:
|
||||
super().__init__()
|
||||
self.problem = problem
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
icon = self.problem.severity_icon
|
||||
severity_class = self.problem.severity.value
|
||||
|
||||
yield Static(
|
||||
f"[{severity_class}]{icon}[/] "
|
||||
f"[dim]{self.problem.file_path}:{self.problem.line}[/] "
|
||||
f"{self.problem.message}",
|
||||
markup=True,
|
||||
)
|
||||
|
||||
|
||||
class ProblemsView(Vertical):
|
||||
"""View for linter problems/diagnostics."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
ProblemsView {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
ProblemsView .problems-header {
|
||||
height: 1;
|
||||
background: $surface;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
ProblemsView ListView {
|
||||
height: 1fr;
|
||||
}
|
||||
|
||||
ProblemsView .error { color: $error; }
|
||||
ProblemsView .warning { color: $warning; }
|
||||
ProblemsView .info { color: $primary; }
|
||||
ProblemsView .hint { color: $secondary; }
|
||||
|
||||
ProblemsView .empty-message {
|
||||
padding: 2;
|
||||
text-align: center;
|
||||
color: $success;
|
||||
}
|
||||
"""
|
||||
|
||||
class ProblemClicked(Message):
|
||||
"""Emitted when a problem is clicked."""
|
||||
|
||||
def __init__(self, problem: Problem) -> None:
|
||||
self.problem = problem
|
||||
super().__init__()
|
||||
|
||||
def __init__(self, problems: list[Problem] | None = None, **kwargs) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._problems = problems or []
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
count = len(self._problems)
|
||||
yield Static(f"Problems ({count})", classes="problems-header", id="problems-header")
|
||||
|
||||
if self._problems:
|
||||
yield ListView(
|
||||
*[ProblemItem(p) for p in self._problems],
|
||||
id="problems-list",
|
||||
)
|
||||
else:
|
||||
yield Static("No problems found ✓", classes="empty-message")
|
||||
|
||||
def update_problems(self, problems: list[Problem]) -> None:
|
||||
"""Update the problems list."""
|
||||
self._problems = problems
|
||||
|
||||
# Update header
|
||||
header = self.query_one("#problems-header", Static)
|
||||
header.update(f"Problems ({len(problems)})")
|
||||
|
||||
# Update list
|
||||
try:
|
||||
problems_list = self.query_one("#problems-list", ListView)
|
||||
problems_list.clear()
|
||||
for problem in problems:
|
||||
problems_list.append(ProblemItem(problem))
|
||||
except Exception:
|
||||
# List might not exist yet, will be created on next compose
|
||||
pass
|
||||
|
||||
def filter_by_file(self, path: Path) -> list[Problem]:
|
||||
"""Get problems for a specific file."""
|
||||
return [p for p in self._problems if p.file_path == path]
|
||||
|
||||
def on_list_view_selected(self, event: ListView.Selected) -> None:
|
||||
"""Handle item selection."""
|
||||
if isinstance(event.item, ProblemItem):
|
||||
self.post_message(self.ProblemClicked(event.item.problem))
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Terminal pane component."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Vertical
|
||||
from textual.message import Message
|
||||
from textual.widgets import Input, RichLog, Static
|
||||
|
||||
|
||||
class TerminalPane(Vertical):
|
||||
"""Simple terminal/command runner pane."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
TerminalPane {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
TerminalPane .terminal-header {
|
||||
height: 1;
|
||||
background: $surface;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
TerminalPane RichLog {
|
||||
height: 1fr;
|
||||
background: $background;
|
||||
}
|
||||
|
||||
TerminalPane Input {
|
||||
dock: bottom;
|
||||
height: 1;
|
||||
}
|
||||
|
||||
TerminalPane .prompt {
|
||||
color: $primary;
|
||||
}
|
||||
|
||||
TerminalPane .output {
|
||||
color: $foreground;
|
||||
}
|
||||
|
||||
TerminalPane .error {
|
||||
color: $error;
|
||||
}
|
||||
"""
|
||||
|
||||
class CommandSubmitted(Message):
|
||||
"""Emitted when a command is submitted."""
|
||||
|
||||
def __init__(self, command: str) -> None:
|
||||
self.command = command
|
||||
super().__init__()
|
||||
|
||||
def __init__(self, cwd: Path | None = None, **kwargs) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._cwd = cwd or Path.cwd()
|
||||
self._history: list[str] = []
|
||||
self._history_index = 0
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Static(f"Terminal - {self._cwd}", classes="terminal-header")
|
||||
yield RichLog(id="terminal-log", highlight=True, markup=True)
|
||||
yield Input(placeholder="Enter command...", id="terminal-input")
|
||||
|
||||
@property
|
||||
def cwd(self) -> Path:
|
||||
"""Get current working directory."""
|
||||
return self._cwd
|
||||
|
||||
@cwd.setter
|
||||
def cwd(self, path: Path) -> None:
|
||||
"""Set current working directory."""
|
||||
self._cwd = path
|
||||
header = self.query_one(".terminal-header", Static)
|
||||
header.update(f"Terminal - {path}")
|
||||
|
||||
def write_output(self, text: str, style: str = "output") -> None:
|
||||
"""Write output to terminal.
|
||||
|
||||
Args:
|
||||
text: Text to write
|
||||
style: Style class (output, error, prompt)
|
||||
"""
|
||||
log = self.query_one("#terminal-log", RichLog)
|
||||
if style == "error":
|
||||
log.write(f"[red]{text}[/]")
|
||||
elif style == "prompt":
|
||||
log.write(f"[bold cyan]$ {text}[/]")
|
||||
else:
|
||||
log.write(text)
|
||||
|
||||
def write_command(self, command: str) -> None:
|
||||
"""Write a command to terminal (with prompt)."""
|
||||
self.write_output(command, "prompt")
|
||||
|
||||
def write_error(self, error: str) -> None:
|
||||
"""Write an error to terminal."""
|
||||
self.write_output(error, "error")
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear terminal output."""
|
||||
log = self.query_one("#terminal-log", RichLog)
|
||||
log.clear()
|
||||
|
||||
def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||
"""Handle command submission."""
|
||||
command = event.value.strip()
|
||||
if not command:
|
||||
return
|
||||
|
||||
# Add to history
|
||||
self._history.append(command)
|
||||
self._history_index = len(self._history)
|
||||
|
||||
# Clear input
|
||||
event.input.clear()
|
||||
|
||||
# Write command to output
|
||||
self.write_command(command)
|
||||
|
||||
# Post message for handling
|
||||
self.post_message(self.CommandSubmitted(command))
|
||||
|
||||
def history_up(self) -> None:
|
||||
"""Navigate history up."""
|
||||
if self._history and self._history_index > 0:
|
||||
self._history_index -= 1
|
||||
input_widget = self.query_one("#terminal-input", Input)
|
||||
input_widget.value = self._history[self._history_index]
|
||||
|
||||
def history_down(self) -> None:
|
||||
"""Navigate history down."""
|
||||
input_widget = self.query_one("#terminal-input", Input)
|
||||
if self._history_index < len(self._history) - 1:
|
||||
self._history_index += 1
|
||||
input_widget.value = self._history[self._history_index]
|
||||
else:
|
||||
self._history_index = len(self._history)
|
||||
input_widget.clear()
|
||||
@@ -0,0 +1,110 @@
|
||||
"""TODOs view component."""
|
||||
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Vertical
|
||||
from textual.message import Message
|
||||
from textual.widgets import ListItem, ListView, Static
|
||||
|
||||
from clide.models.todos import TodoItem, TodoType
|
||||
|
||||
|
||||
class TodoListItem(ListItem):
|
||||
"""A single TODO item."""
|
||||
|
||||
def __init__(self, item: TodoItem) -> None:
|
||||
super().__init__()
|
||||
self.item = item
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
icon = self.item.type_icon
|
||||
type_class = self.item.todo_type.value.lower()
|
||||
|
||||
yield Static(
|
||||
f"[{type_class}]{icon} {self.item.todo_type.value}[/] "
|
||||
f"[dim]{self.item.file_path}:{self.item.line}[/] "
|
||||
f"{self.item.text}",
|
||||
markup=True,
|
||||
)
|
||||
|
||||
|
||||
class TodosView(Vertical):
|
||||
"""View for TODO/FIXME comments."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
TodosView {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
TodosView .todos-header {
|
||||
height: 1;
|
||||
background: $surface;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
TodosView ListView {
|
||||
height: 1fr;
|
||||
}
|
||||
|
||||
TodosView .todo { color: $primary; }
|
||||
TodosView .fixme { color: $warning; }
|
||||
TodosView .hack { color: $error; }
|
||||
TodosView .xxx { color: $error; }
|
||||
TodosView .note { color: $secondary; }
|
||||
TodosView .bug { color: $error; }
|
||||
|
||||
TodosView .empty-message {
|
||||
padding: 2;
|
||||
text-align: center;
|
||||
color: $success;
|
||||
}
|
||||
"""
|
||||
|
||||
class TodoClicked(Message):
|
||||
"""Emitted when a TODO is clicked."""
|
||||
|
||||
def __init__(self, item: TodoItem) -> None:
|
||||
self.item = item
|
||||
super().__init__()
|
||||
|
||||
def __init__(self, items: list[TodoItem] | None = None, **kwargs) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._items = items or []
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
count = len(self._items)
|
||||
yield Static(f"TODOs ({count})", classes="todos-header", id="todos-header")
|
||||
|
||||
if self._items:
|
||||
yield ListView(
|
||||
*[TodoListItem(item) for item in self._items],
|
||||
id="todos-list",
|
||||
)
|
||||
else:
|
||||
yield Static("No TODOs found ✓", classes="empty-message")
|
||||
|
||||
def update_items(self, items: list[TodoItem]) -> None:
|
||||
"""Update the TODOs list."""
|
||||
self._items = items
|
||||
|
||||
# Update header
|
||||
header = self.query_one("#todos-header", Static)
|
||||
header.update(f"TODOs ({len(items)})")
|
||||
|
||||
# Update list
|
||||
try:
|
||||
todos_list = self.query_one("#todos-list", ListView)
|
||||
todos_list.clear()
|
||||
for item in items:
|
||||
todos_list.append(TodoListItem(item))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def filter_by_type(self, todo_type: TodoType) -> list[TodoItem]:
|
||||
"""Filter by TODO type."""
|
||||
return [i for i in self._items if i.todo_type == todo_type]
|
||||
|
||||
def on_list_view_selected(self, event: ListView.Selected) -> None:
|
||||
"""Handle item selection."""
|
||||
if isinstance(event.item, TodoListItem):
|
||||
self.post_message(self.TodoClicked(event.item.item))
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Main layout panels for Clide."""
|
||||
|
||||
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
|
||||
|
||||
__all__ = [
|
||||
"ClaudePanel",
|
||||
"ContextPanel",
|
||||
"SidebarPanel",
|
||||
"WorkspacePanel",
|
||||
]
|
||||
@@ -0,0 +1,444 @@
|
||||
"""Claude panel with embedded terminal running Claude Code."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import fcntl
|
||||
import os
|
||||
import pty
|
||||
import shutil
|
||||
import struct
|
||||
import termios
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pyte
|
||||
from rich.text import Text
|
||||
from textual.containers import Vertical
|
||||
from textual.message import Message
|
||||
from textual.reactive import reactive
|
||||
from textual.strip import Strip
|
||||
from textual.widget import Widget
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from textual.app import ComposeResult
|
||||
|
||||
|
||||
class TerminalDisplay(Widget, can_focus=True):
|
||||
"""A terminal emulator widget using pyte."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
TerminalDisplay {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: $background;
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cols: int = 80,
|
||||
rows: int = 24,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._cols = cols
|
||||
self._rows = rows
|
||||
self._screen = pyte.Screen(cols, rows)
|
||||
self._stream = pyte.Stream(self._screen)
|
||||
self._master_fd: int | None = None
|
||||
self._pid: int | None = None
|
||||
self._read_task: asyncio.Task | None = None
|
||||
self._refresh_task: asyncio.Task | None = None
|
||||
self._needs_refresh: bool = False
|
||||
self._last_refresh: float = 0
|
||||
|
||||
def on_resize(self, event) -> None:
|
||||
"""Handle terminal resize."""
|
||||
# Get new size from widget
|
||||
new_cols = max(event.size.width, 20)
|
||||
new_rows = max(event.size.height, 5)
|
||||
|
||||
if new_cols != self._cols or new_rows != self._rows:
|
||||
self._cols = new_cols
|
||||
self._rows = new_rows
|
||||
self._screen.resize(new_rows, new_cols)
|
||||
|
||||
# Update PTY size if running
|
||||
if self._master_fd is not None:
|
||||
self._set_pty_size(self._master_fd, new_rows, new_cols)
|
||||
|
||||
def _set_pty_size(self, fd: int, rows: int, cols: int) -> None:
|
||||
"""Set the PTY window size."""
|
||||
try:
|
||||
winsize = struct.pack("HHHH", rows, cols, 0, 0)
|
||||
fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def start(self, command: str, cwd: str) -> None:
|
||||
"""Start a process in the terminal."""
|
||||
# Fork a PTY
|
||||
pid, master_fd = pty.fork()
|
||||
|
||||
if pid == 0:
|
||||
# Child process
|
||||
os.chdir(cwd)
|
||||
os.environ["TERM"] = "xterm-256color"
|
||||
os.environ["COLORTERM"] = "truecolor"
|
||||
os.execlp(command, command)
|
||||
else:
|
||||
# Parent process
|
||||
self._pid = pid
|
||||
self._master_fd = master_fd
|
||||
|
||||
# Set non-blocking
|
||||
flags = fcntl.fcntl(master_fd, fcntl.F_GETFL)
|
||||
fcntl.fcntl(master_fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
|
||||
|
||||
# Set initial size
|
||||
self._set_pty_size(master_fd, self._rows, self._cols)
|
||||
|
||||
# Start reading
|
||||
self._read_task = asyncio.create_task(self._read_output())
|
||||
|
||||
def _schedule_refresh(self) -> None:
|
||||
"""Schedule a throttled refresh to avoid glitching."""
|
||||
self._needs_refresh = True
|
||||
if self._refresh_task is None or self._refresh_task.done():
|
||||
self._refresh_task = asyncio.create_task(self._throttled_refresh())
|
||||
|
||||
async def _throttled_refresh(self) -> None:
|
||||
"""Refresh at most every 16ms (~60fps) to avoid glitching."""
|
||||
min_interval = 0.016 # ~60fps
|
||||
while self._needs_refresh:
|
||||
now = time.monotonic()
|
||||
elapsed = now - self._last_refresh
|
||||
if elapsed < min_interval:
|
||||
await asyncio.sleep(min_interval - elapsed)
|
||||
self._needs_refresh = False
|
||||
self._last_refresh = time.monotonic()
|
||||
self.refresh()
|
||||
# Small delay to batch rapid updates
|
||||
await asyncio.sleep(0.008)
|
||||
|
||||
async def _read_output(self) -> None:
|
||||
"""Read output from the PTY."""
|
||||
if self._master_fd is None:
|
||||
return
|
||||
|
||||
while True:
|
||||
try:
|
||||
# Wait for data to be available
|
||||
await asyncio.sleep(0.005)
|
||||
|
||||
try:
|
||||
data = os.read(self._master_fd, 65536)
|
||||
if not data:
|
||||
break
|
||||
|
||||
# Feed data to pyte
|
||||
self._stream.feed(data.decode("utf-8", errors="replace"))
|
||||
self._schedule_refresh()
|
||||
|
||||
except BlockingIOError:
|
||||
# No data available
|
||||
continue
|
||||
except OSError:
|
||||
# PTY closed
|
||||
break
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the terminal process."""
|
||||
if self._read_task:
|
||||
self._read_task.cancel()
|
||||
self._read_task = None
|
||||
|
||||
if self._refresh_task:
|
||||
self._refresh_task.cancel()
|
||||
self._refresh_task = None
|
||||
|
||||
if self._master_fd is not None:
|
||||
try:
|
||||
os.close(self._master_fd)
|
||||
except OSError:
|
||||
pass
|
||||
self._master_fd = None
|
||||
|
||||
if self._pid is not None:
|
||||
try:
|
||||
os.kill(self._pid, 9)
|
||||
os.waitpid(self._pid, 0)
|
||||
except (OSError, ChildProcessError):
|
||||
pass
|
||||
self._pid = None
|
||||
|
||||
def is_running(self) -> bool:
|
||||
"""Check if the process is still running."""
|
||||
if self._pid is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
pid, status = os.waitpid(self._pid, os.WNOHANG)
|
||||
if pid == 0:
|
||||
return True # Still running
|
||||
else:
|
||||
self._pid = None
|
||||
return False
|
||||
except ChildProcessError:
|
||||
self._pid = None
|
||||
return False
|
||||
|
||||
def send(self, data: str) -> None:
|
||||
"""Send data to the terminal."""
|
||||
if self._master_fd is not None:
|
||||
try:
|
||||
os.write(self._master_fd, data.encode("utf-8"))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def render_line(self, y: int) -> Strip:
|
||||
"""Render a line of the terminal."""
|
||||
if y >= self._rows:
|
||||
return Strip.blank(self._cols)
|
||||
|
||||
line = self._screen.buffer[y]
|
||||
text = Text()
|
||||
|
||||
for x in range(self._cols):
|
||||
char = line[x]
|
||||
char_data = char.data if char.data else " "
|
||||
|
||||
# Build style from pyte character attributes
|
||||
style_parts = []
|
||||
|
||||
if char.fg and char.fg != "default":
|
||||
style_parts.append(f"color({char.fg})" if char.fg.startswith("#") else char.fg)
|
||||
|
||||
if char.bg and char.bg != "default":
|
||||
style_parts.append(f"on color({char.bg})" if char.bg.startswith("#") else f"on {char.bg}")
|
||||
|
||||
if char.bold:
|
||||
style_parts.append("bold")
|
||||
if char.italics:
|
||||
style_parts.append("italic")
|
||||
if char.underscore:
|
||||
style_parts.append("underline")
|
||||
if char.reverse:
|
||||
style_parts.append("reverse")
|
||||
|
||||
style = " ".join(style_parts) if style_parts else None
|
||||
text.append(char_data, style=style)
|
||||
|
||||
# Render text to segments for Strip
|
||||
segments = list(text.render(self.app.console))
|
||||
return Strip(segments)
|
||||
|
||||
def on_key(self, event) -> None:
|
||||
"""Handle key presses."""
|
||||
# Map special keys
|
||||
key_map = {
|
||||
"enter": "\r",
|
||||
"tab": "\t",
|
||||
"backspace": "\x7f",
|
||||
"delete": "\x1b[3~",
|
||||
"up": "\x1b[A",
|
||||
"down": "\x1b[B",
|
||||
"right": "\x1b[C",
|
||||
"left": "\x1b[D",
|
||||
"home": "\x1b[H",
|
||||
"end": "\x1b[F",
|
||||
"pageup": "\x1b[5~",
|
||||
"pagedown": "\x1b[6~",
|
||||
"escape": "\x1b",
|
||||
}
|
||||
|
||||
if event.key in key_map:
|
||||
self.send(key_map[event.key])
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
elif event.key == "ctrl+c":
|
||||
self.send("\x03")
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
elif event.key == "ctrl+d":
|
||||
self.send("\x04")
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
elif event.key == "ctrl+z":
|
||||
self.send("\x1a")
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
elif event.key == "ctrl+l":
|
||||
self.send("\x0c")
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
elif event.character and len(event.character) == 1:
|
||||
self.send(event.character)
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
|
||||
|
||||
class ClaudePanel(Vertical):
|
||||
"""Terminal panel running Claude Code CLI.
|
||||
|
||||
Automatically starts Claude Code and restarts when it exits.
|
||||
Takes 100% height when workspace is hidden, 40% when visible.
|
||||
"""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
ClaudePanel {
|
||||
height: 100%;
|
||||
background: $background;
|
||||
}
|
||||
|
||||
ClaudePanel.with-workspace {
|
||||
height: 40%;
|
||||
}
|
||||
|
||||
ClaudePanel TerminalDisplay {
|
||||
height: 100%;
|
||||
}
|
||||
"""
|
||||
|
||||
class ClaudeExited(Message):
|
||||
"""Emitted when Claude Code process exits."""
|
||||
|
||||
def __init__(self, return_code: int) -> None:
|
||||
self.return_code = return_code
|
||||
super().__init__()
|
||||
|
||||
class ClaudeStarted(Message):
|
||||
"""Emitted when Claude Code process starts."""
|
||||
pass
|
||||
|
||||
# Reactive state
|
||||
workspace_visible: reactive[bool] = reactive(False)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
workdir: Path | None = None,
|
||||
auto_start: bool = True,
|
||||
restart_on_exit: bool = True,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self.id = "panel-claude"
|
||||
self._workdir = workdir or Path.cwd()
|
||||
self._auto_start = auto_start
|
||||
self._restart_on_exit = restart_on_exit
|
||||
self._claude_command = self._find_claude_command()
|
||||
self._terminal: TerminalDisplay | None = None
|
||||
self._monitor_task: asyncio.Task | None = None
|
||||
|
||||
def _find_claude_command(self) -> str:
|
||||
"""Find the Claude Code CLI command."""
|
||||
# Check common locations
|
||||
claude_paths = [
|
||||
"claude", # In PATH
|
||||
str(Path.home() / ".claude" / "local" / "claude"),
|
||||
str(Path.home() / ".local" / "bin" / "claude"),
|
||||
"/usr/local/bin/claude",
|
||||
]
|
||||
|
||||
for path in claude_paths:
|
||||
if path and shutil.which(path):
|
||||
return path
|
||||
|
||||
# Fallback to 'claude' and hope it's in PATH
|
||||
return "claude"
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
self._terminal = TerminalDisplay(id="claude-terminal")
|
||||
yield self._terminal
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Start Claude Code when mounted."""
|
||||
if self._auto_start:
|
||||
self.call_later(self.start_claude)
|
||||
|
||||
def watch_workspace_visible(self, visible: bool) -> None:
|
||||
"""Adjust height based on workspace visibility."""
|
||||
if visible:
|
||||
self.add_class("with-workspace")
|
||||
else:
|
||||
self.remove_class("with-workspace")
|
||||
|
||||
def start_claude(self) -> None:
|
||||
"""Start the Claude Code CLI in the terminal."""
|
||||
if self._terminal is None:
|
||||
return
|
||||
|
||||
# Start Claude Code with the working directory
|
||||
self._terminal.start(
|
||||
self._claude_command,
|
||||
str(self._workdir),
|
||||
)
|
||||
|
||||
self.post_message(self.ClaudeStarted())
|
||||
|
||||
# Start monitoring for exit
|
||||
if self._monitor_task:
|
||||
self._monitor_task.cancel()
|
||||
self._monitor_task = asyncio.create_task(self._monitor_claude())
|
||||
|
||||
async def _monitor_claude(self) -> None:
|
||||
"""Monitor Claude process and restart if needed."""
|
||||
if self._terminal is None:
|
||||
return
|
||||
|
||||
# Wait for the terminal process to exit
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# Check if process has exited
|
||||
if not self._terminal.is_running():
|
||||
self.post_message(self.ClaudeExited(0))
|
||||
|
||||
if self._restart_on_exit:
|
||||
# Wait a moment before restarting
|
||||
await asyncio.sleep(0.5)
|
||||
self.start_claude()
|
||||
break
|
||||
|
||||
def stop_claude(self) -> None:
|
||||
"""Stop the Claude Code CLI."""
|
||||
if self._terminal:
|
||||
self._terminal.stop()
|
||||
|
||||
if self._monitor_task:
|
||||
self._monitor_task.cancel()
|
||||
self._monitor_task = None
|
||||
|
||||
def send_input(self, text: str) -> None:
|
||||
"""Send input to the Claude terminal."""
|
||||
if self._terminal:
|
||||
self._terminal.send(text)
|
||||
|
||||
def send_interrupt(self) -> None:
|
||||
"""Send Ctrl+C interrupt to Claude."""
|
||||
if self._terminal:
|
||||
self._terminal.send("\x03") # Ctrl+C
|
||||
|
||||
def focus_terminal(self) -> None:
|
||||
"""Focus the terminal."""
|
||||
if self._terminal:
|
||||
self._terminal.focus()
|
||||
|
||||
@property
|
||||
def workdir(self) -> Path:
|
||||
"""Get the working directory."""
|
||||
return self._workdir
|
||||
|
||||
@workdir.setter
|
||||
def workdir(self, path: Path) -> None:
|
||||
"""Set the working directory (requires restart)."""
|
||||
self._workdir = path
|
||||
|
||||
def set_restart_on_exit(self, restart: bool) -> None:
|
||||
"""Set whether to restart Claude when it exits."""
|
||||
self._restart_on_exit = restart
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Context panel with Problems, TODOs, and Jira tabs."""
|
||||
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Horizontal, Vertical
|
||||
from textual.message import Message
|
||||
from textual.reactive import reactive
|
||||
from textual.widgets import Static, TabbedContent, TabPane
|
||||
|
||||
from clide.models.problems import Problem
|
||||
from clide.models.todos import TodoItem
|
||||
from clide.widgets.components.jira_view import JiraView
|
||||
from clide.widgets.components.problems_view import ProblemsView
|
||||
from clide.widgets.components.todos_view import TodosView
|
||||
|
||||
|
||||
class ContextPanel(Vertical):
|
||||
"""Right context panel with Problems, TODOs, and Jira integration."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
ContextPanel {
|
||||
width: 25%;
|
||||
min-width: 30;
|
||||
height: 100%;
|
||||
background: $surface;
|
||||
}
|
||||
|
||||
ContextPanel TabbedContent {
|
||||
height: 1fr;
|
||||
}
|
||||
|
||||
ContextPanel .context-tab-bar {
|
||||
dock: bottom;
|
||||
height: 1;
|
||||
background: $panel;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
ContextPanel .tab-count {
|
||||
margin: 0 1;
|
||||
}
|
||||
|
||||
ContextPanel .error-count {
|
||||
color: $error;
|
||||
}
|
||||
|
||||
ContextPanel .warning-count {
|
||||
color: $warning;
|
||||
}
|
||||
|
||||
ContextPanel .success-count {
|
||||
color: $success;
|
||||
}
|
||||
"""
|
||||
|
||||
class ProblemClicked(Message):
|
||||
"""Emitted when a problem is clicked."""
|
||||
|
||||
def __init__(self, problem: Problem) -> None:
|
||||
self.problem = problem
|
||||
super().__init__()
|
||||
|
||||
class TodoClicked(Message):
|
||||
"""Emitted when a TODO is clicked."""
|
||||
|
||||
def __init__(self, item: TodoItem) -> None:
|
||||
self.item = item
|
||||
super().__init__()
|
||||
|
||||
class JiraRefreshRequested(Message):
|
||||
"""Emitted when Jira refresh is requested."""
|
||||
pass
|
||||
|
||||
# Reactive state with counts for tab badges
|
||||
problem_count: reactive[int] = reactive(0)
|
||||
todo_count: reactive[int] = reactive(0)
|
||||
visible: reactive[bool] = reactive(True)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
jira_enabled: bool = True,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._jira_enabled = jira_enabled
|
||||
self.id = "panel-context"
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with TabbedContent(id="context-tabs"):
|
||||
with TabPane("Problems", id="context-problems"):
|
||||
yield ProblemsView(id="problems-view")
|
||||
with TabPane("TODOs", id="context-todos"):
|
||||
yield TodosView(id="todos-view")
|
||||
with TabPane("Jira", id="context-jira"):
|
||||
yield JiraView(enabled=self._jira_enabled, id="jira-view")
|
||||
# Tab bar with counts at bottom
|
||||
with Horizontal(classes="context-tab-bar"):
|
||||
yield Static("", id="tab-counts")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Initialize tab counts."""
|
||||
self._update_tab_counts()
|
||||
|
||||
def watch_visible(self, visible: bool) -> None:
|
||||
"""Handle visibility changes."""
|
||||
self.display = visible
|
||||
|
||||
def watch_problem_count(self, count: int) -> None:
|
||||
"""Update problem count display."""
|
||||
self._update_tab_counts()
|
||||
|
||||
def watch_todo_count(self, count: int) -> None:
|
||||
"""Update todo count display."""
|
||||
self._update_tab_counts()
|
||||
|
||||
def _update_tab_counts(self) -> None:
|
||||
"""Update the tab counts display."""
|
||||
try:
|
||||
counts = self.query_one("#tab-counts", Static)
|
||||
problem_style = "error-count" if self.problem_count > 0 else "success-count"
|
||||
todo_style = "warning-count" if self.todo_count > 0 else "success-count"
|
||||
|
||||
# Build count display
|
||||
parts = []
|
||||
if self.problem_count > 0:
|
||||
parts.append(f"[{problem_style}]⚠ {self.problem_count}[/]")
|
||||
else:
|
||||
parts.append(f"[{problem_style}]✓ 0[/]")
|
||||
|
||||
parts.append(f"[{todo_style}]☐ {self.todo_count}[/]")
|
||||
|
||||
counts.update(" │ ".join(parts))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def update_problems(self, problems: list[Problem]) -> None:
|
||||
"""Update problems view and count."""
|
||||
self.problem_count = len(problems)
|
||||
try:
|
||||
view = self.query_one("#problems-view", ProblemsView)
|
||||
view.update_problems(problems)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def update_todos(self, items: list[TodoItem]) -> None:
|
||||
"""Update TODOs view and count."""
|
||||
self.todo_count = len(items)
|
||||
try:
|
||||
view = self.query_one("#todos-view", TodosView)
|
||||
view.update_items(items)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def update_jira(self, content: str) -> None:
|
||||
"""Update Jira view content."""
|
||||
try:
|
||||
view = self.query_one("#jira-view", JiraView)
|
||||
view.update_content(content)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def set_jira_loading(self) -> None:
|
||||
"""Set Jira view to loading state."""
|
||||
try:
|
||||
view = self.query_one("#jira-view", JiraView)
|
||||
view.set_loading()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def set_jira_error(self, error: str) -> None:
|
||||
"""Set Jira view to error state."""
|
||||
try:
|
||||
view = self.query_one("#jira-view", JiraView)
|
||||
view.set_error(error)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def focus_tab(self, tab_id: str) -> None:
|
||||
"""Focus a specific tab."""
|
||||
tabs = self.query_one("#context-tabs", TabbedContent)
|
||||
tabs.active = f"context-{tab_id}"
|
||||
|
||||
def focus_problems(self) -> None:
|
||||
"""Focus problems tab."""
|
||||
self.focus_tab("problems")
|
||||
|
||||
def focus_todos(self) -> None:
|
||||
"""Focus TODOs tab."""
|
||||
self.focus_tab("todos")
|
||||
|
||||
def focus_jira(self) -> None:
|
||||
"""Focus Jira tab."""
|
||||
self.focus_tab("jira")
|
||||
|
||||
# Event forwarding
|
||||
def on_problems_view_problem_clicked(
|
||||
self,
|
||||
event: ProblemsView.ProblemClicked,
|
||||
) -> None:
|
||||
"""Forward problem click."""
|
||||
self.post_message(self.ProblemClicked(event.problem))
|
||||
|
||||
def on_todos_view_todo_clicked(self, event: TodosView.TodoClicked) -> None:
|
||||
"""Forward todo click."""
|
||||
self.post_message(self.TodoClicked(event.item))
|
||||
|
||||
def on_jira_view_refresh_requested(
|
||||
self,
|
||||
event: JiraView.RefreshRequested,
|
||||
) -> None:
|
||||
"""Forward Jira refresh request."""
|
||||
self.post_message(self.JiraRefreshRequested())
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Sidebar panel with Files, Git, and Tree tabs."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Container, Vertical
|
||||
from textual.message import Message
|
||||
from textual.reactive import reactive
|
||||
from textual.widgets import TabbedContent, TabPane
|
||||
|
||||
from clide.widgets.components.branch_status import BranchStatus
|
||||
from clide.widgets.components.files_view import FilesView
|
||||
from clide.widgets.components.git_changes import GitChangesView
|
||||
from clide.widgets.components.git_graph import GitGraphView
|
||||
|
||||
|
||||
class SidebarPanel(Vertical):
|
||||
"""Left sidebar panel with file browser, git changes, and git graph."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
SidebarPanel {
|
||||
width: 20%;
|
||||
min-width: 25;
|
||||
height: 100%;
|
||||
background: $surface;
|
||||
}
|
||||
|
||||
SidebarPanel TabbedContent {
|
||||
height: 1fr;
|
||||
}
|
||||
|
||||
SidebarPanel .sidebar-content {
|
||||
height: 1fr;
|
||||
}
|
||||
|
||||
SidebarPanel BranchStatus {
|
||||
dock: bottom;
|
||||
height: auto;
|
||||
}
|
||||
"""
|
||||
|
||||
class FileSelected(Message):
|
||||
"""Emitted when a file is selected from the file browser."""
|
||||
|
||||
def __init__(self, path: Path) -> None:
|
||||
self.path = path
|
||||
super().__init__()
|
||||
|
||||
class GitFileSelected(Message):
|
||||
"""Emitted when a file is selected from git changes."""
|
||||
|
||||
def __init__(self, path: Path, staged: bool) -> None:
|
||||
self.path = path
|
||||
self.staged = staged
|
||||
super().__init__()
|
||||
|
||||
class BranchChanged(Message):
|
||||
"""Emitted when the branch is changed."""
|
||||
|
||||
def __init__(self, branch: str) -> None:
|
||||
self.branch = branch
|
||||
super().__init__()
|
||||
|
||||
# Reactive state
|
||||
current_branch: reactive[str] = reactive("main")
|
||||
visible: reactive[bool] = reactive(True)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
workdir: Path | None = None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._workdir = workdir or Path.cwd()
|
||||
self.id = "panel-sidebar"
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Container(classes="sidebar-content"), TabbedContent(id="sidebar-tabs"):
|
||||
with TabPane("Files", id="sidebar-files"):
|
||||
yield FilesView(path=self._workdir)
|
||||
with TabPane("Git", id="sidebar-git"):
|
||||
yield GitChangesView()
|
||||
with TabPane("Tree", id="sidebar-tree"):
|
||||
yield GitGraphView()
|
||||
yield BranchStatus(current_branch=self.current_branch)
|
||||
|
||||
def watch_visible(self, visible: bool) -> None:
|
||||
"""Handle visibility changes."""
|
||||
self.display = visible
|
||||
|
||||
def watch_current_branch(self, branch: str) -> None:
|
||||
"""Update branch status when branch changes."""
|
||||
try:
|
||||
branch_status = self.query_one(BranchStatus)
|
||||
branch_status.branch = branch
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def update_git_status(
|
||||
self,
|
||||
staged: list,
|
||||
unstaged: list,
|
||||
) -> None:
|
||||
"""Update git changes view."""
|
||||
try:
|
||||
git_view = self.query_one(GitChangesView)
|
||||
git_view.update_changes(staged, unstaged)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def update_git_graph(self, commits: list) -> None:
|
||||
"""Update git graph view."""
|
||||
try:
|
||||
graph = self.query_one(GitGraphView)
|
||||
graph.update_graph(commits)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def update_branches(self, branches: list[str]) -> None:
|
||||
"""Update available branches."""
|
||||
try:
|
||||
branch_status = self.query_one(BranchStatus)
|
||||
branch_status.update_branches(branches)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def refresh_files(self) -> None:
|
||||
"""Refresh file browser."""
|
||||
try:
|
||||
files_view = self.query_one(FilesView)
|
||||
files_view.reload()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def focus_tab(self, tab_id: str) -> None:
|
||||
"""Focus a specific tab."""
|
||||
tabs = self.query_one("#sidebar-tabs", TabbedContent)
|
||||
tabs.active = tab_id
|
||||
|
||||
def on_files_view_file_selected(self, event: FilesView.FileSelected) -> None:
|
||||
"""Forward file selection."""
|
||||
self.post_message(self.FileSelected(event.path))
|
||||
|
||||
def on_git_changes_view_file_clicked(
|
||||
self,
|
||||
event: GitChangesView.FileClicked,
|
||||
) -> None:
|
||||
"""Forward git file selection."""
|
||||
self.post_message(self.GitFileSelected(event.path, event.staged))
|
||||
|
||||
def on_branch_status_branch_changed(
|
||||
self,
|
||||
event: BranchStatus.BranchChanged,
|
||||
) -> None:
|
||||
"""Forward branch change."""
|
||||
self.current_branch = event.branch
|
||||
self.post_message(self.BranchChanged(event.branch))
|
||||
@@ -0,0 +1,236 @@
|
||||
"""Workspace panel with Editor, Diff, and Terminal tabs."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Vertical
|
||||
from textual.message import Message
|
||||
from textual.reactive import reactive
|
||||
from textual.widgets import TabbedContent, TabPane
|
||||
|
||||
from clide.models.diff import DiffContent
|
||||
from clide.widgets.components.diff_pane import DiffPane
|
||||
from clide.widgets.components.editor_pane import EditorPane
|
||||
from clide.widgets.components.terminal_pane import TerminalPane
|
||||
|
||||
|
||||
class WorkspacePanel(Vertical):
|
||||
"""Center workspace panel with Editor, Diff, and Terminal tabs.
|
||||
|
||||
Hidden by default. Shows when:
|
||||
- File is opened
|
||||
- Diff is displayed
|
||||
- Terminal is activated
|
||||
"""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
WorkspacePanel {
|
||||
height: 60%;
|
||||
background: $background;
|
||||
}
|
||||
|
||||
WorkspacePanel.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
WorkspacePanel TabbedContent {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
WorkspacePanel TabPane {
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
"""
|
||||
|
||||
class FileSaved(Message):
|
||||
"""Emitted when a file is saved."""
|
||||
|
||||
def __init__(self, path: Path) -> None:
|
||||
self.path = path
|
||||
super().__init__()
|
||||
|
||||
class DiffAccepted(Message):
|
||||
"""Emitted when a diff is accepted."""
|
||||
|
||||
def __init__(self, file_path: str) -> None:
|
||||
self.file_path = file_path
|
||||
super().__init__()
|
||||
|
||||
class DiffRejected(Message):
|
||||
"""Emitted when a diff is rejected."""
|
||||
|
||||
def __init__(self, file_path: str) -> None:
|
||||
self.file_path = file_path
|
||||
super().__init__()
|
||||
|
||||
class CommandSubmitted(Message):
|
||||
"""Emitted when a terminal command is submitted."""
|
||||
|
||||
def __init__(self, command: str) -> None:
|
||||
self.command = command
|
||||
super().__init__()
|
||||
|
||||
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")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
workdir: Path | None = None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._workdir = workdir or Path.cwd()
|
||||
self.id = "panel-workspace"
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with TabbedContent(id="workspace-tabs"):
|
||||
with TabPane("Editor", id="workspace-editor"):
|
||||
yield EditorPane(id="editor-pane")
|
||||
with TabPane("Diff", id="workspace-diff"):
|
||||
yield DiffPane(id="diff-pane")
|
||||
with TabPane("Terminal", id="workspace-terminal"):
|
||||
yield TerminalPane(cwd=self._workdir, id="terminal-pane")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Set initial visibility."""
|
||||
self._update_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 show(self, tab: str | None = None) -> None:
|
||||
"""Show workspace, optionally focusing a specific tab."""
|
||||
self.visible = True
|
||||
if tab:
|
||||
self.focus_tab(tab)
|
||||
|
||||
def hide(self) -> None:
|
||||
"""Hide workspace (state is 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."""
|
||||
tabs = self.query_one("#workspace-tabs", TabbedContent)
|
||||
tabs.active = f"workspace-{tab_id}"
|
||||
self.active_tab = tab_id
|
||||
|
||||
# Editor methods
|
||||
def open_file(self, path: Path, line: int | None = None) -> None:
|
||||
"""Open a file in the editor tab."""
|
||||
self.show("editor")
|
||||
try:
|
||||
editor = self.query_one("#editor-pane", EditorPane)
|
||||
editor.load_file(path, goto_line=line)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def get_current_file(self) -> Path | None:
|
||||
"""Get the currently open file."""
|
||||
try:
|
||||
editor = self.query_one("#editor-pane", EditorPane)
|
||||
return editor.current_file
|
||||
except Exception:
|
||||
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
|
||||
|
||||
# Diff methods
|
||||
def show_diff(
|
||||
self,
|
||||
diff: DiffContent,
|
||||
is_proposal: bool = False,
|
||||
) -> None:
|
||||
"""Show a diff in the diff tab."""
|
||||
self.show("diff")
|
||||
try:
|
||||
diff_pane = self.query_one("#diff-pane", DiffPane)
|
||||
diff_pane.load_diff(diff, is_proposal)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def clear_diff(self) -> None:
|
||||
"""Clear the diff view."""
|
||||
try:
|
||||
diff_pane = self.query_one("#diff-pane", DiffPane)
|
||||
diff_pane.clear()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 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_input = terminal.query_one("#terminal-input")
|
||||
terminal_input.focus()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def write_terminal_output(self, text: str, style: str = "output") -> None:
|
||||
"""Write output to terminal."""
|
||||
try:
|
||||
terminal = self.query_one("#terminal-pane", TerminalPane)
|
||||
terminal.write_output(text, style)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def write_terminal_error(self, error: str) -> None:
|
||||
"""Write error to terminal."""
|
||||
try:
|
||||
terminal = self.query_one("#terminal-pane", TerminalPane)
|
||||
terminal.write_error(error)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def clear_terminal(self) -> None:
|
||||
"""Clear terminal output."""
|
||||
try:
|
||||
terminal = self.query_one("#terminal-pane", TerminalPane)
|
||||
terminal.clear()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Event forwarding
|
||||
def on_editor_pane_file_saved(self, event: EditorPane.FileSaved) -> None:
|
||||
"""Forward file save event."""
|
||||
self.post_message(self.FileSaved(event.path))
|
||||
|
||||
def on_diff_pane_accept_clicked(self, event: DiffPane.AcceptClicked) -> None:
|
||||
"""Forward diff accept event."""
|
||||
self.post_message(self.DiffAccepted(event.file_path))
|
||||
|
||||
def on_diff_pane_reject_clicked(self, event: DiffPane.RejectClicked) -> None:
|
||||
"""Forward diff reject event."""
|
||||
self.post_message(self.DiffRejected(event.file_path))
|
||||
|
||||
def on_terminal_pane_command_submitted(
|
||||
self,
|
||||
event: TerminalPane.CommandSubmitted,
|
||||
) -> None:
|
||||
"""Forward terminal command event."""
|
||||
self.post_message(self.CommandSubmitted(event.command))
|
||||
Reference in New Issue
Block a user