feat(mods): 本地 Mod 列表与启停(M224 MODTOGGLE)

三层一次做完:core 读写目录 → presenter DTO/host → 实例详情页 MODS 区。
r1 复审后 rebase 到 main 71144df(M223 已合入),并修掉四条文档疵。

core(crates/core/src/addon/mods/)
- suffix:`.disabled` / `.old` 的**唯一判据**(四态枚举 + 展示名 + 双向改名目标名),
  收敛原先两份同构实现——`export/scan.rs::is_disabled_path` 与 `import/plan/paths.rs::suffix_variant`
  改为委派/再导出,签名与行为不变。
- list:单根语义(入口是 derive_game_dir 派生出的运行目录)列 `<run>/mods/`,不按扩展名过滤,
  符号链接/目录/怪名字逐条登记原因;`mods/` 本身是符号链接时拒(不跟随)。
- toggle:启停 = 在 `mods/` 里改名。五条守卫(裸文件名 / 目录可用 / 普通文件 / 不覆盖 / 不做
  copy+delete 兜底)+ 幂等;`.disabled` 只插在 `.old` 之前。

presenter
- dto/mods:ModsCommand / ModListView / ModFailureView(不拼文案,只给结构 + OS 原文)。
- host/mods:后台线程列目录与启停,运行目录走与启动同一份 D20 派生、不写任何设置;
  游戏在跑时 host 侧直接挡。
- state/mods:快照整体替换、迟到结果丢弃、在飞标记;`AppState::mods`。

app-iced(实例详情页)
- MODS 区(`pages/instances/mods.rs`):名字 + 大小 + `.old` 徽标 + Switch;运行中/在飞只读。
- 进入详情页即后台列一次(不走 3 s 观察窗,避免打红 M192 的「空闲不烧帧」守卫)。
- MODS 卡排在启动参数**之前**(详情列是滚动区,排在后面 1180×1000 下永远看不见)。
- i18n:19 条中英文案(`catalog/instances.rs` + 金表)。

rebase 冲突解法(三个文件,全是"两侧同段新增",都保留):
- `i18n/catalog/instances.rs` + `i18n/catalog/golden.rs`:M223 的 Adopt* 段在前、M224 的
  InstancesMods* 段在后(= `Key::ALL` 顺序 = 金表顺序)。
- `presenter/src/state.rs`:`adopt` 与 `mods` 字段/初始化/apply_core 分支都留;`tiles()` 归
  `state/tiles.rs`(本分支搬的),`apply_agent`/`agent_rail_visible` 归 `state/agent.rs`
  (M223 搬的)——两处搬移不重叠,state.rs 里都不再出现。

r1 四条文档疵:
1. `pages/instances/mods.rs` 模块文档:挂载位置写反("启动参数卡之后"→实际之前),并补上为什么;
2. `state/mods.rs`:引用不存在的 `CoreEvent::ModsChanged/ModsFailed` → 改成 `CoreEvent::Mods`
   + 文字说明 Ok 落快照 / Err 落具名失败;
3. `app/state/mods_shot.rs`:文档说"目录串取真实派生结果"但代码留空 → 改成"故意留空";
4. `app/state/refresh.rs::note_mods_failure`:文档说"连点两次看见两次红字"与去重机制不符 →
   改成按 `(实例, 文件, 大类)` 三元组去重(防刷屏),换文件/换实例/换大类仍会再弹。

顺带(r1 已确认的 main 红):`pages/download/options/view.rs:88-89` 两条
`clippy::doc_lazy_continuation` 只缩进修掉,文字未动。
This commit is contained in:
tower-worker committed 2026-11-10 21:09:37 +08:00
1 parent 71144dfca8
commit a0f61d8bb5
40 files changed
+2762 -91

No files matched your search

+64
View File
@@ -4,6 +4,9 @@
//! 由 [`App::view_dirty`](super::App) 门控(M90 r2/P2-1 起):只有真的改了状态的事情才置位,
//! 30 Hz 空转不重建(重建要把 N 个实例的名字/版本串克隆一遍)。
use unlucky_presenter::dto::{AppCommand, ModsCommand};
use super::mods_shot;
use unlucky_presenter::toast::ToastKind;
use crate::app::view;
@@ -57,6 +60,13 @@ impl App {
}
}
// M224:详情页 ⇄ MODS 区的绑定与取数(换实例就重新列一次 mods/)。
// **必须在建模型之前**:它会把快照写进 presenter 状态,而下一行 `model()` 就读那份状态
// (放在 `model()` 之后等于这一帧画的是旧快照——M224 出图实测:注入的夹具要等下一帧)。
self.sync_mods();
// M224:启停失败的提示语(presenter 只发结构,文案在这里按 i18n 现求)。
self.note_mods_failure();
// 分类是**页面自己持有的过滤条件**(真 host 丢弃 `ListVersions` 的分类参数):
// 重建视图模型时带回去,否则每次事件都会把 chip 打回「全部」(复审 P2-1)。
let category = self.view.download.category;
@@ -110,4 +120,58 @@ impl App {
None => self.launch_form.bound = None,
}
}
/// 详情页 ⇄ MODS 区的绑定与取数。
///
/// 判据只有一条:**绑定变了**(进详情页 / 换实例 / 退出详情页)。
/// - 绑定函数自己会清掉旧快照(A 的列表不许画在 B 的详情页上,见 presenter 的 `ModsState::bind`);
/// - 绑定之后手上没有快照 ⇒ 派一条 `AppCommand::Mods(List)` 去后台列目录。
///
/// 为什么放在 `refresh_view` 里:它是**唯一**的「路由变了」漏斗(点行、返回、首屏自动选中都走它)。
/// 派发会再进一次 `refresh_view`(`dispatch` → `pump` → 重建),但那时 `bound` 已经相等,
/// 这一支直接返回,不会重复发请求、也不会无限递归。
fn sync_mods(&mut self) {
let wanted = match self.state.route() {
Route::Detail(id) => Some(id.clone()),
_ => None,
};
if self.state.mods.bound == wanted {
return;
}
self.state.bind_mods(wanted.as_deref());
let Some(id) = wanted else {
return;
};
// 出图取证(`ULL_MODS_FIXTURE=1`):后台往返赶不上第 3 帧出图,注入一份固定快照。
// 产品路径读不到这个变量 ⇒ 下面这行什么都不做(见 `mods_shot` 的模块文档)。
if mods_shot::enabled() {
self.state.mods.changed(mods_shot::view(&id));
return;
}
// **不走 `App::dispatch`**:那条路会为「等第一条事件」把帧订阅打开 3 秒(`watch_until`),
// 而这里只是"进详情页顺手后台列一次目录"——结果在下一拍心跳(1 Hz)落地就够了。
// 走观察窗会把 M192 的「空闲不烧帧」守卫打红(`subscription` 的三条用例立刻红:实测过)。
self.backend
.dispatch(AppCommand::Mods(Box::new(ModsCommand::List(id))));
}
/// 启停失败的提示语:每条**新**失败弹一次 Toast。
///
/// 判据是 `(实例, 文件, 大类)` 三元组:同一个文件连点两次、每次都失败在同一个大类上时,
/// **只弹一次**——第二次是同一件事的重复,再弹就是刷屏(用户已经看见了那句红字)。
/// 换了文件、换了实例、或失败大类变了(例如"不覆盖冲突"变成"被占用"),三元组就变了,
/// 于是照样会再弹一次:提示语跟的是"新事实",不是"点击次数"。
fn note_mods_failure(&mut self) {
let Some(failure) = self.state.mods.failed.as_ref() else {
self.mods_failed_seen = None;
return;
};
let key = (failure.instance.clone(), failure.file.clone(), failure.kind);
if self.mods_failed_seen.as_ref() == Some(&key) {
return;
}
let text = pages::mods_failure_line(failure, self.lang().catalog());
self.state.toasts.push(ToastKind::Error, text);
self.mods_failed_seen = Some(key);
}
}