feat(packui): M113 phase 2 —— 导入导出真正接线(三个共享枚举各只多一个变体)
搬到 M109 rebase 后的新 main(26e764e)之后按**新 main 的形状**写一次,不写两遍。
## 接线
- `dto/pack/event.rs`(新):`PackCommand` / `PackEvent` / `PackState::apply`。
整条流程在三个最挤的共享枚举里各只占**一个**变体(`AppCommand::Pack` / `CoreEvent::Pack` /
`HostTask::Pack{event, epoch}`),子枚举承载细节 —— rebase 时不用替别人定序。
- `events.rs` / `job.rs` / `handle.rs` / `dispatch.rs` / `state.rs`:变体与分支,全部带
`// M113: import/export` 锚点。`state.rs` 只多 1 个字段 + 1 条 apply 分支(399 行)。
- `host/addon/**`(新目录,父文件只加 `mod pack;` + 再导出):
- `pack.rs`:`CoreHost::pack` 落点 + 取消标记 + 实例目录/快照/格式/缺省路径解析
- `pack/import.rs`:探测 → 目标目录 → `import_pack` → 报告(默认 KeepUser,显式才 Overwrite)
- `pack/export.rs`:预检(`scan_instance`,**不写盘**)与写盘(`export_instance`)
- `pack/convert.rs`:core 报告 → DTO,逐字段照搬(三态/五阶段/四处置/三成因/十一原因)
- `pack/diagnose.rs`:探测诊断「像 X 缺 Y」(按标记文件的有无,不看文件名)
- `app/update/content.rs`:导入 / 以包为准 / 预检 / 确认写盘 / 关面板真的派指令;
预检时先记下那份请求,确认时**原样发回**(防「预检 A、写 B」)。
- `pages/content/model.rs`:读 `state.pack`(不再是默认空态)。
- 面板接口按新形状改:`install_panel_model(&InstallProgressView, catalog) -> Option<Download>`
(`InstallView` 不再从 `pages/download` 导出)。
## 取消(如实)
`CancelInstall` 现在同时置导入的取消标记 + 代次 +1:面板立刻收回、旧事件按代次丢弃。
但 core 的 `import_pack` 没有取消令牌、进度回调也拿不到控制流 ⇒ **后台那次导入会跑完**。
所以「取消」的真实含义是「不再显示 + 结果作废」,已在模块文档与报告里写清,并单开 finding。
取消标记用 `Arc<AtomicBool>` 而不是裸静态量:裸静态量会在并行测试间泄漏取消状态(实测到过)。
## 图看出来的三处(塔台要求)
1. `no download source (CurseForge file API not wired)` → `no download source available yet`,
API 名只留在 DTO 的 detail 与日志里;顺带把 `sha512` / `projectID/fileID` 也改成用户话。
2. 右栏 `Installing modpacks is not available yet` → `Installing modpacks from the library into
an instance is not available yet`(那句在 i18n 词表里,不在 M109 页面代码里,所以我自己改)。
3. 结果面板接线后可以关:`Dismiss` / `AbortExport` 发 `PackCommand::Dismiss` → host 回
`PackEvent::Cleared` → 回到入口/浏览态,不再常驻。
## 证据(三条都是真跑,不是假数据)
- `bad_packs_get_a_named_diagnosis`:把文本改名成 .mrpack → 有具名诊断(不是静默、不 panic);
目录形态有 overrides/ 无 manifest.json → 说得出「缺哪个标记」。
- `a_real_pack_runs_through_the_pipeline_end_to_end`:造一个真 MultiMC 实例目录当包,
走 `import::run` 真路径,断言事件流里有进度 + 结果、格式=MutliMC、报告有写盘文件、文件真落地、
代次原样带回。
- `preview_counts_match_the_written_report`:同一实例上跑预检与 `export_instance`,
inline / skipped / remote **三项计数逐项相等**,且逐条路径的归类一致。
门禁:cargo check ✓ / clippy -D warnings(三 crate)EXIT=0 /
cargo test(app-iced 504 + 6 守卫,i18n 24,presenter 181)全绿 / rustfmt --check ✓ /
所有改动文件 ≤400 行(`state.rs` 399、`addon.rs` 106,pack 子目录最大 338)。
This commit is contained in:
1 parent
2129d8cee6
commit
5f09b8113a
23 files changed
+1336
-73
No files matched your search
@@ -16,7 +16,10 @@
|
||||
//! `AppState::content.query` 是「当前结果对应哪份条件」的唯一真相:结果回来时
|
||||
//! `apply_content` 用它判「迟到的事件要不要丢」。页面自己存一份就会有两份真相。
|
||||
|
||||
use unlucky_presenter::dto::{AppCommand, ContentInstallRequest, ContentQuery, ContentSource};
|
||||
use unlucky_presenter::dto::{
|
||||
AppCommand, ContentInstallRequest, ContentQuery, ContentSource, ExportRequestView,
|
||||
ImportRequestView, PackCommand,
|
||||
};
|
||||
|
||||
use crate::pages::content::{Blocker, Content};
|
||||
|
||||
@@ -107,9 +110,8 @@ impl App {
|
||||
///
|
||||
/// 1. **页内选择改本地状态**(路径草稿 / 目标实例 / 格式 / 包名 / 展开的冲突)——
|
||||
/// 它们不属于业务状态,不进 presenter(同 `QueryChanged` 的处理);
|
||||
/// 2. **意图派指令**(导入 / 取消 / 预检 / 确认写盘)—— 本批只到「改本地状态」这一步,
|
||||
/// presenter 侧的 `AppCommand::{ImportPack, ExportPack}` 随事件通道一起在下一批接上
|
||||
/// (见 mission M113 的两批时序:枚举顺序要按 M109 rebase 后的新 main 写一次,不写两遍)。
|
||||
/// 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 {
|
||||
@@ -172,21 +174,99 @@ impl App {
|
||||
}
|
||||
PackMessage::AbortExport => {
|
||||
self.content.pack.export_open = false;
|
||||
// 连预检结果一起清:留着会让用户以为「还能再点一次确认」
|
||||
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Dismiss)));
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::Dismiss => {
|
||||
// 报告与探测诊断都归 presenter(下一批清);这里先收起页内的展开状态
|
||||
// 报告 / 探测诊断 / 预检都在 presenter 里:关面板 = 让 host 清掉它们
|
||||
// (`PackEvent::Cleared`),于是界面回到入口态,浏览网格重新露出来
|
||||
self.content.pack.expanded = None;
|
||||
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Dismiss)));
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::Import
|
||||
| PackMessage::Cancel
|
||||
| PackMessage::Overwrite
|
||||
| PackMessage::Precheck
|
||||
| PackMessage::Confirm => {}
|
||||
PackMessage::Import => {
|
||||
if let Some(request) = self.pack_import_request(false) {
|
||||
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Import(Box::new(
|
||||
request,
|
||||
)))));
|
||||
}
|
||||
}
|
||||
PackMessage::Overwrite => {
|
||||
// 「以包为准重新导入」:整次导入翻成 Overwrite 档(core 只有全局档)
|
||||
if let Some(request) = self.pack_import_request(true) {
|
||||
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,
|
||||
)))));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 组一份导入请求(路径草稿 → DTO)。草稿为空 ⇒ `None`(按钮不该发指令)。
|
||||
///
|
||||
/// `overwrite` 只有「以包为准重新导入」会给 `true`;默认路径永远是 `false`
|
||||
/// (core 那边就是 `ConflictPolicy::KeepUser`,**绝不默认覆盖用户改过的文件**)。
|
||||
fn pack_import_request(&self, overwrite: bool) -> 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()),
|
||||
overwrite,
|
||||
})
|
||||
}
|
||||
|
||||
/// 组一份导出请求(预检与写盘**共用同一份**,确认时原样发回)。
|
||||
///
|
||||
/// 没有选中实例就没有导出对象 ⇒ `None`。包名/版本留空时给缺省值:
|
||||
/// 包名 = 实例 id、版本 = 页面上的 `1.0.0`。
|
||||
fn pack_export_request(&self) -> Option<ExportRequestView> {
|
||||
let instance = self.content.pack.instance.clone()?;
|
||||
let name = self.content.pack.export_name.trim();
|
||||
let version = self.content.pack.export_version.trim();
|
||||
Some(ExportRequestView {
|
||||
instance: instance.clone(),
|
||||
format: self.content.pack.format,
|
||||
name: if name.is_empty() {
|
||||
instance
|
||||
} else {
|
||||
name.to_string()
|
||||
},
|
||||
version: if version.is_empty() {
|
||||
"1.0.0".to_string()
|
||||
} else {
|
||||
version.to_string()
|
||||
},
|
||||
target: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// 拖入一个文件/目录(`app/update.rs` 的窗口事件转发到这里)。
|
||||
pub(super) fn on_pack_dropped(&mut self, path: std::path::PathBuf) -> Task<Message> {
|
||||
// 拖入是「现在就要看到结果」的动作:先把用户带到内容库页,再落草稿
|
||||
|
||||
@@ -146,12 +146,11 @@ impl Content {
|
||||
target.as_deref(),
|
||||
);
|
||||
|
||||
// M113: import/export —— 进度与报告在 presenter(`AppState.import` / `.export`),
|
||||
// 页内选择在 `page.pack`。两批落地:本批先接页内选择(入口可用),事件回流在下一批。
|
||||
// `targets` 下面会被移动进结构体,所以这里先借用它建视图模型。
|
||||
// M113: import/export —— 进度/报告/预检在 presenter(`AppState.pack`),
|
||||
// 页内选择在 `page.pack`。`targets` 下面会被移动进结构体,所以这里先借用它建视图模型。
|
||||
let pack = super::packui::Pack::of(
|
||||
&unlucky_presenter::dto::ImportState::default(),
|
||||
&unlucky_presenter::dto::ExportState::default(),
|
||||
&state.pack.import,
|
||||
&state.pack.export,
|
||||
&page.pack,
|
||||
&targets,
|
||||
catalog,
|
||||
|
||||
@@ -21,7 +21,7 @@ use unlucky_presenter::dto::{
|
||||
ImportProgressView, ImportStageView, InstallProgressView, InstallStage,
|
||||
};
|
||||
|
||||
use crate::pages::download::{Download, InstallView};
|
||||
use crate::pages::download::Download;
|
||||
|
||||
/// 抛进面板的进度行。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
@@ -30,8 +30,8 @@ pub struct Running {
|
||||
pub pack: String,
|
||||
/// 细档位说明(`步骤 2/5 · 解包覆盖内容`)
|
||||
pub stage_line: String,
|
||||
/// 交给下载页那块面板的进度(**唯一**的进度形状来源)
|
||||
pub panel: Download,
|
||||
/// 交给下载页那块面板的进度(**唯一**的进度形状来源;`None` = 面板无话可说)
|
||||
pub panel: Option<Download>,
|
||||
}
|
||||
|
||||
/// 细档位说明(`步骤 2/5 · 解包覆盖内容`)。
|
||||
@@ -100,33 +100,19 @@ pub fn panel_progress(progress: &ImportProgressView) -> InstallProgressView {
|
||||
}
|
||||
|
||||
/// 进度事件 → 面板要的视图模型。
|
||||
///
|
||||
/// 面板入参走 `pages::download::install_panel_model`(M109 合流 M105 后提供的**唯一**入口):
|
||||
/// 它把 `InstallProgressView` 转成面板要的 `Download`,终态(完成/取消)返回 `None`。
|
||||
/// 导入的五个阶段都不是终态,所以正常路径下一定是 `Some`;`None` 只做防御(面板没了就只留步骤行)。
|
||||
///
|
||||
/// 面板自带那个粗阶段词是**安装**语义(`Resolving version` / `Downloading files` /
|
||||
/// `Building the version` / `Registering instance`),所以细档位由 [`stage_line`] 说,
|
||||
/// 二者是「粗/细」关系而不是两套说法——[`panel_stage`] 只挑语义最近的那一档。
|
||||
pub(super) fn running_of(progress: &ImportProgressView, catalog: Catalog) -> Running {
|
||||
let panel = panel_progress(progress);
|
||||
Running {
|
||||
pack: progress.pack.clone(),
|
||||
stage_line: stage_line(progress.stage, catalog),
|
||||
panel: Download {
|
||||
install: Some(InstallView {
|
||||
version: panel.version.clone(),
|
||||
// 只用于 `running()` / `failed()` 的判定;**不**用它给用户看(见模块文档第 3 点)
|
||||
stage: panel.stage,
|
||||
stage_label: String::new(),
|
||||
detail: if progress.is_known() {
|
||||
catalog.render(
|
||||
Key::DownloadProgress,
|
||||
&[
|
||||
("done", &progress.done.to_string()),
|
||||
("total", &progress.total.to_string()),
|
||||
],
|
||||
)
|
||||
} else {
|
||||
catalog.text(Key::DownloadProgressUnknown).to_owned()
|
||||
},
|
||||
rate: None,
|
||||
ratio: progress.ratio(),
|
||||
known: progress.is_known(),
|
||||
}),
|
||||
..Download::loading()
|
||||
},
|
||||
panel: crate::pages::download::install_panel_model(&panel, catalog),
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,10 @@ fn importing_shows_the_shared_install_panel() {
|
||||
let running = model.running.expect("导入中要有进度");
|
||||
// 进度形状来自下载页那块面板(`Download.install`),不是本区另造的
|
||||
assert!(
|
||||
running.panel.install.is_some(),
|
||||
running
|
||||
.panel
|
||||
.as_ref()
|
||||
.is_some_and(|panel| panel.install.is_some()),
|
||||
"进度必须走下载页的安装面板视图模型"
|
||||
);
|
||||
assert!(
|
||||
|
||||
@@ -27,20 +27,22 @@ pub(super) fn running_block<'a>(
|
||||
catalog: Catalog,
|
||||
running: &'a Running,
|
||||
) -> Element<'a, PageMessage> {
|
||||
column![
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::LabelMedium,
|
||||
false,
|
||||
running.stage_line.as_str(),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
// 同一块面板:进度条、计数行、取消按钮都归它,这里不另画一条。
|
||||
download::install_panel(ui, catalog, &running.panel),
|
||||
]
|
||||
let mut block = column![frame::text_role(
|
||||
ui,
|
||||
TypeRole::LabelMedium,
|
||||
false,
|
||||
running.stage_line.as_str(),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
)]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
.width(Length::Fill);
|
||||
|
||||
// 同一块面板:进度条、计数行、取消按钮都归它,这里不另画一条。
|
||||
// `None` 只会在「阶段已收尾」时出现(导入的五个阶段都不是终态),那时只剩步骤行。
|
||||
if let Some(panel) = &running.panel {
|
||||
block = block.push(download::install_panel(ui, catalog, panel));
|
||||
}
|
||||
block.into()
|
||||
}
|
||||
|
||||
/// 探测失败:把「缺哪个标记」摊开。
|
||||
|
||||
Reference in new issue
Block a user