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:
@@ -0,0 +1,20 @@
|
||||
"""Domain controllers for Clide."""
|
||||
|
||||
from clide.controllers.base import ControllerMixin, controller
|
||||
from clide.controllers.diff import DiffController
|
||||
from clide.controllers.editor import EditorController
|
||||
from clide.controllers.git import GitController
|
||||
from clide.controllers.jira import JiraController
|
||||
from clide.controllers.problems import ProblemsController
|
||||
from clide.controllers.todos import TodosController
|
||||
|
||||
__all__ = [
|
||||
"controller",
|
||||
"ControllerMixin",
|
||||
"GitController",
|
||||
"EditorController",
|
||||
"DiffController",
|
||||
"ProblemsController",
|
||||
"TodosController",
|
||||
"JiraController",
|
||||
]
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Base controller utilities using decorator pattern."""
|
||||
|
||||
from functools import wraps
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from textual.message import Message
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from textual.app import App
|
||||
|
||||
|
||||
def controller[T](cls: type[T]) -> type[T]:
|
||||
"""Decorator to add controller capabilities to a class.
|
||||
|
||||
Adds:
|
||||
- _app attribute for parent application reference
|
||||
- set_app() method to set the application
|
||||
- post_message() method to emit messages
|
||||
- initialize() and shutdown() lifecycle hooks (if not defined)
|
||||
|
||||
Usage:
|
||||
@controller
|
||||
class GitController:
|
||||
def __init__(self, workdir: Path) -> None:
|
||||
self.workdir = workdir
|
||||
|
||||
async def get_status(self) -> GitStatus:
|
||||
...
|
||||
"""
|
||||
original_init = cls.__init__
|
||||
|
||||
@wraps(original_init)
|
||||
def new_init(self, *args, **kwargs):
|
||||
self._app = None
|
||||
original_init(self, *args, **kwargs)
|
||||
|
||||
cls.__init__ = new_init
|
||||
|
||||
def set_app(self, app: "App[object]") -> None:
|
||||
"""Set the parent application."""
|
||||
self._app = app
|
||||
|
||||
def post_message(self, message: Message) -> None:
|
||||
"""Post a message to the application's message queue."""
|
||||
if self._app:
|
||||
self._app.post_message(message)
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""Initialize the controller. Called after app mount."""
|
||||
pass
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
"""Clean up resources. Called before app exit."""
|
||||
pass
|
||||
|
||||
# Only add methods if they don't exist
|
||||
if not hasattr(cls, "set_app"):
|
||||
cls.set_app = set_app
|
||||
if not hasattr(cls, "post_message"):
|
||||
cls.post_message = post_message
|
||||
if not hasattr(cls, "initialize"):
|
||||
cls.initialize = initialize
|
||||
if not hasattr(cls, "shutdown"):
|
||||
cls.shutdown = shutdown
|
||||
|
||||
return cls
|
||||
|
||||
|
||||
class ControllerMixin:
|
||||
"""Mixin alternative for controller capabilities.
|
||||
|
||||
Use this if you prefer inheritance over decorators.
|
||||
|
||||
Usage:
|
||||
class GitController(ControllerMixin):
|
||||
def __init__(self, workdir: Path) -> None:
|
||||
self.workdir = workdir
|
||||
"""
|
||||
|
||||
_app: "App[object] | None" = None
|
||||
|
||||
def set_app(self, app: "App[object]") -> None:
|
||||
"""Set the parent application."""
|
||||
self._app = app
|
||||
|
||||
@property
|
||||
def app(self) -> "App[object] | None":
|
||||
"""Get the parent application."""
|
||||
return self._app
|
||||
|
||||
def post_message(self, message: Message) -> None:
|
||||
"""Post a message to the application's message queue."""
|
||||
if self._app:
|
||||
self._app.post_message(message)
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""Initialize the controller. Called after app mount."""
|
||||
pass
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
"""Clean up resources. Called before app exit."""
|
||||
pass
|
||||
@@ -0,0 +1,330 @@
|
||||
"""Diff controller for viewing and managing diffs."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from textual.message import Message
|
||||
|
||||
from clide.controllers.base import controller
|
||||
from clide.models.diff import ChangeType, DiffContent, DiffHunk, DiffLine, DiffViewState
|
||||
from clide.services.git_service import GitService
|
||||
|
||||
|
||||
@controller
|
||||
class DiffController:
|
||||
"""Controller for diff viewing and Claude-proposed changes."""
|
||||
|
||||
class DiffLoaded(Message):
|
||||
"""Emitted when a diff is loaded."""
|
||||
|
||||
def __init__(self, diff: DiffContent) -> None:
|
||||
self.diff = diff
|
||||
super().__init__()
|
||||
|
||||
class HunkAccepted(Message):
|
||||
"""Emitted when a hunk is accepted."""
|
||||
|
||||
def __init__(self, hunk_index: int) -> None:
|
||||
self.hunk_index = hunk_index
|
||||
super().__init__()
|
||||
|
||||
class HunkRejected(Message):
|
||||
"""Emitted when a hunk is rejected."""
|
||||
|
||||
def __init__(self, hunk_index: int) -> None:
|
||||
self.hunk_index = hunk_index
|
||||
super().__init__()
|
||||
|
||||
class AllChangesAccepted(Message):
|
||||
"""Emitted when all changes are accepted."""
|
||||
pass
|
||||
|
||||
class AllChangesRejected(Message):
|
||||
"""Emitted when all changes are rejected."""
|
||||
pass
|
||||
|
||||
def __init__(self, repo_path: Path) -> None:
|
||||
self._git_service = GitService(repo_path)
|
||||
self._state = DiffViewState()
|
||||
|
||||
@property
|
||||
def state(self) -> DiffViewState:
|
||||
"""Get diff view state."""
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def diff(self) -> DiffContent | None:
|
||||
"""Get current diff content."""
|
||||
return self._state.diff
|
||||
|
||||
@property
|
||||
def is_proposal(self) -> bool:
|
||||
"""Check if current diff is a Claude proposal."""
|
||||
return self._state.is_proposal
|
||||
|
||||
async def load_git_diff(self, path: str, staged: bool = False) -> DiffContent | None:
|
||||
"""Load diff from git.
|
||||
|
||||
Args:
|
||||
path: File path
|
||||
staged: Whether to load staged diff
|
||||
|
||||
Returns:
|
||||
DiffContent or None if no diff
|
||||
"""
|
||||
diff_text = await self._git_service.get_diff(path, staged)
|
||||
if not diff_text:
|
||||
return None
|
||||
|
||||
diff = self._parse_diff(path, diff_text)
|
||||
self._state.diff = diff
|
||||
self._state.is_proposal = False
|
||||
self._state.accepted_hunks = set()
|
||||
self._state.rejected_hunks = set()
|
||||
|
||||
return diff
|
||||
|
||||
def load_proposal(self, path: str, old_content: str, new_content: str) -> DiffContent:
|
||||
"""Load a Claude-proposed change as a diff.
|
||||
|
||||
Args:
|
||||
path: File path
|
||||
old_content: Original content
|
||||
new_content: Proposed content
|
||||
|
||||
Returns:
|
||||
DiffContent of the proposal
|
||||
"""
|
||||
diff = self._create_diff_from_content(path, old_content, new_content)
|
||||
self._state.diff = diff
|
||||
self._state.is_proposal = True
|
||||
self._state.accepted_hunks = set()
|
||||
self._state.rejected_hunks = set()
|
||||
|
||||
return diff
|
||||
|
||||
def accept_hunk(self, index: int) -> None:
|
||||
"""Accept a specific hunk.
|
||||
|
||||
Args:
|
||||
index: Hunk index
|
||||
"""
|
||||
self._state.accepted_hunks.add(index)
|
||||
self._state.rejected_hunks.discard(index)
|
||||
|
||||
def reject_hunk(self, index: int) -> None:
|
||||
"""Reject a specific hunk.
|
||||
|
||||
Args:
|
||||
index: Hunk index
|
||||
"""
|
||||
self._state.rejected_hunks.add(index)
|
||||
self._state.accepted_hunks.discard(index)
|
||||
|
||||
def accept_all(self) -> None:
|
||||
"""Accept all hunks."""
|
||||
if self._state.diff:
|
||||
for i in range(len(self._state.diff.hunks)):
|
||||
self._state.accepted_hunks.add(i)
|
||||
self._state.rejected_hunks.clear()
|
||||
|
||||
def reject_all(self) -> None:
|
||||
"""Reject all hunks."""
|
||||
if self._state.diff:
|
||||
for i in range(len(self._state.diff.hunks)):
|
||||
self._state.rejected_hunks.add(i)
|
||||
self._state.accepted_hunks.clear()
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear current diff."""
|
||||
self._state.diff = None
|
||||
self._state.is_proposal = False
|
||||
self._state.accepted_hunks = set()
|
||||
self._state.rejected_hunks = set()
|
||||
|
||||
def toggle_side_by_side(self) -> bool:
|
||||
"""Toggle side-by-side view.
|
||||
|
||||
Returns:
|
||||
New side_by_side value
|
||||
"""
|
||||
self._state.side_by_side = not self._state.side_by_side
|
||||
return self._state.side_by_side
|
||||
|
||||
def _parse_diff(self, path: str, diff_text: str) -> DiffContent:
|
||||
"""Parse git diff output into DiffContent."""
|
||||
hunks: list[DiffHunk] = []
|
||||
current_hunk_lines: list[DiffLine] = []
|
||||
current_header = ""
|
||||
old_start = old_count = new_start = new_count = 0
|
||||
|
||||
for line in diff_text.split("\n"):
|
||||
if line.startswith("@@"):
|
||||
# Save previous hunk
|
||||
if current_hunk_lines:
|
||||
hunks.append(DiffHunk(
|
||||
header=current_header,
|
||||
old_start=old_start,
|
||||
old_count=old_count,
|
||||
new_start=new_start,
|
||||
new_count=new_count,
|
||||
lines=tuple(current_hunk_lines),
|
||||
))
|
||||
current_hunk_lines = []
|
||||
|
||||
# Parse hunk header
|
||||
current_header = line
|
||||
# Format: @@ -old_start,old_count +new_start,new_count @@
|
||||
import re
|
||||
match = re.match(r"@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@", line)
|
||||
if match:
|
||||
old_start = int(match.group(1))
|
||||
old_count = int(match.group(2)) if match.group(2) else 1
|
||||
new_start = int(match.group(3))
|
||||
new_count = int(match.group(4)) if match.group(4) else 1
|
||||
|
||||
elif line.startswith("+") and not line.startswith("+++"):
|
||||
current_hunk_lines.append(DiffLine(
|
||||
change_type=ChangeType.ADDED,
|
||||
content=line[1:],
|
||||
new_line_num=new_start + len([ln for ln in current_hunk_lines if ln.change_type != ChangeType.REMOVED]),
|
||||
))
|
||||
elif line.startswith("-") and not line.startswith("---"):
|
||||
current_hunk_lines.append(DiffLine(
|
||||
change_type=ChangeType.REMOVED,
|
||||
content=line[1:],
|
||||
old_line_num=old_start + len([ln for ln in current_hunk_lines if ln.change_type != ChangeType.ADDED]),
|
||||
))
|
||||
elif line.startswith(" "):
|
||||
old_num = old_start + len([ln for ln in current_hunk_lines if ln.change_type != ChangeType.ADDED])
|
||||
new_num = new_start + len([ln for ln in current_hunk_lines if ln.change_type != ChangeType.REMOVED])
|
||||
current_hunk_lines.append(DiffLine(
|
||||
change_type=ChangeType.CONTEXT,
|
||||
content=line[1:],
|
||||
old_line_num=old_num,
|
||||
new_line_num=new_num,
|
||||
))
|
||||
|
||||
# Save last hunk
|
||||
if current_hunk_lines:
|
||||
hunks.append(DiffHunk(
|
||||
header=current_header,
|
||||
old_start=old_start,
|
||||
old_count=old_count,
|
||||
new_start=new_start,
|
||||
new_count=new_count,
|
||||
lines=tuple(current_hunk_lines),
|
||||
))
|
||||
|
||||
return DiffContent(
|
||||
file_path=path,
|
||||
hunks=tuple(hunks),
|
||||
)
|
||||
|
||||
async def get_file_diff(self, path: str, staged: bool = False) -> DiffContent | None:
|
||||
"""Get diff for a file (alias for load_git_diff).
|
||||
|
||||
Args:
|
||||
path: File path
|
||||
staged: Whether to get staged diff
|
||||
|
||||
Returns:
|
||||
DiffContent or None
|
||||
"""
|
||||
return await self.load_git_diff(path, staged)
|
||||
|
||||
async def accept_proposal(self, _file_path: str) -> bool:
|
||||
"""Accept a proposed change and apply it.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
if not self._state.diff or not self._state.is_proposal:
|
||||
return False
|
||||
|
||||
self.accept_all()
|
||||
# TODO: Apply the changes to the file
|
||||
self.clear()
|
||||
return True
|
||||
|
||||
async def reject_proposal(self, _file_path: str) -> bool:
|
||||
"""Reject a proposed change.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
self.reject_all()
|
||||
self.clear()
|
||||
return True
|
||||
|
||||
def _create_diff_from_content(self, path: str, old: str, new: str) -> DiffContent:
|
||||
"""Create diff from old and new content."""
|
||||
import difflib
|
||||
|
||||
old_lines = old.splitlines(keepends=True)
|
||||
new_lines = new.splitlines(keepends=True)
|
||||
|
||||
diff_lines: list[DiffLine] = []
|
||||
old_num = new_num = 1
|
||||
|
||||
for tag, i1, i2, j1, j2 in difflib.SequenceMatcher(None, old_lines, new_lines).get_opcodes():
|
||||
if tag == "equal":
|
||||
for line in old_lines[i1:i2]:
|
||||
diff_lines.append(DiffLine(
|
||||
change_type=ChangeType.CONTEXT,
|
||||
content=line.rstrip("\n"),
|
||||
old_line_num=old_num,
|
||||
new_line_num=new_num,
|
||||
))
|
||||
old_num += 1
|
||||
new_num += 1
|
||||
elif tag == "delete":
|
||||
for line in old_lines[i1:i2]:
|
||||
diff_lines.append(DiffLine(
|
||||
change_type=ChangeType.REMOVED,
|
||||
content=line.rstrip("\n"),
|
||||
old_line_num=old_num,
|
||||
))
|
||||
old_num += 1
|
||||
elif tag == "insert":
|
||||
for line in new_lines[j1:j2]:
|
||||
diff_lines.append(DiffLine(
|
||||
change_type=ChangeType.ADDED,
|
||||
content=line.rstrip("\n"),
|
||||
new_line_num=new_num,
|
||||
))
|
||||
new_num += 1
|
||||
elif tag == "replace":
|
||||
for line in old_lines[i1:i2]:
|
||||
diff_lines.append(DiffLine(
|
||||
change_type=ChangeType.REMOVED,
|
||||
content=line.rstrip("\n"),
|
||||
old_line_num=old_num,
|
||||
))
|
||||
old_num += 1
|
||||
for line in new_lines[j1:j2]:
|
||||
diff_lines.append(DiffLine(
|
||||
change_type=ChangeType.ADDED,
|
||||
content=line.rstrip("\n"),
|
||||
new_line_num=new_num,
|
||||
))
|
||||
new_num += 1
|
||||
|
||||
hunk = DiffHunk(
|
||||
header="@@ -1 +1 @@",
|
||||
old_start=1,
|
||||
old_count=len(old_lines),
|
||||
new_start=1,
|
||||
new_count=len(new_lines),
|
||||
lines=tuple(diff_lines),
|
||||
)
|
||||
|
||||
return DiffContent(
|
||||
file_path=path,
|
||||
hunks=(hunk,),
|
||||
)
|
||||
@@ -0,0 +1,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
|
||||
@@ -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}"
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Jira controller for Jira CLI integration."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from textual.message import Message
|
||||
|
||||
from clide.controllers.base import controller
|
||||
from clide.services.process_service import ProcessService
|
||||
|
||||
|
||||
@controller
|
||||
class JiraController:
|
||||
"""Controller for Jira CLI integration."""
|
||||
|
||||
class JiraOutputUpdated(Message):
|
||||
"""Emitted when Jira output is updated."""
|
||||
|
||||
def __init__(self, output: str) -> None:
|
||||
self.output = output
|
||||
super().__init__()
|
||||
|
||||
class JiraError(Message):
|
||||
"""Emitted when Jira command fails."""
|
||||
|
||||
def __init__(self, error: str) -> None:
|
||||
self.error = error
|
||||
super().__init__()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
project_path: Path | None = None,
|
||||
jira_cli_path: str = "jira",
|
||||
enabled: bool = False,
|
||||
) -> None:
|
||||
self._project_path = project_path or Path.cwd()
|
||||
self._jira_cli = jira_cli_path
|
||||
self._enabled = enabled
|
||||
self._last_output: str = ""
|
||||
self._last_error: str = ""
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
"""Check if Jira integration is enabled."""
|
||||
return self._enabled
|
||||
|
||||
@property
|
||||
def last_output(self) -> str:
|
||||
"""Get last Jira output."""
|
||||
return self._last_output
|
||||
|
||||
@property
|
||||
def last_error(self) -> str:
|
||||
"""Get last error message."""
|
||||
return self._last_error
|
||||
|
||||
def enable(self) -> None:
|
||||
"""Enable Jira integration."""
|
||||
self._enabled = True
|
||||
|
||||
def disable(self) -> None:
|
||||
"""Disable Jira integration."""
|
||||
self._enabled = False
|
||||
|
||||
async def run_command(self, *args: str) -> str:
|
||||
"""Run a Jira CLI command.
|
||||
|
||||
Args:
|
||||
*args: Command arguments
|
||||
|
||||
Returns:
|
||||
Command output
|
||||
"""
|
||||
if not self._enabled:
|
||||
return "Jira integration is disabled"
|
||||
|
||||
process = ProcessService(cwd=self._project_path)
|
||||
result = await process.run(self._jira_cli, *args)
|
||||
|
||||
if result.success:
|
||||
self._last_output = result.stdout
|
||||
self._last_error = ""
|
||||
return result.stdout
|
||||
else:
|
||||
self._last_error = result.stderr
|
||||
return f"Error: {result.stderr}"
|
||||
|
||||
async def list_issues(self, project: str | None = None) -> str:
|
||||
"""List Jira issues.
|
||||
|
||||
Args:
|
||||
project: Optional project key
|
||||
|
||||
Returns:
|
||||
Formatted issue list
|
||||
"""
|
||||
args = ["issue", "list"]
|
||||
if project:
|
||||
args.extend(["--project", project])
|
||||
|
||||
return await self.run_command(*args)
|
||||
|
||||
async def get_issue(self, issue_key: str) -> str:
|
||||
"""Get a specific issue.
|
||||
|
||||
Args:
|
||||
issue_key: Issue key (e.g., PROJ-123)
|
||||
|
||||
Returns:
|
||||
Issue details
|
||||
"""
|
||||
return await self.run_command("issue", "view", issue_key)
|
||||
|
||||
async def get_my_issues(self) -> str:
|
||||
"""Get issues assigned to current user.
|
||||
|
||||
Returns:
|
||||
Formatted issue list
|
||||
"""
|
||||
return await self.run_command("issue", "list", "--assignee", "@me")
|
||||
|
||||
async def get_sprint_issues(self) -> str:
|
||||
"""Get issues in current sprint.
|
||||
|
||||
Returns:
|
||||
Formatted issue list
|
||||
"""
|
||||
return await self.run_command("sprint", "list", "--current")
|
||||
|
||||
async def refresh(self) -> str:
|
||||
"""Refresh Jira data (get my issues).
|
||||
|
||||
Returns:
|
||||
Updated output
|
||||
"""
|
||||
return await self.get_my_issues()
|
||||
|
||||
async def check_available(self) -> bool:
|
||||
"""Check if Jira CLI is available.
|
||||
|
||||
Returns:
|
||||
True if available
|
||||
"""
|
||||
process = ProcessService(cwd=self._project_path)
|
||||
result = await process.run(self._jira_cli, "--version")
|
||||
return result.success
|
||||
|
||||
async def get_content(self) -> str | None:
|
||||
"""Get Jira content for display.
|
||||
|
||||
Returns:
|
||||
Markdown content or None
|
||||
"""
|
||||
if not self._enabled:
|
||||
return None
|
||||
try:
|
||||
return await self.get_my_issues()
|
||||
except Exception:
|
||||
return None
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Problems controller for linter integration."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from textual.message import Message
|
||||
|
||||
from clide.controllers.base import controller
|
||||
from clide.models.problems import Problem, ProblemsState, ProblemsSummary, Severity
|
||||
from clide.services.linter_service import LinterService
|
||||
|
||||
|
||||
@controller
|
||||
class ProblemsController:
|
||||
"""Controller for problems/diagnostics from linters."""
|
||||
|
||||
class ProblemsUpdated(Message):
|
||||
"""Emitted when problems list is updated."""
|
||||
|
||||
def __init__(self, problems: list[Problem], summary: ProblemsSummary) -> None:
|
||||
self.problems = problems
|
||||
self.summary = summary
|
||||
super().__init__()
|
||||
|
||||
class ProblemSelected(Message):
|
||||
"""Emitted when a problem is selected."""
|
||||
|
||||
def __init__(self, problem: Problem) -> None:
|
||||
self.problem = problem
|
||||
super().__init__()
|
||||
|
||||
def __init__(self, project_path: Path, linters: list[str] | None = None) -> None:
|
||||
self._service = LinterService(project_path)
|
||||
self._linters = linters or ["ruff"]
|
||||
self._state = ProblemsState()
|
||||
|
||||
@property
|
||||
def state(self) -> ProblemsState:
|
||||
"""Get problems state."""
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def problems(self) -> list[Problem]:
|
||||
"""Get list of problems."""
|
||||
return self._state.problems
|
||||
|
||||
@property
|
||||
def summary(self) -> ProblemsSummary:
|
||||
"""Get problems summary."""
|
||||
return self._state.summary
|
||||
|
||||
@property
|
||||
def error_count(self) -> int:
|
||||
"""Get error count."""
|
||||
return self._state.summary.errors
|
||||
|
||||
@property
|
||||
def warning_count(self) -> int:
|
||||
"""Get warning count."""
|
||||
return self._state.summary.warnings
|
||||
|
||||
async def refresh(self) -> tuple[list[Problem], ProblemsSummary]:
|
||||
"""Refresh problems from all linters.
|
||||
|
||||
Returns:
|
||||
Tuple of (problems, summary)
|
||||
"""
|
||||
problems, summary = await self._service.run_all(self._linters)
|
||||
|
||||
self._state.problems = problems
|
||||
self._state.summary = summary
|
||||
|
||||
return problems, summary
|
||||
|
||||
def filter_by_severity(self, severity: Severity | None) -> list[Problem]:
|
||||
"""Filter problems by severity.
|
||||
|
||||
Args:
|
||||
severity: Severity to filter by, or None for all
|
||||
|
||||
Returns:
|
||||
Filtered list of problems
|
||||
"""
|
||||
self._state.filter_severity = severity
|
||||
|
||||
if severity is None:
|
||||
return self._state.problems
|
||||
|
||||
return [p for p in self._state.problems if p.severity == severity]
|
||||
|
||||
def filter_by_source(self, source: str | None) -> list[Problem]:
|
||||
"""Filter problems by source linter.
|
||||
|
||||
Args:
|
||||
source: Source to filter by, or None for all
|
||||
|
||||
Returns:
|
||||
Filtered list of problems
|
||||
"""
|
||||
self._state.filter_source = source
|
||||
|
||||
if source is None:
|
||||
return self._state.problems
|
||||
|
||||
return [p for p in self._state.problems if p.source == source]
|
||||
|
||||
def get_problems_for_file(self, path: Path) -> list[Problem]:
|
||||
"""Get problems for a specific file.
|
||||
|
||||
Args:
|
||||
path: File path
|
||||
|
||||
Returns:
|
||||
List of problems for that file
|
||||
"""
|
||||
return self._state.problems_for_file(path)
|
||||
|
||||
def select_problem(self, index: int) -> Problem | None:
|
||||
"""Select a problem by index.
|
||||
|
||||
Args:
|
||||
index: Problem index
|
||||
|
||||
Returns:
|
||||
Selected problem or None
|
||||
"""
|
||||
if 0 <= index < len(self._state.problems):
|
||||
self._state.selected_index = index
|
||||
return self._state.problems[index]
|
||||
return None
|
||||
|
||||
def next_problem(self) -> Problem | None:
|
||||
"""Select next problem.
|
||||
|
||||
Returns:
|
||||
Next problem or None
|
||||
"""
|
||||
if not self._state.problems:
|
||||
return None
|
||||
|
||||
if self._state.selected_index is None:
|
||||
self._state.selected_index = 0
|
||||
else:
|
||||
self._state.selected_index = (
|
||||
self._state.selected_index + 1
|
||||
) % len(self._state.problems)
|
||||
|
||||
return self._state.problems[self._state.selected_index]
|
||||
|
||||
def prev_problem(self) -> Problem | None:
|
||||
"""Select previous problem.
|
||||
|
||||
Returns:
|
||||
Previous problem or None
|
||||
"""
|
||||
if not self._state.problems:
|
||||
return None
|
||||
|
||||
if self._state.selected_index is None:
|
||||
self._state.selected_index = len(self._state.problems) - 1
|
||||
else:
|
||||
self._state.selected_index = (
|
||||
self._state.selected_index - 1
|
||||
) % len(self._state.problems)
|
||||
|
||||
return self._state.problems[self._state.selected_index]
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all problems."""
|
||||
self._state.problems = []
|
||||
self._state.summary = ProblemsSummary()
|
||||
self._state.selected_index = None
|
||||
|
||||
async def run_all(self) -> list[Problem]:
|
||||
"""Run all linters and return problems.
|
||||
|
||||
Returns:
|
||||
List of problems
|
||||
"""
|
||||
problems, _ = await self.refresh()
|
||||
return problems
|
||||
@@ -0,0 +1,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
|
||||
Reference in New Issue
Block a user