Files
Unlucky-Launcher/crates/presenter/src/state/mods.rs
T
tower-worker a0f61d8bb5 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` 只缩进修掉,文字未动。
2026-11-10 21:09:37 +08:00

189 lines
6.8 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 本地 Mod 的快照落地与「请求在飞」标记(M224 · MODTOGGLE)。
//!
//! 从 `state.rs` 拆出(那个文件正好贴着 400 行硬线),形状与 [`super::accounts`] / [`super::live`]
//! 一致:**另一个 `impl AppState` 块**,路径与可见性都不变,`apply_core` 只有一行投递。
//!
//! # 为什么快照住在这里而不是页面
//!
//! 「列表画的永远是磁盘上的真相」是本模块唯一要保证的事,所以:
//!
//! - 快照走 [`crate::dto::CoreEvent::Mods`] 的 `Ok` 一支,**整体替换**(不做增量合并)——host 每次
//! 都是重新扫一遍目录才发的,合并只会两处真相;
//! - 失败走同一个事件的 `Err` 一支,记下一条**具名原因**,页面按 i18n key 现求文案
//! (presenter 不拼句子,M161 纪律);
//! - [`ModsState::pending`] 是**页面交互态**(哪个文件的请求在飞),只用来把那个开关先置灰
//! ——**不做乐观翻面**:`view` 里的 `enabled` 只跟磁盘快照走。
//!
//! # 迟到结果
//!
//! 启停是「后台线程 + 快照回来」:用户很可能点完就切到别的实例。所以 `view.instance` 与
//! [`ModsState::bound`] 不一致的结果**直接丢掉**(与内容库按 request id 判迟到同一条纪律)——
//! 否则 A 的列表会被 B 的启停结果顶掉。
use std::collections::BTreeSet;
use crate::dto::{ModFailureView, ModListView};
use super::AppState;
/// 本地 Mod 的视图态。
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ModsState {
/// 当前详情页的实例 id(`None` = 不在详情页)。
pub bound: Option<String>,
/// 最近一份快照(`None` = 还没请求过)。
pub view: Option<ModListView>,
/// 最近一条失败(具名原因;成功一次就清掉)。
pub failed: Option<ModFailureView>,
/// 正在飞的启停请求(磁盘文件名,含后缀)。
pub pending: BTreeSet<String>,
}
impl ModsState {
/// 切到另一个实例(换实例必须清空:A 的列表不能画在 B 的详情页上)。
pub(super) fn bind(&mut self, instance: Option<&str>) {
if self.bound.as_deref() == instance {
return;
}
self.bound = instance.map(str::to_string);
self.view = None;
self.failed = None;
self.pending.clear();
}
/// 一条结果落地(`Ok` = 快照整体替换,`Err` = 记一条具名失败;迟到/串台的丢掉)。
pub fn apply(&mut self, result: Result<ModListView, ModFailureView>) {
match result {
Ok(view) => self.changed(view),
Err(failure) => self.failed(failure),
}
}
/// 列表快照落地(整体替换;迟到/串台的丢掉)。
pub fn changed(&mut self, view: ModListView) {
if self.bound.as_deref() != Some(view.instance.as_str()) {
return;
}
// 这一批结果回来了 ⇒ 那个文件的"在飞"标记可以撤(不管成没成,另一条事件会说)。
self.pending.clear();
self.failed = None;
self.view = Some(view);
}
/// 失败落地(只记**当前绑定实例**的;列表那一份保留——用户还能看见旧列表,只是多一条红字)。
pub fn failed(&mut self, failure: ModFailureView) {
if self.bound.as_deref() != Some(failure.instance.as_str()) {
return;
}
self.pending.clear();
self.failed = Some(failure);
}
/// 标一个文件"请求在飞"(UI 把那个开关置灰,不做乐观翻面)。
pub fn mark_pending(&mut self, file: &str) {
self.pending.insert(file.to_string());
}
/// 某个文件的请求是否在飞。
#[must_use]
pub fn is_pending(&self, file: &str) -> bool {
self.pending.contains(file)
}
/// 当前绑定实例的列表是否已经有一份快照。
#[must_use]
pub fn has_view(&self) -> bool {
self.view.is_some()
}
}
impl AppState {
/// 进/出详情页时对齐绑定(`refresh_view` 调;与 `LaunchForm::sync` 同一处生命周期)。
pub fn bind_mods(&mut self, instance: Option<&str>) {
self.mods.bind(instance);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dto::{ModEntryView, ModFailureKind};
fn view(instance: &str) -> ModListView {
ModListView {
instance: instance.to_string(),
dir: "C:/mc/mods".to_string(),
exists: true,
entries: vec![ModEntryView {
file: "x.jar".to_string(),
name: "x.jar".to_string(),
enabled: true,
bytes: 1,
old: false,
}],
skipped: Vec::new(),
running: false,
known: true,
}
}
fn failure(instance: &str) -> ModFailureView {
ModFailureView {
instance: instance.to_string(),
file: "x.jar".to_string(),
kind: ModFailureKind::Collision,
detail: "exists".to_string(),
}
}
/// 换实例必须清空(A 的列表不许画在 B 的详情页上)。
#[test]
fn binding_another_instance_drops_the_old_view() {
let mut mods = ModsState::default();
mods.bind(Some("a"));
mods.changed(view("a"));
assert!(mods.has_view());
mods.bind(Some("b"));
assert!(!mods.has_view(), "换实例后还留着 A 的列表");
assert_eq!(mods.bound.as_deref(), Some("b"));
// 同一个实例再 bind 一次不该清(`refresh_view` 每帧都调)。
mods.changed(view("b"));
mods.bind(Some("b"));
assert!(mods.has_view());
}
/// 迟到/串台的结果被丢掉;成功一次清掉旧失败。
#[test]
fn late_results_are_dropped() {
let mut mods = ModsState::default();
mods.bind(Some("a"));
mods.changed(view("b"));
assert!(!mods.has_view(), "B 的列表画到了 A 上");
mods.failed(failure("a"));
assert!(mods.failed.is_some());
mods.changed(view("a"));
assert!(mods.failed.is_none(), "成功后旧失败没清掉");
// 不在详情页(`bound = None`)时任何结果都不落地。
let mut idle = ModsState::default();
idle.changed(view("a"));
assert!(!idle.has_view());
}
/// 在飞标记:置位后能查,快照回来后自然清空。
#[test]
fn pending_tracks_in_flight_toggles() {
let mut mods = ModsState::default();
mods.bind(Some("a"));
mods.changed(view("a"));
mods.mark_pending("x.jar");
assert!(mods.is_pending("x.jar"));
assert!(!mods.is_pending("y.jar"));
mods.changed(view("a"));
assert!(!mods.is_pending("x.jar"), "快照回来后还挂着在飞标记");
}
}