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
@@ -0,0 +1,225 @@
|
||||
//! core 报告 → DTO(M113):**逐字段照搬,不重算任何结论**。
|
||||
//!
|
||||
//! 这一层的唯一职责是翻译:三态、五阶段、四种处置、三种冲突成因、十一种导出原因,
|
||||
//! 全部一一对应。任何一个「顺手换算一下」都会让 UI 说的和管线干的不是一回事,
|
||||
//! 所以这里没有一处 re-derive(唯一例外是把 `&'static str` 的稳定码换成人读串)。
|
||||
//!
|
||||
//! 翻译用的是 core 的稳定 slug(`as_str()`)+ DTO 的 `from_slug()`,
|
||||
//! 于是「翻译表」本身就是一份可审计的清单:core 加了新变体,`from_slug` 返回 `None`
|
||||
//! 时这里会落到一个有名字的兜底,而不是悄悄画成别的意思。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use unlucky_core::addon::export::{ExportReport, FileClass, FileReason};
|
||||
use unlucky_core::addon::import::{
|
||||
ConflictChoice, ConflictFile, ConflictReason, FileAction, ImportError, ImportProgress,
|
||||
ImportReport, LoaderTarget, MissingReason,
|
||||
};
|
||||
|
||||
use crate::dto::{
|
||||
ConflictChoiceView, ConflictFileView, ConflictReasonView, ExportClassView, ExportDoneView,
|
||||
ExportReasonView, FileActionView, ImportErrorView, ImportProgressView, ImportReportView,
|
||||
ImportStageView, ImportStatusView, ImportedFileView, MissingFileView, MissingReasonView,
|
||||
PackFormatView,
|
||||
};
|
||||
|
||||
/// 一次导入结果 → DTO。
|
||||
pub(super) fn report_view(report: &ImportReport, target: &Path) -> ImportReportView {
|
||||
ImportReportView {
|
||||
status: ImportStatusView::from_slug(report.status.as_str())
|
||||
.unwrap_or(ImportStatusView::Failed),
|
||||
format: report.format.as_deref().and_then(PackFormatView::from_slug),
|
||||
pack_name: report.pack_name.clone(),
|
||||
pack_version: report.pack_version.clone(),
|
||||
game_version: report.game_version.clone(),
|
||||
loader: report.loader.as_ref().map(loader_label),
|
||||
version_id: report.version_id.clone(),
|
||||
target: target.to_path_buf(),
|
||||
files: report
|
||||
.files
|
||||
.iter()
|
||||
.map(|file| ImportedFileView {
|
||||
path: file.path.clone(),
|
||||
action: action_view(file.action),
|
||||
bytes: file.bytes,
|
||||
})
|
||||
.collect(),
|
||||
conflicts: report.conflicts.iter().map(conflict_view).collect(),
|
||||
missing: report
|
||||
.missing
|
||||
.iter()
|
||||
.map(|file| MissingFileView {
|
||||
path: file.path.clone(),
|
||||
display: file.display.clone(),
|
||||
reason: missing_view(file.reason),
|
||||
detail: file.detail.clone(),
|
||||
})
|
||||
.collect(),
|
||||
rejected: report
|
||||
.rejected_components
|
||||
.iter()
|
||||
.map(|component| match &component.version {
|
||||
Some(version) => format!("{} {version}", component.uid),
|
||||
None => component.uid.clone(),
|
||||
})
|
||||
.collect(),
|
||||
warnings: report
|
||||
.warnings
|
||||
.iter()
|
||||
.map(|issue| issue.message.clone())
|
||||
.collect(),
|
||||
error: report.error.as_ref().map(error_view),
|
||||
registered: report
|
||||
.registered
|
||||
.as_ref()
|
||||
.map(|registered| registered.name.clone()),
|
||||
bytes_written: report.bytes_written,
|
||||
rolled_back: report.rolled_back,
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次导出结果 → DTO。
|
||||
pub(super) fn done_view(report: &ExportReport) -> ExportDoneView {
|
||||
ExportDoneView {
|
||||
path: report.path.clone(),
|
||||
bytes: report.bytes,
|
||||
remote: u32::try_from(report.remote_count()).unwrap_or(u32::MAX),
|
||||
inline: u32::try_from(report.inline_count()).unwrap_or(u32::MAX),
|
||||
skipped: u32::try_from(report.skipped_count()).unwrap_or(u32::MAX),
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条导入进度 → DTO。
|
||||
pub(super) fn progress_view(progress: &ImportProgress, pack: &str) -> ImportProgressView {
|
||||
ImportProgressView {
|
||||
stage: ImportStageView::from_slug(progress.stage.as_str())
|
||||
.unwrap_or(ImportStageView::Probe),
|
||||
done: u32::try_from(progress.done).unwrap_or(u32::MAX),
|
||||
total: u32::try_from(progress.total).unwrap_or(u32::MAX),
|
||||
current: progress.current.clone(),
|
||||
pack: pack.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 致命失败 → DTO(稳定码原样带走,日志与 `--json` 用的是同一串)。
|
||||
fn error_view(error: &ImportError) -> ImportErrorView {
|
||||
ImportErrorView {
|
||||
code: error.kind().as_str().to_string(),
|
||||
label: error.kind().label().to_string(),
|
||||
message: error.message().to_string(),
|
||||
hint: error.hint().map(str::to_string),
|
||||
}
|
||||
}
|
||||
|
||||
fn conflict_view(file: &ConflictFile) -> ConflictFileView {
|
||||
ConflictFileView {
|
||||
path: file.path.clone(),
|
||||
reason: match file.reason {
|
||||
ConflictReason::UserModified => ConflictReasonView::UserModified,
|
||||
ConflictReason::UserDeleted => ConflictReasonView::UserDeleted,
|
||||
ConflictReason::PreExisting => ConflictReasonView::PreExisting,
|
||||
},
|
||||
choice: match file.choice {
|
||||
ConflictChoice::KeepUser => ConflictChoiceView::KeepUser,
|
||||
ConflictChoice::Overwrite => ConflictChoiceView::Overwrite,
|
||||
},
|
||||
pack_sha1: file.pack_sha1.clone(),
|
||||
base_sha1: file.base_sha1.clone(),
|
||||
disk_sha1: file.disk_sha1.clone(),
|
||||
pack_size: file.pack_size,
|
||||
}
|
||||
}
|
||||
|
||||
const fn action_view(action: FileAction) -> FileActionView {
|
||||
match action {
|
||||
FileAction::Installed => FileActionView::Installed,
|
||||
FileAction::Replaced => FileActionView::Replaced,
|
||||
FileAction::SkippedIdentical => FileActionView::SkippedIdentical,
|
||||
FileAction::KeptUser => FileActionView::KeptUser,
|
||||
}
|
||||
}
|
||||
|
||||
const fn missing_view(reason: MissingReason) -> MissingReasonView {
|
||||
match reason {
|
||||
MissingReason::Offline => MissingReasonView::Offline,
|
||||
MissingReason::NoSource => MissingReasonView::NoSource,
|
||||
MissingReason::FetchFailed => MissingReasonView::FetchFailed,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn reason_view(reason: FileReason) -> ExportReasonView {
|
||||
ExportReasonView::from_slug(reason.as_str()).unwrap_or(ExportReasonView::Unreadable)
|
||||
}
|
||||
|
||||
pub(super) fn class_view(class: FileClass) -> ExportClassView {
|
||||
match class {
|
||||
FileClass::Remote => ExportClassView::Remote,
|
||||
FileClass::Inline => ExportClassView::Inline,
|
||||
FileClass::Skipped => ExportClassView::Skipped,
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载器 → 人读串(`fabric-loader 0.15.11` / `vanilla 1.20.1`)。
|
||||
fn loader_label(target: &LoaderTarget) -> String {
|
||||
format!("{} {}", target.kind.as_str(), target.loader_version)
|
||||
}
|
||||
|
||||
/// 「命令跑起来了,但目标实例不可用」这类**host 侧**失败的报告。
|
||||
///
|
||||
/// 仍然走三态里的 `Failed`:界面只有一种失败形状,用户不用学第二套。
|
||||
pub(super) fn failed_report(
|
||||
pack: PathBuf,
|
||||
code: &str,
|
||||
label: &str,
|
||||
message: &str,
|
||||
) -> ImportReportView {
|
||||
ImportReportView {
|
||||
status: ImportStatusView::Failed,
|
||||
format: None,
|
||||
pack_name: Some(pack_stem(&pack)),
|
||||
pack_version: None,
|
||||
game_version: None,
|
||||
loader: None,
|
||||
version_id: None,
|
||||
target: pack,
|
||||
files: Vec::new(),
|
||||
conflicts: Vec::new(),
|
||||
missing: Vec::new(),
|
||||
rejected: Vec::new(),
|
||||
warnings: Vec::new(),
|
||||
error: Some(ImportErrorView {
|
||||
code: code.to_string(),
|
||||
label: label.to_string(),
|
||||
message: message.to_string(),
|
||||
hint: None,
|
||||
}),
|
||||
registered: None,
|
||||
bytes_written: 0,
|
||||
rolled_back: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// 包文件名(`Better MC.mrpack` → `Better MC`)。
|
||||
pub(super) fn pack_stem(pack: &Path) -> String {
|
||||
pack.file_stem()
|
||||
.map(|stem| stem.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "imported-pack".to_string())
|
||||
}
|
||||
|
||||
/// 目录/文件名用的 slug(只留 ASCII 字母数字与 `-` / `_`,其余折成 `-`)。
|
||||
pub(super) fn slug(text: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for ch in text.chars() {
|
||||
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
|
||||
out.push(ch);
|
||||
} else if !out.ends_with('-') {
|
||||
out.push('-');
|
||||
}
|
||||
}
|
||||
let trimmed = out.trim_matches('-').to_string();
|
||||
if trimmed.is_empty() {
|
||||
"pack".to_string()
|
||||
} else {
|
||||
trimmed
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user