Coverage for merco/sandbox/confirm.py: 15%

141 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-07-07 14:04 +0800

1"""用户审批对话框 — 展示 diff 并等待确认,支持 unified / split 两种视图""" 

2 

3import asyncio 

4import difflib 

5import logging 

6import shutil 

7from rich.console import Console 

8from rich.panel import Panel 

9from rich.rule import Rule 

10from rich.syntax import Syntax 

11from rich.table import Table 

12from rich.text import Text 

13from merco.core.config import MercoConfig 

14 

15console = Console() 

16logger = logging.getLogger("merco.sandbox") 

17 

18_SPLIT_MIN_WIDTH = 80 

19 

20 

21def _term_width() -> int: 

22 try: 

23 return shutil.get_terminal_size().columns 

24 except Exception: 

25 return 80 

26 

27 

28def _read_config() -> str: 

29 try: 

30 cfg = MercoConfig.load() 

31 return cfg.sandbox_mode 

32 except Exception: 

33 return "ask" 

34 

35 

36def _render_unified(diff_text: str, title: str) -> Panel: 

37 """行内对照(unified diff)""" 

38 syntax = Syntax(diff_text, "diff", theme="monokai", 

39 line_numbers=False, word_wrap=True) 

40 return Panel(syntax, title=title, border_style="yellow", 

41 subtitle="行内对照(- 删除 + 添加)") 

42 

43 

44_CONTEXT = 3 # 变更块前后保留的上下文行数 

45 

46 

47def _render_split(old_content: str, new_content: str, title: str, filepath: str) -> None: 

48 """左右对照 diff — SequenceMatcher 对齐 + 上下文裁剪 + 仅染色变更行""" 

49 width = _term_width() 

50 # 行号(5) + 分隔符(3) + 行号(5) = 13 字符留给 chrome 

51 gutter = 13 

52 half = max(25, (width - gutter) // 2) 

53 

54 old_lines = old_content.splitlines() 

55 new_lines = new_content.splitlines() 

56 

57 sm = difflib.SequenceMatcher(None, old_lines, new_lines) 

58 opcodes = sm.get_opcodes() 

59 

60 # ── 阶段 1:构建 (old_ln, old_text, new_ln, new_text, change_type) 行 ── 

61 rows: list[tuple[str, str, str, str, str]] = [] 

62 

63 for i, (tag, i1, i2, j1, j2) in enumerate(opcodes): 

64 if tag == "equal": 

65 n = i2 - i1 

66 has_prev_change = i > 0 and opcodes[i - 1][0] != "equal" 

67 has_next_change = i < len(opcodes) - 1 and opcodes[i + 1][0] != "equal" 

68 

69 if has_prev_change and has_next_change: 

70 # 夹在两个变更块之间 

71 if n <= 2 * _CONTEXT + 1: 

72 for k in range(n): 

73 rows.append((str(i1 + k + 1), old_lines[i1 + k], 

74 str(j1 + k + 1), new_lines[j1 + k], "equal")) 

75 else: 

76 for k in range(_CONTEXT): 

77 rows.append((str(i1 + k + 1), old_lines[i1 + k], 

78 str(j1 + k + 1), new_lines[j1 + k], "equal")) 

79 rows.append(("", "···", "", "···", "gap")) 

80 for k in range(n - _CONTEXT, n): 

81 rows.append((str(i1 + k + 1), old_lines[i1 + k], 

82 str(j1 + k + 1), new_lines[j1 + k], "equal")) 

83 elif has_prev_change: 

84 show_n = min(n, _CONTEXT + 1) 

85 for k in range(show_n): 

86 rows.append((str(i1 + k + 1), old_lines[i1 + k], 

87 str(j1 + k + 1), new_lines[j1 + k], "equal")) 

88 if n > show_n: 

89 rows.append(("", "···", "", "···", "gap")) 

90 elif has_next_change: 

91 show_n = min(n, _CONTEXT + 1) 

92 if n > show_n: 

93 rows.append(("", "···", "", "···", "gap")) 

94 for k in range(n - show_n, n): 

95 rows.append((str(i1 + k + 1), old_lines[i1 + k], 

96 str(j1 + k + 1), new_lines[j1 + k], "equal")) 

97 # 孤立的 equal 块(无相邻变更)→ 跳过 

98 elif tag == "replace": 

99 for k in range(max(i2 - i1, j2 - j1)): 

100 old_idx = i1 + k 

101 new_idx = j1 + k 

102 old_ln = str(old_idx + 1) if old_idx < i2 else "" 

103 old_t = old_lines[old_idx] if old_idx < i2 else "" 

104 new_ln = str(new_idx + 1) if new_idx < j2 else "" 

105 new_t = new_lines[new_idx] if new_idx < j2 else "" 

106 rows.append((old_ln, old_t, new_ln, new_t, "replace")) 

107 elif tag == "delete": 

108 for k in range(i1, i2): 

109 rows.append((str(k + 1), old_lines[k], "", "", "delete")) 

110 elif tag == "insert": 

111 for k in range(j1, j2): 

112 rows.append(("", "", str(k + 1), new_lines[k], "insert")) 

113 

114 # 去掉首尾的 gap 

115 while rows and rows[0][4] == "gap": 

116 rows.pop(0) 

117 while rows and rows[-1][4] == "gap": 

118 rows.pop(-1) 

119 

120 if not rows: 

121 console.print(f"[bold yellow]{title}[/bold yellow]") 

122 console.print("[dim](无差异)[/dim]") 

123 return 

124 

125 # ── 阶段 2:渲染 Rich Table ── 

126 table = Table(show_header=False, box=None, padding=(0, 1), 

127 show_edge=False, expand=False, highlight=False) 

128 table.add_column(width=5, justify="right") # 旧行号 

129 table.add_column(width=half, no_wrap=True) # 旧内容 

130 table.add_column(width=1, justify="center") # │ 

131 table.add_column(width=5, justify="right") # 新行号 

132 table.add_column(width=half, no_wrap=True) # 新内容 

133 

134 for old_ln, old_text, new_ln, new_text, change_type in rows: 

135 old_t = _trunc(old_text, half) 

136 new_t = _trunc(new_text, half) 

137 

138 if change_type == "gap": 

139 table.add_row("", "···", "", "", "···") 

140 elif change_type == "equal": 

141 table.add_row( 

142 Text(old_ln, style="dim"), 

143 Text(old_t, style="dim"), 

144 Text("│", style="dim"), 

145 Text(new_ln, style="dim"), 

146 Text(new_t, style="dim"), 

147 ) 

148 elif change_type == "delete": 

149 table.add_row( 

150 Text(old_ln, style="bold red"), 

151 Text(old_t, style="red"), 

152 Text("│", style="dim"), 

153 "", 

154 "", 

155 ) 

156 elif change_type == "insert": 

157 table.add_row( 

158 "", "", 

159 Text("│", style="dim"), 

160 Text(new_ln, style="bold green"), 

161 Text(new_t, style="green"), 

162 ) 

163 elif change_type == "replace": 

164 table.add_row( 

165 Text(old_ln, style="bold red") if old_ln else "", 

166 Text(old_t, style="red") if old_t else "", 

167 Text("│", style="dim"), 

168 Text(new_ln, style="bold green") if new_ln else "", 

169 Text(new_t, style="green") if new_t else "", 

170 ) 

171 

172 console.print(f"[bold yellow]{title}[/bold yellow]") 

173 console.print(table) 

174 

175 

176def _trunc(s: str, width: int) -> str: 

177 """截断到 width 宽度,超长加 …""" 

178 if len(s) <= width: 

179 return s 

180 return s[:width - 1] + "\u2026" 

181 

182 

183async def confirm_edit( 

184 diff_text: str, 

185 path: str, 

186 edits_count: int = 1, 

187 old_content: str | None = None, 

188 new_content: str | None = None, 

189 view: str = "unified", 

190) -> bool: 

191 """展示 diff 并等待用户确认修改 

192 

193 Args: 

194 diff_text: unified diff 文本 

195 path: 文件路径 

196 edits_count: 编辑块数量 

197 old_content: 旧内容(split view 需要) 

198 new_content: 新内容(split view 需要) 

199 view: 显示模式 — "unified"(行内对照)或 "split"(左右对照) 

200 

201 Returns: 

202 True=确认, False=取消 

203 """ 

204 sandbox_mode = _read_config() 

205 if sandbox_mode == "auto": 

206 return True # 完全静默 

207 if not diff_text.strip(): 

208 return True 

209 

210 title: str = "\U0001f4dd 修改预览" 

211 if edits_count > 1: 

212 title += f"{edits_count} 处改动)" 

213 

214 want_split = view == "split" and old_content is not None and new_content is not None 

215 if want_split and _term_width() < _SPLIT_MIN_WIDTH: 

216 title += f" [dim](终端 {_term_width()}列, 降级为行内对照)[/dim]" 

217 want_split = False 

218 

219 # ── 展示 diff(ask / show 都展示)── 

220 if want_split: 

221 _render_split(old_content, new_content, title, path) 

222 else: 

223 console.print(_render_unified(diff_text, title)) 

224 

225 console.print(Rule(style="dim")) 

226 

227 try: 

228 if sandbox_mode == "show": 

229 console.print("[dim] ✓ 自动应用修改[/dim]") 

230 return True 

231 elif sandbox_mode == "ask": 

232 console.print("[bold yellow]确认修改?[/bold yellow] " 

233 "[dim]按 y 确认 / 其他任意键取消 [/dim]", end="") 

234 resp = await asyncio.to_thread(input, "") 

235 return resp.strip().lower() in ("y", "yes") 

236 else: 

237 logger.warning("未知 sandbox_mode=%s, 默认拒绝", sandbox_mode) 

238 return False 

239 except KeyboardInterrupt: 

240 return False # 拒绝编辑