feat(saves): 实例存档 / 截图的只读列表与打开文件夹(M235 STOREMVP)
用户诉求是 PCL 差距四项里的「存档截图 / 导入」,M230 审计确认三层全缺。塔裁决 U3
把这一版钉成「只读列表 + 打开文件夹」,不做缩略图墙(缩略图要引图片解码依赖,而
AGENTS §2 只允许 palette-extract 引 image 类依赖;截图/导入产物形态也没定型)。
core
- `game::saves`(新):只读列 `<run_dir>/saves`(世界目录)与 `<run_dir>/screenshots`
(图片文件)。只调 symlink_metadata / read_dir / metadata,不建目录、不写文件、不读
文件内容(`icon.png` 只判存在)。目录不存在 ⇒ 空列表不是错误;存在但不可用(符号
链接 / 不是目录 / 读不了)⇒ 具名错误。条目级异常逐条登记原因,不静默丢。
- `platform::opener`(新):`open_dir` 是**目录**打开器的唯一收敛点(explorer / open /
xdg-open),内部复用 `child::quiet`,cfg 分支只此一处。塔明确要求按 AGENTS §6 放
platform 而不是业务模块,并要一条可执行判据防退化
(`the_launcher_literals_live_here_only` 扫 `crates/core/src`:除本文件外不许出现
`"explorer"` / `"xdg-open"` 字面量)。
presenter
- `dto::saves`:SavesCommand(List / OpenFolder)、SavesListView、SavesFailureView
(结构 + OS 原文,不拼中文)、`SavesListView` 的计数助手。
- `host::saves`:走 `job::spawn`,UI 只收快照;运行目录仍是
`isolation::derive_game_dir` 的同一个答案(与启动 / mod 列表同源,**不写盘**)。
「打开文件夹」打开哪个目录由 host 定:`saves/` → `screenshots/` → 运行目录。
失败分三类(InstanceUnavailable / DirUnusable / OpenFailed),可重试。
- `state::saves`:快照整体替换、迟到结果丢弃、失败保留绑定(重试才有意义)。
- 接线:CoreEvent::Saves / AppCommand::Saves / HostTask::Saves。
app-iced
- `pages::instances::saves`:详情页 SAVES 卡片(MODS 之后、启动参数之前;**不套第二层
滚动条**,整列那一条是唯一入口)。世界行带 UTC 时间与「有图标」,截图行带大小与时间;
第一行固定画「这一版只读」(U3 边界让用户看得见)。读盘失败画一条红字 + 「重试」;
打开文件夹失败**不画**重试(那个动作的重试就是再点一次按钮)。
- 接线:Message::{ReloadSaves, OpenSavesFolder}、`sync_saves`(与 `sync_mods` 同一判据)、
Model::saves、`saves_shot` 出图夹具(ULL_SAVES_FIXTURE=1)。
i18n
- `InstancesSaves*` 20 条中英成对,catalog 与金表在**同一条锚点**后追加、逐字节一致。
证据
- core 只读判据:`game::saves::tests` 在真盘建世界/截图,调用前后各拍一次
(相对路径, 是否目录, 字节数, mtime)指纹并逐条比对;另有用例钉「目录缺失不建目录」。
- presenter:`host::saves::tests` 真盘端到端(custom 档位派生运行目录 → 快照)。
- GUI:`--software-renderer` 1180×760 浅/深各一张 + 1180×1280 两张「整块卡片可见」
(1180×760 下 SAVES 卡片位于折叠线附近,只能看到卡头;第二对用 ULL_WINDOW_SIZE
把窗口拉高,口径与 M98 的矩阵一致),落 `DevDocs/artifacts/M235/`。
没做的(U3 边界内,见 handoff):缩略图墙、导入、删除/重命名、`level.dat` 的
LevelName 解析(世界名暂时就是目录名)。
This commit is contained in:
1 parent
3789cb7e61
commit
f217d83680
36 files changed
+2702
-17
No files matched your search
@@ -0,0 +1,155 @@
|
||||
//! 实例存档 / 截图快照的落地(M235 · STOREMVP)。
|
||||
//!
|
||||
//! 从 `state.rs` 拆出(那个文件贴着 400 行硬线),形状与 [`super::mods`] 一致:
|
||||
//! **另一个 `impl AppState` 块**,路径与可见性都不变,`apply_core` 只有一行投递。
|
||||
//!
|
||||
//! # 为什么快照住在这里而不是页面
|
||||
//!
|
||||
//! 「列表画的永远是磁盘上的真相」是本模块唯一要保证的事,所以:
|
||||
//!
|
||||
//! - 快照走 [`crate::dto::CoreEvent::Saves`] 的 `Ok` 一支,**整体替换**(不做增量合并)——
|
||||
//! host 每次都是重新扫一遍目录才发的,合并只会两处真相;
|
||||
//! - 失败走同一个事件的 `Err` 一支,记下一条**具名原因**,页面按 i18n key 现求文案;
|
||||
//! - **可重试**:失败时不清 `bound`,页面据此画"重试"按钮(再发一条
|
||||
//! [`crate::dto::SavesCommand::List`] 就行,没有别的状态要复位)。
|
||||
//!
|
||||
//! # 迟到结果
|
||||
//!
|
||||
//! 用户很可能点开 A 的详情页、还没等后台回来就切到 B。所以 `view.instance` 与
|
||||
//! [`SavesState::bound`] 不一致的结果**直接丢掉**(与 [`super::mods`] 同一条纪律)。
|
||||
|
||||
use crate::dto::{SavesFailureView, SavesListView};
|
||||
|
||||
use super::AppState;
|
||||
|
||||
/// 存档区的视图态。
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct SavesState {
|
||||
/// 当前详情页的实例 id(`None` = 不在详情页)。
|
||||
pub bound: Option<String>,
|
||||
/// 最近一份快照(`None` = 还没请求过)。
|
||||
pub view: Option<SavesListView>,
|
||||
/// 最近一条失败(具名原因;成功一次就清掉)。
|
||||
pub failed: Option<SavesFailureView>,
|
||||
}
|
||||
|
||||
impl SavesState {
|
||||
/// 切到另一个实例(换实例必须清空: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;
|
||||
}
|
||||
|
||||
/// 一条结果落地(`Ok` = 快照整体替换,`Err` = 记一条具名失败;迟到/串台的丢掉)。
|
||||
pub fn apply(&mut self, result: Result<SavesListView, SavesFailureView>) {
|
||||
match result {
|
||||
Ok(view) => self.changed(view),
|
||||
Err(failure) => self.failed(failure),
|
||||
}
|
||||
}
|
||||
|
||||
/// 快照落地(整体替换;迟到/串台的丢掉)。
|
||||
pub fn changed(&mut self, view: SavesListView) {
|
||||
if self.bound.as_deref() != Some(view.instance.as_str()) {
|
||||
return;
|
||||
}
|
||||
self.failed = None;
|
||||
self.view = Some(view);
|
||||
}
|
||||
|
||||
/// 失败落地(只记**当前绑定实例**的;old 列表保留——用户还能看见旧列表,只是多一条红字)。
|
||||
pub fn failed(&mut self, failure: SavesFailureView) {
|
||||
if self.bound.as_deref() != Some(failure.instance.as_str()) {
|
||||
return;
|
||||
}
|
||||
self.failed = Some(failure);
|
||||
}
|
||||
|
||||
/// 当前绑定实例的列表是否已经有一份快照。
|
||||
#[must_use]
|
||||
pub fn has_view(&self) -> bool {
|
||||
self.view.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
/// 进/出详情页时对齐绑定(`refresh_view` 调;与 `bind_mods` 同一处生命周期)。
|
||||
pub fn bind_saves(&mut self, instance: Option<&str>) {
|
||||
self.saves.bind(instance);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::dto::{SavesFailureKind, WorldView};
|
||||
|
||||
fn view(instance: &str) -> SavesListView {
|
||||
SavesListView {
|
||||
instance: instance.to_string(),
|
||||
dir: "C:/mc/saves".to_string(),
|
||||
exists: true,
|
||||
worlds: vec![WorldView {
|
||||
dir: "World".to_string(),
|
||||
icon: false,
|
||||
modified_ms: None,
|
||||
}],
|
||||
screenshots_dir: "C:/mc/screenshots".to_string(),
|
||||
screenshots_exists: false,
|
||||
screenshots: Vec::new(),
|
||||
skipped: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn failure(instance: &str) -> SavesFailureView {
|
||||
SavesFailureView {
|
||||
instance: instance.to_string(),
|
||||
kind: SavesFailureKind::DirUnusable,
|
||||
detail: "Access is denied.".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 换实例必须清空(A 的存档不许画在 B 的详情页上)。
|
||||
#[test]
|
||||
fn binding_another_instance_drops_the_old_view() {
|
||||
let mut saves = SavesState::default();
|
||||
saves.bind(Some("a"));
|
||||
saves.changed(view("a"));
|
||||
assert!(saves.has_view());
|
||||
|
||||
saves.bind(Some("b"));
|
||||
assert!(!saves.has_view(), "换实例后还留着 A 的存档");
|
||||
assert_eq!(saves.bound.as_deref(), Some("b"));
|
||||
|
||||
// 同一个实例再 bind 一次不该清(`refresh_view` 每帧都调)。
|
||||
saves.changed(view("b"));
|
||||
saves.bind(Some("b"));
|
||||
assert!(saves.has_view());
|
||||
}
|
||||
|
||||
/// 迟到/串台的结果被丢掉;成功一次清掉旧失败(重试成功后红字要消失)。
|
||||
#[test]
|
||||
fn late_results_are_dropped_and_success_clears_the_failure() {
|
||||
let mut saves = SavesState::default();
|
||||
saves.bind(Some("a"));
|
||||
saves.changed(view("b"));
|
||||
assert!(!saves.has_view(), "B 的列表画到了 A 上");
|
||||
|
||||
saves.failed(failure("a"));
|
||||
assert!(saves.failed.is_some());
|
||||
assert!(saves.bound.is_some(), "失败不许把绑定清掉(要能重试)");
|
||||
saves.changed(view("a"));
|
||||
assert!(saves.failed.is_none(), "成功后旧失败没清掉");
|
||||
|
||||
// 不在详情页(`bound = None`)时任何结果都不落地。
|
||||
let mut idle = SavesState::default();
|
||||
idle.changed(view("a"));
|
||||
idle.failed(failure("a"));
|
||||
assert!(!idle.has_view());
|
||||
assert!(idle.failed.is_none());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user