复审 reviewer-modpackpick-r1(p2-1items / fix-then-merge)的返工。 P2-1 预检失败被吞掉(错误呈现回归) - host::import::spawn_scan 在 `scan.error.is_some()` 时**再发一条 PackEvent::Report** (`convert::failed_report_of` 把 core 的 ImportError 含 hint 逐字段搬过去),于是页面回到 既有的「致命 —— 已回滚」结果块,不再落回入口态。 - convert 里把失败报告的组装收成一处(`failed_report` / `failed_report_of` → `failed_report_with`)。 - 证据:`a_failed_scan_still_reaches_the_screen_as_a_report` 断言**事件序列** `["scan-with-error", "report-failed"]` + 报告里 code/message 与预检同源(MultiMC `formatVersion: 2`,detect 认得、read 才失败);`a_clean_scan_does_not_emit_a_report` 是对照。 变异验证:删掉那条 Report 发送 ⇒ 该用例红。 P3-1 写侧补条目数闸 - `WriteLimits` 增加 `entries`(生产口径 = `PACK_MAX_ENTRIES`);`preflight` 在**读盘之前** 按「清单 1 条 + 覆盖文件 N 条」判 `count > limit`。 - 证据:`the_write_side_refuses_a_pack_over_the_unified_entry_count`(20001 条声明只放 1 个 真文件 ⇒ 报 `pack_limits` 而不是 `pack_io`,即闸在读盘前)、 `the_entry_count_gate_is_the_same_number_as_the_read_side`(两侧同一个数)。 变异验证:注释掉 `guard_entries` 调用 ⇒ 前一条红。 P3-2 「内容相同」判据归一 - 删掉 `scan::judge` 里与 `merge::decide` 重复的 `disk.sha1 == pack_sha1` 早退,只留 `decide().reason?`;两处文档互相指路(`merge.rs` 注明这是唯一判据)。 - 变异验证:把 `decide` 的相同分支改成"冲突" ⇒ **8 条**红(含 4 条 scan 用例与 merge 单测)。 P3-3/P3-4 口径与注释 - `TogglePick` 注释改成与代码一致(保留项留在表里,靠 `decisions()` 过滤)。 - `unknown_remote` 的计数与文案统一:判不了的两种来路(补全前没路径 / 有路径但缺 sha1)都算, 注记载明两者;新增 `a_remote_entry_with_a_path_but_no_sha1_is_counted_as_unjudgeable`。 视觉(tower 看图给的三条) - 冲突行两种取值都改成**同几何的 pill**(保留=描边、覆盖=tonal),各自右对齐进同宽度的槽 ⇒ 右边缘同一条线;路径列吃 `Fill` 把槽推到行右缘。 - 列表与动作行之间加 8dp 呼吸;分页行的 Previous/Next 用 `Space::Fill` 顶到右端, 与内容列对齐(不再是右侧一大片留白)。 - 重新出图:C:/Temp/m160-shots/pick-light.png / pick-dark.png。 顺手 - `format/write.rs` 的单测拆到 `format/write/tests.rs`(纯搬移,守 400 行),并把里面两个 自己拼 `%TEMP%` 路径的用例改用 `TempRoot`(断言红了也不留垃圾)。 - `import_decisions.rs` 的夹具拆到 `tests/decisions_support.rs`(纯搬移,守 400 行)。 证据:fmt 干净、clippy -D warnings 干净、`cargo test --workspace --all-targets -j 4` = 2512 passed / 0 failed / **4 ignored**(51 个 test binary);跑前/跑后 `%TEMP%` 的 `ull-pack-*` 计数 1922→1922、`ull-pack-*-zip64-*` 0→0、`ull-import-*` 1360→1360(增量 0), df 106G→106G 余量不变。
241 lines
11 KiB
Rust
241 lines
11 KiB
Rust
//! 整合包导入/导出区的消息落点(M113 + M160):**从 `content.rs` 拆出来守 400 行硬线**。
|
||
//!
|
||
//! ## 为什么单独一个文件
|
||
//!
|
||
//! M160 给这一区加了预检、冲突清单、逐项决策三条通道,`content.rs` 到 409 行越线。拆的口径与
|
||
//! 仓库其它拆分一致:**纯搬移**,`on_pack` 及其私有 helper(组请求)原样搬过来,别处一行不改。
|
||
//!
|
||
//! ## 两条口径(与原来同一句)
|
||
//!
|
||
//! 1. **页内选择改本地状态**(路径草稿 / 目标实例 / 格式 / 包名 / 展开的冲突)——它们不属于业务
|
||
//! 状态,不进 presenter;
|
||
//! 2. **意图只派指令,不做乐观回显**(导入 / 预检 / 确认 / 关面板):进度、报告、预检结果全部由
|
||
//! `CoreEvent::Pack` 回来(`dto::pack::event` 的 `PackState::apply` 是唯一写入点)。
|
||
//!
|
||
//! ## M160 加的那条纪律
|
||
//!
|
||
//! 点「导入」先发 `PackCommand::Scan`(只读),拿到 `PackEvent::Scan` 之后:**没有冲突就直接往下导**
|
||
//! (`clock::pump` 里的 `continue_import_after_clean_scan`),有冲突才画清单等用户点头。决策的缺省
|
||
//! 档永远是 `KeepAll`——没问过用户就不会覆盖。
|
||
|
||
use unlucky_presenter::dto::{
|
||
AppCommand, ConflictChoiceView, ConflictDecisionsView, ImportRequestView, PackCommand,
|
||
};
|
||
|
||
use crate::pages;
|
||
|
||
use super::*;
|
||
|
||
impl App {
|
||
/// 导入/导出区的消息(M113)。
|
||
///
|
||
/// 两条口径:
|
||
///
|
||
/// 1. **页内选择改本地状态**(路径草稿 / 目标实例 / 格式 / 包名 / 展开的冲突)——
|
||
/// 它们不属于业务状态,不进 presenter(同 `QueryChanged` 的处理);
|
||
/// 2. **意图只派指令,不做乐观回显**(导入 / 预检 / 确认写盘 / 关面板):进度、报告、预检结果
|
||
/// 全部由 `CoreEvent::Pack` 回来(`dto::pack::event` 的 `PackState::apply` 是唯一写入点)。
|
||
pub(super) fn on_pack(&mut self, message: pages::content::PackMessage) {
|
||
use pages::content::PackMessage;
|
||
match message {
|
||
PackMessage::Dropped(path) => {
|
||
// 拖进来的包属于「整合包」这一类:顺手把类型切过去,否则这一块根本不在屏上
|
||
self.content.kind = unlucky_presenter::dto::ContentKind::Modpack;
|
||
self.content.pack.draft = path.display().to_string();
|
||
self.content.pack.hover = false;
|
||
self.view_dirty = true;
|
||
}
|
||
PackMessage::Hover(hover) => {
|
||
self.content.pack.hover = hover;
|
||
self.view_dirty = true;
|
||
}
|
||
PackMessage::PathChanged(path) => {
|
||
self.content.pack.draft = path;
|
||
self.view_dirty = true;
|
||
}
|
||
PackMessage::SetInstance(instance) => {
|
||
self.content.pack.instance = instance;
|
||
self.view_dirty = true;
|
||
}
|
||
PackMessage::SetInstanceName(name) => {
|
||
self.content.pack.instance_name = name;
|
||
self.view_dirty = true;
|
||
}
|
||
PackMessage::SetFormat(format) => {
|
||
self.content.pack.format = format;
|
||
self.view_dirty = true;
|
||
}
|
||
PackMessage::SetExportName(name) => {
|
||
self.content.pack.export_name = name;
|
||
self.view_dirty = true;
|
||
}
|
||
PackMessage::SetExportVersion(version) => {
|
||
self.content.pack.export_version = version;
|
||
self.view_dirty = true;
|
||
}
|
||
PackMessage::SetOutput(path) => {
|
||
self.content.pack.output = path;
|
||
self.view_dirty = true;
|
||
}
|
||
PackMessage::ToggleConflict(path) => {
|
||
self.content.pack.expanded = if self.content.pack.expanded.as_deref() == Some(&path)
|
||
{
|
||
None
|
||
} else {
|
||
Some(path)
|
||
};
|
||
self.view_dirty = true;
|
||
}
|
||
PackMessage::OpenExport => {
|
||
self.content.pack.export_open = true;
|
||
// 包名缺省取目标实例名(用户不该为一个名字再敲一遍键盘)
|
||
if self.content.pack.export_name.trim().is_empty() {
|
||
self.content.pack.export_name =
|
||
self.content.pack.instance.clone().unwrap_or_default();
|
||
}
|
||
self.view_dirty = true;
|
||
}
|
||
PackMessage::AbortExport => {
|
||
self.content.pack.export_open = false;
|
||
// 连预检结果一起清:留着会让用户以为「还能再点一次确认」
|
||
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Dismiss)));
|
||
self.view_dirty = true;
|
||
}
|
||
PackMessage::Dismiss => {
|
||
// 报告 / 探测诊断 / 预检都在 presenter 里:关面板 = 让 host 清掉它们
|
||
// (`PackEvent::Cleared`),于是界面回到入口态,浏览网格重新露出来
|
||
self.content.pack.expanded = None;
|
||
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Dismiss)));
|
||
self.view_dirty = true;
|
||
}
|
||
PackMessage::Import => {
|
||
// 先预检(M160):没有冲突就直接导,有冲突就把清单画出来等用户点头。
|
||
self.content.pack.pick_page = 0;
|
||
self.content.pack.pick_choices.clear();
|
||
self.content.pack.pick_overwrite_all = false;
|
||
if let Some(request) = self.pack_import_request() {
|
||
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Scan(Box::new(
|
||
request,
|
||
)))));
|
||
}
|
||
}
|
||
PackMessage::PickPage(page) => {
|
||
self.content.pack.pick_page = page;
|
||
self.view_dirty = true;
|
||
}
|
||
PackMessage::TogglePick(path) => {
|
||
// 点一下就翻到另一侧,并把结果**原样记进表里**(保留也记)。
|
||
// 表里为什么保留"保留"项:整批覆盖之后单独放回某几条时,「保留」必须能盖住
|
||
// 整批档;等到发请求时 `PackPageState::decisions` 再把"与缺省档相同"的项过滤掉
|
||
// (所以"一次都没点过"仍然等于 `KeepAll`)。
|
||
let choice = self.content.pack.pick_choice(&path);
|
||
let flipped = match choice {
|
||
ConflictChoiceView::KeepUser => ConflictChoiceView::Overwrite,
|
||
ConflictChoiceView::Overwrite => ConflictChoiceView::KeepUser,
|
||
};
|
||
self.content.pack.pick_choices.insert(path, flipped);
|
||
self.view_dirty = true;
|
||
}
|
||
PackMessage::PickKeepAll => {
|
||
self.content.pack.pick_overwrite_all = false;
|
||
self.content.pack.pick_choices.clear();
|
||
self.view_dirty = true;
|
||
}
|
||
PackMessage::PickOverwriteAll => {
|
||
self.content.pack.pick_overwrite_all = true;
|
||
self.content.pack.pick_choices.clear();
|
||
self.view_dirty = true;
|
||
}
|
||
PackMessage::ConfirmImport => {
|
||
let decisions = self
|
||
.content
|
||
.pack
|
||
.decisions(self.state.pack.import.scan.as_ref());
|
||
if let Some(request) = self.pack_import_request_with(decisions) {
|
||
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Import(Box::new(
|
||
request,
|
||
)))));
|
||
}
|
||
}
|
||
PackMessage::CancelImport => {
|
||
// 取消 = 什么都不导(预检是只读的,没有东西要撤)
|
||
self.content.pack.pick_page = 0;
|
||
self.content.pack.pick_choices.clear();
|
||
self.content.pack.pick_overwrite_all = false;
|
||
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Dismiss)));
|
||
self.view_dirty = true;
|
||
}
|
||
PackMessage::Overwrite => {
|
||
// 「以包为准重新导入」:这条是**导入之后**从报告里点的,所以直接带整批覆盖再跑一遍。
|
||
if let Some(request) =
|
||
self.pack_import_request_with(ConflictDecisionsView::OverwriteAll)
|
||
{
|
||
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Import(Box::new(
|
||
request,
|
||
)))));
|
||
}
|
||
}
|
||
PackMessage::Cancel => {
|
||
// 与面板上那个「取消」同一个指令:host 会把导入的取消标记与代次一起处理
|
||
self.dispatch(AppCommand::CancelInstall);
|
||
}
|
||
PackMessage::Precheck => {
|
||
if let Some(request) = self.pack_export_request() {
|
||
// 先记下「这次预检对的是哪份请求」,确认写盘时原样发回
|
||
self.state.pack.set_export_request(request.clone());
|
||
self.view_dirty = true;
|
||
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Export(Box::new(
|
||
request,
|
||
)))));
|
||
}
|
||
}
|
||
PackMessage::Confirm => {
|
||
if let Some(request) = self.state.pack.export.request.clone() {
|
||
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Confirm(Box::new(
|
||
request,
|
||
)))));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 预检干净(没有冲突)时直接往下导。
|
||
///
|
||
/// 这条是"不打扰不该打扰的文件"的另一半:清单为空时弹一屏"共 0 项冲突"只会让人以为出错了。
|
||
/// 决策固定给 [`ConflictDecisionsView::KeepAll`]——即使预检与导入之间磁盘上突然多出个同名文件,
|
||
/// 结论也只会是"保留用户的"。
|
||
pub(in crate::app) fn continue_import_after_clean_scan(&mut self) {
|
||
if let Some(request) = self.pack_import_request_with(ConflictDecisionsView::KeepAll) {
|
||
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Import(Box::new(
|
||
request,
|
||
)))));
|
||
}
|
||
}
|
||
|
||
/// 组一份导入请求(路径草稿 → DTO)。草稿为空 ⇒ `None`(按钮不该发指令)。
|
||
///
|
||
/// **决策缺省是「全部保留」**([`ConflictDecisionsView::default`]):预检时还不知道有没有冲突,
|
||
/// 而 core 侧那份缺省同样落在保留上——所以"还没问过用户"绝不会变成"覆盖"。
|
||
fn pack_import_request(&self) -> Option<ImportRequestView> {
|
||
self.pack_import_request_with(ConflictDecisionsView::default())
|
||
}
|
||
|
||
/// 组一份带决策的导入请求(逐项 / 整批都从这里出去)。
|
||
fn pack_import_request_with(
|
||
&self,
|
||
decisions: ConflictDecisionsView,
|
||
) -> Option<ImportRequestView> {
|
||
let draft = self.content.pack.draft.trim();
|
||
if draft.is_empty() {
|
||
return None;
|
||
}
|
||
let name = self.content.pack.instance_name.trim();
|
||
Some(ImportRequestView {
|
||
pack: std::path::PathBuf::from(draft),
|
||
instance: self.content.pack.instance.clone(),
|
||
instance_name: (!name.is_empty()).then(|| name.to_string()),
|
||
decisions,
|
||
})
|
||
}
|
||
}
|