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

@@ -8,7 +8,7 @@
//!
//! | 变量 | 取值 | 作用 |
//! |---|---|---|
//! | `ULL_CONTENT_PACK` | `importing` / `partial` / `conflict` / `failed` / `export` | 选导入导出区的哪一态(不设 = 只有入口) |
//! | `ULL_CONTENT_PACK` | `importing` / `pick` / `partial` / `conflict` / `failed` / `export` | 选导入导出区的哪一态(不设 = 只有入口) |
//!
//! 夹具**不直接造视图模型**,而是造 presenter 侧的快照(`ImportState` / `ExportState`),
//! 再走产品路径同一个 [`Pack::of`]。这样截图里看到的每一行字都是产品路径会画的那一行,
@@ -68,6 +68,21 @@ pub(super) fn pack_in(state: &str, lang: Lang) -> (Pack, PackPageState) {
match state {
"importing" => import.progress = Some(progress()),
// M160:导入前的冲突清单(分页 + 三个入口 + 计数摘要)
"pick" => {
import.scan = Some(scan(312));
// 停在第 2 页,且已经逐项点过两条「覆盖」——图上能同时看见分页与逐项状态
page.pick_page = 1;
// 两条落到**第 2 页**的路径上:图上能同时看见「保留」与「覆盖」两种行状态
page.pick_choices.insert(
"config/generated/module-041.toml".to_string(),
unlucky_presenter::dto::ConflictChoiceView::Overwrite,
);
page.pick_choices.insert(
"config/generated/module-044.toml".to_string(),
unlucky_presenter::dto::ConflictChoiceView::Overwrite,
);
}
"partial" => import.report = Some(report(ImportStatusView::Partial, false)),
"conflict" => {
import.report = Some(report(ImportStatusView::Partial, true));
@@ -194,6 +209,59 @@ fn conflict(path: &str, reason: ConflictReasonView) -> ConflictFileView {
}
}
/// 一条待决冲突(M160 预览清单的一行)。
fn pending(index: usize) -> unlucky_presenter::dto::ImportConflictView {
// 前几条用真实形态的路径,其余按序号生成:截图里既要有可读的真实感,也要有"很多条"的证据。
let path = match index {
0 => "config/sodium-options.json".to_string(),
1 => "mods/create-1.2.3.jar".to_string(),
2 => "config/jei/jei-client.ini".to_string(),
3 => "options.txt".to_string(),
_ => format!("config/generated/module-{index:03}.toml"),
};
unlucky_presenter::dto::ImportConflictView {
path,
source: if index.is_multiple_of(3) {
unlucky_presenter::dto::ConflictSourceView::ClientOverrides
} else {
unlucky_presenter::dto::ConflictSourceView::Overrides
},
reason: if index.is_multiple_of(2) {
unlucky_presenter::dto::ConflictReasonView::PreExisting
} else {
unlucky_presenter::dto::ConflictReasonView::UserModified
},
disabled: false,
pack_sha1: "9f2c41ab77de3350aa1b8c0d5e6f708192a3b4c5".to_string(),
pack_size: 3_800 + (index as u64 * 37) % 900,
disk_sha1: "c1d2e3f4050617283940a5b6c7d8e9f0a1b2c3d4".to_string(),
disk_size: 4_100 + (index as u64 * 53) % 1_200,
base_sha1: (!index.is_multiple_of(2))
.then(|| "3a7d90ff11bc22445566ef7788990011aabbccdd".to_string()),
}
}
/// 预检结果(`count` 条冲突;图上靠分页只画当前这一页)。
fn scan(count: usize) -> unlucky_presenter::dto::ImportScanView {
let conflicts: Vec<unlucky_presenter::dto::ImportConflictView> =
(0..count).map(pending).collect();
let lost_bytes = conflicts.iter().map(|each| each.disk_size).sum();
let incoming_bytes = conflicts.iter().map(|each| each.pack_size).sum();
unlucky_presenter::dto::ImportScanView {
target: PathBuf::from("C:/ULL/instances/survival/.minecraft"),
format: Some(PackFormatView::Modrinth),
pack_name: Some("Better MC".to_string()),
pack_version: Some("1.2.0".to_string()),
game_version: Some("1.20.1".to_string()),
conflicts,
unknown_remote: 4,
lost_bytes,
incoming_bytes,
notes: Vec::new(),
error: None,
}
}
/// 导出预检:12 内联 / 1 跳过(包不完整)。
fn preview() -> ExportPreviewView {
let mut files = vec![ExportFileView {
@@ -30,10 +30,22 @@ pub enum Message {
Cancel,
/// 关掉结果面板(报告 / 致命错误都走这一条)
Dismiss,
/// 以包为准重新导入(对应 `overwrite = true`;**只在用户显式点过之后**)
/// 以包为准重新导入(**只在用户显式点过之后**;载荷是整批覆盖档)
Overwrite,
/// 展开/收起某一条冲突的三方明细
ToggleConflict(String),
/// 冲突清单翻页(M160;页码会夹在 `[0, pages)` 里)
PickPage(usize),
/// 逐项切换某一条的处置(保留 ⇄ 覆盖)
TogglePick(String),
/// 整批档:全部保留(逐项里"覆盖"的那些一起清掉)
PickKeepAll,
/// 整批档:全部覆盖
PickOverwriteAll,
/// 按当前选择开始导入
ConfirmImport,
/// 取消这次导入(不写盘,回到入口态)
CancelImport,
/// 换目标实例(`None` = 按包名新建)
SetInstance(Option<InstanceId>),
/// 换新实例名(`None` 语义由 `SetInstance` 承担,这里只改名字)
@@ -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()
@@ -0,0 +1,260 @@
//! 导入**冲突清单**的视图模型(M160):分页、计数、逐项处置。
//!
//! # 为什么是分页而不是"一次画完"
//!
//! 冲突清单可能有几千条(一个用户改过很多配置的老实例)。`iced` 的每一行都是真实 widget,
//! 一次画几千行会同时拖慢布局与滚动,而用户真正要看的永远只有"这一屏"。所以模型里只出
//! **当前这一页**([`PAGE_SIZE`] 条),页面上的「上一页 / 下一页」改的是
//! [`PackPageState::pick_page`]——数据本身留在 presenter 的 `ImportScanView` 里,页面不复制。
//!
//! # 三个入口如何体现在计数上
//!
//! | 用户点了 | 决策表 | 计数 |
//! |---|---|---|
//! | 全部保留 | [`ConflictDecisionsView::KeepAll`] | 覆盖 0 |
//! | 全部覆盖 | [`ConflictDecisionsView::OverwriteAll`] | 覆盖 = 全部 |
//! | 逐项 | `PerItem`(只列"覆盖"的那些,其余按保留) | 覆盖 = 点过的条数 |
//!
//! 页面显示的两个数直接来自 [`PackPageState::decisions`]、[`PackPageState::pick_choice`] 的计算,
//! 与发给 host 的那份决策**同源**——不会出现"界面说覆盖 3 个、实际覆盖 0 个"。
use std::collections::BTreeMap;
use unlucky_i18n::{Catalog, Key};
use unlucky_presenter::dto::{
human_bytes, ConflictChoiceView, ConflictReasonView, ConflictSourceView, ImportConflictView,
ImportScanView,
};
/// 一页画多少行(一次画几千行是性能陷阱;40 行在一屏内可读且不滚动太多)。
pub const PAGE_SIZE: usize = 40;
/// 冲突清单块(导入前那一屏)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Pick {
/// 标题(`导入前:有 12 个文件已存在且内容不同`)
pub title: String,
/// 计数摘要(`保留 9 · 覆盖 3 · 覆盖将丢弃 2.4 MB`)
pub summary: String,
/// 缺省说明("没改动的一律按保留")
pub default_note: &'static str,
/// 判不了的远端条目(`Some` 才画那一行)
pub unknown_note: Option<String>,
/// **当前页**的行(最多 [`PAGE_SIZE`] 条)
pub rows: Vec<PickRow>,
/// 当前页(0 起)
pub page: usize,
/// 总页数(至少 1)
pub pages: usize,
/// 区间说明(`第 1-40 项,共 312 项`)
pub range: String,
/// 保留几条
pub keep_count: usize,
/// 覆盖几条
pub overwrite_count: usize,
/// 能不能翻页
pub can_prev: bool,
/// 能不能翻页
pub can_next: bool,
/// 三个入口的文案
pub keep_all: &'static str,
/// 三个入口的文案
pub overwrite_all: &'static str,
/// 逐项按钮(保留)的文案
pub row_keep: &'static str,
/// 逐项按钮(覆盖)的文案
pub row_overwrite: &'static str,
/// 翻页文案
pub prev: &'static str,
/// 翻页文案
pub next: &'static str,
/// 确认导入
pub confirm: String,
/// 取消导入
pub cancel: &'static str,
/// 「全部保留」这一档当前是否生效(整批档切到保留时高亮)
pub keep_all_active: bool,
/// 「全部覆盖」这一档当前是否生效
pub overwrite_all_active: bool,
}
/// 清单里的一行。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PickRow {
/// 实例内路径
pub path: String,
/// 来源(`包内 overrides/` / `清单 files[]`)
pub source: String,
/// 成因(`本来就在` / `你改过`)
pub reason: String,
/// 两侧体积(`你的:2.1 MB · 包内:1.8 MB`)
pub sizes: String,
/// 当前处置([`ConflictChoiceView::KeepUser`] = 保留)
pub choice: ConflictChoiceView,
/// 这一行按钮上写的字(点了就变成另一侧)
pub toggle: &'static str,
}
impl PickRow {
/// 这一行是"覆盖"吗。
#[must_use]
pub const fn overwrites(&self) -> bool {
matches!(self.choice, ConflictChoiceView::Overwrite)
}
}
/// 从预检结果 + 页面选择造出这一块(`None` = 没有待决冲突,页面不该画它)。
#[must_use]
pub fn pick_of(
scan: &ImportScanView,
page_number: usize,
per_item: &BTreeMap<String, ConflictChoiceView>,
batch_overwrite: bool,
catalog: Catalog,
) -> Option<Pick> {
if scan.conflicts.is_empty() {
return None;
}
let total = scan.conflicts.len();
let pages = total.div_ceil(PAGE_SIZE).max(1);
let page = page_number.min(pages - 1);
let from = page * PAGE_SIZE;
let to = (from + PAGE_SIZE).min(total);
let rows: Vec<PickRow> = scan.conflicts[from..to]
.iter()
.map(|conflict| row_of(conflict, per_item, batch_overwrite, catalog))
.collect();
let overwrite_count = scan
.conflicts
.iter()
.filter(|conflict| {
effective(conflict.path.as_str(), per_item, batch_overwrite)
== ConflictChoiceView::Overwrite
})
.count();
let keep_count = total - overwrite_count;
// 覆盖会丢掉多少:只算"你真选了覆盖"的那些,避免用最坏数字吓人。
let lost = scan
.conflicts
.iter()
.filter(|conflict| {
effective(conflict.path.as_str(), per_item, batch_overwrite)
== ConflictChoiceView::Overwrite
})
.map(|conflict| conflict.disk_size)
.fold(0_u64, u64::saturating_add);
Some(Pick {
title: catalog.render(Key::PackPickTitle, &[("count", &total.to_string())]),
summary: catalog.render(
Key::PackPickSummary,
&[
("keep", &keep_count.to_string()),
("overwrite", &overwrite_count.to_string()),
("bytes", &human_bytes(lost)),
],
),
default_note: catalog.text(Key::PackPickDefaultNote),
unknown_note: (scan.unknown_remote > 0).then(|| {
catalog.render(
Key::PackPickUnknownRemote,
&[("count", &scan.unknown_remote.to_string())],
)
}),
rows,
page,
pages,
range: catalog.render(
Key::PackPickPage,
&[
("from", &(from + 1).to_string()),
("to", &to.to_string()),
("total", &total.to_string()),
],
),
keep_count,
overwrite_count,
can_prev: page > 0,
can_next: page + 1 < pages,
keep_all: catalog.text(Key::PackPickKeepAll),
overwrite_all: catalog.text(Key::PackPickOverwriteAll),
row_keep: catalog.text(Key::PackPickRowKeep),
row_overwrite: catalog.text(Key::PackPickRowOverwrite),
prev: catalog.text(Key::PackPickPrev),
next: catalog.text(Key::PackPickNext),
confirm: catalog.render(Key::PackPickConfirm, &[]),
cancel: catalog.text(Key::PackPickCancel),
keep_all_active: !batch_overwrite,
overwrite_all_active: batch_overwrite,
})
}
/// 一条冲突的**有效**处置:逐项优先,其次整批档,缺省保留(与 core 侧同一条规则)。
fn effective(
path: &str,
per_item: &BTreeMap<String, ConflictChoiceView>,
batch_overwrite: bool,
) -> ConflictChoiceView {
if let Some(choice) = per_item.get(path) {
return *choice;
}
if batch_overwrite {
ConflictChoiceView::Overwrite
} else {
ConflictChoiceView::KeepUser
}
}
fn row_of(
conflict: &ImportConflictView,
per_item: &BTreeMap<String, ConflictChoiceView>,
batch_overwrite: bool,
catalog: Catalog,
) -> PickRow {
let choice = effective(conflict.path.as_str(), per_item, batch_overwrite);
PickRow {
path: conflict.path.clone(),
source: source_label(conflict.source, catalog).to_owned(),
reason: reason_label(conflict.reason, catalog).to_owned(),
sizes: format!(
"{} · {}",
catalog.render(
Key::PackPickDisk,
&[("size", &human_bytes(conflict.disk_size))]
),
catalog.render(
Key::PackPickIncoming,
&[("size", &human_bytes(conflict.pack_size))]
)
),
choice,
// 按钮写"点了会变成什么":当前保留 ⇒ 按钮是「覆盖」。
toggle: if matches!(choice, ConflictChoiceView::Overwrite) {
catalog.text(Key::PackPickRowKeep)
} else {
catalog.text(Key::PackPickRowOverwrite)
},
}
}
/// 来源的人读串。
fn source_label(source: ConflictSourceView, catalog: Catalog) -> &'static str {
match source {
ConflictSourceView::Overrides => catalog.text(Key::PackPickSourceOverrides),
ConflictSourceView::ClientOverrides => catalog.text(Key::PackPickSourceClientOverrides),
ConflictSourceView::CurseForge | ConflictSourceView::Modrinth => {
catalog.text(Key::PackPickSourceManifest)
}
}
}
/// 成因的人读串(复用导入报告那三句,不另造一套说法)。
fn reason_label(reason: ConflictReasonView, catalog: Catalog) -> &'static str {
match reason {
ConflictReasonView::UserModified => catalog.text(Key::PackConflictUserModified),
ConflictReasonView::UserDeleted => catalog.text(Key::PackConflictUserDeleted),
ConflictReasonView::PreExisting => catalog.text(Key::PackConflictPreExisting),
}
}
@@ -3,6 +3,8 @@
//! 这里**不测画法**(画法靠出图),测的是「决策」:哪些数字进了哪一行、哪些字段被截短、
//! 哪一态该出现哪一块。所有断言都能在没有图形环境的情况下跑。
mod pick;
use std::path::PathBuf;
use unlucky_i18n::Lang;
@@ -0,0 +1,189 @@
//! 冲突清单(M160)的视图模型守门:分页、计数、三个入口 → 决策表。
//!
//! 这里钉的是**不画**的那部分——一屏只出 `PAGE_SIZE` 行、翻页只换数据、计数与最终决策同源。
//! 画法本身靠出图(`ULL_CONTENT_PACK=pick`)。
use std::collections::BTreeMap;
use super::super::pick::{pick_of, PAGE_SIZE};
use super::super::PackPageState;
use super::catalog;
use unlucky_presenter::dto::{
ConflictChoiceView, ConflictDecisionsView, ConflictReasonView, ConflictSourceView,
ImportConflictView, ImportScanView,
};
/// `count` 条冲突的预检结果。
fn scan(count: usize) -> ImportScanView {
ImportScanView {
target: std::path::PathBuf::from("instance"),
format: None,
pack_name: Some("Better MC".to_string()),
pack_version: None,
game_version: None,
conflicts: (0..count)
.map(|index| ImportConflictView {
path: format!("config/row-{index:03}.toml"),
source: ConflictSourceView::Overrides,
reason: ConflictReasonView::PreExisting,
disabled: false,
pack_sha1: "aa".repeat(20),
pack_size: 1_000,
disk_sha1: "bb".repeat(20),
disk_size: 2_000,
base_sha1: None,
})
.collect(),
unknown_remote: 0,
lost_bytes: 0,
incoming_bytes: 0,
notes: Vec::new(),
error: None,
}
}
fn pick(
scan: &ImportScanView,
page: usize,
per_item: &BTreeMap<String, ConflictChoiceView>,
batch: bool,
) -> super::super::pick::Pick {
pick_of(scan, page, per_item, batch, catalog()).expect("有冲突就该画这一块")
}
#[test]
fn only_the_current_page_is_drawn() {
let scan = scan(312);
let first = pick(&scan, 0, &BTreeMap::new(), false);
assert_eq!(first.rows.len(), PAGE_SIZE, "一屏只许画一页的行");
assert_eq!(first.pages, 8, "312 条 / 40 条一页 = 8 页");
assert_eq!(first.page, 0);
assert!(first.can_next, "还有下一页");
assert!(!first.can_prev, "第一页没有上一页");
assert_eq!(first.range, "Items 1-40 of 312");
assert_eq!(first.rows[0].path, "config/row-000.toml");
let second = pick(&scan, 1, &BTreeMap::new(), false);
assert_eq!(second.page, 1);
assert_eq!(
second.rows[0].path,
format!("config/row-{PAGE_SIZE:03}.toml")
);
assert_eq!(second.range, "Items 41-80 of 312");
assert!(second.can_prev && second.can_next);
}
#[test]
fn the_last_page_is_short_and_the_page_number_is_clamped() {
let scan = scan(312);
let last = pick(&scan, 7, &BTreeMap::new(), false);
assert_eq!(last.rows.len(), 312 - 7 * PAGE_SIZE, "最后一页只剩尾巴");
assert_eq!(last.range, "Items 281-312 of 312");
assert!(!last.can_next);
// 页码越界(例如用户先在 300 条的清单上翻到第 6 页,再重扫一份 10 条的)⇒ 夹到最后一页。
let clamped = pick(&scan, 99, &BTreeMap::new(), false);
assert_eq!(clamped.page, 7);
}
#[test]
fn the_summary_counts_what_the_user_actually_picked() {
let scan = scan(10);
let keep_all = pick(&scan, 0, &BTreeMap::new(), false);
assert_eq!(keep_all.keep_count, 10);
assert_eq!(keep_all.overwrite_count, 0);
assert!(keep_all.summary.contains("Keep 10"), "{}", keep_all.summary);
assert!(keep_all.keep_all_active && !keep_all.overwrite_all_active);
let mut per_item = BTreeMap::new();
per_item.insert(
"config/row-001.toml".to_string(),
ConflictChoiceView::Overwrite,
);
per_item.insert(
"config/row-004.toml".to_string(),
ConflictChoiceView::Overwrite,
);
let mixed = pick(&scan, 0, &per_item, false);
assert_eq!(mixed.overwrite_count, 2);
assert_eq!(mixed.keep_count, 8);
// 丢弃量只算"真选了覆盖"的那两条(各 2 000 字节)。
assert!(
mixed.summary.contains("Overwrite 2") || mixed.summary.contains("overwrite 2"),
"{}",
mixed.summary
);
assert_eq!(
mixed.rows[1].choice,
ConflictChoiceView::Overwrite,
"点过的那一行要显示成覆盖"
);
assert_eq!(mixed.rows[0].choice, ConflictChoiceView::KeepUser);
let all = pick(&scan, 0, &BTreeMap::new(), true);
assert_eq!(all.overwrite_count, 10);
assert!(all.overwrite_all_active && !all.keep_all_active);
}
#[test]
fn no_conflict_means_no_block() {
let scan = scan(0);
assert!(
pick_of(&scan, 0, &BTreeMap::new(), false, catalog()).is_none(),
"没有冲突就不该画这一屏(不打扰用户)"
);
}
#[test]
fn decisions_default_to_keep_all() {
let page = PackPageState::default();
assert_eq!(page.decisions(None), ConflictDecisionsView::KeepAll);
// 逐项点一个"覆盖" ⇒ 逐项表里只有它,缺省档仍是保留。
let mut page = PackPageState::default();
page.pick_choices
.insert("mods/a.jar".to_string(), ConflictChoiceView::Overwrite);
let decisions = page.decisions(Some(&scan(3)));
assert_eq!(decisions.default_choice(), ConflictChoiceView::KeepUser);
assert_eq!(decisions.overwrite_count(), 1);
assert_eq!(decisions.len(), 1);
}
#[test]
fn batch_overwrite_plus_a_kept_file_is_expressible() {
// 全部覆盖之后单独放回一条:缺省档是覆盖、逐项里那条是保留。
let mut page = PackPageState {
pick_overwrite_all: true,
..PackPageState::default()
};
assert_eq!(
page.decisions(Some(&scan(3))),
ConflictDecisionsView::OverwriteAll,
"只点整批覆盖时不需要逐项表"
);
page.pick_choices
.insert("mods/pin.jar".to_string(), ConflictChoiceView::KeepUser);
let decisions = page.decisions(Some(&scan(3)));
assert_eq!(decisions.default_choice(), ConflictChoiceView::Overwrite);
assert_eq!(decisions.keep_count(), 1);
assert_eq!(decisions.overwrite_count(), 0);
}
#[test]
fn toggling_flips_the_row_and_clearing_the_batch_drops_per_item_choices() {
let mut page = PackPageState::default();
let path = "mods/a.jar";
assert_eq!(page.pick_choice(path), ConflictChoiceView::KeepUser);
page.pick_choices
.insert(path.to_string(), ConflictChoiceView::Overwrite);
assert_eq!(page.pick_choice(path), ConflictChoiceView::Overwrite);
// 整批覆盖之后,那条逐项"保留"仍然优先于整批档。
page.pick_overwrite_all = true;
page.pick_choices
.insert(path.to_string(), ConflictChoiceView::KeepUser);
assert_eq!(page.pick_choice(path), ConflictChoiceView::KeepUser);
assert_eq!(
page.pick_choice("mods/other.jar"),
ConflictChoiceView::Overwrite
);
}
@@ -8,8 +8,8 @@ use unlucky_i18n::Lang;
use super::demo::pack_in;
use super::model::Pack;
/// D30 要求的四态(导入中 / 部分成功 / 冲突选择 / 导出预检)。
const STATES: [&str; 4] = ["importing", "partial", "conflict", "export"];
/// 出图态:导入中 / **导入前冲突清单(M160)** / 部分成功 / 导入后冲突账 / 导出预检。
const STATES: [&str; 5] = ["importing", "pick", "partial", "conflict", "export"];
#[test]
fn every_screenshot_state_fills_something() {
@@ -28,6 +28,33 @@ fn every_screenshot_state_fills_something() {
}
}
#[test]
fn the_pick_state_shows_a_paginated_conflict_list() {
// M160 的那一屏:只画一页、三个入口都在、计数与逐项状态一致。
let (model, page) = pack_in("pick", Lang::En);
let pick = model.pick.expect("pick 态必须有冲突清单");
// 一屏只画一页:行数上限来自 `model::pick::PAGE_SIZE`(40)。
assert_eq!(pick.rows.len(), 40, "一屏一页(PAGE_SIZE)");
assert_eq!(pick.pages, 8, "312 条 / 40 条一页");
assert_eq!(pick.page, 1, "夹具停在第 2 页(0 起)");
assert!(pick.can_prev && pick.can_next);
assert_eq!(pick.overwrite_count, 2, "夹具逐项点了两条覆盖");
assert!(
pick.keep_all_active && !pick.overwrite_all_active,
"整批档停在缺省那一档(全部保留)"
);
assert_eq!(
pick.rows[0].choice,
unlucky_presenter::dto::ConflictChoiceView::KeepUser,
"没点过的那一行必须是保留"
);
// 页面自留的选择与模型一致(逐项那条显示成覆盖)
assert_eq!(
page.pick_choice("config/generated/module-041.toml"),
unlucky_presenter::dto::ConflictChoiceView::Overwrite
);
}
#[test]
fn importing_shows_the_shared_install_panel() {
let (model, _page) = pack_in("importing", Lang::En);
@@ -41,6 +41,7 @@
mod export;
mod parts;
mod pick;
mod progress;
mod report;
@@ -64,6 +65,7 @@ use crate::pages::Message as PageMessage;
use crate::theme::Ui;
use self::export::export_block;
use self::pick::pick_block;
use self::progress::{probe_block, running_block};
use self::report::report_block;
use super::model::Pack;
@@ -88,6 +90,9 @@ pub(crate) fn section<'a>(
// 事件,任何时候把文件拖进来都算数。
if model.export.open {
block = block.push(export_block(ui, catalog, &model.export));
} else if let Some(pick) = &model.pick {
// 冲突清单优先于一切:用户此刻要做的决定就是"哪几个文件覆盖"
block = block.push(pick_block(ui, catalog, pick));
} else if let Some(running) = &model.running {
block = block.push(running_block(ui, catalog, running));
} else if let Some(report) = &model.report {
@@ -0,0 +1,273 @@
//! 冲突清单那一屏的画法(M160):**分页** + 三个入口 + 计数摘要 + 确认/取消。
//!
//! ## 版面
//!
//! ```text
//! ┌ 导入前:有 312 个文件已存在且内容不同 ───────────────┐
//! │ 保留 309 · 覆盖 3 · 覆盖将丢弃 2.4 MB │
//! │ 没有改动的一律按「保留」:… │
//! │ [全部保留我的] [全部覆盖] │
//! ├ 第 1-40 项,共 312 项 [上一页] [下一页] │
//! │ config/sodium-options.json 包内 overrides/ · 本来就在 │
//! │ 你的:4.1 KB · 包内:3.8 KB [覆盖]│
//! │ …(**只画当前这一页**) │
//! ├ [按这些选择导入] [取消导入] │
//! └─────────────────────────────────────────────────────┘
//! ```
//!
//! ## 三处「不误导」
//!
//! 1. **缺省说明写在按钮下面**:用户不必猜"没点过的那 309 条会怎样";
//! 2. **计数摘要里的丢弃量只算真选了覆盖的**:用最坏数字吓人不算如实;
//! 3. **按钮写的是"点了会变成什么"**(当前保留 ⇒ 按钮写「覆盖」),所以点一下就知道后果。
//!
//! ## 为什么分页而不是滚动
//!
//! 几千条冲突一次画完 = 几千个 widget:布局与滚动都卡。分页把 widget 数量钉在
//! [`super::super::model::PAGE_SIZE`] 条以内,翻页只是换数据。
use iced::widget::{column, container, row};
use iced::{Alignment, Background, Border, Element, Length};
use unlucky_design::color::Role;
use unlucky_design::icons::IconKind;
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::Catalog;
use crate::app::components::buttons::{self, Button};
use crate::app::components::selection::{chip, Chip};
use crate::pages::frame;
use crate::pages::Message as PageMessage;
use crate::theme::Ui;
use super::super::model::{Pick, PickRow};
use super::super::PackMessage;
use super::intent;
/// 整块。
pub(super) fn pick_block<'a>(
ui: &'a Ui,
catalog: Catalog,
pick: &'a Pick,
) -> Element<'a, PageMessage> {
let mut block = column![
frame::text_role(
ui,
TypeRole::TitleSmall,
true,
pick.title.as_str(),
ui.color(Role::OnSurface),
),
// 计数摘要:这是"我要丢掉多少"的唯一一处数字,用强调色
frame::text_role(
ui,
TypeRole::BodyMedium,
true,
pick.summary.as_str(),
ui.color(Role::Primary),
),
frame::text_role(
ui,
TypeRole::BodySmall,
false,
pick.default_note,
ui.color(Role::OnSurfaceVariant),
),
]
.spacing(ui.space(Space::X1))
.width(Length::Fill);
if let Some(note) = &pick.unknown_note {
block = block.push(
row![
frame::glyph(IconKind::Warn, ui.color(Role::Tertiary), frame::ICON_PX),
frame::text_role(
ui,
TypeRole::BodySmall,
false,
note.as_str(),
ui.color(Role::Tertiary),
),
]
.spacing(ui.space(Space::X1))
.align_y(Alignment::Center),
);
}
// 三个入口:整批保留 / 整批覆盖(逐项就是下面每行那个按钮)
block = block.push(
row![
chip(
ui,
Chip::filter(
pick.keep_all,
pick.keep_all_active,
intent(PackMessage::PickKeepAll),
),
),
chip(
ui,
Chip::filter(
pick.overwrite_all,
pick.overwrite_all_active,
intent(PackMessage::PickOverwriteAll),
),
),
]
.spacing(ui.space(Space::X2)),
);
// 分页头(当前页之外的行一律不画)
block = block.push(bounded(
column(
std::iter::once(pager(ui, catalog, pick))
.chain(pick.rows.iter().map(|item| pick_row(ui, item)))
.collect::<Vec<_>>(),
)
.spacing(ui.space(Space::X1))
.width(Length::Fill),
375.0,
));
block = block.push(
row![
buttons::filled(
ui,
Button::new(pick.confirm.as_str(), intent(PackMessage::ConfirmImport))
.leading(IconKind::Check),
),
buttons::text(
ui,
Button::new(pick.cancel, intent(PackMessage::CancelImport)),
),
]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center),
);
container(block)
.width(Length::Fill)
.padding(ui.space(Space::X3))
.style(move |_theme| container::Style {
border: Border {
color: ui.color(Role::Outline),
width: 1.0,
radius: ui.radius_dp(ShapeScale::Large),
},
background: Some(Background::Color(ui.color(Role::Surface))),
..container::Style::default()
})
.into()
}
/// 分页头:区间 + 上一页 / 下一页。
fn pager<'a>(ui: &'a Ui, _catalog: Catalog, pick: &'a Pick) -> Element<'a, PageMessage> {
let previous = if pick.can_prev {
buttons::text(
ui,
Button::new(
pick.prev,
intent(PackMessage::PickPage(pick.page.saturating_sub(1))),
)
.leading(IconKind::ChevronDown),
)
} else {
// 到边了就把按钮指回当前页(点了不动的死按钮比"没有按钮"更让人困惑)
buttons::text(
ui,
Button::new(pick.prev, intent(PackMessage::PickPage(pick.page))),
)
};
let next = if pick.can_next {
buttons::text(
ui,
Button::new(pick.next, intent(PackMessage::PickPage(pick.page + 1)))
.leading(IconKind::ChevronDown),
)
} else {
buttons::text(
ui,
Button::new(pick.next, intent(PackMessage::PickPage(pick.page))),
)
};
row![
frame::text_role(
ui,
TypeRole::LabelMedium,
false,
pick.range.as_str(),
ui.color(Role::OnSurfaceVariant),
),
previous,
next,
]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center)
.width(Length::Fill)
.into()
}
/// 一条冲突:**第一行**路径 + 处置按钮,**第二行**来源 · 成因 · 两侧体积。
///
/// 为什么两行:单行里塞四个字段(路径 / 来源 / 体积 / 按钮)会把路径压成一条窄柱,
/// 出图实测每行折成四五段——扫读成本比两行高得多。
fn pick_row<'a>(ui: &'a Ui, item: &'a PickRow) -> Element<'a, PageMessage> {
let toggle = if item.overwrites() {
// 已经是覆盖 ⇒ 用描边按钮(弱化:覆盖是更危险的那一侧),字面写"点了会退回保留"
buttons::outlined(
ui,
Button::new(
item.toggle,
intent(PackMessage::TogglePick(item.path.clone())),
),
)
} else {
buttons::text(
ui,
Button::new(
item.toggle,
intent(PackMessage::TogglePick(item.path.clone())),
)
.leading(IconKind::Refresh),
)
};
let head = row![
crate::pages::frame::text_role(
ui,
TypeRole::BodySmall,
false,
item.path.as_str(),
ui.color(Role::OnSurface),
),
toggle,
]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center)
.width(Length::Fill);
let detail = frame::text_role_owned(
ui,
TypeRole::BodySmall,
false,
format!("{} · {} · {}", item.source, item.reason, item.sizes),
ui.color(Role::OnSurfaceVariant),
);
column![head, detail]
.spacing(ui.space(Space::X1))
.width(Length::Fill)
.into()
}
/// 有界容器:内容多时自己滚(与结果块的 `bounded` 同一条口径,但这里限高不同)。
fn bounded<'a>(
content: impl Into<Element<'a, PageMessage>>,
height: f32,
) -> Element<'a, PageMessage> {
iced::widget::scrollable(container(content).width(Length::Fill))
.height(Length::Fixed(height))
.into()
}