refactor(widgets): add TileList component and improve panel widgets
Add reusable TileListView component for consistent card-style lists. Refactor sidebar and context panels to use improved git integration, enhanced branch status display, and better component organization. Key changes: - New TileListView/TileItem for reusable styled lists - Enhanced git controller with graph traversal - Improved branch status with remote tracking info - Better file watcher integration Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude
parent
f9d37e3e7e
commit
6293b93d98
+85
-10
@@ -3,11 +3,13 @@
|
||||
from pathlib import Path
|
||||
from typing import ClassVar
|
||||
|
||||
from textual import work
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Horizontal, Vertical
|
||||
from textual.reactive import reactive
|
||||
from textual.widgets import Footer, Header
|
||||
from textual.worker import Worker, WorkerState
|
||||
|
||||
from clide.controllers.diff import DiffController
|
||||
from clide.controllers.editor import EditorController
|
||||
@@ -113,6 +115,32 @@ class ClideApp(App[None]):
|
||||
height: 100%;
|
||||
layer: fullscreen;
|
||||
}
|
||||
|
||||
/* Global button styling - outlined look */
|
||||
Button {
|
||||
background: transparent;
|
||||
border: solid $secondary;
|
||||
color: $foreground;
|
||||
margin: 0 1;
|
||||
}
|
||||
|
||||
Button:hover {
|
||||
background: $secondary 20%;
|
||||
border: solid $secondary;
|
||||
}
|
||||
|
||||
Button:focus {
|
||||
border: solid $primary;
|
||||
}
|
||||
|
||||
Button.-primary {
|
||||
border: solid $primary;
|
||||
color: $primary;
|
||||
}
|
||||
|
||||
Button.-primary:hover {
|
||||
background: $primary 20%;
|
||||
}
|
||||
"""
|
||||
|
||||
# Keybindings
|
||||
@@ -182,8 +210,10 @@ class ClideApp(App[None]):
|
||||
self.diff_controller = DiffController(self.workdir)
|
||||
self.problems_controller = ProblemsController(self.workdir)
|
||||
self.todos_controller = TodosController(self.workdir)
|
||||
# Use settings parameter if jira_enabled explicitly set, otherwise user settings
|
||||
jira_enabled = self.settings.jira_enabled or self._user_settings.jira_enabled
|
||||
self.jira_controller = JiraController(
|
||||
enabled=self._user_settings.jira_enabled,
|
||||
enabled=jira_enabled,
|
||||
)
|
||||
|
||||
# Register themes
|
||||
@@ -199,8 +229,12 @@ class ClideApp(App[None]):
|
||||
if theme_def:
|
||||
self.register_theme(theme_def.to_textual_theme())
|
||||
|
||||
# Set initial theme from user settings
|
||||
self.theme = self._user_settings.theme
|
||||
# Set initial theme: settings parameter takes precedence over user settings
|
||||
# Use settings.theme if different from default, otherwise user settings
|
||||
if self.settings.theme != "summer-night":
|
||||
self.theme = self.settings.theme
|
||||
else:
|
||||
self.theme = self._user_settings.theme
|
||||
|
||||
def set_theme(self, theme_name: str, *, save: bool = True) -> None:
|
||||
"""Set the application theme.
|
||||
@@ -309,9 +343,21 @@ class ClideApp(App[None]):
|
||||
"""
|
||||
event = message.event
|
||||
|
||||
# Ignore files in .clide directory (settings, etc.)
|
||||
if ".clide" in str(event.path):
|
||||
return
|
||||
|
||||
# Trigger extension hooks
|
||||
self.extension_manager.trigger_file_changed(event)
|
||||
|
||||
# Debounce: skip if we refreshed recently (within 1 second)
|
||||
import time
|
||||
|
||||
now = time.time()
|
||||
if hasattr(self, "_last_file_refresh") and now - self._last_file_refresh < 1.0:
|
||||
return
|
||||
self._last_file_refresh = now
|
||||
|
||||
# Refresh file tree for created/deleted/moved files
|
||||
if event.event_type in ("created", "deleted", "moved"):
|
||||
try:
|
||||
@@ -778,14 +824,43 @@ class ClideApp(App[None]):
|
||||
"""Handle Claude command request from git panel.
|
||||
|
||||
Sends skill commands (e.g., /commit) to Claude terminal.
|
||||
Ensures the git-workflow skill is installed before sending.
|
||||
Ensures the specific skill is installed before sending.
|
||||
"""
|
||||
# Ensure skill is available
|
||||
self.git_controller._ensure_git_skill()
|
||||
from clide.services.skill_installer import get_skill_installer
|
||||
|
||||
# Send command to Claude terminal
|
||||
# Extract skill name from command (e.g., "/commit" -> "commit")
|
||||
skill_name = event.command.lstrip("/").split()[0]
|
||||
|
||||
installer = get_skill_installer(project_dir=self.workdir)
|
||||
|
||||
# Quick check if already installed
|
||||
if installer.is_installed(skill_name):
|
||||
self._send_claude_command(event.command)
|
||||
return
|
||||
|
||||
# Need to install - show notification and do in background
|
||||
self.notify(f"Installing {skill_name} skill...", timeout=10)
|
||||
self._install_skill_and_run(skill_name, event.command)
|
||||
|
||||
@work(thread=True)
|
||||
def _install_skill_and_run(self, skill_name: str, command: str) -> tuple[str, str]:
|
||||
"""Install skill in background thread and return command to run."""
|
||||
self.git_controller._ensure_skill(skill_name)
|
||||
return (skill_name, command)
|
||||
|
||||
def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
|
||||
"""Handle worker completion."""
|
||||
if event.state == WorkerState.SUCCESS:
|
||||
# Check if this was a skill installation worker
|
||||
if event.worker.name == "_install_skill_and_run":
|
||||
result = event.worker.result
|
||||
if result:
|
||||
skill_name, command = result
|
||||
self._send_claude_command(command)
|
||||
self.notify(f"{skill_name} skill installed!", severity="information", timeout=3)
|
||||
|
||||
def _send_claude_command(self, command: str) -> None:
|
||||
"""Send a command to Claude terminal."""
|
||||
claude = self.query_one(ClaudePanel)
|
||||
claude.send_input(event.command)
|
||||
|
||||
# Focus Claude panel so user can see the response
|
||||
claude.send_input(command)
|
||||
self.action_focus_claude()
|
||||
|
||||
+104
-2
@@ -1,13 +1,23 @@
|
||||
"""Git controller for managing git operations."""
|
||||
|
||||
from pathlib import Path
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
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
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from clide.models.git import GitBranch, GitCommit, GitStatus
|
||||
from clide.services.skill_installer import get_skill_installer
|
||||
|
||||
# Git operations that can be delegated to Claude via skills
|
||||
GitSkillCommand = Literal["commit", "stash", "pull", "push", "branch"]
|
||||
|
||||
|
||||
@controller
|
||||
class GitController:
|
||||
@@ -199,3 +209,95 @@ class GitController:
|
||||
Diff string
|
||||
"""
|
||||
return await self._service.get_diff(path, staged)
|
||||
|
||||
# =========================================================================
|
||||
# Claude Skill Integration
|
||||
# =========================================================================
|
||||
|
||||
class ClaudeCommandRequested(Message):
|
||||
"""Emitted when a git command should be sent to Claude."""
|
||||
|
||||
def __init__(self, command: str) -> None:
|
||||
self.command = command
|
||||
super().__init__()
|
||||
|
||||
def _ensure_skill(self, skill_name: str) -> bool:
|
||||
"""Ensure a specific skill is installed.
|
||||
|
||||
Args:
|
||||
skill_name: The skill name (e.g., "commit", "stash").
|
||||
|
||||
Returns:
|
||||
True if skill is available.
|
||||
"""
|
||||
installer = get_skill_installer()
|
||||
|
||||
# Check if already installed
|
||||
if installer.is_installed(skill_name):
|
||||
return True
|
||||
|
||||
# Try to install from template (project scope by default)
|
||||
try:
|
||||
installer.install(skill_name, scope="project")
|
||||
return True
|
||||
except ValueError:
|
||||
# Template not found
|
||||
return False
|
||||
except FileExistsError:
|
||||
# Already exists (race condition)
|
||||
return True
|
||||
|
||||
def _ensure_git_skill(self) -> bool:
|
||||
"""Ensure all git skills are installed (legacy compatibility).
|
||||
|
||||
Returns:
|
||||
True if commit skill is available.
|
||||
"""
|
||||
return self._ensure_skill("commit")
|
||||
|
||||
def request_claude_commit(self) -> bool:
|
||||
"""Request Claude to handle the commit workflow.
|
||||
|
||||
Emits ClaudeCommandRequested with /commit command.
|
||||
|
||||
Returns:
|
||||
True if skill is available and command was requested.
|
||||
"""
|
||||
# The app will handle this message and send to Claude terminal
|
||||
return self._ensure_git_skill()
|
||||
|
||||
def request_claude_stash(self) -> bool:
|
||||
"""Request Claude to handle stashing changes.
|
||||
|
||||
Returns:
|
||||
True if skill is available and command was requested.
|
||||
"""
|
||||
return self._ensure_git_skill()
|
||||
|
||||
def request_claude_pull(self) -> bool:
|
||||
"""Request Claude to handle pulling changes.
|
||||
|
||||
Returns:
|
||||
True if skill is available and command was requested.
|
||||
"""
|
||||
return self._ensure_git_skill()
|
||||
|
||||
def request_claude_push(self) -> bool:
|
||||
"""Request Claude to handle pushing changes.
|
||||
|
||||
Returns:
|
||||
True if skill is available and command was requested.
|
||||
"""
|
||||
return self._ensure_git_skill()
|
||||
|
||||
def get_claude_command(self, action: GitSkillCommand) -> str:
|
||||
"""Get the Claude command string for a git action.
|
||||
|
||||
Args:
|
||||
action: The git action to perform.
|
||||
|
||||
Returns:
|
||||
The command string to send to Claude (e.g., "/commit").
|
||||
"""
|
||||
self._ensure_git_skill()
|
||||
return f"/{action}"
|
||||
|
||||
@@ -4,6 +4,7 @@ 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.skill_installer import SkillInstaller, get_skill_installer
|
||||
from clide.services.todo_scanner import TodoScanner
|
||||
|
||||
__all__ = [
|
||||
@@ -11,7 +12,9 @@ __all__ = [
|
||||
"LinterService",
|
||||
"ProcessService",
|
||||
"SettingsService",
|
||||
"SkillInstaller",
|
||||
"TodoScanner",
|
||||
"UserSettings",
|
||||
"get_settings_service",
|
||||
"get_skill_installer",
|
||||
]
|
||||
|
||||
@@ -6,27 +6,32 @@ enabling reactive updates when files change on disk.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Callable, Literal
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from textual.message import Message
|
||||
|
||||
try:
|
||||
from watchdog.observers import Observer as WatchdogObserver
|
||||
from watchdog.events import (
|
||||
DirCreatedEvent,
|
||||
DirDeletedEvent,
|
||||
DirModifiedEvent,
|
||||
DirMovedEvent,
|
||||
FileCreatedEvent,
|
||||
FileDeletedEvent,
|
||||
FileModifiedEvent,
|
||||
FileMovedEvent,
|
||||
)
|
||||
from watchdog.events import (
|
||||
FileSystemEventHandler as WatchdogHandler,
|
||||
FileCreatedEvent,
|
||||
FileModifiedEvent,
|
||||
FileDeletedEvent,
|
||||
FileMovedEvent,
|
||||
DirCreatedEvent,
|
||||
DirModifiedEvent,
|
||||
DirDeletedEvent,
|
||||
DirMovedEvent,
|
||||
)
|
||||
from watchdog.observers import Observer as WatchdogObserver
|
||||
|
||||
WATCHDOG_AVAILABLE = True
|
||||
except ImportError:
|
||||
WATCHDOG_AVAILABLE = False
|
||||
@@ -103,6 +108,8 @@ class FileWatcher:
|
||||
".ruff_cache",
|
||||
".pytest_cache",
|
||||
"*.egg-info",
|
||||
".clide",
|
||||
".claude",
|
||||
]
|
||||
self._handlers: list[Callable[[FileEvent], None]] = []
|
||||
self._observer: WatchdogObserver | None = None # type: ignore[valid-type]
|
||||
@@ -219,21 +226,15 @@ class _WatchdogHandler(WatchdogHandler): # type: ignore[misc, valid-type]
|
||||
)
|
||||
|
||||
def on_created(self, event) -> None: # type: ignore[no-untyped-def]
|
||||
file_event = self._create_event(
|
||||
event.src_path, "created", event.is_directory
|
||||
)
|
||||
file_event = self._create_event(event.src_path, "created", event.is_directory)
|
||||
self._watcher._emit_event(file_event)
|
||||
|
||||
def on_modified(self, event) -> None: # type: ignore[no-untyped-def]
|
||||
file_event = self._create_event(
|
||||
event.src_path, "modified", event.is_directory
|
||||
)
|
||||
file_event = self._create_event(event.src_path, "modified", event.is_directory)
|
||||
self._watcher._emit_event(file_event)
|
||||
|
||||
def on_deleted(self, event) -> None: # type: ignore[no-untyped-def]
|
||||
file_event = self._create_event(
|
||||
event.src_path, "deleted", event.is_directory
|
||||
)
|
||||
file_event = self._create_event(event.src_path, "deleted", event.is_directory)
|
||||
self._watcher._emit_event(file_event)
|
||||
|
||||
def on_moved(self, event) -> None: # type: ignore[no-untyped-def]
|
||||
|
||||
@@ -44,25 +44,35 @@ class GitService:
|
||||
|
||||
# Parse status
|
||||
if index_status == "?":
|
||||
# Untracked files go in both untracked list and unstaged
|
||||
untracked.append(path)
|
||||
unstaged.append(
|
||||
GitChange(
|
||||
path=path,
|
||||
status=ChangeStatus.UNTRACKED,
|
||||
staged=False,
|
||||
)
|
||||
)
|
||||
else:
|
||||
if index_status != " ":
|
||||
staged.append(GitChange(
|
||||
path=path,
|
||||
status=self._parse_status(index_status),
|
||||
staged=True,
|
||||
))
|
||||
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,
|
||||
))
|
||||
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_result = await self._process.run("git", "branch", "--show-current")
|
||||
branch = branch_result.stdout.strip() if branch_result.success else "HEAD"
|
||||
|
||||
# Get ahead/behind
|
||||
@@ -80,8 +90,7 @@ class GitService:
|
||||
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}}"
|
||||
"git", "rev-list", "--left-right", "--count", f"{branch}...@{{upstream}}"
|
||||
)
|
||||
if result.success:
|
||||
parts = result.stdout.strip().split()
|
||||
@@ -146,8 +155,11 @@ class GitService:
|
||||
List of GitBranch objects
|
||||
"""
|
||||
result = await self._process.run(
|
||||
"git", "branch", "-a", "--format",
|
||||
"%(HEAD)%(refname:short)|%(upstream:short)|%(objectname:short)|%(subject)"
|
||||
"git",
|
||||
"branch",
|
||||
"-a",
|
||||
"--format",
|
||||
"%(HEAD)%(refname:short)|%(upstream:short)|%(objectname:short)|%(subject)",
|
||||
)
|
||||
|
||||
branches: list[GitBranch] = []
|
||||
@@ -159,14 +171,16 @@ class GitService:
|
||||
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],
|
||||
))
|
||||
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
|
||||
|
||||
@@ -227,7 +241,8 @@ class GitService:
|
||||
List of GitCommit objects
|
||||
"""
|
||||
result = await self._process.run(
|
||||
"git", "log",
|
||||
"git",
|
||||
"log",
|
||||
f"--max-count={max_count}",
|
||||
"--format=%H|%h|%s|%an|%ar|%P|%D",
|
||||
"--all",
|
||||
@@ -242,15 +257,17 @@ class GitService:
|
||||
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,
|
||||
))
|
||||
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
|
||||
|
||||
@@ -9,6 +9,7 @@ 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.tile_list import TileItem, TileListView
|
||||
from clide.widgets.components.todos_view import TodosView
|
||||
|
||||
__all__ = [
|
||||
@@ -21,5 +22,7 @@ __all__ = [
|
||||
"JiraView",
|
||||
"ProblemsView",
|
||||
"TerminalPane",
|
||||
"TileItem",
|
||||
"TileListView",
|
||||
"TodosView",
|
||||
]
|
||||
|
||||
@@ -28,7 +28,27 @@ class BranchStatus(Vertical):
|
||||
}
|
||||
|
||||
BranchStatus .branch-name {
|
||||
width: auto;
|
||||
min-width: 12;
|
||||
}
|
||||
|
||||
BranchStatus .toggle-icon {
|
||||
width: auto;
|
||||
margin-right: 1;
|
||||
}
|
||||
|
||||
BranchStatus .git-stats {
|
||||
width: 1fr;
|
||||
text-align: right;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
BranchStatus .staged-count {
|
||||
color: $success;
|
||||
}
|
||||
|
||||
BranchStatus .unstaged-count {
|
||||
color: $warning;
|
||||
}
|
||||
|
||||
BranchStatus .popout {
|
||||
@@ -50,10 +70,22 @@ class BranchStatus(Vertical):
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
BranchStatus #branch-list {
|
||||
height: auto;
|
||||
max-height: 8;
|
||||
}
|
||||
|
||||
BranchStatus .popout-actions {
|
||||
height: 1;
|
||||
height: auto;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
BranchStatus .popout-actions Button {
|
||||
width: 1fr;
|
||||
min-width: 10;
|
||||
height: 3;
|
||||
margin: 0 1;
|
||||
}
|
||||
"""
|
||||
|
||||
class BranchChanged(Message):
|
||||
@@ -68,24 +100,30 @@ class BranchStatus(Vertical):
|
||||
|
||||
class NewBranchRequested(Message):
|
||||
"""Emitted when new branch creation is requested."""
|
||||
|
||||
pass
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
current_branch: str = "main",
|
||||
branches: list[GitBranch] | None = None,
|
||||
staged: int = 0,
|
||||
unstaged: int = 0,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._current = current_branch
|
||||
self._branches = branches or []
|
||||
self._popout_visible = False
|
||||
self._staged = staged
|
||||
self._unstaged = unstaged
|
||||
|
||||
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")
|
||||
yield Static(self._format_stats(), classes="git-stats", id="git-stats")
|
||||
|
||||
with Vertical(classes="popout", id="branch-popout"):
|
||||
yield Label("Recent branches", classes="popout-header")
|
||||
@@ -115,6 +153,25 @@ class BranchStatus(Vertical):
|
||||
"""Update current branch display."""
|
||||
self.branch = branch
|
||||
|
||||
def _format_stats(self) -> str:
|
||||
"""Format git stats display."""
|
||||
parts = []
|
||||
if self._staged > 0:
|
||||
parts.append(f"[staged-count]staged: {self._staged}[/]")
|
||||
if self._unstaged > 0:
|
||||
parts.append(f"[unstaged-count]unstaged: {self._unstaged}[/]")
|
||||
return " · ".join(parts) if parts else ""
|
||||
|
||||
def update_stats(self, staged: int, unstaged: int) -> None:
|
||||
"""Update staged/unstaged counts."""
|
||||
self._staged = staged
|
||||
self._unstaged = unstaged
|
||||
try:
|
||||
stats = self.query_one("#git-stats", Static)
|
||||
stats.update(self._format_stats())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def update_branches(self, branches: list[GitBranch] | list[str]) -> None:
|
||||
"""Update branches list."""
|
||||
self._branches = branches # type: ignore
|
||||
@@ -146,14 +203,14 @@ class BranchStatus(Vertical):
|
||||
"""Handle click on status bar."""
|
||||
self.toggle_popout()
|
||||
|
||||
def on_button_pressed(self, _event: Button.Pressed) -> None:
|
||||
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("● ○ ")
|
||||
branch = str(label.renderable).lstrip("●").lstrip("○").lstrip()
|
||||
self.post_message(self.BranchChangeRequested(branch))
|
||||
self.toggle_popout()
|
||||
elif event.button.id == "btn-new":
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from rich.text import Text
|
||||
@@ -10,6 +9,8 @@ from textual.message import Message
|
||||
from textual.widgets import DirectoryTree
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from rich.style import Style
|
||||
from textual.widgets._directory_tree import DirEntry
|
||||
from textual.widgets._tree import TreeNode
|
||||
@@ -55,32 +56,47 @@ class FilesView(DirectoryTree):
|
||||
classes=classes,
|
||||
)
|
||||
|
||||
def render_label(
|
||||
self, node: TreeNode[DirEntry], base_style: Style, style: Style
|
||||
) -> Text:
|
||||
def render_label(self, node: TreeNode[DirEntry], base_style: Style, style: Style) -> Text:
|
||||
"""Render a label with minimal Unicode icons."""
|
||||
path = node.data.path
|
||||
is_dimmed = path.name.startswith(".") or path.name in self.DIMMED_PATHS
|
||||
|
||||
if path.is_dir():
|
||||
icon = ICON_FOLDER_OPEN if node.is_expanded else ICON_FOLDER_CLOSED
|
||||
icon_style = "bold cyan"
|
||||
icon_style = "dim cyan" if is_dimmed else "bold cyan"
|
||||
else:
|
||||
icon = ICON_FILE
|
||||
icon_style = "dim"
|
||||
|
||||
label = Text()
|
||||
label.append(f"{icon} ", style=icon_style)
|
||||
label.append(path.name, style=style)
|
||||
label.append(path.name, style="dim" if is_dimmed else 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")
|
||||
]
|
||||
# Directories that clutter the tree and are never needed in the IDE
|
||||
HIDDEN_DIRS = {
|
||||
"__pycache__",
|
||||
"node_modules",
|
||||
".git",
|
||||
".mypy_cache",
|
||||
".pytest_cache",
|
||||
".ruff_cache",
|
||||
".tox",
|
||||
}
|
||||
|
||||
# Directories/files shown but dimmed (less important)
|
||||
DIMMED_PATHS = {
|
||||
".venv",
|
||||
"venv",
|
||||
"dist",
|
||||
"build",
|
||||
}
|
||||
|
||||
def filter_paths(self, paths: list[Path]) -> list[Path]:
|
||||
"""Filter out noisy directories but show hidden files."""
|
||||
return [
|
||||
p for p in paths if p.name not in self.HIDDEN_DIRS and not p.name.endswith(".egg-info")
|
||||
]
|
||||
|
||||
def refresh_tree(self) -> None:
|
||||
"""Refresh the directory tree."""
|
||||
@@ -100,7 +116,7 @@ class FilesView(DirectoryTree):
|
||||
# Find and select the node
|
||||
def find_node(node, target_path):
|
||||
"""Recursively find a node by path."""
|
||||
if node.data and hasattr(node.data, 'path'):
|
||||
if node.data and hasattr(node.data, "path"):
|
||||
if node.data.path.resolve() == target_path:
|
||||
return node
|
||||
for child in node.children:
|
||||
@@ -121,9 +137,7 @@ class FilesView(DirectoryTree):
|
||||
self.select_node(target_node)
|
||||
self.scroll_to_node(target_node)
|
||||
|
||||
def on_directory_tree_file_selected(
|
||||
self, event: DirectoryTree.FileSelected
|
||||
) -> None:
|
||||
def on_directory_tree_file_selected(self, event: DirectoryTree.FileSelected) -> None:
|
||||
"""Re-emit file selection as FilesView.FileSelected."""
|
||||
event.stop()
|
||||
self.post_message(self.FileSelected(node=event.node, path=event.path))
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""Git changes view component."""
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Vertical
|
||||
from textual.containers import Horizontal, Vertical
|
||||
from textual.message import Message
|
||||
from textual.widgets import Label, ListItem, ListView, Static
|
||||
from textual.widgets import Button, Label, ListItem, ListView, Static
|
||||
|
||||
from clide.models.git import ChangeStatus, GitChange
|
||||
|
||||
@@ -22,15 +22,30 @@ class GitChangeItem(ListItem):
|
||||
ChangeStatus.IGNORED: "I",
|
||||
}
|
||||
|
||||
# Map status to Rich color styles
|
||||
STATUS_COLORS = {
|
||||
ChangeStatus.ADDED: "green",
|
||||
ChangeStatus.MODIFIED: "yellow",
|
||||
ChangeStatus.DELETED: "red",
|
||||
ChangeStatus.RENAMED: "cyan",
|
||||
ChangeStatus.UNTRACKED: "magenta",
|
||||
ChangeStatus.COPIED: "cyan",
|
||||
ChangeStatus.UNMERGED: "red bold",
|
||||
ChangeStatus.IGNORED: "dim",
|
||||
}
|
||||
|
||||
def __init__(self, change: GitChange) -> None:
|
||||
super().__init__()
|
||||
self.change = change
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
from rich.markup import escape
|
||||
|
||||
icon = self.STATUS_ICONS.get(self.change.status, "?")
|
||||
status_class = self.change.status.value
|
||||
color = self.STATUS_COLORS.get(self.change.status, "white")
|
||||
safe_path = escape(self.change.path)
|
||||
yield Static(
|
||||
f"[{status_class}]{icon}[/] {self.change.path}",
|
||||
f"[{color}]{icon}[/] {safe_path}",
|
||||
markup=True,
|
||||
)
|
||||
|
||||
@@ -40,24 +55,69 @@ class GitChangesView(Vertical):
|
||||
|
||||
DEFAULT_CSS = """
|
||||
GitChangesView {
|
||||
height: 100%;
|
||||
height: 1fr;
|
||||
background: $background;
|
||||
}
|
||||
|
||||
GitChangesView .section-header {
|
||||
background: $surface;
|
||||
padding: 0 1;
|
||||
height: 1;
|
||||
text-style: bold;
|
||||
border-bottom: solid $primary;
|
||||
}
|
||||
|
||||
GitChangesView ListView {
|
||||
height: auto;
|
||||
max-height: 50%;
|
||||
height: 1fr;
|
||||
min-height: 3;
|
||||
scrollbar-size: 1 1;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
GitChangesView .added { color: $success; }
|
||||
GitChangesView .modified { color: $warning; }
|
||||
GitChangesView .deleted { color: $error; }
|
||||
GitChangesView .untracked { color: $accent; }
|
||||
GitChangesView #staged-list {
|
||||
background: $panel;
|
||||
}
|
||||
|
||||
GitChangesView #staged-list ListItem:even {
|
||||
background: $panel;
|
||||
}
|
||||
|
||||
GitChangesView #staged-list ListItem:odd {
|
||||
background: $surface;
|
||||
}
|
||||
|
||||
GitChangesView #unstaged-list {
|
||||
background: $background;
|
||||
}
|
||||
|
||||
GitChangesView #unstaged-list ListItem:even {
|
||||
background: $background;
|
||||
}
|
||||
|
||||
GitChangesView #unstaged-list ListItem:odd {
|
||||
background: $panel;
|
||||
}
|
||||
|
||||
GitChangesView ListItem {
|
||||
height: auto;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
GitChangesView ListItem Static {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
GitChangesView .action-bar {
|
||||
dock: bottom;
|
||||
height: 3;
|
||||
padding: 0 1;
|
||||
background: $surface;
|
||||
border-top: solid $primary;
|
||||
}
|
||||
|
||||
GitChangesView .action-bar Button {
|
||||
min-width: 6;
|
||||
}
|
||||
"""
|
||||
|
||||
class FileClicked(Message):
|
||||
@@ -81,6 +141,13 @@ class GitChangesView(Vertical):
|
||||
self.path = path
|
||||
super().__init__()
|
||||
|
||||
class ClaudeActionRequested(Message):
|
||||
"""Emitted when a Claude git action is requested."""
|
||||
|
||||
def __init__(self, action: str) -> None:
|
||||
self.action = action # "commit", "stash", "pull", "push"
|
||||
super().__init__()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
staged: list[GitChange] | None = None,
|
||||
@@ -102,6 +169,11 @@ class GitChangesView(Vertical):
|
||||
*[GitChangeItem(c) for c in self._unstaged],
|
||||
id="unstaged-list",
|
||||
)
|
||||
with Horizontal(classes="action-bar"):
|
||||
yield Button("Commit", id="btn-commit", variant="primary")
|
||||
yield Button("Stash", id="btn-stash")
|
||||
yield Button("Pull", id="btn-pull")
|
||||
yield Button("Push", id="btn-push")
|
||||
|
||||
def update_changes(
|
||||
self,
|
||||
@@ -127,3 +199,15 @@ class GitChangesView(Vertical):
|
||||
"""Handle item selection."""
|
||||
if isinstance(event.item, GitChangeItem):
|
||||
self.post_message(self.FileClicked(event.item.change))
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
"""Handle action button clicks."""
|
||||
button_id = event.button.id
|
||||
action_map = {
|
||||
"btn-commit": "commit",
|
||||
"btn-stash": "stash",
|
||||
"btn-pull": "pull",
|
||||
"btn-push": "push",
|
||||
}
|
||||
if button_id in action_map:
|
||||
self.post_message(self.ClaudeActionRequested(action_map[button_id]))
|
||||
|
||||
@@ -1,37 +1,48 @@
|
||||
"""Git graph visualization component."""
|
||||
|
||||
from rich.markup import escape
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Vertical
|
||||
from textual.message import Message
|
||||
from textual.widgets import RichLog
|
||||
from textual.widgets import ListView, Static
|
||||
|
||||
from clide.models.git import GitCommit
|
||||
from clide.widgets.components.tile_list import TileItem, TileListView
|
||||
|
||||
|
||||
class GitGraphView(Vertical):
|
||||
"""View for git commit graph visualization."""
|
||||
class CommitItem(TileItem):
|
||||
"""A single commit item displayed as a tile."""
|
||||
|
||||
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 = "┴"
|
||||
|
||||
def __init__(self, commit: GitCommit) -> None:
|
||||
super().__init__()
|
||||
self.commit = commit
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
symbol = self.MERGE if self.commit.is_merge else self.COMMIT
|
||||
|
||||
# Format refs (branches, tags)
|
||||
refs_str = ""
|
||||
if self.commit.refs:
|
||||
refs = ", ".join(self.commit.refs)
|
||||
refs_str = f" [bold cyan]({escape(refs)})[/]"
|
||||
|
||||
# Truncate message
|
||||
message = self.commit.message[:60]
|
||||
if len(self.commit.message) > 60:
|
||||
message += "..."
|
||||
|
||||
# Multi-line tile format
|
||||
yield Static(
|
||||
f"[bold yellow]{symbol}[/] [bold]{escape(message)}[/]{refs_str}\n"
|
||||
f" [dim]{self.commit.short_hash} · {escape(self.commit.author)} · {self.commit.date}[/]",
|
||||
markup=True,
|
||||
)
|
||||
|
||||
|
||||
class GitGraphView(TileListView):
|
||||
"""View for git commit graph visualization."""
|
||||
|
||||
class CommitSelected(Message):
|
||||
"""Emitted when a commit is selected."""
|
||||
@@ -45,46 +56,23 @@ class GitGraphView(Vertical):
|
||||
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()
|
||||
yield ListView(
|
||||
*[CommitItem(c) for c in self._commits],
|
||||
id="commit-list",
|
||||
)
|
||||
|
||||
def update_commits(self, commits: list[GitCommit]) -> None:
|
||||
"""Update the commit list."""
|
||||
self._commits = commits
|
||||
self._render_graph()
|
||||
try:
|
||||
commit_list = self.query_one("#commit-list", ListView)
|
||||
commit_list.clear()
|
||||
for commit in commits:
|
||||
commit_list.append(CommitItem(commit))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
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}[/]"
|
||||
)
|
||||
def on_list_view_selected(self, event: ListView.Selected) -> None:
|
||||
"""Handle commit selection."""
|
||||
if isinstance(event.item, CommitItem):
|
||||
self.post_message(self.CommitSelected(event.item.commit))
|
||||
|
||||
@@ -11,35 +11,41 @@ class JiraView(Vertical):
|
||||
|
||||
DEFAULT_CSS = """
|
||||
JiraView {
|
||||
height: 100%;
|
||||
height: 1fr;
|
||||
background: $background;
|
||||
}
|
||||
|
||||
JiraView .jira-header {
|
||||
height: 1;
|
||||
background: $surface;
|
||||
padding: 0 1;
|
||||
border-bottom: solid $primary;
|
||||
}
|
||||
|
||||
JiraView Markdown {
|
||||
height: 1fr;
|
||||
padding: 1;
|
||||
background: $background;
|
||||
}
|
||||
|
||||
JiraView .jira-actions {
|
||||
height: auto;
|
||||
padding: 0 1;
|
||||
background: $panel;
|
||||
padding: 1;
|
||||
background: $surface;
|
||||
border-top: solid $primary;
|
||||
}
|
||||
|
||||
JiraView .disabled-message {
|
||||
padding: 2;
|
||||
text-align: center;
|
||||
color: $warning;
|
||||
background: $panel;
|
||||
}
|
||||
"""
|
||||
|
||||
class RefreshRequested(Message):
|
||||
"""Emitted when refresh is requested."""
|
||||
|
||||
pass
|
||||
|
||||
class IssueClicked(Message):
|
||||
|
||||
@@ -2,16 +2,17 @@
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from rich.markup import escape
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Vertical
|
||||
from textual.message import Message
|
||||
from textual.widgets import ListItem, ListView, Static
|
||||
from textual.widgets import ListView, Static
|
||||
|
||||
from clide.models.problems import Problem
|
||||
from clide.widgets.components.tile_list import TileItem, TileListView
|
||||
|
||||
|
||||
class ProblemItem(ListItem):
|
||||
"""A single problem item."""
|
||||
class ProblemItem(TileItem):
|
||||
"""A single problem item displayed as a tile."""
|
||||
|
||||
def __init__(self, problem: Problem) -> None:
|
||||
super().__init__()
|
||||
@@ -20,31 +21,35 @@ class ProblemItem(ListItem):
|
||||
def compose(self) -> ComposeResult:
|
||||
icon = self.problem.severity_icon
|
||||
severity_class = self.problem.severity.value
|
||||
# Show just filename
|
||||
filename = (
|
||||
self.problem.file_path.name
|
||||
if hasattr(self.problem.file_path, "name")
|
||||
else str(self.problem.file_path).split("/")[-1]
|
||||
)
|
||||
safe_message = escape(self.problem.message)
|
||||
|
||||
yield Static(
|
||||
f"[{severity_class}]{icon}[/] "
|
||||
f"[dim]{self.problem.file_path}:{self.problem.line}[/] "
|
||||
f"{self.problem.message}",
|
||||
f"[{severity_class}]{icon}[/] [{severity_class}]{safe_message}[/]\n"
|
||||
f" [dim]{filename}:{self.problem.line}[/]",
|
||||
markup=True,
|
||||
)
|
||||
|
||||
|
||||
class ProblemsView(Vertical):
|
||||
class ProblemsView(TileListView):
|
||||
"""View for linter problems/diagnostics."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
ProblemsView {
|
||||
height: 100%;
|
||||
height: 1fr;
|
||||
background: $background;
|
||||
}
|
||||
|
||||
ProblemsView .problems-header {
|
||||
height: 1;
|
||||
background: $surface;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
ProblemsView ListView {
|
||||
height: 1fr;
|
||||
border-bottom: solid $primary;
|
||||
}
|
||||
|
||||
ProblemsView .error { color: $error; }
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Reusable tile/card list components for consistent styling."""
|
||||
|
||||
from textual.containers import Vertical
|
||||
from textual.widgets import ListItem
|
||||
|
||||
|
||||
class TileItem(ListItem):
|
||||
"""A styled tile/card list item.
|
||||
|
||||
Subclass this and override compose() to create custom tiles.
|
||||
The tile will automatically get alternating background colors
|
||||
and hover effects from TileListView.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class TileListView(Vertical):
|
||||
"""A list view with tile/card styling.
|
||||
|
||||
Provides:
|
||||
- Alternating row colors using theme colors
|
||||
- Hover effects
|
||||
- Consistent padding and spacing
|
||||
- Scrollbar styling
|
||||
|
||||
Usage:
|
||||
class MyTileItem(TileItem):
|
||||
def __init__(self, data: MyData) -> None:
|
||||
super().__init__()
|
||||
self.data = data
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Static(f"[bold]{self.data.title}[/]\\n[dim]{self.data.subtitle}[/]", markup=True)
|
||||
|
||||
class MyView(TileListView):
|
||||
def compose(self) -> ComposeResult:
|
||||
yield ListView(*[MyTileItem(d) for d in self.items], id="my-list")
|
||||
"""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
TileListView {
|
||||
height: 1fr;
|
||||
background: $background;
|
||||
}
|
||||
|
||||
TileListView ListView {
|
||||
height: 1fr;
|
||||
scrollbar-size: 1 1;
|
||||
background: $background;
|
||||
}
|
||||
|
||||
TileListView ListItem {
|
||||
height: auto;
|
||||
padding: 1 1;
|
||||
}
|
||||
|
||||
TileListView ListItem:even {
|
||||
background: $background;
|
||||
}
|
||||
|
||||
TileListView ListItem:odd {
|
||||
background: $panel;
|
||||
}
|
||||
|
||||
TileListView ListItem:hover {
|
||||
background: $surface;
|
||||
}
|
||||
|
||||
TileListView ListItem:focus {
|
||||
background: $surface;
|
||||
}
|
||||
|
||||
TileListView ListItem Static {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
TileListView .tile-header {
|
||||
background: $surface;
|
||||
padding: 0 1;
|
||||
height: 1;
|
||||
text-style: bold;
|
||||
border-bottom: solid $primary;
|
||||
}
|
||||
|
||||
TileListView .empty-message {
|
||||
padding: 2;
|
||||
text-align: center;
|
||||
color: $secondary;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
# CSS that can be included in other components for tile styling
|
||||
TILE_LIST_CSS = """
|
||||
/* Tile list styling - include in your component's DEFAULT_CSS */
|
||||
|
||||
ListView {
|
||||
height: 1fr;
|
||||
scrollbar-size: 1 1;
|
||||
background: $background;
|
||||
}
|
||||
|
||||
ListItem {
|
||||
height: auto;
|
||||
padding: 1 1;
|
||||
}
|
||||
|
||||
ListItem:even {
|
||||
background: $background;
|
||||
}
|
||||
|
||||
ListItem:odd {
|
||||
background: $panel;
|
||||
}
|
||||
|
||||
ListItem:hover {
|
||||
background: $surface;
|
||||
}
|
||||
|
||||
ListItem:focus {
|
||||
background: $surface;
|
||||
}
|
||||
|
||||
ListItem Static {
|
||||
width: 100%;
|
||||
}
|
||||
"""
|
||||
@@ -1,13 +1,15 @@
|
||||
"""TODOs view component."""
|
||||
"""TODOs view component with sub-tabs for Project and Comment TODOs."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from rich.markup import escape
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Vertical
|
||||
from textual.message import Message
|
||||
from textual.widgets import Button, Collapsible, ListItem, ListView, Static
|
||||
from textual.widgets import Button, ListView, Static, TabbedContent, TabPane
|
||||
|
||||
from clide.models.todos import ProjectTodoItem, TodoItem, TodoType
|
||||
from clide.widgets.components.tile_list import TileItem, TileListView
|
||||
|
||||
# Template for new TODO.md files
|
||||
TODO_MD_TEMPLATE = """# TODO
|
||||
@@ -42,8 +44,8 @@ Project TODO items.
|
||||
"""
|
||||
|
||||
|
||||
class TodoListItem(ListItem):
|
||||
"""A single code TODO item."""
|
||||
class TodoListItem(TileItem):
|
||||
"""A single code TODO item displayed as a tile."""
|
||||
|
||||
def __init__(self, item: TodoItem) -> None:
|
||||
super().__init__()
|
||||
@@ -53,17 +55,23 @@ class TodoListItem(ListItem):
|
||||
def compose(self) -> ComposeResult:
|
||||
icon = self.item.type_icon
|
||||
type_class = self.item.todo_type.value.lower()
|
||||
# Show just filename, not full path
|
||||
filename = (
|
||||
self.item.file_path.name
|
||||
if hasattr(self.item.file_path, "name")
|
||||
else str(self.item.file_path).split("/")[-1]
|
||||
)
|
||||
# Escape user content to prevent markup errors
|
||||
safe_text = escape(self.item.text)
|
||||
|
||||
yield Static(
|
||||
f"[{type_class}]{icon} {self.item.todo_type.value}[/] "
|
||||
f"[dim]{self.item.file_path}:{self.item.line}[/] "
|
||||
f"{self.item.text}",
|
||||
f"[{type_class}]{icon}[/] {safe_text}\n" f" [dim]{filename}:{self.item.line}[/]",
|
||||
markup=True,
|
||||
)
|
||||
|
||||
|
||||
class ProjectTodoListItem(ListItem):
|
||||
"""A single project TODO item from TODO.md."""
|
||||
class ProjectTodoListItem(TileItem):
|
||||
"""A single project TODO item from TODO.md displayed as a tile."""
|
||||
|
||||
def __init__(self, item: ProjectTodoItem) -> None:
|
||||
super().__init__()
|
||||
@@ -72,54 +80,54 @@ class ProjectTodoListItem(ListItem):
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
icon = self.item.icon
|
||||
checked_style = "dim strike" if self.item.checked else ""
|
||||
category = f"[dim]{self.item.category}[/] " if self.item.subsection else ""
|
||||
category = f"[dim]{escape(self.item.category)}[/] " if self.item.subsection else ""
|
||||
safe_text = escape(self.item.text)
|
||||
|
||||
text_part = f"[dim strike]{safe_text}[/]" if self.item.checked else safe_text
|
||||
|
||||
yield Static(
|
||||
f"[project]{icon}[/] {category}" f"[{checked_style}]{self.item.text}[/]",
|
||||
f"[project]{icon}[/] {category}{text_part}",
|
||||
markup=True,
|
||||
)
|
||||
|
||||
|
||||
class TodosView(Vertical):
|
||||
"""View for TODO/FIXME comments and project TODOs."""
|
||||
class TodosView(TileListView):
|
||||
"""View for TODO/FIXME comments and project TODOs with sub-tabs."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
TodosView {
|
||||
height: 100%;
|
||||
height: 1fr;
|
||||
background: $background;
|
||||
}
|
||||
|
||||
TodosView .todos-header {
|
||||
height: 1;
|
||||
background: $surface;
|
||||
padding: 0 1;
|
||||
TodosView TabbedContent {
|
||||
height: 1fr;
|
||||
}
|
||||
|
||||
TodosView .section-header {
|
||||
height: 1;
|
||||
background: $panel;
|
||||
padding: 0 1;
|
||||
color: $text-muted;
|
||||
TodosView Tabs {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
TodosView ListView {
|
||||
height: auto;
|
||||
max-height: 50%;
|
||||
TodosView Tab {
|
||||
width: 1fr;
|
||||
}
|
||||
|
||||
TodosView TabPane {
|
||||
height: 1fr;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
TodosView ContentSwitcher {
|
||||
height: 1fr;
|
||||
}
|
||||
|
||||
TodosView #project-todos-list,
|
||||
TodosView #code-todos-list {
|
||||
height: 1fr;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
TodosView Collapsible {
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
TodosView CollapsibleTitle {
|
||||
background: $panel;
|
||||
padding: 0 1;
|
||||
TodosView ListItem {
|
||||
padding: 1 1;
|
||||
}
|
||||
|
||||
TodosView .todo { color: $primary; }
|
||||
@@ -133,7 +141,7 @@ class TodosView(Vertical):
|
||||
TodosView .empty-message {
|
||||
padding: 2;
|
||||
text-align: center;
|
||||
color: $success;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
TodosView .create-todo-section {
|
||||
@@ -193,26 +201,28 @@ class TodosView(Vertical):
|
||||
self._has_todo_md = (self._project_path / "TODO.md").exists()
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
total = len(self._items) + len(self._project_items)
|
||||
yield Static(f"TODOs ({total})", classes="todos-header", id="todos-header")
|
||||
# Calculate initial counts
|
||||
unchecked_project = sum(1 for i in self._project_items if not i.checked)
|
||||
code_count = len(self._items)
|
||||
|
||||
# Create TODO.md section (shown when file doesn't exist)
|
||||
with Vertical(classes="create-todo-section", id="create-todo-section"):
|
||||
yield Static(
|
||||
"No TODO.md found in project",
|
||||
classes="create-todo-message",
|
||||
)
|
||||
yield Button("Create TODO.md", id="create-todo-btn", variant="primary")
|
||||
with TabbedContent(id="todos-tabs"):
|
||||
# Project TODOs tab (default, for Claude collaboration)
|
||||
with TabPane(f"Project ({unchecked_project})", id="tab-project"):
|
||||
# Create TODO.md section (shown when file doesn't exist)
|
||||
with Vertical(classes="create-todo-section", id="create-todo-section"):
|
||||
yield Static(
|
||||
"No TODO.md found in project",
|
||||
classes="create-todo-message",
|
||||
)
|
||||
yield Button("Create TODO.md", id="create-todo-btn", variant="primary")
|
||||
|
||||
# Project TODOs section (collapsible)
|
||||
with Collapsible(title="Project TODOs", id="project-todos-section"):
|
||||
yield ListView(id="project-todos-list")
|
||||
yield ListView(id="project-todos-list")
|
||||
yield Static("No project TODOs", classes="empty-message", id="project-empty")
|
||||
|
||||
# Code TODOs section
|
||||
yield Static("Code TODOs", classes="section-header", id="code-todos-header")
|
||||
yield ListView(id="code-todos-list")
|
||||
|
||||
yield Static("No TODOs found", classes="empty-message", id="todos-empty")
|
||||
# Code Comment TODOs tab
|
||||
with TabPane(f"Comments ({code_count})", id="tab-comments"):
|
||||
yield ListView(id="code-todos-list")
|
||||
yield Static("No code TODOs found ✓", classes="empty-message", id="code-empty")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Initialize the lists with items."""
|
||||
@@ -222,11 +232,10 @@ class TodosView(Vertical):
|
||||
"""Refresh both list views with current items."""
|
||||
try:
|
||||
create_section = self.query_one("#create-todo-section", Vertical)
|
||||
project_section = self.query_one("#project-todos-section", Collapsible)
|
||||
project_list = self.query_one("#project-todos-list", ListView)
|
||||
code_header = self.query_one("#code-todos-header", Static)
|
||||
project_empty = self.query_one("#project-empty", Static)
|
||||
code_list = self.query_one("#code-todos-list", ListView)
|
||||
empty_msg = self.query_one("#todos-empty", Static)
|
||||
code_empty = self.query_one("#code-empty", Static)
|
||||
|
||||
# Clear both lists
|
||||
project_list.clear()
|
||||
@@ -235,54 +244,77 @@ class TodosView(Vertical):
|
||||
# Check if TODO.md exists
|
||||
self._has_todo_md = (self._project_path / "TODO.md").exists()
|
||||
|
||||
has_items = False
|
||||
|
||||
# Show create button if no TODO.md and no project items
|
||||
# Project TODOs tab
|
||||
if not self._has_todo_md and not self._project_items:
|
||||
# Show create button
|
||||
create_section.display = True
|
||||
project_section.display = False
|
||||
project_list.display = False
|
||||
project_empty.display = False
|
||||
else:
|
||||
create_section.display = False
|
||||
|
||||
# Populate project TODOs
|
||||
if self._project_items:
|
||||
has_items = True
|
||||
# Group by section
|
||||
sections: dict[str, list[ProjectTodoItem]] = {}
|
||||
# Add unchecked items
|
||||
for item in self._project_items:
|
||||
if item.section not in sections:
|
||||
sections[item.section] = []
|
||||
sections[item.section].append(item)
|
||||
|
||||
# Add items (flat list, grouped display can be added later)
|
||||
for item in self._project_items:
|
||||
if not item.checked: # Only show unchecked by default
|
||||
if not item.checked:
|
||||
project_list.append(ProjectTodoListItem(item))
|
||||
|
||||
unchecked = sum(1 for i in self._project_items if not i.checked)
|
||||
project_section.title = f"Project TODOs ({unchecked})"
|
||||
project_section.display = True
|
||||
project_list.display = unchecked > 0
|
||||
project_empty.display = unchecked == 0
|
||||
else:
|
||||
project_section.display = False
|
||||
project_list.display = False
|
||||
project_empty.display = True
|
||||
|
||||
# Populate code TODOs
|
||||
# Code TODOs tab
|
||||
if self._items:
|
||||
has_items = True
|
||||
for item in self._items:
|
||||
code_list.append(TodoListItem(item))
|
||||
code_header.update(f"Code TODOs ({len(self._items)})")
|
||||
code_header.display = True
|
||||
code_list.display = True
|
||||
code_empty.display = False
|
||||
else:
|
||||
code_header.display = False
|
||||
code_list.display = False
|
||||
code_empty.display = True
|
||||
|
||||
# Show empty message only if no items at all and TODO.md exists
|
||||
empty_msg.display = not has_items and self._has_todo_md
|
||||
# Update tab labels with counts
|
||||
self._update_tab_labels()
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _update_tab_labels(self) -> None:
|
||||
"""Update tab labels with current counts."""
|
||||
try:
|
||||
tabs = self.query_one("#todos-tabs", TabbedContent)
|
||||
|
||||
unchecked_project = sum(1 for i in self._project_items if not i.checked)
|
||||
code_count = len(self._items)
|
||||
|
||||
# Update tab labels via the Tabs widget
|
||||
# Tab IDs include the pane ID, e.g., "--content-tab-tab-project"
|
||||
for tab in tabs.query("Tab"):
|
||||
tab_id = str(tab.id) if tab.id else ""
|
||||
if "tab-project" in tab_id:
|
||||
tab.label = f"Project ({unchecked_project})"
|
||||
elif "tab-comments" in tab_id:
|
||||
tab.label = f"Comments ({code_count})"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def update_tab_counts(self, project_count: int, code_count: int) -> None:
|
||||
"""Update tab labels with provided counts (called from ContextPanel)."""
|
||||
try:
|
||||
tabs = self.query_one("#todos-tabs", TabbedContent)
|
||||
|
||||
for tab in tabs.query("Tab"):
|
||||
tab_id = str(tab.id) if tab.id else ""
|
||||
if "tab-project" in tab_id:
|
||||
tab.label = f"Project ({project_count})"
|
||||
elif "tab-comments" in tab_id:
|
||||
tab.label = f"Comments ({code_count})"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def update_items(
|
||||
self,
|
||||
items: list[TodoItem],
|
||||
@@ -292,15 +324,6 @@ class TodosView(Vertical):
|
||||
self._items = items
|
||||
self._project_items = project_items or []
|
||||
|
||||
# Update header with total count
|
||||
try:
|
||||
unchecked_project = sum(1 for p in self._project_items if not p.checked)
|
||||
total = len(items) + unchecked_project
|
||||
header = self.query_one("#todos-header", Static)
|
||||
header.update(f"TODOs ({total})")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Refresh the lists
|
||||
self._refresh_lists()
|
||||
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
from pathlib import Path
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Horizontal, Vertical
|
||||
from textual.containers import Vertical
|
||||
from textual.message import Message
|
||||
from textual.reactive import reactive
|
||||
from textual.widgets import Static, TabbedContent, TabPane
|
||||
from textual.widgets import TabbedContent, TabPane
|
||||
|
||||
from clide.models.problems import Problem
|
||||
from clide.models.todos import ProjectTodoItem, TodoItem
|
||||
@@ -30,11 +30,9 @@ class ContextPanel(Vertical):
|
||||
height: 1fr;
|
||||
}
|
||||
|
||||
ContextPanel .context-tab-bar {
|
||||
dock: bottom;
|
||||
height: 1;
|
||||
background: $panel;
|
||||
padding: 0 1;
|
||||
ContextPanel TabPane {
|
||||
height: 1fr;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
ContextPanel .tab-count {
|
||||
@@ -90,6 +88,8 @@ class ContextPanel(Vertical):
|
||||
# Reactive state with counts for tab badges
|
||||
problem_count: reactive[int] = reactive(0)
|
||||
todo_count: reactive[int] = reactive(0)
|
||||
project_todo_count: reactive[int] = reactive(0)
|
||||
code_todo_count: reactive[int] = reactive(0)
|
||||
visible: reactive[bool] = reactive(True)
|
||||
|
||||
def __init__(
|
||||
@@ -107,17 +107,14 @@ class ContextPanel(Vertical):
|
||||
with TabbedContent(id="context-tabs"):
|
||||
with TabPane("Jira", id="context-jira"):
|
||||
yield JiraView(enabled=self._jira_enabled, id="jira-view")
|
||||
with TabPane("TODOs", id="context-todos"):
|
||||
with TabPane("TODOs (0)", id="context-todos"):
|
||||
yield TodosView(project_path=self._project_path, id="todos-view")
|
||||
with TabPane("Problems", id="context-problems"):
|
||||
with TabPane("Problems (0)", id="context-problems"):
|
||||
yield ProblemsView(id="problems-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()
|
||||
self._update_tab_headers()
|
||||
|
||||
def watch_visible(self, visible: bool) -> None:
|
||||
"""Handle visibility changes."""
|
||||
@@ -125,29 +122,32 @@ class ContextPanel(Vertical):
|
||||
|
||||
def watch_problem_count(self, count: int) -> None:
|
||||
"""Update problem count display."""
|
||||
self._update_tab_counts()
|
||||
self._update_tab_headers()
|
||||
|
||||
def watch_todo_count(self, count: int) -> None:
|
||||
"""Update todo count display."""
|
||||
self._update_tab_counts()
|
||||
self._update_tab_headers()
|
||||
|
||||
def _update_tab_counts(self) -> None:
|
||||
"""Update the tab counts display."""
|
||||
def _update_tab_headers(self) -> None:
|
||||
"""Update the tab headers with counts."""
|
||||
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"
|
||||
tabs = self.query_one("#context-tabs", TabbedContent)
|
||||
|
||||
# Build count display (order matches tab order: TODOs, Problems)
|
||||
parts = []
|
||||
parts.append(f"[{todo_style}]☐ {self.todo_count}[/]")
|
||||
# Update tab labels via the Tabs widget
|
||||
for tab in tabs.query("Tab"):
|
||||
tab_id = str(tab.id) if tab.id else ""
|
||||
if "context-todos" in tab_id:
|
||||
tab.label = f"TODOs ({self.todo_count})"
|
||||
elif "context-problems" in tab_id:
|
||||
tab.label = f"Problems ({self.problem_count})"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if self.problem_count > 0:
|
||||
parts.append(f"[{problem_style}]⚠ {self.problem_count}[/]")
|
||||
else:
|
||||
parts.append(f"[{problem_style}]✓ 0[/]")
|
||||
|
||||
counts.update(" │ ".join(parts))
|
||||
def _update_todos_subtabs(self) -> None:
|
||||
"""Update the TODOs view sub-tab labels."""
|
||||
try:
|
||||
view = self.query_one("#todos-view", TodosView)
|
||||
view.update_tab_counts(self.project_todo_count, self.code_todo_count)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -169,10 +169,14 @@ class ContextPanel(Vertical):
|
||||
project_items = project_items or []
|
||||
# Count includes both code TODOs and unchecked project TODOs
|
||||
unchecked_project = sum(1 for p in project_items if not p.checked)
|
||||
self.todo_count = len(items) + unchecked_project
|
||||
self.project_todo_count = unchecked_project
|
||||
self.code_todo_count = len(items)
|
||||
self.todo_count = unchecked_project + len(items)
|
||||
try:
|
||||
view = self.query_one("#todos-view", TodosView)
|
||||
view.update_items(items, project_items)
|
||||
# Update sub-tab counts
|
||||
self._update_todos_subtabs()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@@ -33,6 +33,15 @@ class SidebarPanel(Vertical):
|
||||
height: 1fr;
|
||||
}
|
||||
|
||||
SidebarPanel TabbedContent {
|
||||
height: 1fr;
|
||||
}
|
||||
|
||||
SidebarPanel TabPane {
|
||||
height: 1fr;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
SidebarPanel BranchStatus {
|
||||
dock: bottom;
|
||||
height: auto;
|
||||
@@ -61,6 +70,13 @@ class SidebarPanel(Vertical):
|
||||
self.branch = branch
|
||||
super().__init__()
|
||||
|
||||
class ClaudeCommandRequested(Message):
|
||||
"""Emitted when a Claude command is requested (e.g., /commit)."""
|
||||
|
||||
def __init__(self, command: str) -> None:
|
||||
self.command = command
|
||||
super().__init__()
|
||||
|
||||
# Reactive state
|
||||
current_branch: reactive[str] = reactive("main")
|
||||
visible: reactive[bool] = reactive(True)
|
||||
@@ -101,18 +117,25 @@ class SidebarPanel(Vertical):
|
||||
staged: list,
|
||||
unstaged: list,
|
||||
) -> None:
|
||||
"""Update git changes view."""
|
||||
"""Update git changes view and branch stats."""
|
||||
try:
|
||||
git_view = self.query_one(GitChangesView)
|
||||
git_view.update_changes(staged, unstaged)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Update branch status with staged/unstaged counts
|
||||
try:
|
||||
branch_status = self.query_one(BranchStatus)
|
||||
branch_status.update_stats(len(staged), len(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)
|
||||
graph.update_commits(commits)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -157,7 +180,7 @@ class SidebarPanel(Vertical):
|
||||
event: GitChangesView.FileClicked,
|
||||
) -> None:
|
||||
"""Forward git file selection."""
|
||||
self.post_message(self.GitFileSelected(event.path, event.staged))
|
||||
self.post_message(self.GitFileSelected(Path(event.change.path), event.change.staged))
|
||||
|
||||
def on_branch_status_branch_changed(
|
||||
self,
|
||||
@@ -166,3 +189,12 @@ class SidebarPanel(Vertical):
|
||||
"""Forward branch change."""
|
||||
self.current_branch = event.branch
|
||||
self.post_message(self.BranchChanged(event.branch))
|
||||
|
||||
def on_git_changes_view_claude_action_requested(
|
||||
self,
|
||||
event: GitChangesView.ClaudeActionRequested,
|
||||
) -> None:
|
||||
"""Forward Claude action request (commit, stash, pull, push)."""
|
||||
# Convert action to Claude skill command
|
||||
command = f"/{event.action}"
|
||||
self.post_message(self.ClaudeCommandRequested(command))
|
||||
|
||||
Reference in New Issue
Block a user