389 lines
12 KiB
Python
389 lines
12 KiB
Python
"""Textual TUI for reviewing high-risk plan operations."""
|
|||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
from dataclasses import dataclass
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
from textual import on
|
||
|
|
from textual.app import App, ComposeResult
|
||
|
|
from textual.binding import Binding
|
||
|
|
from textual.containers import Container, Horizontal, ScrollableContainer, Vertical
|
||
|
|
from textual.geometry import Size
|
||
|
|
from textual.screen import ModalScreen, Screen
|
||
|
|
from textual.widgets import DataTable, Footer, Static
|
||
|
|
|
||
|
|
from vlm.plan_review import save_review_csv
|
||
|
|
from vlm.review_display import (
|
||
|
|
build_csv_rows,
|
||
|
|
format_paths_for_detail,
|
||
|
|
review_row_status_symbol,
|
||
|
|
risk_flags_to_labels,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _index_from_row_key(row_key) -> int: # noqa: ANN001 - RowKey | str
|
||
|
|
if isinstance(row_key, str):
|
||
|
|
return int(row_key)
|
||
|
|
val = getattr(row_key, "value", None)
|
||
|
|
if val is None:
|
||
|
|
return int(row_key)
|
||
|
|
return int(val)
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass(frozen=True)
|
||
|
|
class ReviewTUIContext:
|
||
|
|
"""Inputs for the plan review TUI."""
|
||
|
|
|
||
|
|
rows: list[dict[str, str]]
|
||
|
|
counters: dict[str, int]
|
||
|
|
library_root: Path
|
||
|
|
output_csv: Path
|
||
|
|
plan_input: Path
|
||
|
|
summary_text: str
|
||
|
|
|
||
|
|
|
||
|
|
class SummaryScreen(Screen):
|
||
|
|
"""Migration summary; Enter continues, q aborts."""
|
||
|
|
|
||
|
|
BINDINGS = [
|
||
|
|
Binding("enter", "continue_", "继续", show=True),
|
||
|
|
Binding("q", "quit", "退出", show=True),
|
||
|
|
]
|
||
|
|
|
||
|
|
def __init__(self, ctx: ReviewTUIContext) -> None:
|
||
|
|
super().__init__()
|
||
|
|
self._ctx = ctx
|
||
|
|
|
||
|
|
def compose(self) -> ComposeResult:
|
||
|
|
stats_lines = [
|
||
|
|
"---",
|
||
|
|
"计划统计",
|
||
|
|
f" 总操作: {self._ctx.counters['total_operations']}",
|
||
|
|
f" 高危: {self._ctx.counters['high_risk_operations']}",
|
||
|
|
f" manual_review: {self._ctx.counters['manual_review']}",
|
||
|
|
f" sample_source: {self._ctx.counters['sample_source']}",
|
||
|
|
f" high_season: {self._ctx.counters['high_season']}",
|
||
|
|
f" high_episode: {self._ctx.counters['high_episode']}",
|
||
|
|
f" conflicts: {self._ctx.counters['conflicts']}",
|
||
|
|
"",
|
||
|
|
f"将要写入: {self._ctx.output_csv}",
|
||
|
|
"",
|
||
|
|
"默认仅审核标记为高危的操作(与 CSV 行一致)。",
|
||
|
|
"",
|
||
|
|
"Enter 进入审核 · q 退出",
|
||
|
|
]
|
||
|
|
body = self._ctx.summary_text + "\n\n" + "\n".join(stats_lines)
|
||
|
|
yield ScrollableContainer(Static(body, id="summary_body"))
|
||
|
|
yield Footer()
|
||
|
|
|
||
|
|
def action_continue_(self) -> None:
|
||
|
|
self.dismiss(True)
|
||
|
|
|
||
|
|
def action_quit(self) -> None:
|
||
|
|
self.dismiss(False)
|
||
|
|
|
||
|
|
|
||
|
|
class ConfirmDiscardScreen(ModalScreen[bool]):
|
||
|
|
"""Confirm discarding unsaved edits."""
|
||
|
|
|
||
|
|
BINDINGS = [
|
||
|
|
Binding("y", "yes", show=False),
|
||
|
|
Binding("n", "no", show=False),
|
||
|
|
]
|
||
|
|
|
||
|
|
def compose(self) -> ComposeResult:
|
||
|
|
yield Container(
|
||
|
|
Static("未保存的修改将丢失。放弃? (y / n)", id="confirm_text"),
|
||
|
|
id="confirm_box",
|
||
|
|
)
|
||
|
|
|
||
|
|
def action_yes(self) -> None:
|
||
|
|
self.dismiss(True)
|
||
|
|
|
||
|
|
def action_no(self) -> None:
|
||
|
|
self.dismiss(False)
|
||
|
|
|
||
|
|
DEFAULT_CSS = """
|
||
|
|
ConfirmDiscardScreen {
|
||
|
|
align: center middle;
|
||
|
|
}
|
||
|
|
#confirm_box {
|
||
|
|
width: auto;
|
||
|
|
height: auto;
|
||
|
|
padding: 1 2;
|
||
|
|
border: thick $primary;
|
||
|
|
background: $surface;
|
||
|
|
}
|
||
|
|
"""
|
||
|
|
|
||
|
|
|
||
|
|
class ReviewMainScreen(Screen):
|
||
|
|
"""High-risk table + detail pane."""
|
||
|
|
|
||
|
|
BINDINGS = [
|
||
|
|
Binding("up", "cursor_up", show=False),
|
||
|
|
Binding("down", "cursor_down", show=False),
|
||
|
|
Binding("k", "cursor_up", show=False),
|
||
|
|
Binding("j", "cursor_down", show=False),
|
||
|
|
Binding("a", "keep_row", "保留", show=True),
|
||
|
|
Binding("r", "reject_row", "驳回", show=True),
|
||
|
|
Binding("u", "undo_row", "撤销", show=True),
|
||
|
|
Binding("s", "save", "保存", show=True),
|
||
|
|
Binding("q", "request_quit", "退出", show=True),
|
||
|
|
]
|
||
|
|
|
||
|
|
def __init__(self, ctx: ReviewTUIContext) -> None:
|
||
|
|
super().__init__()
|
||
|
|
self.ctx = ctx
|
||
|
|
self._by_index: dict[int, dict[str, str]] = {
|
||
|
|
int(r["index"]): r for r in ctx.rows
|
||
|
|
}
|
||
|
|
self.initial_op_by_index: dict[int, str] = {
|
||
|
|
int(r["index"]): r["operation_type"] for r in ctx.rows
|
||
|
|
}
|
||
|
|
self.op_by_index: dict[int, str] = dict(self.initial_op_by_index)
|
||
|
|
|
||
|
|
def compose(self) -> ComposeResult:
|
||
|
|
plan_s = str(self.ctx.plan_input)
|
||
|
|
if len(plan_s) > 72:
|
||
|
|
plan_s = plan_s[:35] + "…" + plan_s[-34:]
|
||
|
|
hdr = (
|
||
|
|
f"{plan_s} · 高危 {self.ctx.counters['high_risk_operations']}"
|
||
|
|
f" / {self.ctx.counters['total_operations']}"
|
||
|
|
)
|
||
|
|
yield Static(hdr, id="header_line")
|
||
|
|
with Horizontal(id="body"):
|
||
|
|
yield DataTable(id="review_table", cursor_type="row", zebra_stripes=True)
|
||
|
|
with ScrollableContainer(id="detail_scroll"):
|
||
|
|
yield Static("", id="detail_text")
|
||
|
|
yield Static(
|
||
|
|
"↑↓ j/k 移动 · a 保留 · r 驳回(no-op) · u 撤销本条 · s 保存退出 · q 退出",
|
||
|
|
id="footer_line",
|
||
|
|
)
|
||
|
|
yield Footer()
|
||
|
|
|
||
|
|
DEFAULT_CSS = """
|
||
|
|
#header_line {
|
||
|
|
dock: top;
|
||
|
|
padding: 0 1;
|
||
|
|
background: $primary-darken-2;
|
||
|
|
color: $text;
|
||
|
|
}
|
||
|
|
#footer_line {
|
||
|
|
dock: bottom;
|
||
|
|
padding: 0 1;
|
||
|
|
background: $panel;
|
||
|
|
color: $text-muted;
|
||
|
|
}
|
||
|
|
#body {
|
||
|
|
layout: horizontal;
|
||
|
|
height: 1fr;
|
||
|
|
}
|
||
|
|
#body.vertical-split {
|
||
|
|
layout: vertical;
|
||
|
|
height: 1fr;
|
||
|
|
}
|
||
|
|
#review_table {
|
||
|
|
width: 1fr;
|
||
|
|
min-height: 5;
|
||
|
|
}
|
||
|
|
#body.vertical-split #review_table {
|
||
|
|
height: 40%;
|
||
|
|
}
|
||
|
|
#detail_scroll {
|
||
|
|
width: 1fr;
|
||
|
|
min-height: 5;
|
||
|
|
border-left: solid $primary-darken-3;
|
||
|
|
padding: 0 1;
|
||
|
|
}
|
||
|
|
#body.vertical-split #detail_scroll {
|
||
|
|
border-left: none;
|
||
|
|
border-top: solid $primary-darken-3;
|
||
|
|
height: 1fr;
|
||
|
|
}
|
||
|
|
"""
|
||
|
|
|
||
|
|
def on_mount(self) -> None:
|
||
|
|
table = self.query_one("#review_table", DataTable)
|
||
|
|
table.cursor_type = "row"
|
||
|
|
table.add_column(" ", key="sym", width=3)
|
||
|
|
table.add_column("#", key="idx", width=4)
|
||
|
|
table.add_column("类型", key="op", width=11)
|
||
|
|
table.add_column("风险", key="risk", width=18)
|
||
|
|
table.add_column("文件", key="file")
|
||
|
|
|
||
|
|
for r in self.ctx.rows:
|
||
|
|
idx = int(r["index"])
|
||
|
|
sym = self._symbol_for(idx)
|
||
|
|
table.add_row(
|
||
|
|
sym,
|
||
|
|
str(idx),
|
||
|
|
self.op_by_index[idx],
|
||
|
|
risk_flags_to_labels(r["risk_flags"]),
|
||
|
|
Path(r["source_path"]).name,
|
||
|
|
key=str(idx),
|
||
|
|
)
|
||
|
|
self._apply_body_layout(self.app.size)
|
||
|
|
if table.row_count > 0:
|
||
|
|
table.focus()
|
||
|
|
self._refresh_detail(_index_from_row_key(table.ordered_rows[0].key))
|
||
|
|
else:
|
||
|
|
self.query_one("#detail_text", Static).update(
|
||
|
|
"无高危项。按 s 保存仅含表头的 CSV(与无 --tui 行为一致)。"
|
||
|
|
)
|
||
|
|
|
||
|
|
def _table(self) -> DataTable:
|
||
|
|
return self.query_one("#review_table", DataTable)
|
||
|
|
|
||
|
|
def _symbol_for(self, index: int) -> str:
|
||
|
|
return review_row_status_symbol(
|
||
|
|
self.op_by_index,
|
||
|
|
self.initial_op_by_index,
|
||
|
|
index,
|
||
|
|
)
|
||
|
|
|
||
|
|
def _apply_body_layout(self, app_size: Size) -> None:
|
||
|
|
body = self.query_one("#body", Horizontal)
|
||
|
|
if app_size.width < 100:
|
||
|
|
body.add_class("vertical-split")
|
||
|
|
else:
|
||
|
|
body.remove_class("vertical-split")
|
||
|
|
|
||
|
|
def on_resize(self, event) -> None: # noqa: ANN001 - textual Resize
|
||
|
|
self._apply_body_layout(self.app.size)
|
||
|
|
|
||
|
|
def _current_index(self) -> int | None:
|
||
|
|
table = self._table()
|
||
|
|
if table.row_count == 0:
|
||
|
|
return None
|
||
|
|
row_index = table.cursor_coordinate.row
|
||
|
|
row = table.ordered_rows[row_index]
|
||
|
|
return _index_from_row_key(row.key)
|
||
|
|
|
||
|
|
@on(DataTable.RowHighlighted) # type: ignore[misc]
|
||
|
|
def on_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
|
||
|
|
if event.data_table.id != "review_table":
|
||
|
|
return
|
||
|
|
idx = _index_from_row_key(event.row_key)
|
||
|
|
self._refresh_detail(idx)
|
||
|
|
|
||
|
|
def _refresh_detail(self, index: int) -> None:
|
||
|
|
r = self._by_index[index]
|
||
|
|
op = self.op_by_index[index]
|
||
|
|
paths = format_paths_for_detail(
|
||
|
|
r["source_path"],
|
||
|
|
r.get("destination_path", ""),
|
||
|
|
self.ctx.library_root,
|
||
|
|
)
|
||
|
|
risk_cn = risk_flags_to_labels(r["risk_flags"], max_len=120)
|
||
|
|
summary = (
|
||
|
|
f"#{index} · {op} · {Path(r['source_path']).name}"
|
||
|
|
+ (f" · {risk_cn}" if risk_cn else "")
|
||
|
|
)
|
||
|
|
text = (
|
||
|
|
f"{summary}\n\n"
|
||
|
|
f"变更\n{paths}\n\n"
|
||
|
|
f"依据\n{r['reason']}\n\n"
|
||
|
|
f"标记\n{r['risk_flags']}"
|
||
|
|
)
|
||
|
|
self.query_one("#detail_text", Static).update(text)
|
||
|
|
|
||
|
|
def _refresh_row_cells(self, index: int) -> None:
|
||
|
|
table = self._table()
|
||
|
|
key = str(index)
|
||
|
|
sym = self._symbol_for(index)
|
||
|
|
table.update_cell(key, "sym", sym)
|
||
|
|
table.update_cell(key, "op", self.op_by_index[index])
|
||
|
|
|
||
|
|
def _update_dirty_header(self) -> None:
|
||
|
|
dirty = self._is_dirty()
|
||
|
|
hdr = self.query_one("#header_line", Static)
|
||
|
|
plan_s = str(self.ctx.plan_input)
|
||
|
|
if len(plan_s) > 72:
|
||
|
|
plan_s = plan_s[:35] + "…" + plan_s[-34:]
|
||
|
|
star = " *" if dirty else ""
|
||
|
|
hdr.update(
|
||
|
|
f"{plan_s}{star} · 高危 {self.ctx.counters['high_risk_operations']}"
|
||
|
|
f" / {self.ctx.counters['total_operations']}"
|
||
|
|
)
|
||
|
|
|
||
|
|
def _is_dirty(self) -> bool:
|
||
|
|
return self.op_by_index != self.initial_op_by_index
|
||
|
|
|
||
|
|
def action_cursor_up(self) -> None:
|
||
|
|
if self._table().row_count:
|
||
|
|
self._table().action_cursor_up()
|
||
|
|
|
||
|
|
def action_cursor_down(self) -> None:
|
||
|
|
if self._table().row_count:
|
||
|
|
self._table().action_cursor_down()
|
||
|
|
|
||
|
|
def action_keep_row(self) -> None:
|
||
|
|
idx = self._current_index()
|
||
|
|
if idx is None:
|
||
|
|
return
|
||
|
|
self.op_by_index[idx] = self.initial_op_by_index[idx]
|
||
|
|
self._refresh_row_cells(idx)
|
||
|
|
self._refresh_detail(idx)
|
||
|
|
self._update_dirty_header()
|
||
|
|
|
||
|
|
def action_reject_row(self) -> None:
|
||
|
|
idx = self._current_index()
|
||
|
|
if idx is None:
|
||
|
|
return
|
||
|
|
self.op_by_index[idx] = "no-op"
|
||
|
|
self._refresh_row_cells(idx)
|
||
|
|
self._refresh_detail(idx)
|
||
|
|
self._update_dirty_header()
|
||
|
|
|
||
|
|
def action_undo_row(self) -> None:
|
||
|
|
self.action_keep_row()
|
||
|
|
|
||
|
|
def action_save(self) -> None:
|
||
|
|
out_rows = build_csv_rows(self.ctx.rows, self.op_by_index)
|
||
|
|
save_review_csv(out_rows, self.ctx.output_csv)
|
||
|
|
self.dismiss("saved")
|
||
|
|
|
||
|
|
def action_request_quit(self) -> None:
|
||
|
|
if not self._is_dirty():
|
||
|
|
self.dismiss("aborted")
|
||
|
|
return
|
||
|
|
|
||
|
|
def after_confirm(confirmed: bool | None) -> None:
|
||
|
|
if confirmed:
|
||
|
|
self.dismiss("aborted")
|
||
|
|
|
||
|
|
self.app.push_screen(ConfirmDiscardScreen(), callback=after_confirm)
|
||
|
|
|
||
|
|
|
||
|
|
class PlanReviewApp(App):
|
||
|
|
"""Application shell: summary screen then review screen."""
|
||
|
|
|
||
|
|
def __init__(self, ctx: ReviewTUIContext) -> None:
|
||
|
|
super().__init__()
|
||
|
|
self.ctx = ctx
|
||
|
|
|
||
|
|
def on_mount(self) -> None:
|
||
|
|
self.push_screen(SummaryScreen(self.ctx), self._after_summary)
|
||
|
|
|
||
|
|
def _after_summary(self, result: bool | None) -> None:
|
||
|
|
if not result:
|
||
|
|
self.exit(return_code=1)
|
||
|
|
return
|
||
|
|
self.push_screen(ReviewMainScreen(self.ctx), self._after_main)
|
||
|
|
|
||
|
|
def _after_main(self, result: str | None) -> None:
|
||
|
|
if result == "saved":
|
||
|
|
self.exit(return_code=0)
|
||
|
|
else:
|
||
|
|
self.exit(return_code=1)
|
||
|
|
|
||
|
|
|
||
|
|
def run_plan_review_tui(ctx: ReviewTUIContext) -> int:
|
||
|
|
"""Block until the user finishes the TUI. Returns process exit code."""
|
||
|
|
app = PlanReviewApp(ctx)
|
||
|
|
app.run()
|
||
|
|
code = app.return_code
|
||
|
|
return 0 if code is None else code
|