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
@@ -54,11 +54,13 @@ pub use logs::{Diagnosis, DiagnosisReport, LogLevel, LogLine, LogOutcome, LogSes
|
||||
pub use notices::{NoticeLevel, NoticeView};
|
||||
// M113: import/export
|
||||
pub use pack::{
|
||||
ConflictChoiceView, ConflictFileView, ConflictReasonView, ExportClassView, ExportDoneView,
|
||||
ExportFileView, ExportPreviewView, ExportReasonView, ExportRequestView, ExportState,
|
||||
FileActionView, ImportErrorView, ImportProgressView, ImportReportView, ImportRequestView,
|
||||
ImportStageView, ImportState, ImportStatusView, ImportedFileView, MissingFileView,
|
||||
MissingReasonView, PackCommand, PackEvent, PackFormatView, PackState, ProbeView,
|
||||
ConflictChoiceView, ConflictDecisionView, ConflictDecisionsView, ConflictFileView,
|
||||
ConflictReasonView, ConflictSourceView, ExportClassView, ExportDoneView, ExportFileView,
|
||||
ExportPreviewView, ExportReasonView, ExportRequestView, ExportState, FileActionView,
|
||||
ImportConflictView, ImportErrorView, ImportProgressView, ImportReportView, ImportRequestView,
|
||||
ImportScanView, ImportStageView, ImportState, ImportStatusView, ImportedFileView,
|
||||
MissingFileView, MissingReasonView, PackCommand, PackEvent, PackFormatView, PackState,
|
||||
ProbeView,
|
||||
};
|
||||
pub use settings::{SettingControl, SettingGroupView, SettingRowView};
|
||||
// M159: 自更新
|
||||
|
||||
@@ -26,10 +26,18 @@
|
||||
//! | [`conflict`] | 冲突成因、处置、三方哈希 |
|
||||
//! | [`export`] | 三分类 / 逐条原因 / 预检 |
|
||||
//!
|
||||
//! ## 冲突:默认一侧永远是用户那一侧
|
||||
//! ## 冲突:预检清单 → 用户点头 → 事后账(M160)
|
||||
//!
|
||||
//! [`ImportRequestView::overwrite`] 缺省 `false` = `ConflictPolicy::KeepUser`。这是 M113 的硬线:
|
||||
//! **绝不默认覆盖用户改过的文件**。
|
||||
//! 三样东西在三处,别混:
|
||||
//!
|
||||
//! | 类型 | 时机 | 语义 |
|
||||
//! |---|---|---|
|
||||
//! | [`ImportScanView`] / [`ImportConflictView`] | 导入**前**([`PackEvent::Scan`]) | 待用户点头的冲突清单 |
|
||||
//! | [`ConflictDecisionsView`] | 页面 → 宿主([`ImportRequestView::decisions`]) | 全部保留 / 全部覆盖 / 逐项 |
|
||||
//! | [`ConflictFileView`] | 导入**后**([`PackEvent::Report`]) | 每条实际怎么处置的 |
|
||||
//!
|
||||
//! 两处缺省都是**保留**([`ConflictDecisionsView::default`] = `KeepAll`,core 的
|
||||
//! `ConflictChoice::default()` = `KeepUser`):**绝不默认覆盖用户已有文件**。
|
||||
|
||||
mod conflict;
|
||||
mod event;
|
||||
@@ -37,7 +45,10 @@ mod export;
|
||||
mod format;
|
||||
mod import;
|
||||
|
||||
pub use conflict::{ConflictChoiceView, ConflictFileView, ConflictReasonView};
|
||||
pub use conflict::{
|
||||
ConflictChoiceView, ConflictDecisionView, ConflictDecisionsView, ConflictFileView,
|
||||
ConflictReasonView, ConflictSourceView, ImportConflictView, ImportScanView,
|
||||
};
|
||||
pub use event::{PackCommand, PackEvent, PackState};
|
||||
pub use export::{
|
||||
ExportClassView, ExportDoneView, ExportFileView, ExportPreviewView, ExportReasonView,
|
||||
|
||||
@@ -1,11 +1,238 @@
|
||||
//! 冲突的 DTO:成因、本次处置、以及三方哈希的那一条账。
|
||||
//! 冲突的 DTO:成因、本次处置、**预检的待决清单**、以及三方哈希的那一条账。
|
||||
//!
|
||||
//! 三条判据与 core 的 `ConflictReason` 一一对应;`choice` 是**事后记录**(本次实际怎么处置的),
|
||||
//! 不是「逐文件决策通道」——core 今天没有那种通道(`ConflictPolicy` 是整次导入一个全局档)。
|
||||
//! 「默认保留用户那一侧」这条硬线在 `ImportRequestView::overwrite` 缺省 `false` 上。
|
||||
//! 三种东西分得清清楚楚(混起来就会把"要不要覆盖"变成"已经覆盖了"):
|
||||
//!
|
||||
//! | 类型 | 什么时候出现 | 语义 |
|
||||
//! |---|---|---|
|
||||
//! | [`ImportConflictView`] | **导入前**(`PackEvent::Scan`) | 待用户点头的一条:路径、来源、目标现有体积与哈希 |
|
||||
//! | [`ConflictFileView`] | **导入后**(`PackEvent::Report`) | 事后账:这条实际怎么处置的(`choice`) |
|
||||
//! | [`ConflictDecisionsView`] | 从页面发回去(`ImportRequestView::decisions`) | 用户点的「全部保留 / 全部覆盖 / 逐项」 |
|
||||
//!
|
||||
//! 「默认保留用户那一侧」这条硬线在 [`ConflictDecisionsView::default`] 上:缺省 = 全部保留,
|
||||
//! 而 core 侧 [`ConflictChoiceView::KeepUser`] 也是缺省档——两侧都不许"没收到决策就覆盖"。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 一条冲突**来源**(预检清单里那一列)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ConflictSourceView {
|
||||
/// 包内 `overrides/`(CF 清单点名的那个目录 / MultiMC 的 `.minecraft/` 也走这条)。
|
||||
Overrides,
|
||||
/// 包内 `client-overrides/`(叠层里胜出的那一侧)。
|
||||
ClientOverrides,
|
||||
/// CurseForge `files[]` 条目。
|
||||
CurseForge,
|
||||
/// Modrinth `files[]` 条目。
|
||||
Modrinth,
|
||||
}
|
||||
|
||||
impl ConflictSourceView {
|
||||
/// 稳定 slug。
|
||||
#[must_use]
|
||||
pub const fn slug(self) -> &'static str {
|
||||
match self {
|
||||
Self::Overrides => "overrides",
|
||||
Self::ClientOverrides => "client_overrides",
|
||||
Self::CurseForge => "curseforge",
|
||||
Self::Modrinth => "modrinth",
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 core 的 id 反解。
|
||||
#[must_use]
|
||||
pub fn from_slug(slug: &str) -> Option<Self> {
|
||||
match slug {
|
||||
"overrides" => Some(Self::Overrides),
|
||||
"client_overrides" => Some(Self::ClientOverrides),
|
||||
"curseforge" => Some(Self::CurseForge),
|
||||
"modrinth" => Some(Self::Modrinth),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 是不是"包内的覆盖目录"(相对于清单里的远端条目)。
|
||||
#[must_use]
|
||||
pub const fn is_override_dir(self) -> bool {
|
||||
matches!(self, Self::Overrides | Self::ClientOverrides)
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条**待用户点头**的冲突(导入前那张清单里的一行)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ImportConflictView {
|
||||
/// 实例内相对路径。
|
||||
pub path: String,
|
||||
/// 来源(包内覆盖目录 / 清单远端条目)。
|
||||
pub source: ConflictSourceView,
|
||||
/// 成因。
|
||||
pub reason: ConflictReasonView,
|
||||
/// 包内那条是不是禁用态(`.disabled`)。
|
||||
pub disabled: bool,
|
||||
/// 包内声明的 SHA-1。
|
||||
pub pack_sha1: String,
|
||||
/// 包内声明的字节数。
|
||||
pub pack_size: u64,
|
||||
/// 目标现有文件的 SHA-1。
|
||||
pub disk_sha1: String,
|
||||
/// 目标现有文件的字节数(覆盖会丢掉的量)。
|
||||
pub disk_size: u64,
|
||||
/// 上次导入写下的 SHA-1(没有基线时 `None`)。
|
||||
pub base_sha1: Option<String>,
|
||||
}
|
||||
|
||||
/// 用户的冲突决策(三条入口共用一个类型)。
|
||||
///
|
||||
/// 三条入口与 core 的 `ConflictDecisions`(`default` + `entries`)是同一件事的两种说法:
|
||||
///
|
||||
/// | 变体 | core 形状 |
|
||||
/// |---|---|
|
||||
/// | [`ConflictDecisionsView::KeepAll`] | `default = KeepUser`, 空表 |
|
||||
/// | [`ConflictDecisionsView::OverwriteAll`] | `default = Overwrite`, 空表 |
|
||||
/// | `PerItem { default, items }` | `default` + 逐项表 |
|
||||
///
|
||||
/// `PerItem` 带一个 `default` 是为了让"全部覆盖之后我又单独放回两条"这种真实操作可表达:
|
||||
/// 若只有"逐项覆盖 + 缺省保留",用户就得把剩下的几百条全点一遍。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ConflictDecisionsView {
|
||||
/// **全部保留**(缺省):用户已有文件一个都不动。
|
||||
KeepAll,
|
||||
/// **全部覆盖**:整合包说了算。
|
||||
OverwriteAll,
|
||||
/// **逐项**:点过的路径按 `items`,其余按 `default`。
|
||||
PerItem {
|
||||
/// 没点过的路径按它处置。
|
||||
default: ConflictChoiceView,
|
||||
/// 点过的路径。
|
||||
items: Vec<ConflictDecisionView>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Default for ConflictDecisionsView {
|
||||
/// **缺省 = 全部保留**(绝不默认覆盖用户已有文件)。
|
||||
fn default() -> Self {
|
||||
Self::KeepAll
|
||||
}
|
||||
}
|
||||
|
||||
impl ConflictDecisionsView {
|
||||
/// 逐项构造(缺省档 + 点过的那些)。
|
||||
#[must_use]
|
||||
pub fn per_item(default: ConflictChoiceView, items: Vec<ConflictDecisionView>) -> Self {
|
||||
if items.is_empty() {
|
||||
return match default {
|
||||
ConflictChoiceView::KeepUser => Self::KeepAll,
|
||||
ConflictChoiceView::Overwrite => Self::OverwriteAll,
|
||||
};
|
||||
}
|
||||
Self::PerItem { default, items }
|
||||
}
|
||||
|
||||
/// 逐项表里的缺省档(`KeepAll` / `OverwriteAll` 也答得出)。
|
||||
#[must_use]
|
||||
pub const fn default_choice(&self) -> ConflictChoiceView {
|
||||
match self {
|
||||
Self::KeepAll => ConflictChoiceView::KeepUser,
|
||||
Self::OverwriteAll => ConflictChoiceView::Overwrite,
|
||||
Self::PerItem { default, .. } => *default,
|
||||
}
|
||||
}
|
||||
|
||||
/// 用户显式要求"以包为准"吗(整批覆盖)。
|
||||
#[must_use]
|
||||
pub fn overwrites_everything(&self) -> bool {
|
||||
matches!(self, Self::OverwriteAll)
|
||||
}
|
||||
|
||||
/// 逐项表里"覆盖"的条数(不含缺省档)。
|
||||
#[must_use]
|
||||
pub fn overwrite_count(&self) -> usize {
|
||||
self.items()
|
||||
.iter()
|
||||
.filter(|item| item.choice == ConflictChoiceView::Overwrite)
|
||||
.count()
|
||||
}
|
||||
|
||||
/// 逐项表里"保留"的条数。
|
||||
#[must_use]
|
||||
pub fn keep_count(&self) -> usize {
|
||||
self.items()
|
||||
.iter()
|
||||
.filter(|item| item.choice == ConflictChoiceView::KeepUser)
|
||||
.count()
|
||||
}
|
||||
|
||||
/// 逐项表(`KeepAll` / `OverwriteAll` 是空表)。
|
||||
#[must_use]
|
||||
pub fn items(&self) -> &[ConflictDecisionView] {
|
||||
match self {
|
||||
Self::PerItem { items, .. } => items,
|
||||
Self::KeepAll | Self::OverwriteAll => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// 逐项表的条数。
|
||||
#[must_use]
|
||||
pub fn len(&self) -> usize {
|
||||
self.items().len()
|
||||
}
|
||||
|
||||
/// 有没有逐项决策。
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.items().is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// 逐项决策的一条(路径 → 处置)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ConflictDecisionView {
|
||||
/// 实例内相对路径。
|
||||
pub path: String,
|
||||
/// 处置。
|
||||
pub choice: ConflictChoiceView,
|
||||
}
|
||||
|
||||
/// 一次导入预检的结果(`PackEvent::Scan` 的载荷)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ImportScanView {
|
||||
/// 目标实例目录(人要看到"装到哪")。
|
||||
pub target: std::path::PathBuf,
|
||||
/// 命中的格式。
|
||||
pub format: Option<crate::dto::PackFormatView>,
|
||||
/// 包名。
|
||||
pub pack_name: Option<String>,
|
||||
/// 包版本。
|
||||
pub pack_version: Option<String>,
|
||||
/// 游戏版本。
|
||||
pub game_version: Option<String>,
|
||||
/// 待用户点头的冲突(按路径排序)。
|
||||
pub conflicts: Vec<ImportConflictView>,
|
||||
/// 补全前没有路径、因而判不了的远端条目数。
|
||||
pub unknown_remote: usize,
|
||||
/// 覆盖会丢掉的字节总量。
|
||||
pub lost_bytes: u64,
|
||||
/// 包内那些条目声明了多大。
|
||||
pub incoming_bytes: u64,
|
||||
/// 注记(与导入报告同源的人读串)。
|
||||
pub notes: Vec<String>,
|
||||
/// 预检本身失败(探测不过 / 开包失败)时的原因。
|
||||
pub error: Option<crate::dto::ImportErrorView>,
|
||||
}
|
||||
|
||||
impl ImportScanView {
|
||||
/// 需要用户点头吗(`false` = 可以直接导入,别打扰)。
|
||||
#[must_use]
|
||||
pub fn needs_decision(&self) -> bool {
|
||||
!self.conflicts.is_empty()
|
||||
}
|
||||
|
||||
/// 一条冲突都没有、预检也成功(可以直接导)。
|
||||
#[must_use]
|
||||
pub fn is_clean(&self) -> bool {
|
||||
self.error.is_none() && self.conflicts.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// 冲突成因(core 的三条判据)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ConflictReasonView {
|
||||
|
||||
@@ -26,12 +26,17 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::dto::pack::{
|
||||
ExportDoneView, ExportPreviewView, ExportRequestView, ExportState, ImportProgressView,
|
||||
ImportReportView, ImportRequestView, ImportState, ProbeView,
|
||||
ImportReportView, ImportRequestView, ImportScanView, ImportState, ProbeView,
|
||||
};
|
||||
|
||||
/// app → host 的导入/导出意图。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum PackCommand {
|
||||
/// **预检**导入(只读:算出冲突清单,**不写一个字节**)
|
||||
///
|
||||
/// 页面先发它,拿到 [`PackEvent::Scan`] 之后:没有冲突就直接发 [`PackCommand::Import`]
|
||||
/// (不打扰用户);有冲突就把清单画出来,用户点完再带着决策发 `Import`。
|
||||
Scan(Box<ImportRequestView>),
|
||||
/// 开始导入(目标实例已选好;`instance` 为 `None` = 按 `instance_name` 新建目录)
|
||||
Import(Box<ImportRequestView>),
|
||||
/// 跑**预检**(只扫描分类,**不写盘**)
|
||||
@@ -47,6 +52,8 @@ pub enum PackCommand {
|
||||
pub enum PackEvent {
|
||||
/// 导入进度(`None` = 收回面板:取消,或流程已经给出结果)
|
||||
Progress(Option<Box<ImportProgressView>>),
|
||||
/// 导入**预检**结果:冲突清单(M160)。空清单 = 没有文件需要用户点头。
|
||||
Scan(Box<ImportScanView>),
|
||||
/// 导入结果(三态 + 逐项原因;致命失败也走它,`report.error` 里有原因)
|
||||
Report(Box<ImportReportView>),
|
||||
/// 探测失败:这个包看起来像什么、缺哪个标记文件
|
||||
@@ -80,15 +87,24 @@ impl PackState {
|
||||
PackEvent::Progress(progress) => {
|
||||
self.import.progress = progress.map(|progress| *progress);
|
||||
}
|
||||
PackEvent::Report(report) => {
|
||||
// 结果到了:进度面板让位,探测诊断作废(这一轮已经有结论了)
|
||||
PackEvent::Scan(scan) => {
|
||||
// 预检到了:进度让位,上一轮的结果/诊断作废(这一轮还没开始导)。
|
||||
self.import.progress = None;
|
||||
self.import.probe = None;
|
||||
self.import.report = None;
|
||||
self.import.scan = Some(*scan);
|
||||
}
|
||||
PackEvent::Report(report) => {
|
||||
// 结果到了:进度面板让位,探测诊断与预检清单都作废(这一轮已经有结论了)
|
||||
self.import.progress = None;
|
||||
self.import.probe = None;
|
||||
self.import.scan = None;
|
||||
self.import.report = Some(*report);
|
||||
}
|
||||
PackEvent::Probe(probe) => {
|
||||
self.import.progress = None;
|
||||
self.import.report = None;
|
||||
self.import.scan = None;
|
||||
self.import.probe = Some(*probe);
|
||||
}
|
||||
PackEvent::Preview(preview) => {
|
||||
|
||||
@@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::dto::InstanceId;
|
||||
|
||||
use super::conflict::ConflictFileView;
|
||||
use super::conflict::{ConflictDecisionsView, ConflictFileView, ImportScanView};
|
||||
use super::format::PackFormatView;
|
||||
|
||||
/// 导入阶段(与 core 的 `ImportStage` 一一对应)。
|
||||
@@ -144,10 +144,11 @@ pub struct ImportRequestView {
|
||||
pub instance: Option<InstanceId>,
|
||||
/// 新实例的展示名(`None` = 用包名)。
|
||||
pub instance_name: Option<String>,
|
||||
/// 冲突翻档:`false`(**默认**)= 保留用户那一侧;`true` = 以包为准覆盖。
|
||||
/// 冲突决策(**缺省 [`ConflictDecisionsView::KeepAll`]** = 用户已有文件一个都不动)。
|
||||
///
|
||||
/// 页面上把 `true` 那条写成「以包为准重新导入」,并且**只在用户显式点过之后**才发。
|
||||
pub overwrite: bool,
|
||||
/// 页面上:`KeepAll` 是缺省与「全部保留」,`OverwriteAll` 是「全部覆盖」,
|
||||
/// `PerItem` 是逐项勾选的结果。只有用户**显式点过**之后才会出现后两者。
|
||||
pub decisions: ConflictDecisionsView,
|
||||
}
|
||||
|
||||
/// 一条导入进度。
|
||||
@@ -334,6 +335,8 @@ pub struct ImportState {
|
||||
/// 用户点了取消(M113 r1):**面板收回但后台还在跑**,所以这一位要留在界面上——
|
||||
/// 否则用户会以为「没建成」,回头再导一次就撞「directory already exists」。
|
||||
pub cancelled: bool,
|
||||
/// 最近一次的预检(M160:冲突清单;`None` = 还没预检过或已被新结果顶掉)。
|
||||
pub scan: Option<ImportScanView>,
|
||||
/// 最近一次的结果(`None` = 还没导过)。
|
||||
pub report: Option<ImportReportView>,
|
||||
/// 探测失败的具名诊断(有它就让用户看清「像 X 缺 Y」,而不是一句「无法识别」)。
|
||||
|
||||
Reference in new issue
Block a user