m2f-cli 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- m2f_cli/__init__.py +3 -0
- m2f_cli/__main__.py +3 -0
- m2f_cli/api_client.py +365 -0
- m2f_cli/config.py +61 -0
- m2f_cli/i18n.py +326 -0
- m2f_cli/main.py +16 -0
- m2f_cli/tui/__init__.py +5 -0
- m2f_cli/tui/app.py +276 -0
- m2f_cli/tui/components/__init__.py +1 -0
- m2f_cli/tui/components/api_key_setup.py +65 -0
- m2f_cli/tui/components/file_input.py +75 -0
- m2f_cli/tui/components/logo.py +32 -0
- m2f_cli/tui/components/main_menu.py +85 -0
- m2f_cli/tui/components/message.py +21 -0
- m2f_cli/tui/components/progress.py +199 -0
- m2f_cli/tui/components/project_table.py +189 -0
- m2f_cli/tui/theme.py +16 -0
- m2f_cli/tui/workflows/__init__.py +1 -0
- m2f_cli/tui/workflows/config.py +130 -0
- m2f_cli/tui/workflows/fill_sorry.py +627 -0
- m2f_cli/tui/workflows/formalize.py +400 -0
- m2f_cli/tui/workflows/project_detail.py +32 -0
- m2f_cli/tui/workflows/project_list.py +48 -0
- m2f_cli/tui/workflows/upload.py +161 -0
- m2f_cli-0.2.0.dist-info/METADATA +10 -0
- m2f_cli-0.2.0.dist-info/RECORD +29 -0
- m2f_cli-0.2.0.dist-info/WHEEL +5 -0
- m2f_cli-0.2.0.dist-info/entry_points.txt +2 -0
- m2f_cli-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
"""Fill Sorry workflow - upload Lean project and fill sorry placeholders."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import difflib
|
|
7
|
+
import io
|
|
8
|
+
import os
|
|
9
|
+
from datetime import datetime, timezone, timedelta
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from textual.widgets import Static, Input, DataTable
|
|
13
|
+
from textual.containers import Vertical
|
|
14
|
+
from textual.message import Message
|
|
15
|
+
|
|
16
|
+
from m2f_cli.api_client import APIError, M2FClient
|
|
17
|
+
from m2f_cli.i18n import t
|
|
18
|
+
|
|
19
|
+
POLL_INTERVAL = 5
|
|
20
|
+
TERMINAL_STATUSES = {"succeeded", "failed"}
|
|
21
|
+
|
|
22
|
+
# Files/dirs to exclude from project zip
|
|
23
|
+
EXCLUDE_PATTERNS = {
|
|
24
|
+
".git", ".lake", "lake-packages", "build", "__pycache__",
|
|
25
|
+
".DS_Store", "Thumbs.db", "node_modules",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _find_project_root(lean_file: Path) -> Path | None:
|
|
30
|
+
"""Walk up from a .lean file to find the project root (containing lakefile.lean)."""
|
|
31
|
+
current = lean_file.parent
|
|
32
|
+
for _ in range(10):
|
|
33
|
+
if (current / "lakefile.lean").exists() or (current / "lakefile.toml").exists():
|
|
34
|
+
return current
|
|
35
|
+
parent = current.parent
|
|
36
|
+
if parent == current:
|
|
37
|
+
break
|
|
38
|
+
current = parent
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _make_tar_gz(project_root: Path) -> bytes:
|
|
43
|
+
"""Create an in-memory tar.gz of the project, excluding build artifacts."""
|
|
44
|
+
import tarfile as _tarfile
|
|
45
|
+
buf = io.BytesIO()
|
|
46
|
+
|
|
47
|
+
def _filter(tarinfo):
|
|
48
|
+
parts = set(tarinfo.name.replace(os.sep, '/').split('/'))
|
|
49
|
+
if parts & EXCLUDE_PATTERNS:
|
|
50
|
+
return None
|
|
51
|
+
return tarinfo
|
|
52
|
+
|
|
53
|
+
with _tarfile.open(fileobj=buf, mode='w:gz') as tar:
|
|
54
|
+
tar.add(str(project_root), arcname='.', filter=_filter)
|
|
55
|
+
return buf.getvalue()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
_BJT = timezone(timedelta(hours=8))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _fmt_time(iso_str: str) -> str:
|
|
62
|
+
"""Convert ISO timestamp to Beijing time 'YYYY-MM-DD HH:MM:SS'."""
|
|
63
|
+
if not iso_str:
|
|
64
|
+
return ""
|
|
65
|
+
try:
|
|
66
|
+
dt = datetime.fromisoformat(iso_str.replace("Z", "+00:00"))
|
|
67
|
+
return dt.astimezone(_BJT).strftime("%Y-%m-%d %H:%M:%S")
|
|
68
|
+
except (ValueError, TypeError):
|
|
69
|
+
return iso_str[:19]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _status_label(status: str) -> str:
|
|
73
|
+
key = f"status_{status}"
|
|
74
|
+
label = t(key)
|
|
75
|
+
return label if label != key else status
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _status_color(status: str) -> str:
|
|
79
|
+
colors = {
|
|
80
|
+
"queued": "#f0a020",
|
|
81
|
+
"template_creating": "#2080f0",
|
|
82
|
+
"compiling": "#2080f0",
|
|
83
|
+
"running": "#2080f0",
|
|
84
|
+
"succeeded": "#22c55e",
|
|
85
|
+
"failed": "#d03050",
|
|
86
|
+
}
|
|
87
|
+
return colors.get(status, "#999")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class FillSorryFormWidget(Vertical, can_focus=True):
|
|
91
|
+
"""Form to input target .lean file path."""
|
|
92
|
+
|
|
93
|
+
class Submitted(Message):
|
|
94
|
+
def __init__(self, lean_file: str, project_root: str, local_path: str) -> None:
|
|
95
|
+
self.lean_file = lean_file
|
|
96
|
+
self.project_root = project_root
|
|
97
|
+
self.local_path = local_path # absolute path of the original .lean file
|
|
98
|
+
super().__init__()
|
|
99
|
+
|
|
100
|
+
class BackRequested(Message):
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
DEFAULT_CSS = """
|
|
104
|
+
FillSorryFormWidget {
|
|
105
|
+
height: auto;
|
|
106
|
+
padding: 1 2;
|
|
107
|
+
}
|
|
108
|
+
FillSorryFormWidget .label {
|
|
109
|
+
margin: 1 0 0 0;
|
|
110
|
+
}
|
|
111
|
+
FillSorryFormWidget .hint {
|
|
112
|
+
color: #64748b;
|
|
113
|
+
}
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
def compose(self):
|
|
117
|
+
yield Static(f"[bold]{t('fill_sorry_new')}[/]")
|
|
118
|
+
yield Static(f"{t('fill_sorry_select_file')}:", classes="label")
|
|
119
|
+
yield Static(f"[dim]{t('fill_sorry_select_file_hint')}[/]", classes="hint")
|
|
120
|
+
yield Input(placeholder="path/to/file.lean", id="lean-file-input")
|
|
121
|
+
yield Static("", id="fs-project-info")
|
|
122
|
+
yield Static(f"[dim]{t('enter_submit_esc_back')}[/]")
|
|
123
|
+
|
|
124
|
+
def on_mount(self) -> None:
|
|
125
|
+
self.query_one("#lean-file-input", Input).focus()
|
|
126
|
+
|
|
127
|
+
def on_input_changed(self, event: Input.Changed) -> None:
|
|
128
|
+
if event.input.id != "lean-file-input":
|
|
129
|
+
return
|
|
130
|
+
path = Path(event.value.strip())
|
|
131
|
+
info = self.query_one("#fs-project-info", Static)
|
|
132
|
+
if path.exists() and path.suffix == ".lean":
|
|
133
|
+
root = _find_project_root(path)
|
|
134
|
+
if root:
|
|
135
|
+
info.update(f"[green]{t('fill_sorry_detected_project')}: {root}[/]")
|
|
136
|
+
else:
|
|
137
|
+
info.update(f"[yellow]{t('project_root_not_found')}[/]")
|
|
138
|
+
elif event.value.strip():
|
|
139
|
+
info.update(f"[red]{t('not_lean_file')}[/]" if not path.suffix == ".lean" else "")
|
|
140
|
+
else:
|
|
141
|
+
info.update("")
|
|
142
|
+
|
|
143
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
144
|
+
value = event.value.strip()
|
|
145
|
+
if not value:
|
|
146
|
+
return
|
|
147
|
+
path = Path(value)
|
|
148
|
+
if not path.exists() or path.suffix != ".lean":
|
|
149
|
+
return
|
|
150
|
+
root = _find_project_root(path)
|
|
151
|
+
if not root:
|
|
152
|
+
return
|
|
153
|
+
# Disable input immediately to prevent duplicate submits
|
|
154
|
+
inp = self.query_one("#lean-file-input", Input)
|
|
155
|
+
inp.disabled = True
|
|
156
|
+
self.query_one("#fs-project-info", Static).update(
|
|
157
|
+
f"[bold yellow]{t('fill_sorry_packaging')}...[/]"
|
|
158
|
+
)
|
|
159
|
+
# Make target_file relative to project root
|
|
160
|
+
abs_path = path.resolve()
|
|
161
|
+
try:
|
|
162
|
+
rel = abs_path.relative_to(root.resolve())
|
|
163
|
+
except ValueError:
|
|
164
|
+
rel = path.name
|
|
165
|
+
self.post_message(self.Submitted(
|
|
166
|
+
lean_file=str(rel).replace("\\", "/"),
|
|
167
|
+
project_root=str(root),
|
|
168
|
+
local_path=str(abs_path),
|
|
169
|
+
))
|
|
170
|
+
|
|
171
|
+
def on_key(self, event) -> None:
|
|
172
|
+
if event.key == "escape":
|
|
173
|
+
self.post_message(self.BackRequested())
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class FillSorryProgressWidget(Vertical, can_focus=True):
|
|
177
|
+
"""Monitor a fill sorry job's progress with auto-polling."""
|
|
178
|
+
|
|
179
|
+
class BackRequested(Message):
|
|
180
|
+
pass
|
|
181
|
+
|
|
182
|
+
DEFAULT_CSS = """
|
|
183
|
+
FillSorryProgressWidget {
|
|
184
|
+
height: 1fr;
|
|
185
|
+
padding: 1 2;
|
|
186
|
+
}
|
|
187
|
+
FillSorryProgressWidget #fs-diff-area {
|
|
188
|
+
margin-top: 1;
|
|
189
|
+
max-height: 30;
|
|
190
|
+
overflow-y: auto;
|
|
191
|
+
}
|
|
192
|
+
"""
|
|
193
|
+
|
|
194
|
+
def __init__(
|
|
195
|
+
self,
|
|
196
|
+
job_id: str,
|
|
197
|
+
job_data: dict,
|
|
198
|
+
local_path: str | None = None,
|
|
199
|
+
) -> None:
|
|
200
|
+
super().__init__()
|
|
201
|
+
self.job_id = job_id
|
|
202
|
+
self.job_data = job_data
|
|
203
|
+
self.local_path = local_path # absolute path of original .lean file (None when viewed from history)
|
|
204
|
+
self._monitor_task: asyncio.Task | None = None
|
|
205
|
+
self._filled_content: str | None = None # cached filled content after success
|
|
206
|
+
self._original_content: str | None = None
|
|
207
|
+
|
|
208
|
+
def compose(self):
|
|
209
|
+
target = self.job_data.get("target_file", "")
|
|
210
|
+
yield Static(f"[bold]{t('fill_sorry')}[/] [cyan]{target}[/]")
|
|
211
|
+
yield Static(f"[bold]{t('formalize_job_id')}:[/] [dim]{self.job_id[:8]}[/]")
|
|
212
|
+
if self.local_path:
|
|
213
|
+
yield Static(f"[dim]Local: {self.local_path}[/]")
|
|
214
|
+
yield Static("", id="fs-status-line")
|
|
215
|
+
yield Static("", id="fs-stage-line")
|
|
216
|
+
yield Static("", id="fs-message-line")
|
|
217
|
+
yield Static("", id="fs-diff-area")
|
|
218
|
+
yield Static(f"[dim]{t('fill_sorry_monitoring')}[/]", id="fs-hint-line")
|
|
219
|
+
|
|
220
|
+
def on_mount(self) -> None:
|
|
221
|
+
self._update_display()
|
|
222
|
+
self.focus()
|
|
223
|
+
status = self.job_data.get("status", "")
|
|
224
|
+
if status not in TERMINAL_STATUSES:
|
|
225
|
+
self._monitor_task = asyncio.create_task(self._poll())
|
|
226
|
+
elif status == "succeeded":
|
|
227
|
+
asyncio.create_task(self._load_and_render_diff())
|
|
228
|
+
else:
|
|
229
|
+
# Already failed — show failure reason immediately
|
|
230
|
+
fail_msg = self.job_data.get("message", "")
|
|
231
|
+
hint = self.query_one("#fs-hint-line", Static)
|
|
232
|
+
hint.update(
|
|
233
|
+
f"[red]{t('fill_sorry_finished')} ({_status_label(status)})[/]\n"
|
|
234
|
+
f"[dim]{fail_msg}[/]\n\n"
|
|
235
|
+
f"[dim]{t('esc_back_list')}[/]"
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
def _update_display(self) -> None:
|
|
239
|
+
status = self.job_data.get("status", "unknown")
|
|
240
|
+
stage = self.job_data.get("stage", "")
|
|
241
|
+
message = self.job_data.get("message", "")
|
|
242
|
+
color = _status_color(status)
|
|
243
|
+
|
|
244
|
+
self.query_one("#fs-status-line", Static).update(
|
|
245
|
+
f"[bold]{t('status')}:[/] [{color}]{_status_label(status)}[/]"
|
|
246
|
+
)
|
|
247
|
+
if stage:
|
|
248
|
+
self.query_one("#fs-stage-line", Static).update(
|
|
249
|
+
f"[bold]{t('formalize_stage')}:[/] {stage}"
|
|
250
|
+
)
|
|
251
|
+
if message:
|
|
252
|
+
self.query_one("#fs-message-line", Static).update(
|
|
253
|
+
f"[bold]{t('formalize_message')}:[/] {message}"
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
async def _poll(self) -> None:
|
|
257
|
+
try:
|
|
258
|
+
client = M2FClient()
|
|
259
|
+
while True:
|
|
260
|
+
await asyncio.sleep(POLL_INTERVAL)
|
|
261
|
+
try:
|
|
262
|
+
data = client.sorry_status(self.job_id)
|
|
263
|
+
self.job_data = data
|
|
264
|
+
self._update_display()
|
|
265
|
+
|
|
266
|
+
status = data.get("status", "")
|
|
267
|
+
if status in TERMINAL_STATUSES:
|
|
268
|
+
if status == "succeeded":
|
|
269
|
+
await self._load_and_render_diff()
|
|
270
|
+
else:
|
|
271
|
+
hint = self.query_one("#fs-hint-line", Static)
|
|
272
|
+
fail_msg = data.get("message", "")
|
|
273
|
+
hint.update(
|
|
274
|
+
f"[red]{t('fill_sorry_finished')} ({_status_label(status)})[/]\n"
|
|
275
|
+
f"[dim]{fail_msg}[/]\n\n"
|
|
276
|
+
f"[dim]{t('esc_back_list')}[/]"
|
|
277
|
+
)
|
|
278
|
+
break
|
|
279
|
+
except (APIError, Exception):
|
|
280
|
+
pass
|
|
281
|
+
except asyncio.CancelledError:
|
|
282
|
+
pass
|
|
283
|
+
|
|
284
|
+
async def _load_and_render_diff(self) -> None:
|
|
285
|
+
"""Fetch filled content from server, read local original, render diff."""
|
|
286
|
+
hint = self.query_one("#fs-hint-line", Static)
|
|
287
|
+
target_file = self.job_data.get("target_file") or ""
|
|
288
|
+
if not target_file:
|
|
289
|
+
hint.update(f"[yellow]{t('fill_sorry_no_target')}[/]")
|
|
290
|
+
return
|
|
291
|
+
|
|
292
|
+
hint.update(f"[dim]{t('fill_sorry_loading_diff')}[/]")
|
|
293
|
+
try:
|
|
294
|
+
client = M2FClient()
|
|
295
|
+
resp = client.sorry_read_file(self.job_id, target_file)
|
|
296
|
+
filled = resp.get("content", "")
|
|
297
|
+
self._filled_content = filled
|
|
298
|
+
except (APIError, Exception) as exc:
|
|
299
|
+
detail = getattr(exc, "detail", str(exc))
|
|
300
|
+
hint.update(f"[red]{t('fill_sorry_load_failed')}: {detail}[/]")
|
|
301
|
+
return
|
|
302
|
+
|
|
303
|
+
# Load original if we have the local path
|
|
304
|
+
if self.local_path and Path(self.local_path).exists():
|
|
305
|
+
try:
|
|
306
|
+
self._original_content = Path(self.local_path).read_text(encoding="utf-8")
|
|
307
|
+
except Exception:
|
|
308
|
+
self._original_content = None
|
|
309
|
+
|
|
310
|
+
self._render_diff()
|
|
311
|
+
|
|
312
|
+
if self.local_path:
|
|
313
|
+
hint.update(f"[dim]{t('fill_sorry_action_hint')}[/]")
|
|
314
|
+
else:
|
|
315
|
+
hint.update(f"[dim]{t('fill_sorry_action_hint_no_local')}[/]")
|
|
316
|
+
|
|
317
|
+
def _render_diff(self) -> None:
|
|
318
|
+
"""Render git-style unified diff in the fs-diff-area widget."""
|
|
319
|
+
area = self.query_one("#fs-diff-area", Static)
|
|
320
|
+
filled = self._filled_content or ""
|
|
321
|
+
|
|
322
|
+
if self._original_content is None:
|
|
323
|
+
# No local file — just show the filled content
|
|
324
|
+
lines = filled.splitlines()
|
|
325
|
+
if len(lines) > 100:
|
|
326
|
+
preview = "\n".join(lines[:100]) + f"\n... ({len(lines) - 100} more lines)"
|
|
327
|
+
else:
|
|
328
|
+
preview = "\n".join(lines)
|
|
329
|
+
area.update(
|
|
330
|
+
f"[bold]{t('fill_sorry_filled_content')}:[/]\n[green]{preview}[/]"
|
|
331
|
+
)
|
|
332
|
+
return
|
|
333
|
+
|
|
334
|
+
# Git-style unified diff
|
|
335
|
+
diff_lines = list(difflib.unified_diff(
|
|
336
|
+
self._original_content.splitlines(),
|
|
337
|
+
filled.splitlines(),
|
|
338
|
+
fromfile=f"a/{self.job_data.get('target_file', 'file.lean')}",
|
|
339
|
+
tofile=f"b/{self.job_data.get('target_file', 'file.lean')}",
|
|
340
|
+
lineterm="",
|
|
341
|
+
))
|
|
342
|
+
|
|
343
|
+
if not diff_lines:
|
|
344
|
+
area.update(f"[dim]{t('fill_sorry_no_changes')}[/]")
|
|
345
|
+
return
|
|
346
|
+
|
|
347
|
+
rendered: list[str] = []
|
|
348
|
+
for line in diff_lines:
|
|
349
|
+
# Escape Rich markup characters
|
|
350
|
+
escaped = line.replace("[", "\\[")
|
|
351
|
+
if line.startswith("+++") or line.startswith("---"):
|
|
352
|
+
rendered.append(f"[bold]{escaped}[/]")
|
|
353
|
+
elif line.startswith("@@"):
|
|
354
|
+
rendered.append(f"[cyan]{escaped}[/]")
|
|
355
|
+
elif line.startswith("+"):
|
|
356
|
+
rendered.append(f"[green]{escaped}[/]")
|
|
357
|
+
elif line.startswith("-"):
|
|
358
|
+
rendered.append(f"[red]{escaped}[/]")
|
|
359
|
+
else:
|
|
360
|
+
rendered.append(f"[dim]{escaped}[/]")
|
|
361
|
+
|
|
362
|
+
# Cap huge diffs
|
|
363
|
+
MAX_LINES = 200
|
|
364
|
+
if len(rendered) > MAX_LINES:
|
|
365
|
+
rendered = rendered[:MAX_LINES] + [f"[dim]... ({len(diff_lines) - MAX_LINES} more lines)[/]"]
|
|
366
|
+
|
|
367
|
+
area.update("\n".join(rendered))
|
|
368
|
+
|
|
369
|
+
def on_key(self, event) -> None:
|
|
370
|
+
key = (event.key or "").lower()
|
|
371
|
+
if key == "escape":
|
|
372
|
+
if self._monitor_task and not self._monitor_task.done():
|
|
373
|
+
self._monitor_task.cancel()
|
|
374
|
+
self.post_message(self.BackRequested())
|
|
375
|
+
return
|
|
376
|
+
if self.job_data.get("status") != "succeeded":
|
|
377
|
+
return
|
|
378
|
+
if key == "o":
|
|
379
|
+
asyncio.create_task(self._overwrite_local())
|
|
380
|
+
elif key == "s":
|
|
381
|
+
asyncio.create_task(self._save_as())
|
|
382
|
+
elif key == "d":
|
|
383
|
+
self._render_diff() # re-render existing diff
|
|
384
|
+
|
|
385
|
+
async def _overwrite_local(self) -> None:
|
|
386
|
+
hint = self.query_one("#fs-hint-line", Static)
|
|
387
|
+
if not self.local_path or self._filled_content is None:
|
|
388
|
+
hint.update(f"[yellow]{t('fill_sorry_cannot_overwrite')}[/]")
|
|
389
|
+
return
|
|
390
|
+
try:
|
|
391
|
+
p = Path(self.local_path)
|
|
392
|
+
backup = p.with_suffix(p.suffix + ".bak")
|
|
393
|
+
if p.exists():
|
|
394
|
+
backup.write_bytes(p.read_bytes())
|
|
395
|
+
p.write_text(self._filled_content, encoding="utf-8")
|
|
396
|
+
hint.update(
|
|
397
|
+
f"[green]{t('fill_sorry_overwritten')}: {p}[/]\n"
|
|
398
|
+
f"[dim]Backup: {backup}[/]"
|
|
399
|
+
)
|
|
400
|
+
except Exception as exc:
|
|
401
|
+
hint.update(f"[red]{t('fill_sorry_overwrite_failed')}: {exc}[/]")
|
|
402
|
+
|
|
403
|
+
async def _save_as(self) -> None:
|
|
404
|
+
hint = self.query_one("#fs-hint-line", Static)
|
|
405
|
+
if self._filled_content is None:
|
|
406
|
+
hint.update(f"[yellow]{t('fill_sorry_no_content')}[/]")
|
|
407
|
+
return
|
|
408
|
+
target = self.job_data.get("target_file", "filled.lean")
|
|
409
|
+
# Save to current working directory preserving the file name
|
|
410
|
+
out_path = Path.cwd() / Path(target).name
|
|
411
|
+
try:
|
|
412
|
+
out_path.write_text(self._filled_content, encoding="utf-8")
|
|
413
|
+
hint.update(f"[green]{t('fill_sorry_saved')}: {out_path}[/]")
|
|
414
|
+
except Exception as exc:
|
|
415
|
+
hint.update(f"[red]{t('fill_sorry_save_failed')}: {exc}[/]")
|
|
416
|
+
|
|
417
|
+
def on_unmount(self) -> None:
|
|
418
|
+
if self._monitor_task and not self._monitor_task.done():
|
|
419
|
+
self._monitor_task.cancel()
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
class FillSorryJobListWidget(Vertical, can_focus=True):
|
|
423
|
+
"""Display a paginated list of fill sorry jobs."""
|
|
424
|
+
|
|
425
|
+
class BackRequested(Message):
|
|
426
|
+
pass
|
|
427
|
+
|
|
428
|
+
class JobSelected(Message):
|
|
429
|
+
def __init__(self, job_id: str, job_data: dict) -> None:
|
|
430
|
+
self.job_id = job_id
|
|
431
|
+
self.job_data = job_data
|
|
432
|
+
super().__init__()
|
|
433
|
+
|
|
434
|
+
class PageChanged(Message):
|
|
435
|
+
def __init__(self, page: int) -> None:
|
|
436
|
+
self.page = page
|
|
437
|
+
super().__init__()
|
|
438
|
+
|
|
439
|
+
DEFAULT_CSS = """
|
|
440
|
+
FillSorryJobListWidget {
|
|
441
|
+
height: 1fr;
|
|
442
|
+
padding: 1 2;
|
|
443
|
+
}
|
|
444
|
+
FillSorryJobListWidget DataTable {
|
|
445
|
+
height: 1fr;
|
|
446
|
+
}
|
|
447
|
+
"""
|
|
448
|
+
|
|
449
|
+
def __init__(
|
|
450
|
+
self,
|
|
451
|
+
jobs: list[dict],
|
|
452
|
+
page: int = 1,
|
|
453
|
+
page_size: int = 20,
|
|
454
|
+
total: int = 0,
|
|
455
|
+
) -> None:
|
|
456
|
+
super().__init__()
|
|
457
|
+
self._jobs = jobs
|
|
458
|
+
self.page = page
|
|
459
|
+
self.page_size = page_size
|
|
460
|
+
self.total = total
|
|
461
|
+
|
|
462
|
+
@property
|
|
463
|
+
def total_pages(self) -> int:
|
|
464
|
+
if self.total <= 0 or self.page_size <= 0:
|
|
465
|
+
return 1
|
|
466
|
+
return max(1, (self.total + self.page_size - 1) // self.page_size)
|
|
467
|
+
|
|
468
|
+
def compose(self):
|
|
469
|
+
yield Static(
|
|
470
|
+
f"[bold]{t('fill_sorry_history')}[/] "
|
|
471
|
+
f"[dim]Enter=详情 Esc=返回 n/p=翻页 r=刷新[/]"
|
|
472
|
+
)
|
|
473
|
+
yield DataTable(id="fs-table")
|
|
474
|
+
yield Static(f"第 {self.page}/{self.total_pages} 页 · 共 {self.total} 条", id="fs-page-info")
|
|
475
|
+
|
|
476
|
+
def on_mount(self) -> None:
|
|
477
|
+
table = self.query_one("#fs-table", DataTable)
|
|
478
|
+
table.add_columns(
|
|
479
|
+
t("col_name"), t("col_status"), t("col_stage"),
|
|
480
|
+
t("col_message"), t("col_created"),
|
|
481
|
+
)
|
|
482
|
+
table.cursor_type = "row"
|
|
483
|
+
for job in self._jobs:
|
|
484
|
+
name = job.get("statement_prompt", "")[:50]
|
|
485
|
+
if len(job.get("statement_prompt", "")) > 50:
|
|
486
|
+
name += "..."
|
|
487
|
+
status = job.get("status", "")
|
|
488
|
+
color = _status_color(status)
|
|
489
|
+
table.add_row(
|
|
490
|
+
name,
|
|
491
|
+
f"[{color}]{_status_label(status)}[/]",
|
|
492
|
+
job.get("stage", ""),
|
|
493
|
+
(job.get("message", "") or "")[:30],
|
|
494
|
+
_fmt_time(job.get("created_at", "")),
|
|
495
|
+
key=job.get("id", ""),
|
|
496
|
+
)
|
|
497
|
+
table.focus()
|
|
498
|
+
|
|
499
|
+
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
|
|
500
|
+
row_key = str(event.row_key.value)
|
|
501
|
+
for job in self._jobs:
|
|
502
|
+
if job.get("id") == row_key:
|
|
503
|
+
self.post_message(self.JobSelected(job_id=row_key, job_data=job))
|
|
504
|
+
return
|
|
505
|
+
|
|
506
|
+
def on_key(self, event) -> None:
|
|
507
|
+
key = (event.key or "").lower()
|
|
508
|
+
if key == "escape":
|
|
509
|
+
self.post_message(self.BackRequested())
|
|
510
|
+
elif key == "n":
|
|
511
|
+
if self.page < self.total_pages:
|
|
512
|
+
self.post_message(self.PageChanged(page=self.page + 1))
|
|
513
|
+
elif key == "p":
|
|
514
|
+
if self.page > 1:
|
|
515
|
+
self.post_message(self.PageChanged(page=self.page - 1))
|
|
516
|
+
elif key == "r":
|
|
517
|
+
self.post_message(self.PageChanged(page=self.page))
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
async def run_fill_sorry_submit(
|
|
521
|
+
app, lean_file: str, project_root: str, local_path: str | None = None,
|
|
522
|
+
) -> None:
|
|
523
|
+
"""Package project as tar.gz, upload via sorry-cli, list sorries, start run."""
|
|
524
|
+
container = app.query_one("#main-scroll")
|
|
525
|
+
|
|
526
|
+
status_widget = Static(f"[bold]{t('fill_sorry_packaging')}[/]")
|
|
527
|
+
container.mount(status_widget)
|
|
528
|
+
|
|
529
|
+
# Step 1: Package project as tar.gz
|
|
530
|
+
try:
|
|
531
|
+
root = Path(project_root)
|
|
532
|
+
tar_data = _make_tar_gz(root)
|
|
533
|
+
size_kb = len(tar_data) / 1024
|
|
534
|
+
status_widget.update(
|
|
535
|
+
f"[bold]{t('fill_sorry_uploading')}[/] ({size_kb:.0f} KB)"
|
|
536
|
+
)
|
|
537
|
+
except Exception as exc:
|
|
538
|
+
from m2f_cli.tui.components.message import MessageWidget
|
|
539
|
+
container.mount(MessageWidget(f"{t('fill_sorry_submit_failed')}: {exc}", "error"))
|
|
540
|
+
container.mount(Static(f"[dim]{t('press_any_key_back')}[/]"))
|
|
541
|
+
return
|
|
542
|
+
|
|
543
|
+
# Step 2: Upload via sorry-cli
|
|
544
|
+
try:
|
|
545
|
+
client = M2FClient()
|
|
546
|
+
data = client.sorry_upload(tar_data, project_name=Path(project_root).name)
|
|
547
|
+
except (APIError, Exception) as exc:
|
|
548
|
+
from m2f_cli.tui.components.message import MessageWidget
|
|
549
|
+
detail = exc.detail if isinstance(exc, APIError) else str(exc)
|
|
550
|
+
container.mount(MessageWidget(f"{t('fill_sorry_submit_failed')}: {detail}", "error"))
|
|
551
|
+
container.mount(Static(f"[dim]{t('press_any_key_back')}[/]"))
|
|
552
|
+
return
|
|
553
|
+
|
|
554
|
+
job_id = data.get("id", "")
|
|
555
|
+
status_widget.update(
|
|
556
|
+
f"[bold green]{t('fill_sorry_submitted')}[/] {t('formalize_job_id')}: [cyan]{job_id[:8]}[/]"
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
# Step 3: List sorries → auto-start run with first sorry
|
|
560
|
+
try:
|
|
561
|
+
sorries_data = client.sorry_list_sorries(job_id, lean_file)
|
|
562
|
+
sorry_count = sorries_data.get("count", 0)
|
|
563
|
+
if sorry_count > 0:
|
|
564
|
+
sorries = sorries_data.get("sorries", [])
|
|
565
|
+
first = sorries[0] if sorries else {}
|
|
566
|
+
container.mount(Static(
|
|
567
|
+
f"[bold]发现 {sorry_count} 个 sorry[/],"
|
|
568
|
+
f"自动处理第 1 个 (line {first.get('line', '?')} — {first.get('declaration', '')[:50]})"
|
|
569
|
+
))
|
|
570
|
+
else:
|
|
571
|
+
container.mount(Static(f"[yellow]该文件没有 sorry[/]"))
|
|
572
|
+
container.mount(Static(f"[dim]{t('press_any_key_back')}[/]"))
|
|
573
|
+
return
|
|
574
|
+
except (APIError, Exception):
|
|
575
|
+
container.mount(Static("[dim]无法列出 sorry,使用 first 模式[/]"))
|
|
576
|
+
|
|
577
|
+
# Step 4: Start run (compile + prove) via Worker
|
|
578
|
+
try:
|
|
579
|
+
run_payload = {
|
|
580
|
+
"lean_file": lean_file,
|
|
581
|
+
"target_sorry": {"mode": "first"},
|
|
582
|
+
"strict_target_only": True,
|
|
583
|
+
}
|
|
584
|
+
client.sorry_run(job_id, run_payload)
|
|
585
|
+
except (APIError, Exception) as exc:
|
|
586
|
+
from m2f_cli.tui.components.message import MessageWidget
|
|
587
|
+
detail = exc.detail if isinstance(exc, APIError) else str(exc)
|
|
588
|
+
container.mount(MessageWidget(f"{t('fill_sorry_submit_failed')}: {detail}", "error"))
|
|
589
|
+
container.mount(Static(f"[dim]{t('press_any_key_back')}[/]"))
|
|
590
|
+
return
|
|
591
|
+
|
|
592
|
+
# Show progress widget — polls sorry_status
|
|
593
|
+
from m2f_cli.tui.components.logo import LogoWidget
|
|
594
|
+
container.remove_children()
|
|
595
|
+
container.mount(LogoWidget())
|
|
596
|
+
container.mount(FillSorryProgressWidget(
|
|
597
|
+
job_id=job_id, job_data=data, local_path=local_path,
|
|
598
|
+
))
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def fetch_and_show_fill_sorry_jobs(app, page: int = 1, page_size: int = 20) -> None:
|
|
602
|
+
"""Fetch paginated CLI fill sorry job list and display."""
|
|
603
|
+
from m2f_cli.tui.components.message import MessageWidget
|
|
604
|
+
|
|
605
|
+
try:
|
|
606
|
+
client = M2FClient()
|
|
607
|
+
result = client.list_cli_jobs(page=page, page_size=page_size)
|
|
608
|
+
jobs = result.get("items", [])
|
|
609
|
+
total = result.get("total", len(jobs))
|
|
610
|
+
except APIError as exc:
|
|
611
|
+
app._clear_and_mount(MessageWidget(f"Error: {exc.detail}", "error"))
|
|
612
|
+
return
|
|
613
|
+
except Exception as exc:
|
|
614
|
+
app._clear_and_mount(MessageWidget(f"{t('connection_error')}: {exc}", "error"))
|
|
615
|
+
return
|
|
616
|
+
|
|
617
|
+
if not jobs and page == 1:
|
|
618
|
+
from textual.widgets import Static
|
|
619
|
+
app._clear_and_mount(
|
|
620
|
+
Static(f"[dim]{t('no_fill_sorry_jobs')}[/]"),
|
|
621
|
+
Static(f"\n[dim]{t('press_any_key_back')}[/]"),
|
|
622
|
+
)
|
|
623
|
+
return
|
|
624
|
+
|
|
625
|
+
app._clear_and_mount(FillSorryJobListWidget(
|
|
626
|
+
jobs=jobs, page=page, page_size=page_size, total=total,
|
|
627
|
+
))
|