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:
worker-modpackpick authored and AskaEth committed 2026-11-07 13:52:43 +08:00
1 parent a4f096eea3
commit 4defc41e8d
64 files changed
+3819 -442

No files matched your search

@@ -26,15 +26,22 @@
//! 是「已整包回滚」。两者字段类型不同、画的时候颜色与图标也不同,不可能混成一个列表。
mod export;
mod pick;
mod progress;
mod report;
pub use export::{format_label, request_of as request_export, Export, Preview};
pub use pick::{Pick, PickRow};
pub use progress::Running;
pub use report::{ConflictRow, Report, Row};
use std::collections::BTreeMap;
use unlucky_i18n::{Catalog, Key};
use unlucky_presenter::dto::{ExportState, ImportState, InstanceId, PackFormatView, ProbeView};
use unlucky_presenter::dto::{
ConflictChoiceView, ConflictDecisionsView, ExportState, ImportState, InstanceId,
PackFormatView, ProbeView,
};
use crate::pages::content::Target;
@@ -61,6 +68,12 @@ pub struct PackPageState {
pub export_open: bool,
/// 展开的那条冲突路径
pub expanded: Option<String>,
/// 冲突清单翻到第几页(0 起;M160)
pub pick_page: usize,
/// 逐项决策(**只存"覆盖"的路径**:保留 = 表里没有这条,于是"没点过"永远是保留)
pub pick_choices: BTreeMap<String, ConflictChoiceView>,
/// 「全部覆盖」这一档是否被点过(整批档;逐项决策仍然优先于它)
pub pick_overwrite_all: bool,
}
impl Default for PackPageState {
@@ -78,10 +91,65 @@ impl Default for PackPageState {
output: String::new(),
export_open: false,
expanded: None,
pick_page: 0,
pick_choices: BTreeMap::new(),
pick_overwrite_all: false,
}
}
}
impl PackPageState {
/// 用户在冲突清单上点的处置(逐项优先,其次整批档,缺省保留)。
#[must_use]
pub fn pick_choice(&self, path: &str) -> ConflictChoiceView {
if let Some(choice) = self.pick_choices.get(path) {
return *choice;
}
if self.pick_overwrite_all {
ConflictChoiceView::Overwrite
} else {
ConflictChoiceView::KeepUser
}
}
/// 把这一屏的选择变成发给宿主的那份决策。
///
/// 一个都没点 ⇒ `KeepAll`(与 core 的"全部保留"同一个语义);点过 ⇒ `PerItem { default, items }`,
/// 其中 `default` 就是整批档。于是"全部覆盖之后又单独放回两条"也能表达(不需要把剩下几百条
/// 全点一遍),而"没点过"永远落在保留上。
#[must_use]
pub fn decisions(
&self,
scan: Option<&unlucky_presenter::dto::ImportScanView>,
) -> ConflictDecisionsView {
use unlucky_presenter::dto::{ConflictDecisionView, ConflictDecisionsView};
let default = if self.pick_overwrite_all {
ConflictChoiceView::Overwrite
} else {
ConflictChoiceView::KeepUser
};
// 只发"与缺省档不同"的项:其余在 core 侧自然落到缺省档。
let items: Vec<ConflictDecisionView> = self
.pick_choices
.iter()
.filter(|(_, choice)| **choice != default)
.map(|(path, choice)| ConflictDecisionView {
path: path.clone(),
choice: *choice,
})
.collect();
// 冲突清单为空时,`PerItem` 没有对象,直接给整批档(语义等价、日志更短)。
let empty = scan.is_none_or(|scan| scan.conflicts.is_empty());
if items.is_empty() || empty {
return match default {
ConflictChoiceView::KeepUser => ConflictDecisionsView::KeepAll,
ConflictChoiceView::Overwrite => ConflictDecisionsView::OverwriteAll,
};
}
ConflictDecisionsView::per_item(default, items)
}
}
/// 一个实例候选(导入目标与导出源共用)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PackTarget {
@@ -132,6 +200,8 @@ pub struct Pack {
pub cancelled_note: &'static str,
/// 导入结果
pub report: Option<Report>,
/// **导入前的冲突清单**(M160:`Some` 就画那一屏,别的东西都让位)
pub pick: Option<Pick>,
/// 导出块
pub export: Export,
}
@@ -181,6 +251,15 @@ impl Pack {
.report
.as_ref()
.map(|report| report::report_of(report, page, catalog)),
pick: import.scan.as_ref().and_then(|scan| {
pick::pick_of(
scan,
page.pick_page,
&page.pick_choices,
page.pick_overwrite_all,
catalog,
)
}),
export: export::export_of(export, page, candidates, catalog),
}
}
@@ -190,6 +269,7 @@ impl Pack {
self.running.is_none()
&& self.probe.is_none()
&& self.report.is_none()
&& self.pick.is_none()
&& !self.cancelled
&& !self.export.open
&& self.draft.is_empty()