feat(m160): 整合包逐文件覆盖/保留决策 + 导入前冲突清单 + 限额口径统一
主任务(逐文件覆盖/保留)
- core: 新增 import/scan.rs —— 只读预检产出「冲突清单」(目标已存在且 SHA-1 不同才进),
逐条给路径/来源/两侧体积与哈希;用户删过的、内容相同的、被叠层遮蔽的一律不打扰。
- core: 新增 import/decision.rs —— ConflictDecisions(整批档 + 逐项表,逐项优先),
缺省档恒为 KeepUser;ImportRequest.conflict_policy 改由它承载,CLI 调用点不变。
- core: phase.rs 的 settle 按 path 取决策;非冲突路径不受决策影响。
- presenter: ImportScanView / ImportConflictView / ConflictDecisionsView(全部保留 /
全部覆盖 / 逐项{default,items})+ PackCommand::Scan / PackEvent::Scan;host 新增只读
spawn_scan,且新实例路径不再提前建目录(target_of 只算路径,prepare_target 才建)。
- app-iced: 导入前冲突清单页(分页 40 条/页、三入口、计数摘要、确认/取消、两行式行布局);
预检干净时直接往下导,不打扰用户。出图见 C:/Temp/m160-shots/{pick-light,pick-dark}.png。
- i18n: 新增 18 条 en/zh 文案 + 更新「两者都留」那条口径说明。
限额口径统一
- pack/archive/limits.rs 成为唯一来源:PACK_MAX_ENTRIES/UNCOMPRESSED_BYTES/ENTRY_BYTES
(20k / 512 MiB / 64 MiB);读侧 PackLimits::default 与写侧 WriteLimits::production 同源。
- 写侧不再直接用 zip::ZIP64_BYTES_THR;新增单条目闸,于是「能导出的包一定能导入」。
不等式在 pack/tests/limits.rs 里用 const 断言钉死。
测试临时物泄漏(塔台追加项)
- addon/temp.rs:TempRoot —— Drop 即删(panic 展开同样删),Deref/AsRef 到落点路径,
under()/at() 支持"子树落点、删父根"。形状照 core::update::tests::support::TempDir。
- addon::pack 与 addon::tests 两族全部改用 TempRoot(含 fixtures::Fixture 持守卫、
multimc/fabric_instance 等返回守卫);4 GiB 稀疏文件的语义一字未改,只改"谁负责删"。
- 自证:pack/tests/mod.rs 三条用例(drop 删 / panic 也删 / 子树删父根)。
测试
- import_decisions.rs(11 例):预检清单、不打扰、逐项决策后的最终文件集、基线记包内哈希。
- roundtrip_three.rs(2 例):叠层 + 冲突夹具 import→export→import 三轮文件集逐字节一致,
以及「选保留就按设计分叉」的反向证据。
- pack/tests/limits.rs(5 例):单条/总量边界、读侧用同一批常量、写出即可读回。
证据:cargo fmt --check 干净;cargo clippy --workspace --all-targets -D warnings 干净;
cargo test --workspace --all-targets -j 4 → 2507 passed / 0 failed(51 个 test binary)。
This commit is contained in:
1 parent
a4f096eea3
commit
4defc41e8d
64 files changed
+3819
-442
No files matched your search
@@ -0,0 +1,237 @@
|
||||
//! 整合包导入/导出区的消息落点(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) => {
|
||||
// 点上就翻到另一侧;翻回"保留"时把这条从表里删掉(表里只有"偏离缺省档"的项)
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user