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
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-07 14:04 +0800
1"""用户审批对话框 — 展示 diff 并等待确认,支持 unified / split 两种视图"""
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
15console = Console()
16logger = logging.getLogger("merco.sandbox")
18_SPLIT_MIN_WIDTH = 80
21def _term_width() -> int:
22 try:
23 return shutil.get_terminal_size().columns
24 except Exception:
25 return 80
28def _read_config() -> str:
29 try:
30 cfg = MercoConfig.load()
31 return cfg.sandbox_mode
32 except Exception:
33 return "ask"
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="行内对照(- 删除 + 添加)")
44_CONTEXT = 3 # 变更块前后保留的上下文行数
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)
54 old_lines = old_content.splitlines()
55 new_lines = new_content.splitlines()
57 sm = difflib.SequenceMatcher(None, old_lines, new_lines)
58 opcodes = sm.get_opcodes()
60 # ── 阶段 1:构建 (old_ln, old_text, new_ln, new_text, change_type) 行 ──
61 rows: list[tuple[str, str, str, str, str]] = []
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"
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"))
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)
120 if not rows:
121 console.print(f"[bold yellow]{title}[/bold yellow]")
122 console.print("[dim](无差异)[/dim]")
123 return
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) # 新内容
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)
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 )
172 console.print(f"[bold yellow]{title}[/bold yellow]")
173 console.print(table)
176def _trunc(s: str, width: int) -> str:
177 """截断到 width 宽度,超长加 …"""
178 if len(s) <= width:
179 return s
180 return s[:width - 1] + "\u2026"
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 并等待用户确认修改
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"(左右对照)
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
210 title: str = "\U0001f4dd 修改预览"
211 if edits_count > 1:
212 title += f"({edits_count} 处改动)"
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
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))
225 console.print(Rule(style="dim"))
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 # 拒绝编辑