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>
331 lines
11 KiB
Python
331 lines
11 KiB
Python
"""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,),
|
|
)
|