Coverage for vibefpmgui/edit_cards.py: 92%
151 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
1"""The edits the AI proposes, one card each, in the Review panel: a diff
2of the change (made from the file, so it's right whatever the model wrote)
3with Accept and Reject, and Accept All / Reject All below them.
5The cards only show `vibefpmgui.ai_edits.Edit` objects and say what the
6user chose; `vibefpmgui.window.ai_chat` changes the editor's text.
7"""
9from __future__ import annotations
11import difflib
12import html
14from qtpy.QtCore import Qt, Signal
15from qtpy.QtWidgets import (
16 QFrame,
17 QHBoxLayout,
18 QLabel,
19 QPushButton,
20 QScrollArea,
21 QVBoxLayout,
22 QWidget,
23)
25from . import ai_edits as ed
27CONTEXT_LINES = 2 # unchanged lines shown around a change
28DIFF_COLORS = { # (background, text) of removed and added lines
29 "light": {"-": ("#ffebe9", "#82071e"), "+": ("#dafbe1", "#116329")},
30 "dark": {"-": ("#3d1d20", "#ffa198"), "+": ("#12261e", "#7ee787")},
31}
34def diff_rows(file_text: str, edit: ed.Edit) -> list[tuple[str, str]]:
35 """The change as (" " | "-" | "+", line) rows, with a few unchanged lines
36 around it; for an edit that wasn't found, the model's own text."""
37 if edit.diff is not None: # (made or turned down: as it was then)
38 return edit.diff
39 if not edit.located:
40 return ([("-", line) for line in edit.search.split("\n")]
41 + [("+", line) for line in edit.replace.split("\n")])
42 old = file_text.split("\n")
43 new = ed.apply(file_text, edit).split("\n")
44 first = max(0, edit.line - 1 - CONTEXT_LINES)
45 grown = edit.new_text.count("\n") - file_text.count("\n", edit.start, edit.end)
46 last_old = min(len(old), edit.end_line + CONTEXT_LINES)
47 last_new = min(len(new), last_old + grown)
48 rows = []
49 for line in difflib.ndiff(old[first:last_old], new[first:last_new]):
50 if line[:1] in " -+":
51 rows.append((line[0], line[2:]))
52 return rows
55class EditCard(QFrame):
56 """One proposed edit."""
58 accept = Signal()
59 reject = Signal()
60 clicked = Signal()
62 def __init__(self, parent=None):
63 super().__init__(parent)
64 self.setObjectName("EditCard")
65 self.setCursor(Qt.PointingHandCursor)
66 self.title = QLabel()
67 self.state = QLabel()
68 self.accept_button = QPushButton("Accept")
69 self.accept_button.clicked.connect(self.accept)
70 self.reject_button = QPushButton("Reject")
71 self.reject_button.clicked.connect(self.reject)
72 self.show_button = QPushButton("Show")
73 self.show_button.setCheckable(True)
74 self.show_button.setToolTip("The block as the model wrote it")
75 self.show_button.toggled.connect(lambda on: self.diff.setVisible(on))
76 header = QHBoxLayout()
77 header.setSpacing(8)
78 header.addWidget(self.title)
79 header.addStretch(1)
80 header.addWidget(self.state)
81 header.addWidget(self.show_button)
82 header.addWidget(self.reject_button)
83 header.addWidget(self.accept_button)
84 self.diff = QLabel()
85 self.diff.setTextFormat(Qt.RichText)
86 self.diff.setTextInteractionFlags(Qt.TextSelectableByMouse)
87 layout = QVBoxLayout(self)
88 layout.setContentsMargins(10, 8, 10, 8)
89 layout.setSpacing(6)
90 layout.addLayout(header)
91 layout.addWidget(self.diff)
93 def mousePressEvent(self, event):
94 if event.button() == Qt.LeftButton:
95 self.clicked.emit()
96 super().mousePressEvent(event)
98 def show_edit(self, number: int, edit: ed.Edit, file_text: str, name: str, colors: dict,
99 muted: str, font_family: str):
100 where = (f"line {edit.line}" if edit.line == edit.end_line else f"lines {edit.line}–{edit.end_line}") \
101 if edit.located else "not applied"
102 note = {"spacing": "spacing ignored", "indentation": "indentation adjusted",
103 "line numbers": "line numbers taken out"}.get(edit.how, "")
104 extra = f" <span style='color:{muted}'>· {note}</span>" if note else ""
105 self.title.setText(f"<b>{number}.</b> {html.escape(name)} · {where}{extra}")
106 pending = edit.status == ed.PENDING and edit.located
107 self.accept_button.setVisible(pending)
108 self.reject_button.setVisible(pending)
109 self.show_button.setVisible(edit.status == ed.FAILED)
110 state = {ed.APPLIED: "✓ applied", ed.REJECTED: "rejected",
111 ed.STALE: "⚠ the code changed since: not applied",
112 ed.FAILED: f"⚠ {edit.problem}"}.get(edit.status, "")
113 self.state.setText(f"<span style='color:{muted}'>{html.escape(state)}</span>" if state else "")
114 rows = []
115 for kind, line in diff_rows(file_text, edit):
116 text = html.escape(line) or " "
117 if kind == " ":
118 rows.append(f"<div style='color:{muted}'> {text}</div>")
119 else:
120 background, color = colors[kind]
121 rows.append(f"<div style='background:{background}; color:{color}'>{kind} {text}</div>")
122 self.diff.setText(f"<div style='font-family:\"{font_family}\"; white-space:pre'>{''.join(rows)}</div>")
123 self.diff.setVisible(edit.status != ed.FAILED or self.show_button.isChecked())
124 self.setEnabled(True)
125 dim = edit.status in (ed.REJECTED, ed.STALE)
126 self.diff.setStyleSheet("color: gray;" if dim else "")
129class EditList(QWidget):
130 """The cards, and Accept All / Reject All (with ``scrolling`` False, as
131 tall as the cards: in something that scrolls itself)."""
133 accept_requested = Signal(int)
134 reject_requested = Signal(int)
135 accept_all_requested = Signal()
136 reject_all_requested = Signal()
137 edit_clicked = Signal(int)
139 def __init__(self, parent=None, scrolling: bool = True):
140 super().__init__(parent)
141 self.cards: list[EditCard] = []
142 self.is_light = True
143 self.muted = "#8b949e"
144 self.font_family = "Menlo"
145 self.container = QWidget()
146 self.container.setObjectName("EditCards")
147 self.cards_layout = QVBoxLayout(self.container)
148 self.cards_layout.setContentsMargins(0, 0, 0, 0)
149 self.cards_layout.setSpacing(6)
150 if scrolling: 150 ↛ 151line 150 didn't jump to line 151 because the condition on line 150 was never true
151 self.cards_layout.addStretch(1)
152 self.scroll = QScrollArea()
153 self.scroll.setWidgetResizable(True)
154 self.scroll.setFrameShape(QFrame.NoFrame)
155 self.scroll.setWidget(self.container)
156 self.summary = QLabel()
157 self.accept_all = QPushButton("Accept All")
158 self.accept_all.setToolTip("Make every pending edit (one step to undo)")
159 self.accept_all.clicked.connect(self.accept_all_requested)
160 self.reject_all = QPushButton("Reject All")
161 self.reject_all.clicked.connect(self.reject_all_requested)
162 footer = QHBoxLayout()
163 footer.addWidget(self.summary, 1)
164 footer.addWidget(self.reject_all)
165 footer.addWidget(self.accept_all)
166 layout = QVBoxLayout(self)
167 layout.setContentsMargins(0, 0, 0, 0)
168 layout.setSpacing(6)
169 layout.addWidget(self.scroll if scrolling else self.container, 1)
170 layout.addLayout(footer)
172 def set_palette(self, pal: dict, is_light: bool):
173 self.is_light = is_light
174 self.muted = pal["muted"]
175 self.setStyleSheet(f"#EditCard {{ border: 1px solid {pal['line']}; border-radius: 6px; "
176 f"background: {pal['bg']}; }}"
177 f"QScrollArea, #EditCards {{ background: {pal['bg']}; border: none; }}")
179 def show_edits(self, edits: list[ed.Edit], file_text: str, name: str, font_family: str):
180 """Show ``edits`` (located in ``file_text``, the editor's text)."""
181 self.font_family = font_family
182 while len(self.cards) < len(edits):
183 card = EditCard()
184 i = len(self.cards)
185 card.accept.connect(lambda i=i: self.accept_requested.emit(i))
186 card.reject.connect(lambda i=i: self.reject_requested.emit(i))
187 card.clicked.connect(lambda i=i: self.edit_clicked.emit(i))
188 self.cards_layout.insertWidget(len(self.cards), card)
189 self.cards.append(card)
190 while len(self.cards) > len(edits): 190 ↛ 191line 190 didn't jump to line 191 because the condition on line 190 was never true
191 self.cards.pop().deleteLater()
192 colors = DIFF_COLORS["light" if self.is_light else "dark"]
193 for i, (card, edit) in enumerate(zip(self.cards, edits, strict=True)):
194 card.show_edit(i + 1, edit, file_text, name, colors, self.muted, font_family)
195 pending = sum(e.status == ed.PENDING and e.located for e in edits)
196 counts = {s: sum(e.status == s for e in edits) for s in (ed.APPLIED, ed.REJECTED, ed.FAILED, ed.STALE)}
197 parts = [f"{len(edits)} edit{'s' * (len(edits) != 1)} proposed"]
198 parts += [f"{n} {word}" for word, n in (("applied", counts[ed.APPLIED]), ("rejected", counts[ed.REJECTED]),
199 ("not found", counts[ed.FAILED]), ("out of date", counts[ed.STALE]))
200 if n]
201 self.summary.setText(f"<span style='color:{self.muted}'>{' · '.join(parts)}</span>")
202 self.accept_all.setEnabled(pending > 0)
203 self.accept_all.setText(f"Accept All ({pending})" if pending else "Accept All")
204 self.reject_all.setEnabled(pending > 0)
205 self.setVisible(bool(edits))