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:
1 parent
71144dfca8
commit
a0f61d8bb5
40 files changed
+2762
-91
No files matched your search
@@ -0,0 +1,188 @@
|
||||
//! 本地 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"), "快照回来后还挂着在飞标记");
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,14 @@
|
||||
//!
|
||||
//! **纯搬移**:类型、函数、文档一字未改,只是换了文件;`state.rs` 用 `pub use` 再导出,
|
||||
//! 于是 `crate::state::{TileCell, grid_positions}` 这两个老路径继续可用。
|
||||
//!
|
||||
//! M224 又把 [`AppState::tiles`] 从 `state.rs` 搬到这里(同一个 400 行原因):筛选 + 排版是
|
||||
//! 磁贴域自己的事,方法留在 `impl AppState` 的另一个块里,调用点 `state.tiles()` 不变。
|
||||
|
||||
use crate::dto::InstanceSummary;
|
||||
|
||||
use super::AppState;
|
||||
|
||||
/// 磁贴网格单元:位置由 [`grid_positions`] 算好,UI 侧只消费 row/col。
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct TileCell<'a> {
|
||||
@@ -25,3 +30,21 @@ pub fn grid_positions(count: usize, columns: usize) -> Vec<(usize, usize)> {
|
||||
.map(|index| (index / columns, index % columns))
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
/// 按搜索词过滤后的磁贴网格(顺序稳定 = 实例列表顺序)。
|
||||
pub fn tiles(&self) -> Vec<TileCell<'_>> {
|
||||
let query = self.instance_query.trim().to_lowercase();
|
||||
let filtered: Vec<&InstanceSummary> = self
|
||||
.instances
|
||||
.iter()
|
||||
.filter(|instance| query.is_empty() || instance.name.to_lowercase().contains(&query))
|
||||
.collect();
|
||||
let positions = grid_positions(filtered.len(), self.prefs.tile_size.columns());
|
||||
filtered
|
||||
.into_iter()
|
||||
.zip(positions)
|
||||
.map(|(instance, (row, col))| TileCell { instance, row, col })
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user