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

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. 

4 

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""" 

8 

9from __future__ import annotations 

10 

11import difflib 

12import html 

13 

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) 

24 

25from . import ai_edits as ed 

26 

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} 

32 

33 

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 

53 

54 

55class EditCard(QFrame): 

56 """One proposed edit.""" 

57 

58 accept = Signal() 

59 reject = Signal() 

60 clicked = Signal() 

61 

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) 

92 

93 def mousePressEvent(self, event): 

94 if event.button() == Qt.LeftButton: 

95 self.clicked.emit() 

96 super().mousePressEvent(event) 

97 

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}'>&nbsp; {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 "") 

127 

128 

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).""" 

132 

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) 

138 

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) 

171 

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; }}") 

178 

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))