主任务(逐文件覆盖/保留)
- 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)。
263 lines
9.3 KiB
Rust
263 lines
9.3 KiB
Rust
//! 导入/导出区的画法:入口(拖拽 + 路径)→ 进度(**下载页那块面板**)→ 结果 → 导出预检。
|
||
//!
|
||
//! ## 版面
|
||
//!
|
||
//! ```text
|
||
//! ┌ 导入整合包 ──────────────────────────────────┐
|
||
//! │ ┌ 拖拽区(hover 时变主色描边) ─────────────┐ │
|
||
//! │ └ 路径输入框 ──────────┘ [导入] │ │
|
||
//! │ [装到实例:chips] [实例名] │ │
|
||
//! ├ 进度:步骤 2/5 · 解包覆盖内容 │
|
||
//! │ <下载页的安装面板:进度条 + 计数 + 取消> │
|
||
//! ├ 探测失败:看起来像 X,但缺 Y │
|
||
//! ├ 结果:三态标题 + 缺文件逐条 + 冲突逐条 + 致命块 │
|
||
//! └ 导出:格式 chips + 预检(将内联 N / 无法导出 M)│
|
||
//! ```
|
||
//!
|
||
//! ## 三处「视觉可区分」不是靠颜色一种通道
|
||
//!
|
||
//! | 域 | 图标 | 颜色角色 | 容器 | 措辞 |
|
||
//! |---|---|---|---|---|
|
||
//! | 容忍(缺文件 / 被拒 component) | `Warn` | `Tertiary` | 无底色 | 「可容忍」 |
|
||
//! | 致命(已回滚) | `Error` | `Error` | `ErrorContainer` | 「已回滚 —— 零残留」 |
|
||
//! | 成功 | `Check` | `Primary` | 无底色 | 「已导入」 |
|
||
//!
|
||
//! 关掉颜色也还认得出(图标 + 措辞),这是任务书「必须视觉可区分」的落点。
|
||
//!
|
||
//! ## 进度只有一份
|
||
//!
|
||
//! 进行中的进度**整块交给** `pages::download::install_panel`(同一个函数、同一套几何与文案);
|
||
//! 本区只额外画一行 [`Running::stage_line`] 补足「解包 / overrides / 补文件」三档的粒度。
|
||
//!
|
||
//! ## 文件分工(守 400 行)
|
||
//!
|
||
//! | 文件 | 职责 |
|
||
//! |---|---|
|
||
//! | 本文件 | 区入口 + 拖拽区 + 路径输入 + 目标实例 |
|
||
//! | [`parts`] | 三个共用小件(底色容器 / 容忍标题 / 逐条列表) |
|
||
//! | [`progress`] | 进度块(复用面板)与探测失败块 |
|
||
//! | [`report`] | 结果块:三态 + 冲突 + 致命 |
|
||
//! | [`export`] | 导出块与预检块 |
|
||
|
||
mod export;
|
||
mod parts;
|
||
mod pick;
|
||
mod progress;
|
||
mod report;
|
||
|
||
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, Key};
|
||
|
||
use crate::app::components::buttons::{self, Button};
|
||
use crate::app::components::divider;
|
||
use crate::app::components::inputs::{text_field, Field};
|
||
use crate::app::components::selection::{chip, Chip};
|
||
use crate::pages::frame;
|
||
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;
|
||
use super::PackMessage;
|
||
|
||
/// 内容库意图 → 页面消息(唯一的一层包装)。
|
||
pub(super) fn intent(message: PackMessage) -> PageMessage {
|
||
PageMessage::Content(super::super::Message::Pack(message))
|
||
}
|
||
|
||
/// 整块(导入入口 + 进度 + 结果 + 导出)。
|
||
pub(crate) fn section<'a>(
|
||
ui: &'a Ui,
|
||
catalog: Catalog,
|
||
model: &'a Pack,
|
||
) -> Element<'a, PageMessage> {
|
||
let mut block = column![].spacing(ui.space(Space::X3)).width(Length::Fill);
|
||
|
||
// **一次只摊开一件事**:入口(拖什么/装到哪)、进度、结果、导出预检各占一态。
|
||
// 全都堆在一起时,内容库页正列没有整页滚动 ⇒ 动作按钮被顶出屏幕(出图实测:冲突态与
|
||
// 导出预检态的「确认写盘 / 关闭」都落在 900px 以下)。收起的入口不会丢功能:拖拽是窗口级
|
||
// 事件,任何时候把文件拖进来都算数。
|
||
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 {
|
||
block = block.push(report_block(ui, catalog, report));
|
||
} else if model.cancelled {
|
||
// 取消过、还没有新结果:把「后台还在跑」这句话留在屏上(复审 r1 P1-2)
|
||
block = block.push(
|
||
column![
|
||
frame::text_role(
|
||
ui,
|
||
TypeRole::BodySmall,
|
||
false,
|
||
model.cancelled_note,
|
||
ui.color(Role::Tertiary),
|
||
),
|
||
buttons::text(
|
||
ui,
|
||
Button::new(catalog.text(Key::PackDismiss), intent(PackMessage::Dismiss)),
|
||
),
|
||
]
|
||
.spacing(ui.space(Space::X1))
|
||
.width(Length::Fill),
|
||
);
|
||
} else {
|
||
block = block.push(entry(ui, catalog, model));
|
||
if let Some(probe) = &model.probe {
|
||
// 探测失败要和路径输入框在一起:用户下一步就是改路径
|
||
block = block.push(probe_block(ui, catalog, probe));
|
||
}
|
||
block = block.push(divider::full(ui));
|
||
block = block.push(export_block(ui, catalog, &model.export));
|
||
}
|
||
|
||
container(block).width(Length::Fill).into()
|
||
}
|
||
|
||
/// 入口:拖拽区 + 路径输入 + 目标实例。
|
||
fn entry<'a>(ui: &'a Ui, catalog: Catalog, model: &'a Pack) -> Element<'a, PageMessage> {
|
||
let zone = container(
|
||
column![
|
||
frame::text_role(
|
||
ui,
|
||
TypeRole::BodyMedium,
|
||
false,
|
||
if model.hover {
|
||
catalog.text(Key::PackDropActive)
|
||
} else {
|
||
model.drop_hint
|
||
},
|
||
ui.color(if model.hover {
|
||
Role::Primary
|
||
} else {
|
||
Role::OnSurface
|
||
}),
|
||
),
|
||
// 没有系统文件框这件事**写在界面上**:不能让用户点了半天「选择文件」找不到按钮
|
||
frame::text_role(
|
||
ui,
|
||
TypeRole::BodySmall,
|
||
false,
|
||
model.no_dialog_note,
|
||
ui.color(Role::OnSurfaceVariant),
|
||
),
|
||
]
|
||
.spacing(ui.space(Space::X1))
|
||
.width(Length::Fill),
|
||
)
|
||
.width(Length::Fill)
|
||
.padding(ui.space(Space::X4))
|
||
.style(move |_theme| container::Style {
|
||
border: Border {
|
||
color: ui.color(if model.hover {
|
||
Role::Primary
|
||
} else {
|
||
Role::Outline
|
||
}),
|
||
width: 1.0,
|
||
radius: ui.radius_dp(ShapeScale::Large),
|
||
},
|
||
background: model
|
||
.hover
|
||
.then(|| Background::Color(ui.color(Role::PrimaryContainer))),
|
||
..container::Style::default()
|
||
});
|
||
|
||
let field = text_field(
|
||
ui,
|
||
Field::new(model.draft.as_str())
|
||
.label(catalog.text(Key::PackPathLabel))
|
||
.placeholder(catalog.text(Key::PackPathPlaceholder))
|
||
.leading(IconKind::Folder),
|
||
move |text| intent(PackMessage::PathChanged(text)),
|
||
);
|
||
let submit = buttons::filled(
|
||
ui,
|
||
Button::new(
|
||
catalog.text(Key::PackImportAction),
|
||
intent(PackMessage::Import),
|
||
)
|
||
.leading(IconKind::Folder),
|
||
);
|
||
|
||
let mut block = column![
|
||
frame::text_role(
|
||
ui,
|
||
TypeRole::TitleSmall,
|
||
true,
|
||
catalog.text(Key::PackImportTitle),
|
||
ui.color(Role::OnSurface),
|
||
),
|
||
zone,
|
||
row![field, submit]
|
||
.spacing(ui.space(Space::X2))
|
||
.align_y(Alignment::End)
|
||
.width(Length::Fill),
|
||
targets(ui, catalog, model),
|
||
]
|
||
.spacing(ui.space(Space::X2))
|
||
.width(Length::Fill);
|
||
|
||
if model.instance.is_none() {
|
||
block = block.push(text_field(
|
||
ui,
|
||
Field::new(model.instance_name.as_str()).label(catalog.text(Key::PackInstanceName)),
|
||
move |text| intent(PackMessage::SetInstanceName(text)),
|
||
));
|
||
}
|
||
|
||
block.into()
|
||
}
|
||
|
||
/// 目标实例那一行(chips + 「新建实例」)。
|
||
fn targets<'a>(ui: &'a Ui, catalog: Catalog, model: &'a Pack) -> Element<'a, PageMessage> {
|
||
let mut chips: Vec<Element<'a, PageMessage>> = Vec::new();
|
||
for target in &model.targets {
|
||
chips.push(chip(
|
||
ui,
|
||
Chip::filter(
|
||
target.label.as_str(),
|
||
model.instance.as_deref() == Some(target.id.as_str()),
|
||
intent(PackMessage::SetInstance(Some(target.id.clone()))),
|
||
),
|
||
));
|
||
}
|
||
chips.push(chip(
|
||
ui,
|
||
Chip::filter(
|
||
catalog.text(Key::PackTargetNew),
|
||
model.instance.is_none(),
|
||
intent(PackMessage::SetInstance(None)),
|
||
),
|
||
));
|
||
|
||
column![
|
||
frame::text_role(
|
||
ui,
|
||
TypeRole::LabelMedium,
|
||
false,
|
||
catalog.text(Key::PackTargetLabel),
|
||
ui.color(Role::OnSurfaceVariant),
|
||
),
|
||
row(chips).spacing(ui.space(Space::X2)).wrap(),
|
||
]
|
||
.spacing(ui.space(Space::X1))
|
||
.width(Length::Fill)
|
||
.into()
|
||
}
|