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:
Jeroen Schweitzer
2026-02-01 21:22:00 +01:00
co-authored by Claude
parent f9d37e3e7e
commit 6293b93d98
16 changed files with 842 additions and 300 deletions
+85 -10
View File
@@ -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
View File
@@ -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}"
+3
View File
@@ -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",
]
+21 -20
View File
@@ -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]
+53 -36
View File
@@ -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
+3
View File
@@ -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",
]
+60 -3
View File
@@ -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":
+31 -17
View File
@@ -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))
+95 -11
View File
@@ -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]))
+49 -61
View File
@@ -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))
+9 -3
View File
@@ -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):
+18 -13
View File
@@ -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; }
+128
View File
@@ -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%;
}
"""
+114 -91
View File
@@ -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()
+34 -30
View File
@@ -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
+35 -3
View File
@@ -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))