move python clide to legacy/

Clide is being rebuilt as a Flutter desktop app. The Python Textual
implementation moves wholesale into legacy/ rather than being deleted:
its pane model, panel set, git skills, and panel communication design
are real thought that should remain readable next to the new code
while the rebuild finds its shape. Git's rename tracking preserves
history, so `git log -- legacy/` still works.

The Flutter rebuild lives at the repo root alongside a Go sidecar
(the architecture claudian was heading toward, which folds into
clide as a core component rather than a separate plugin project).
Bootstrap of the new shape lands in subsequent commits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-20 20:30:51 +02:00
co-authored by Claude Opus 4.7
parent 30b8cf7db9
commit a355751437
163 changed files with 0 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
"""Domain controllers for Clide."""
from clide.controllers.base import ControllerMixin, controller
from clide.controllers.diff import DiffController
from clide.controllers.editor import EditorController
from clide.controllers.git import GitController
from clide.controllers.jira import JiraController
from clide.controllers.problems import ProblemsController
from clide.controllers.todos import TodosController
__all__ = [
"controller",
"ControllerMixin",
"GitController",
"EditorController",
"DiffController",
"ProblemsController",
"TodosController",
"JiraController",
]
+102
View File
@@ -0,0 +1,102 @@
"""Base controller utilities using decorator pattern."""
from functools import wraps
from typing import TYPE_CHECKING
from textual.message import Message
if TYPE_CHECKING:
from textual.app import App
def controller[T](cls: type[T]) -> type[T]:
"""Decorator to add controller capabilities to a class.
Adds:
- _app attribute for parent application reference
- set_app() method to set the application
- post_message() method to emit messages
- initialize() and shutdown() lifecycle hooks (if not defined)
Usage:
@controller
class GitController:
def __init__(self, workdir: Path) -> None:
self.workdir = workdir
async def get_status(self) -> GitStatus:
...
"""
original_init = cls.__init__
@wraps(original_init)
def new_init(self, *args, **kwargs):
self._app = None
original_init(self, *args, **kwargs)
cls.__init__ = new_init
def set_app(self, app: "App[object]") -> None:
"""Set the parent application."""
self._app = app
def post_message(self, message: Message) -> None:
"""Post a message to the application's message queue."""
if self._app:
self._app.post_message(message)
async def initialize(self) -> None:
"""Initialize the controller. Called after app mount."""
pass
async def shutdown(self) -> None:
"""Clean up resources. Called before app exit."""
pass
# Only add methods if they don't exist
if not hasattr(cls, "set_app"):
cls.set_app = set_app
if not hasattr(cls, "post_message"):
cls.post_message = post_message
if not hasattr(cls, "initialize"):
cls.initialize = initialize
if not hasattr(cls, "shutdown"):
cls.shutdown = shutdown
return cls
class ControllerMixin:
"""Mixin alternative for controller capabilities.
Use this if you prefer inheritance over decorators.
Usage:
class GitController(ControllerMixin):
def __init__(self, workdir: Path) -> None:
self.workdir = workdir
"""
_app: "App[object] | None" = None
def set_app(self, app: "App[object]") -> None:
"""Set the parent application."""
self._app = app
@property
def app(self) -> "App[object] | None":
"""Get the parent application."""
return self._app
def post_message(self, message: Message) -> None:
"""Post a message to the application's message queue."""
if self._app:
self._app.post_message(message)
async def initialize(self) -> None:
"""Initialize the controller. Called after app mount."""
pass
async def shutdown(self) -> None:
"""Clean up resources. Called before app exit."""
pass
+330
View File
@@ -0,0 +1,330 @@
"""Diff controller for viewing and managing diffs."""
from pathlib import Path
from textual.message import Message
from clide.controllers.base import controller
from clide.models.diff import ChangeType, DiffContent, DiffHunk, DiffLine, DiffViewState
from clide.services.git_service import GitService
@controller
class DiffController:
"""Controller for diff viewing and Claude-proposed changes."""
class DiffLoaded(Message):
"""Emitted when a diff is loaded."""
def __init__(self, diff: DiffContent) -> None:
self.diff = diff
super().__init__()
class HunkAccepted(Message):
"""Emitted when a hunk is accepted."""
def __init__(self, hunk_index: int) -> None:
self.hunk_index = hunk_index
super().__init__()
class HunkRejected(Message):
"""Emitted when a hunk is rejected."""
def __init__(self, hunk_index: int) -> None:
self.hunk_index = hunk_index
super().__init__()
class AllChangesAccepted(Message):
"""Emitted when all changes are accepted."""
pass
class AllChangesRejected(Message):
"""Emitted when all changes are rejected."""
pass
def __init__(self, repo_path: Path) -> None:
self._git_service = GitService(repo_path)
self._state = DiffViewState()
@property
def state(self) -> DiffViewState:
"""Get diff view state."""
return self._state
@property
def diff(self) -> DiffContent | None:
"""Get current diff content."""
return self._state.diff
@property
def is_proposal(self) -> bool:
"""Check if current diff is a Claude proposal."""
return self._state.is_proposal
async def load_git_diff(self, path: str, staged: bool = False) -> DiffContent | None:
"""Load diff from git.
Args:
path: File path
staged: Whether to load staged diff
Returns:
DiffContent or None if no diff
"""
diff_text = await self._git_service.get_diff(path, staged)
if not diff_text:
return None
diff = self._parse_diff(path, diff_text)
self._state.diff = diff
self._state.is_proposal = False
self._state.accepted_hunks = set()
self._state.rejected_hunks = set()
return diff
def load_proposal(self, path: str, old_content: str, new_content: str) -> DiffContent:
"""Load a Claude-proposed change as a diff.
Args:
path: File path
old_content: Original content
new_content: Proposed content
Returns:
DiffContent of the proposal
"""
diff = self._create_diff_from_content(path, old_content, new_content)
self._state.diff = diff
self._state.is_proposal = True
self._state.accepted_hunks = set()
self._state.rejected_hunks = set()
return diff
def accept_hunk(self, index: int) -> None:
"""Accept a specific hunk.
Args:
index: Hunk index
"""
self._state.accepted_hunks.add(index)
self._state.rejected_hunks.discard(index)
def reject_hunk(self, index: int) -> None:
"""Reject a specific hunk.
Args:
index: Hunk index
"""
self._state.rejected_hunks.add(index)
self._state.accepted_hunks.discard(index)
def accept_all(self) -> None:
"""Accept all hunks."""
if self._state.diff:
for i in range(len(self._state.diff.hunks)):
self._state.accepted_hunks.add(i)
self._state.rejected_hunks.clear()
def reject_all(self) -> None:
"""Reject all hunks."""
if self._state.diff:
for i in range(len(self._state.diff.hunks)):
self._state.rejected_hunks.add(i)
self._state.accepted_hunks.clear()
def clear(self) -> None:
"""Clear current diff."""
self._state.diff = None
self._state.is_proposal = False
self._state.accepted_hunks = set()
self._state.rejected_hunks = set()
def toggle_side_by_side(self) -> bool:
"""Toggle side-by-side view.
Returns:
New side_by_side value
"""
self._state.side_by_side = not self._state.side_by_side
return self._state.side_by_side
def _parse_diff(self, path: str, diff_text: str) -> DiffContent:
"""Parse git diff output into DiffContent."""
hunks: list[DiffHunk] = []
current_hunk_lines: list[DiffLine] = []
current_header = ""
old_start = old_count = new_start = new_count = 0
for line in diff_text.split("\n"):
if line.startswith("@@"):
# Save previous hunk
if current_hunk_lines:
hunks.append(DiffHunk(
header=current_header,
old_start=old_start,
old_count=old_count,
new_start=new_start,
new_count=new_count,
lines=tuple(current_hunk_lines),
))
current_hunk_lines = []
# Parse hunk header
current_header = line
# Format: @@ -old_start,old_count +new_start,new_count @@
import re
match = re.match(r"@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@", line)
if match:
old_start = int(match.group(1))
old_count = int(match.group(2)) if match.group(2) else 1
new_start = int(match.group(3))
new_count = int(match.group(4)) if match.group(4) else 1
elif line.startswith("+") and not line.startswith("+++"):
current_hunk_lines.append(DiffLine(
change_type=ChangeType.ADDED,
content=line[1:],
new_line_num=new_start + len([ln for ln in current_hunk_lines if ln.change_type != ChangeType.REMOVED]),
))
elif line.startswith("-") and not line.startswith("---"):
current_hunk_lines.append(DiffLine(
change_type=ChangeType.REMOVED,
content=line[1:],
old_line_num=old_start + len([ln for ln in current_hunk_lines if ln.change_type != ChangeType.ADDED]),
))
elif line.startswith(" "):
old_num = old_start + len([ln for ln in current_hunk_lines if ln.change_type != ChangeType.ADDED])
new_num = new_start + len([ln for ln in current_hunk_lines if ln.change_type != ChangeType.REMOVED])
current_hunk_lines.append(DiffLine(
change_type=ChangeType.CONTEXT,
content=line[1:],
old_line_num=old_num,
new_line_num=new_num,
))
# Save last hunk
if current_hunk_lines:
hunks.append(DiffHunk(
header=current_header,
old_start=old_start,
old_count=old_count,
new_start=new_start,
new_count=new_count,
lines=tuple(current_hunk_lines),
))
return DiffContent(
file_path=path,
hunks=tuple(hunks),
)
async def get_file_diff(self, path: str, staged: bool = False) -> DiffContent | None:
"""Get diff for a file (alias for load_git_diff).
Args:
path: File path
staged: Whether to get staged diff
Returns:
DiffContent or None
"""
return await self.load_git_diff(path, staged)
async def accept_proposal(self, _file_path: str) -> bool:
"""Accept a proposed change and apply it.
Args:
file_path: Path to the file
Returns:
True if successful
"""
if not self._state.diff or not self._state.is_proposal:
return False
self.accept_all()
# TODO: Apply the changes to the file
self.clear()
return True
async def reject_proposal(self, _file_path: str) -> bool:
"""Reject a proposed change.
Args:
file_path: Path to the file
Returns:
True if successful
"""
self.reject_all()
self.clear()
return True
def _create_diff_from_content(self, path: str, old: str, new: str) -> DiffContent:
"""Create diff from old and new content."""
import difflib
old_lines = old.splitlines(keepends=True)
new_lines = new.splitlines(keepends=True)
diff_lines: list[DiffLine] = []
old_num = new_num = 1
for tag, i1, i2, j1, j2 in difflib.SequenceMatcher(None, old_lines, new_lines).get_opcodes():
if tag == "equal":
for line in old_lines[i1:i2]:
diff_lines.append(DiffLine(
change_type=ChangeType.CONTEXT,
content=line.rstrip("\n"),
old_line_num=old_num,
new_line_num=new_num,
))
old_num += 1
new_num += 1
elif tag == "delete":
for line in old_lines[i1:i2]:
diff_lines.append(DiffLine(
change_type=ChangeType.REMOVED,
content=line.rstrip("\n"),
old_line_num=old_num,
))
old_num += 1
elif tag == "insert":
for line in new_lines[j1:j2]:
diff_lines.append(DiffLine(
change_type=ChangeType.ADDED,
content=line.rstrip("\n"),
new_line_num=new_num,
))
new_num += 1
elif tag == "replace":
for line in old_lines[i1:i2]:
diff_lines.append(DiffLine(
change_type=ChangeType.REMOVED,
content=line.rstrip("\n"),
old_line_num=old_num,
))
old_num += 1
for line in new_lines[j1:j2]:
diff_lines.append(DiffLine(
change_type=ChangeType.ADDED,
content=line.rstrip("\n"),
new_line_num=new_num,
))
new_num += 1
hunk = DiffHunk(
header="@@ -1 +1 @@",
old_start=1,
old_count=len(old_lines),
new_start=1,
new_count=len(new_lines),
lines=tuple(diff_lines),
)
return DiffContent(
file_path=path,
hunks=(hunk,),
)
+214
View File
@@ -0,0 +1,214 @@
"""Editor controller for managing open files."""
from pathlib import Path
from textual.message import Message
from clide.controllers.base import controller
from clide.models.editor import CursorPosition, EditorState, FileBuffer
@controller
class EditorController:
"""Controller for editor state and file operations."""
class FileOpened(Message):
"""Emitted when a file is opened."""
def __init__(self, buffer: FileBuffer) -> None:
self.buffer = buffer
super().__init__()
class FileClosed(Message):
"""Emitted when a file is closed."""
def __init__(self, path: Path) -> None:
self.path = path
super().__init__()
class FileSaved(Message):
"""Emitted when a file is saved."""
def __init__(self, path: Path) -> None:
self.path = path
super().__init__()
class FileModified(Message):
"""Emitted when file content changes."""
def __init__(self, path: Path, is_modified: bool) -> None:
self.path = path
self.is_modified = is_modified
super().__init__()
class ActiveBufferChanged(Message):
"""Emitted when active buffer changes."""
def __init__(self, buffer: FileBuffer | None) -> None:
self.buffer = buffer
super().__init__()
def __init__(self, project_path: Path | None = None) -> None:
self._project_path = project_path or Path.cwd()
self._state = EditorState()
@property
def state(self) -> EditorState:
"""Get editor state."""
return self._state
@property
def active_buffer(self) -> FileBuffer | None:
"""Get currently active buffer."""
return self._state.active_buffer
@property
def open_files(self) -> list[FileBuffer]:
"""Get list of open file buffers."""
return self._state.buffers
@property
def has_unsaved_changes(self) -> bool:
"""Check if any buffer has unsaved changes."""
return any(b.is_modified for b in self._state.buffers)
async def open_file(self, path: Path, line: int | None = None) -> FileBuffer:
"""Open a file in the editor.
Args:
path: File path to open
line: Optional line number to jump to
Returns:
FileBuffer for the opened file
"""
# Check if already open
existing = self._state.get_buffer_by_path(path)
if existing:
self._set_active_buffer(existing)
if line:
existing.cursor = CursorPosition(line=line - 1, column=0)
return existing
# Read file content
content = await self._service.read_file(path)
language = await self._service.get_language(path)
cursor = CursorPosition(line=line - 1 if line else 0, column=0)
buffer = FileBuffer(
path=path,
content=content,
language=language,
cursor=cursor,
)
self._state.buffers.append(buffer)
self._set_active_buffer(buffer)
# Add to recent files
if path not in self._state.recent_files:
self._state.recent_files.insert(0, path)
self._state.recent_files = self._state.recent_files[:20]
return buffer
async def close_file(self, path: Path) -> bool:
"""Close a file buffer.
Args:
path: File path to close
Returns:
True if closed (may be False if unsaved and user cancels)
"""
buffer = self._state.get_buffer_by_path(path)
if not buffer:
return True
# Remove from buffers
self._state.buffers.remove(buffer)
# Update active buffer
if self._state.active_buffer_index is not None and self._state.active_buffer_index >= len(
self._state.buffers
):
self._state.active_buffer_index = (
len(self._state.buffers) - 1 if self._state.buffers else None
)
return True
async def save_file(self, path: Path | None = None) -> bool:
"""Save a file.
Args:
path: File path (defaults to active buffer)
Returns:
True if saved successfully
"""
buffer = self._state.get_buffer_by_path(path) if path else self.active_buffer
if not buffer:
return False
await self._service.write_file(buffer.path, buffer.content)
buffer.is_modified = False
return True
async def save_all(self) -> int:
"""Save all modified buffers.
Returns:
Number of files saved
"""
count = 0
for buffer in self._state.buffers:
if buffer.is_modified:
await self._service.write_file(buffer.path, buffer.content)
buffer.is_modified = False
count += 1
return count
def update_content(self, path: Path, content: str) -> None:
"""Update buffer content.
Args:
path: File path
content: New content
"""
buffer = self._state.get_buffer_by_path(path)
if buffer:
buffer.content = content
buffer.is_modified = True
def update_cursor(self, path: Path, line: int, column: int) -> None:
"""Update cursor position.
Args:
path: File path
line: Line number (0-indexed)
column: Column number (0-indexed)
"""
buffer = self._state.get_buffer_by_path(path)
if buffer:
buffer.cursor = CursorPosition(line=line, column=column)
def set_active_by_index(self, index: int) -> None:
"""Set active buffer by index.
Args:
index: Buffer index
"""
if 0 <= index < len(self._state.buffers):
self._state.active_buffer_index = index
def _set_active_buffer(self, buffer: FileBuffer) -> None:
"""Set active buffer."""
try:
index = self._state.buffers.index(buffer)
self._state.active_buffer_index = index
except ValueError:
pass
+303
View File
@@ -0,0 +1,303 @@
"""Git controller for managing git operations."""
from __future__ import annotations
from typing import TYPE_CHECKING, Literal
from textual.message import Message
from clide.controllers.base import controller
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:
"""Controller for git operations."""
class StatusUpdated(Message):
"""Emitted when git status changes."""
def __init__(self, status: GitStatus) -> None:
self.status = status
super().__init__()
class BranchesUpdated(Message):
"""Emitted when branches list changes."""
def __init__(self, branches: list[GitBranch]) -> None:
self.branches = branches
super().__init__()
class LogUpdated(Message):
"""Emitted when commit log is refreshed."""
def __init__(self, commits: list[GitCommit]) -> None:
self.commits = commits
super().__init__()
class FileStaged(Message):
"""Emitted when a file is staged."""
def __init__(self, path: str) -> None:
self.path = path
super().__init__()
class FileUnstaged(Message):
"""Emitted when a file is unstaged."""
def __init__(self, path: str) -> None:
self.path = path
super().__init__()
def __init__(self, repo_path: Path) -> None:
self._service = GitService(repo_path)
self._status: GitStatus | None = None
self._branches: list[GitBranch] = []
self._commits: list[GitCommit] = []
@property
def status(self) -> GitStatus | None:
"""Get current git status."""
return self._status
@property
def branches(self) -> list[GitBranch]:
"""Get list of branches."""
return self._branches
@property
def commits(self) -> list[GitCommit]:
"""Get commit log."""
return self._commits
@property
def current_branch(self) -> str:
"""Get current branch name."""
return self._status.branch if self._status else "unknown"
async def refresh_status(self) -> GitStatus:
"""Refresh git status.
Returns:
Updated GitStatus
"""
self._status = await self._service.get_status()
return self._status
async def get_status(self) -> GitStatus | None:
"""Get git status (refreshes if needed)."""
return await self.refresh_status()
async def refresh_branches(self) -> list[GitBranch]:
"""Refresh branches list.
Returns:
Updated list of branches
"""
self._branches = await self._service.get_branches()
return self._branches
async def get_branches(self) -> list[GitBranch]:
"""Get branches list (refreshes if needed)."""
return await self.refresh_branches()
async def refresh_log(self, max_count: int = 50) -> list[GitCommit]:
"""Refresh commit log.
Args:
max_count: Maximum commits to fetch
Returns:
Updated list of commits
"""
self._commits = await self._service.get_log(max_count)
return self._commits
async def get_log(self, limit: int = 50) -> list[GitCommit]:
"""Get commit log (refreshes if needed)."""
return await self.refresh_log(limit)
async def stage_file(self, path: str) -> bool:
"""Stage a file.
Args:
path: File path to stage
Returns:
True if successful
"""
success = await self._service.stage_file(path)
if success:
await self.refresh_status()
return success
async def unstage_file(self, path: str) -> bool:
"""Unstage a file.
Args:
path: File path to unstage
Returns:
True if successful
"""
success = await self._service.unstage_file(path)
if success:
await self.refresh_status()
return success
async def discard_changes(self, path: str) -> bool:
"""Discard changes to a file.
Args:
path: File path
Returns:
True if successful
"""
success = await self._service.discard_changes(path)
if success:
await self.refresh_status()
return success
async def checkout_branch(self, branch: str) -> bool:
"""Checkout a branch.
Args:
branch: Branch name
Returns:
True if successful
"""
success = await self._service.checkout_branch(branch)
if success:
await self.refresh_status()
await self.refresh_branches()
return success
async def create_branch(self, name: str) -> bool:
"""Create and checkout a new branch.
Args:
name: New branch name
Returns:
True if successful
"""
success = await self._service.create_branch(name)
if success:
await self.refresh_status()
await self.refresh_branches()
return success
async def get_file_diff(self, path: str, staged: bool = False) -> str:
"""Get diff for a file.
Args:
path: File path
staged: Whether to get staged diff
Returns:
Diff string
"""
return await self._service.get_diff(path, staged)
# =========================================================================
# 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}"
+158
View File
@@ -0,0 +1,158 @@
"""Jira controller for Jira CLI integration."""
from pathlib import Path
from textual.message import Message
from clide.controllers.base import controller
from clide.services.process_service import ProcessService
@controller
class JiraController:
"""Controller for Jira CLI integration."""
class JiraOutputUpdated(Message):
"""Emitted when Jira output is updated."""
def __init__(self, output: str) -> None:
self.output = output
super().__init__()
class JiraError(Message):
"""Emitted when Jira command fails."""
def __init__(self, error: str) -> None:
self.error = error
super().__init__()
def __init__(
self,
project_path: Path | None = None,
jira_cli_path: str = "jira",
enabled: bool = False,
) -> None:
self._project_path = project_path or Path.cwd()
self._jira_cli = jira_cli_path
self._enabled = enabled
self._last_output: str = ""
self._last_error: str = ""
@property
def enabled(self) -> bool:
"""Check if Jira integration is enabled."""
return self._enabled
@property
def last_output(self) -> str:
"""Get last Jira output."""
return self._last_output
@property
def last_error(self) -> str:
"""Get last error message."""
return self._last_error
def enable(self) -> None:
"""Enable Jira integration."""
self._enabled = True
def disable(self) -> None:
"""Disable Jira integration."""
self._enabled = False
async def run_command(self, *args: str) -> str:
"""Run a Jira CLI command.
Args:
*args: Command arguments
Returns:
Command output
"""
if not self._enabled:
return "Jira integration is disabled"
process = ProcessService(cwd=self._project_path)
result = await process.run(self._jira_cli, *args)
if result.success:
self._last_output = result.stdout
self._last_error = ""
return result.stdout
else:
self._last_error = result.stderr
return f"Error: {result.stderr}"
async def list_issues(self, project: str | None = None) -> str:
"""List Jira issues.
Args:
project: Optional project key
Returns:
Formatted issue list
"""
args = ["issue", "list"]
if project:
args.extend(["--project", project])
return await self.run_command(*args)
async def get_issue(self, issue_key: str) -> str:
"""Get a specific issue.
Args:
issue_key: Issue key (e.g., PROJ-123)
Returns:
Issue details
"""
return await self.run_command("issue", "view", issue_key)
async def get_my_issues(self) -> str:
"""Get issues assigned to current user.
Returns:
Formatted issue list
"""
return await self.run_command("issue", "list", "--assignee", "@me")
async def get_sprint_issues(self) -> str:
"""Get issues in current sprint.
Returns:
Formatted issue list
"""
return await self.run_command("sprint", "list", "--current")
async def refresh(self) -> str:
"""Refresh Jira data (get my issues).
Returns:
Updated output
"""
return await self.get_my_issues()
async def check_available(self) -> bool:
"""Check if Jira CLI is available.
Returns:
True if available
"""
process = ProcessService(cwd=self._project_path)
result = await process.run(self._jira_cli, "--version")
return result.success
async def get_content(self) -> str | None:
"""Get Jira content for display.
Returns:
Markdown content or None
"""
if not self._enabled:
return None
try:
return await self.get_my_issues()
except Exception:
return None
+180
View File
@@ -0,0 +1,180 @@
"""Problems controller for linter integration."""
from pathlib import Path
from textual.message import Message
from clide.controllers.base import controller
from clide.models.problems import Problem, ProblemsState, ProblemsSummary, Severity
from clide.services.linter_service import LinterService
@controller
class ProblemsController:
"""Controller for problems/diagnostics from linters."""
class ProblemsUpdated(Message):
"""Emitted when problems list is updated."""
def __init__(self, problems: list[Problem], summary: ProblemsSummary) -> None:
self.problems = problems
self.summary = summary
super().__init__()
class ProblemSelected(Message):
"""Emitted when a problem is selected."""
def __init__(self, problem: Problem) -> None:
self.problem = problem
super().__init__()
def __init__(self, project_path: Path, linters: list[str] | None = None) -> None:
self._service = LinterService(project_path)
self._linters = linters or ["ruff"]
self._state = ProblemsState()
@property
def state(self) -> ProblemsState:
"""Get problems state."""
return self._state
@property
def problems(self) -> list[Problem]:
"""Get list of problems."""
return self._state.problems
@property
def summary(self) -> ProblemsSummary:
"""Get problems summary."""
return self._state.summary
@property
def error_count(self) -> int:
"""Get error count."""
return self._state.summary.errors
@property
def warning_count(self) -> int:
"""Get warning count."""
return self._state.summary.warnings
async def refresh(self) -> tuple[list[Problem], ProblemsSummary]:
"""Refresh problems from all linters.
Returns:
Tuple of (problems, summary)
"""
problems, summary = await self._service.run_all(self._linters)
self._state.problems = problems
self._state.summary = summary
return problems, summary
def filter_by_severity(self, severity: Severity | None) -> list[Problem]:
"""Filter problems by severity.
Args:
severity: Severity to filter by, or None for all
Returns:
Filtered list of problems
"""
self._state.filter_severity = severity
if severity is None:
return self._state.problems
return [p for p in self._state.problems if p.severity == severity]
def filter_by_source(self, source: str | None) -> list[Problem]:
"""Filter problems by source linter.
Args:
source: Source to filter by, or None for all
Returns:
Filtered list of problems
"""
self._state.filter_source = source
if source is None:
return self._state.problems
return [p for p in self._state.problems if p.source == source]
def get_problems_for_file(self, path: Path) -> list[Problem]:
"""Get problems for a specific file.
Args:
path: File path
Returns:
List of problems for that file
"""
return self._state.problems_for_file(path)
def select_problem(self, index: int) -> Problem | None:
"""Select a problem by index.
Args:
index: Problem index
Returns:
Selected problem or None
"""
if 0 <= index < len(self._state.problems):
self._state.selected_index = index
return self._state.problems[index]
return None
def next_problem(self) -> Problem | None:
"""Select next problem.
Returns:
Next problem or None
"""
if not self._state.problems:
return None
if self._state.selected_index is None:
self._state.selected_index = 0
else:
self._state.selected_index = (
self._state.selected_index + 1
) % len(self._state.problems)
return self._state.problems[self._state.selected_index]
def prev_problem(self) -> Problem | None:
"""Select previous problem.
Returns:
Previous problem or None
"""
if not self._state.problems:
return None
if self._state.selected_index is None:
self._state.selected_index = len(self._state.problems) - 1
else:
self._state.selected_index = (
self._state.selected_index - 1
) % len(self._state.problems)
return self._state.problems[self._state.selected_index]
def clear(self) -> None:
"""Clear all problems."""
self._state.problems = []
self._state.summary = ProblemsSummary()
self._state.selected_index = None
async def run_all(self) -> list[Problem]:
"""Run all linters and return problems.
Returns:
List of problems
"""
problems, _ = await self.refresh()
return problems
+163
View File
@@ -0,0 +1,163 @@
"""TODOs controller for tracking TODO comments."""
from pathlib import Path
from textual.message import Message
from clide.controllers.base import controller
from clide.models.todos import (
ProjectTodoItem,
TodoItem,
TodosState,
TodosSummary,
TodoType,
)
from clide.services.todo_scanner import TodoScanner
@controller
class TodosController:
"""Controller for TODO/FIXME comment tracking."""
class TodosUpdated(Message):
"""Emitted when TODOs list is updated."""
def __init__(
self,
items: list[TodoItem],
project_items: list[ProjectTodoItem],
summary: TodosSummary,
) -> None:
self.items = items
self.project_items = project_items
self.summary = summary
super().__init__()
class TodoSelected(Message):
"""Emitted when a TODO is selected."""
def __init__(self, item: TodoItem) -> None:
self.item = item
super().__init__()
def __init__(self, project_path: Path) -> None:
self._scanner = TodoScanner(project_path)
self._state = TodosState()
@property
def state(self) -> TodosState:
"""Get TODOs state."""
return self._state
@property
def items(self) -> list[TodoItem]:
"""Get list of code TODO items."""
return self._state.items
@property
def project_items(self) -> list[ProjectTodoItem]:
"""Get list of project TODO items from TODO.md."""
return self._state.project_items
@property
def summary(self) -> TodosSummary:
"""Get TODOs summary."""
return self._state.summary
@property
def total_count(self) -> int:
"""Get total code TODO count."""
return self._state.summary.total
@property
def project_count(self) -> int:
"""Get total project TODO count."""
return self._state.summary.project_total
async def refresh(
self,
) -> tuple[list[TodoItem], list[ProjectTodoItem], TodosSummary]:
"""Refresh TODOs from project.
Returns:
Tuple of (code items, project items, summary)
"""
items, project_items, summary = await self._scanner.scan()
self._state.items = items
self._state.project_items = project_items
self._state.summary = summary
return items, project_items, summary
def filter_by_type(self, todo_type: TodoType | None) -> list[TodoItem]:
"""Filter TODOs by type.
Args:
todo_type: Type to filter by, or None for all
Returns:
Filtered list of TODOs
"""
self._state.filter_type = todo_type
if todo_type is None:
return self._state.items
return self._state.items_by_type(todo_type)
def get_items_for_file(self, path: Path) -> list[TodoItem]:
"""Get TODOs for a specific file.
Args:
path: File path
Returns:
List of TODOs for that file
"""
return self._state.items_for_file(path)
def select_item(self, index: int) -> TodoItem | None:
"""Select a TODO by index.
Args:
index: Item index
Returns:
Selected item or None
"""
if 0 <= index < len(self._state.items):
self._state.selected_index = index
return self._state.items[index]
return None
def toggle_group_by_file(self) -> bool:
"""Toggle grouping by file.
Returns:
New group_by_file value
"""
self._state.group_by_file = not self._state.group_by_file
return self._state.group_by_file
def get_grouped_items(self) -> dict[Path, list[TodoItem]]:
"""Get TODOs grouped by file.
Returns:
Dictionary mapping file paths to TODO lists
"""
grouped: dict[Path, list[TodoItem]] = {}
for item in self._state.items:
if item.file_path not in grouped:
grouped[item.file_path] = []
grouped[item.file_path].append(item)
return grouped
async def scan(self) -> tuple[list[TodoItem], list[ProjectTodoItem]]:
"""Scan for TODOs and return items.
Returns:
Tuple of (code items, project items)
"""
items, project_items, _ = await self.refresh()
return items, project_items