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:
AskaEth committed 2026-11-11 06:00:27 +08:00
1 parent 3789cb7e61
commit f217d83680
36 files changed
+2702 -17

No files matched your search

+2
View File
@@ -71,6 +71,8 @@ mod boot_probe;
mod construct;
mod mods_shot;
mod refresh;
// M235: 出图取证用的 SAVES 夹具(`ULL_SAVES_FIXTURE=1`)
mod saves_shot;
mod theme;
/// iced 侧的 presenter 接线层(M119):物理目录 `src/presenter/`,逻辑名字在 `crate::app::state` 下。
+35 -2
View File
@@ -4,9 +4,9 @@
//! 由 [`App::view_dirty`](super::App) 门控(M90 r2/P2-1 起):只有真的改了状态的事情才置位,
//! 30 Hz 空转不重建(重建要把 N 个实例的名字/版本串克隆一遍)。
use unlucky_presenter::dto::{AppCommand, ModsCommand};
use unlucky_presenter::dto::{AppCommand, ModsCommand, SavesCommand};
use super::mods_shot;
use super::{mods_shot, saves_shot};
use unlucky_presenter::toast::ToastKind;
use crate::app::view;
@@ -64,6 +64,8 @@ impl App {
// **必须在建模型之前**:它会把快照写进 presenter 状态,而下一行 `model()` 就读那份状态
// (放在 `model()` 之后等于这一帧画的是旧快照——M224 出图实测:注入的夹具要等下一帧)。
self.sync_mods();
// M235:详情页 ⇄ SAVES 区的绑定与取数(换实例就重新列一次存档)。同样是**建模型之前**。
self.sync_saves();
// M224:启停失败的提示语(presenter 只发结构,文案在这里按 i18n 现求)。
self.note_mods_failure();
@@ -155,6 +157,37 @@ impl App {
.dispatch(AppCommand::Mods(Box::new(ModsCommand::List(id))));
}
/// 详情页 ⇄ SAVES 区的绑定与取数(M235)。
///
/// 判据与 `sync_mods` **一字不差**:绑定变了(进详情页 / 换实例 / 退出详情页)才动;
/// 绑定之后手上没有快照 ⇒ 派一条 `AppCommand::Saves(List)` 去后台列目录。
///
/// 不弹 Toast:存档区的失败就地画在卡片里(带「重试」),再弹一条是同一件事说两遍。
fn sync_saves(&mut self) {
let wanted = match self.state.route() {
Route::Detail(id) => Some(id.clone()),
_ => None,
};
if self.state.saves.bound == wanted {
return;
}
self.state.bind_saves(wanted.as_deref());
let Some(id) = wanted else {
return;
};
// 出图取证(`ULL_SAVES_FIXTURE=1`):后台往返赶不上第 3 帧出图,注入一份固定快照。
// 产品路径读不到这个变量 ⇒ 下面这行什么都不做(见 `saves_shot` 的模块文档)。
if saves_shot::enabled() {
self.state.saves.changed(saves_shot::view(&id));
return;
}
// **不走 `App::dispatch`**:那条路会为「等第一条事件」把帧订阅打开 3 秒,
// 这里只是"进详情页顺手后台列一次目录",结果在下一拍心跳落地就够了(M224 同款理由:
// 走观察窗会把 M192 的「空闲不烧帧」守卫打红)。
self.backend
.dispatch(AppCommand::Saves(Box::new(SavesCommand::List(id))));
}
/// 启停失败的提示语:每条**新**失败弹一次 Toast。
///
/// 判据是 `(实例, 文件, 大类)` 三元组:同一个文件连点两次、每次都失败在同一个大类上时,
@@ -0,0 +1,81 @@
//! 出图取证用的 SAVES 夹具(`ULL_SAVES_FIXTURE=1`)。
//!
//! # 它解决什么
//!
//! D30 的 SAVES 区截图需要一屏**有内容的列表**(世界 + 截图)。产品路径的列表来自后台线程
//! (列 `saves/` → 快照),而截图通道在第 3 帧就出图(`CAPTURE_AT_FRAME`):那条后台往返来不及
//! 完成,截出来会是「正在读取 saves/…」。M157 / M222 / M223 / M224 都是同一个问题,处理方式一致:
//! **状态注入**,注入点放在「快照 → 页面模型」之前,于是转换逻辑照样被执行。
//!
//! # 边界(不要把它读成产品能力)
//!
//! - 只在环境变量存在时生效;产品路径读不到它 ⇒ 一帧都不影响;
//! - 它注入的是**快照**([`SavesListView`]),不是页面模型——`Saves::from_state` 的时间格式化、
//! 计数、「有图标」标记走的都是产品代码;
//! - 真实链路由端到端用例钉住:`core::game::saves::tests`(真盘只读列举)与
//! `presenter::host::saves::tests`(派生运行目录 → 快照)。
//!
//! ```text
//! ULL_SAVES_FIXTURE=1 ULL_INSTANCE_STATE=demo ULL_START_ROUTE=instances \
//! unlucky-launcher-iced --screenshot=saves.png --software-renderer
//! ```
/// 夹具开关是否打开(`ULL_SAVES_FIXTURE` 非空且不为 `0`)。
#[must_use]
pub fn enabled() -> bool {
std::env::var("ULL_SAVES_FIXTURE")
.map(|value| {
let value = value.trim();
!value.is_empty() && value != "0"
})
.unwrap_or(false)
}
/// 2024-05-01 10:00 UTC(写死的 UTC 毫秒,不是"现在")。
///
/// 写死有两个原因:两张主题图要能逐像素对照;而且它能证明「毫秒 → `YYYY-MM-DD HH:MM UTC`」
/// 这条换算在页面上真的走通了(`docs` 里的期望值就是这个时刻)。
const NEWEST: u64 = 1_714_557_600_000;
/// 比 [`NEWEST`] 早 32 天。
const MIDDLE: u64 = NEWEST - 32 * 24 * 3_600_000;
/// 比 [`NEWEST`] 早 42 天。
const OLDEST: u64 = NEWEST - 42 * 24 * 3_600_000;
/// 一份固定快照(实例 id 用调用方给的那个)。
///
/// 其中一个世界/一张截图的 mtime 是 `None`:那是「时间未知」这条分支的唯一出图证据。
#[must_use]
pub fn view(instance: &str) -> unlucky_presenter::dto::SavesListView {
use unlucky_presenter::dto::{SavesListView, ScreenshotView, WorldView};
let world = |dir: &str, icon: bool, modified_ms: Option<u64>| WorldView {
dir: dir.to_string(),
icon,
modified_ms,
};
let shot = |file: &str, bytes: u64, modified_ms: Option<u64>| ScreenshotView {
file: file.to_string(),
bytes,
modified_ms,
};
SavesListView {
instance: instance.to_string(),
dir: "D:/ULL/.ULL/data/instances/Better-MC/saves".to_string(),
exists: true,
worlds: vec![
world("Better World", true, Some(NEWEST)),
world("Redstone Lab", true, Some(MIDDLE)),
world("Skyblock", false, Some(OLDEST)),
world("Test Flat", false, None),
],
screenshots_dir: "D:/ULL/.ULL/data/instances/Better-MC/screenshots".to_string(),
screenshots_exists: true,
screenshots: vec![
shot("2024-05-01_10.00.00.png", 1_887_436, Some(NEWEST)),
shot("2024-04-30_21.14.05.png", 2_097_152, Some(MIDDLE)),
shot("2024-04-20_08.30.00.png", 964_608, None),
],
skipped: Vec::new(),
}
}
+17 -1
View File
@@ -11,7 +11,7 @@
//! 要等回程事件,写盘失败时旧快照回来必须留住用户敲的值(M157 r3 复审 P2-2)。
use iced::Task;
use unlucky_presenter::dto::{AppCommand, LaunchArgsPatch, ModsCommand};
use unlucky_presenter::dto::{AppCommand, LaunchArgsPatch, ModsCommand, SavesCommand};
use crate::pages::InstanceMessage;
@@ -40,6 +40,22 @@ impl App {
})));
return Task::none();
}
// M235:重新列一次存档(失败后的「重试」也走这条)。
//
// 与 `SetModEnabled` 一样放在 `bound_to` 守卫**之前**:存档区只认"当前选中实例",
// 与启动参数表单播没播种无关(那条守卫是给表单草稿用的)。
InstanceMessage::ReloadSaves => {
self.dispatch(AppCommand::Saves(Box::new(SavesCommand::List(instance))));
return Task::none();
}
// M235:打开存档文件夹——拉起哪个目录由 host 定(`saves/` → `screenshots/` → 运行目录),
// 真正的 opener 在 `core::platform::opener`(平台差异不散到 app 这一层)。
InstanceMessage::OpenSavesFolder => {
self.dispatch(AppCommand::Saves(Box::new(SavesCommand::OpenFolder {
id: instance,
})));
return Task::none();
}
// 表单还没对着这个实例(首帧还没播种):不接编辑,免得改到别的实例上。
_ if !self.launch_form.bound_to(&instance) => return Task::none(),
InstanceMessage::Jvm(text) => {
+1
View File
@@ -257,6 +257,7 @@ pub(super) fn model(
logs: logs_model(state).with_status(lang),
// M224:详情页 MODS 区的列表 / 启停(presenter 的视图态 → 展示字段)
mods: instances::mods_of(state, catalog),
saves: instances::saves_of(state, catalog),
// M159:更新快照 → 页面视图(设置页「更新」区 + 顶部提示条都读它)
update: update_of(&state.update),
// M201:设置组快照(`SettingsChanged` 的回流结果)→ 页面视图。设置页的下载组从这里按
+9 -1
View File
@@ -25,7 +25,7 @@ use unlucky_presenter::dto::{
};
use unlucky_presenter::state::AppState;
use crate::pages::{Detail, LaunchFacts, Meta, Mods};
use crate::pages::{Detail, LaunchFacts, Meta, Mods, Saves};
/// 列表行的副文案:`1.20.4 · Fabric 0.16.5`(原版只写「原版 / Vanilla」,见 [`loader_badge`])。
pub(super) fn supporting_line(instance: &InstanceSummary, catalog: Catalog) -> String {
@@ -273,3 +273,11 @@ pub(super) fn last_played(raw: Option<&str>, catalog: Catalog) -> String {
pub(super) fn mods_of(state: &AppState, catalog: Catalog) -> Mods {
crate::pages::mods_from_state(&state.mods, catalog)
}
/// 存档 / 截图的展示模型(M235):presenter 的视图态 → 页面要画的东西。
///
/// 与 [`mods_of`] 同一条接法:转换本体在 `pages::instances::saves::from_state`(文案要用
/// `Texts`),这里只把"当前语言"给过去。
pub(super) fn saves_of(state: &AppState, catalog: Catalog) -> Saves {
crate::pages::saves_from_state(&state.saves, catalog)
}
+33 -6
View File
@@ -51,6 +51,8 @@ mod detail;
mod launch_args;
// M224: 详情页的 MODS 区(本地 mod 列表 + 启停开关)
mod mods;
// M235: 详情页的 SAVES 区(存档 / 截图的只读列表 + 打开文件夹)
mod saves;
mod state;
mod text;
@@ -61,6 +63,17 @@ mod tests;
pub(crate) use data::{DEMO, NOTE, SELECTED};
pub(crate) use demo::{demo, demo_form};
pub use mods::{from_state as mods_from_state, Mods};
pub use saves::{from_state as saves_from_state, Saves};
/// 存档区失败的一句话(app 层就地画红字用;取词入口仍是 [`Texts`])。
///
/// 本版**不弹 Toast**:只读列表的失败离用户的眼睛很近(就在卡片里),再弹一条是刷屏。
pub fn saves_failure_line(
failure: &unlucky_presenter::dto::SavesFailureView,
catalog: unlucky_i18n::Catalog,
) -> String {
saves::failure_line(failure, Texts::of(catalog))
}
/// 启停失败/读目录失败的一句话(app 层弹 Toast 用;取词入口仍是 [`Texts`])。
pub fn mods_failure_line(
@@ -142,16 +155,30 @@ pub fn view<'a>(
metrics,
list_column(ui, block, forced, texts),
match &block.detail {
Some(detail) => {
detail::detail_column(ui, metrics, detail, form, &model.mods, forced, texts)
}
Some(detail) => detail::detail_column(
ui,
metrics,
detail,
form,
&model.mods,
&model.saves,
forced,
texts,
),
None => detail::detail_empty(ui, texts),
},
),
_ => match &block.detail {
Some(detail) => {
detail::narrow_detail(ui, metrics, detail, form, &model.mods, forced, texts)
}
Some(detail) => detail::narrow_detail(
ui,
metrics,
detail,
form,
&model.mods,
&model.saves,
forced,
texts,
),
None => frame::body(metrics, detail::detail_empty(ui, texts)),
},
};
@@ -35,6 +35,7 @@ pub(super) fn detail_column<'a>(
detail: &'a Detail,
form: &'a LaunchForm,
mods: &'a super::Mods,
saves: &'a super::Saves,
forced: Option<Interaction>,
texts: Texts,
) -> Element<'a, Message> {
@@ -51,6 +52,9 @@ pub(super) fn detail_column<'a>(
// 排在它后面意味着「本地 mod 列表」永远在折叠线以下(M224 出图实测:1180×1000 完全看不见)。
// 顺序因此是:身份(页头 + 元信息)→ 内容(MODS)→ 选项(启动参数)→ 动作。
body = body.push(super::mods::section(ui, mods, forced, texts));
// M235:SAVES 区紧跟 MODS 区(同为"内容"),理由与 MODS 一样:排在启动参数卡后面会落到
// 折叠线以下——详情列是一个滚动区,而启动参数卡本身就有一屏高。
body = body.push(super::saves::section(ui, saves, forced, texts));
// M157:启动参数区在正文段之后、动作行之前(详情列 424px 内,卡片式)。
body = body.push(super::launch_args::section(ui, &detail.launch, form, texts));
@@ -69,6 +73,7 @@ pub(super) fn narrow_detail<'a>(
detail: &'a Detail,
form: &'a LaunchForm,
mods: &'a super::Mods,
saves: &'a super::Saves,
forced: Option<Interaction>,
texts: Texts,
) -> Element<'a, Message> {
@@ -93,6 +98,7 @@ pub(super) fn narrow_detail<'a>(
body = push_text(ui, metrics, body, detail);
// 顺序与宽窗那一支一致:内容(MODS)在选项(启动参数)之前,理由见 `detail_column`。
body = body.push(super::mods::section(ui, mods, forced, texts));
body = body.push(super::saves::section(ui, saves, forced, texts));
body = body.push(super::launch_args::section(ui, &detail.launch, form, texts));
body = body.push(frame::text_role(
@@ -0,0 +1,380 @@
//! 实例详情页的 **SAVES 区**(M235 · STOREMVP):存档 / 截图的**只读**列表 + 打开文件夹。
//!
//! # 位置与形状
//!
//! 挂在详情列里、MODS 区之后、启动参数卡**之前**(`pages/instances/detail.rs` 的 `detail_column`)。
//! 与 MODS 区同一套卡片口径:[`frame::card`] + 一个 `LabelMedium` 小标 + 内容。
//!
//! **为什么在启动参数之前**:与 MODS 区同一个理由(M224 出图实测)——详情列整列是一个滚动区,
//! 而启动参数卡本身就有一屏高;排在它后面等于这整块落在折叠线以下、根本看不见。顺序因此是:
//! 身份(页头 + 元信息)→ 内容(MODS → SAVES)→ 选项(启动参数)→ 动作。
//!
//! # 为什么不自己再套一层滚动区
//!
//! 详情列**整列**已经是滚动区(M157 r2)。M224 踩过一次「滚动条里的滚动条」:1180×1000 下两个条
//! 都认得出来,用户不知道该拖哪一个。这里同样只排内容,长度交给外层那一条。
//!
//! # 只读(U3 的边界,页面固定画出来)
//!
//! 塔裁决 U3 把这一版钉成「只读列表 + 打开文件夹」:没有删除、没有重命名、没有导入、**没有缩略图**
//! (缩略图要引图片解码依赖,`AGENTS.md` §2 不允许)。所以卡片里第一行永远画
//! [`Texts::saves_read_only`]——用户点开就看见"这一版只读",不会以为漏了删除按钮。
//!
//! # 失败与重试
//!
//! 读盘失败画一条红字 + 一个「重试」(再发一条 `SavesCommand::List`)。**打开文件夹失败不画重试**:
//! 那个动作的重试就是再点一次按钮,给一个会去重新列目录的「重试」是错的affordance
//! (两种失败共用 `detail`,只有大类不同,见 [`Saves::retryable`])。
use iced::widget::{column, container, row};
use iced::{Alignment, Element, Length};
use unlucky_design::color::Role;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::Catalog;
use unlucky_presenter::dto::{human_bytes, SavesFailureKind, SavesFailureView};
use unlucky_presenter::state::SavesState;
use crate::app::components::buttons::{self, Button};
use crate::app::components::interaction::Interaction;
use crate::theme::Ui;
use super::super::frame;
use super::super::Message;
use super::forced_state;
use super::state::Message as PageMessage;
use super::Texts;
/// 列表里的一行(世界 / 截图共用:一个名字 + 一条副文案)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Row {
/// 世界目录名或截图文件名(磁盘原样)。
pub name: String,
/// 右侧/下方副文案(时间、大小、图标标记,已由这一层拼好)。
pub detail: String,
}
/// SAVES 区的展示模型(`Model::saves`)。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Saves {
/// 这份数据属于哪个实例(`None` = 不在详情页 / 还没请求过)。
pub bound: Option<String>,
/// 正在读盘(进详情页首帧:请求已发出、快照还没回来)。
pub loading: bool,
/// 读盘/打开失败的人读原因(app 层按 i18n 拼好)。
pub error: Option<String>,
/// 这一条失败能不能靠「再列一次」解决(打开文件夹失败不算)。
pub retryable: bool,
/// 手里有没有一份**快照**(`false` = 只拿到了失败,"还没有世界"那几句不许画)。
pub has_view: bool,
/// 两个目录都不存在(新实例的常态:还没进过游戏)。
pub missing: bool,
/// 世界行。
pub worlds: Vec<Row>,
/// 截图行(新的在前,顺序由 core 给)。
pub screenshots: Vec<Row>,
/// 计数行(`3 个世界 · 12 张截图`):**在这一层算好**,页面只借 `&str`。
///
/// 为什么不留到页面里 `format!`:iced 的 `Element` 只借这一帧的数据,页面函数返回的是
/// **借用局部变量的树**,局部 `String` 一出栈就悬空(`E0515`)。与 [`super::mods::Mods`]
/// 的 `count` 同一条做法。
pub count: String,
/// 跳过条目数(符号链接 / 非图片 / 读不出)。
pub skipped: usize,
/// 跳过提示(`跳过 3 项`)。
pub skipped_note: String,
}
impl Saves {
/// 有没有拿到一份快照(成功或失败都算;`loading` 中不算)。
#[must_use]
pub fn loaded(&self) -> bool {
self.bound.is_some() && !self.loading
}
/// 两个目录都在、但里面什么都没有(**必须先有一份快照**:纯失败态不是"这里空的")。
#[must_use]
pub fn nothing_yet(&self) -> bool {
self.has_view && !self.missing && self.worlds.is_empty() && self.screenshots.is_empty()
}
}
/// presenter 的视图态 → 页面要画的东西(**唯一**的转换点,`app/view/instances.rs` 调)。
///
/// 两条口径:
///
/// 1. **失败不清列表**:list 失败时保留上一份 `worlds`/`screenshots`(用户还能看见旧列表,
/// 只是多一条红字),只有"从来没有过快照"时才是一条纯错误态;
/// 2. **只读**:这里没有任何"乐观更新"可言——快照就是磁盘,下一次进详情页或点重试才刷新。
#[must_use]
pub fn from_state(state: &SavesState, catalog: Catalog) -> Saves {
let texts = Texts::of(catalog);
let mut out = Saves {
bound: state.bound.clone(),
loading: state.bound.is_some() && state.view.is_none() && state.failed.is_none(),
..Saves::default()
};
if let Some(view) = &state.view {
out.has_view = true;
out.missing = !view.exists && !view.screenshots_exists;
// 计数直接问快照(`view.world_count()`),不从刚装好的 `out.worlds` 再数一遍——
// M224 出图实测过"先算计数后装列表"画出 `0 启用 · 0 禁用` 的那种顺序错,这里从根上绕开。
out.worlds = view
.worlds
.iter()
.map(|world| Row {
name: world.dir.clone(),
detail: world_detail(world.icon, world.modified_ms, texts),
})
.collect();
out.screenshots = view
.screenshots
.iter()
.map(|shot| Row {
name: shot.file.clone(),
detail: shot_detail(shot.bytes, shot.modified_ms, texts),
})
.collect();
out.count = texts.saves_count(view.world_count(), view.screenshot_count());
out.skipped = view.skipped_count();
out.skipped_note = texts.saves_skipped(out.skipped);
}
if let Some(failure) = &state.failed {
out.loading = false;
out.error = Some(failure_line(failure, texts));
out.retryable = failure.kind != SavesFailureKind::OpenFailed;
}
out
}
/// 失败的一句话(**唯一产地**:页面内红字用它;本版没有 Toast——只读列表的失败就地说明更近)。
#[must_use]
pub fn failure_line(failure: &SavesFailureView, texts: Texts) -> String {
let reason = texts.saves_reason(failure.kind);
let sentence = match failure.kind {
SavesFailureKind::OpenFailed => texts.saves_open_failed(reason),
SavesFailureKind::DirUnusable | SavesFailureKind::InstanceUnavailable => {
texts.saves_failed(reason)
}
};
if failure.detail.is_empty() {
return sentence;
}
format!("{sentence}({})", failure.detail)
}
/// 世界那一行的副文案:`2024-05-01 10:00 UTC`,有图标时再挂一个「有图标」。
fn world_detail(icon: bool, modified_ms: Option<u64>, texts: Texts) -> String {
let stamp = stamp(modified_ms, texts);
if icon {
format!("{stamp} · {}", texts.saves_world_icon())
} else {
stamp
}
}
/// 截图那一行的副文案:`1.2 MiB · 2024-05-01 10:00 UTC`。
fn shot_detail(bytes: u64, modified_ms: Option<u64>, texts: Texts) -> String {
format!("{} · {}", human_bytes(bytes), stamp(modified_ms, texts))
}
/// mtime(UTC 毫秒)→ `2024-05-01 10:00 UTC`;取不到就说"时间未知"(不拿 0 或当前时间糊)。
///
/// 换算走 `core::launch::time` 的 RFC3339 格式化,再按 `app/view/instances.rs::last_played`
/// 同一刀切开——**UTC 是 core 的口径,不做本地时区换算**(那属时间格式 mission)。
fn stamp(modified_ms: Option<u64>, texts: Texts) -> String {
let Some(text) = modified_ms.and_then(stamp_utc) else {
return texts.saves_time_unknown().to_string();
};
text
}
/// 毫秒 → `YYYY-MM-DD HH:MM UTC`(`None` = 早于 1970 / 溢出,不猜)。
fn stamp_utc(ms: u64) -> Option<String> {
let at = std::time::UNIX_EPOCH.checked_add(std::time::Duration::from_millis(ms))?;
let text = unlucky_core::launch::time::format_rfc3339_millis(at);
// `2024-05-01T10:00:00.000Z` → `2024-05-01 10:00 UTC`(切两段,不解析、不重排)。
// 模板与 `app/view/instances.rs::last_played` **一字不差**:同一句话只能有一份写法,
// 页面源码的字面量白名单里也只有这一条(`instances/tests/i18n.rs` 的 `ALLOWED_LITERALS`)。
let date = text.get(0..10)?;
let time = text.get(11..16)?;
Some(format!("{date} {time} UTC"))
}
/// 画 SAVES 区(实例页唯一入口,`detail.rs` 调它)。
pub(super) fn section<'a>(
ui: &'a Ui,
saves: &'a Saves,
forced: Option<Interaction>,
texts: Texts,
) -> Element<'a, Message> {
let mut body = column![head(ui, saves, forced, texts)]
.spacing(ui.space(Space::X3))
.width(Length::Fill);
// 只读边界固定画在第一行(见模块文档)。
body = body.push(note(ui, texts.saves_read_only()));
if saves.loading {
body = body.push(note(ui, texts.saves_loading()));
return frame::card(ui, body);
}
if let Some(error) = saves.error.as_deref() {
body = body.push(frame::text_role(
ui,
TypeRole::BodySmall,
false,
error,
ui.color(Role::Error),
));
if saves.retryable {
body = body.push(buttons::text(
ui,
forced_state(
Button::new(
texts.saves_retry(),
Message::Instances(PageMessage::ReloadSaves),
),
forced,
),
));
}
// 旧列表还在时照旧画出来(失败不清列表),用户能看见上一次读到的东西;
// 一份快照都没有时到此为止——**不许**在"读不了"下面再补一句"这里还没有世界"。
if !saves.has_view {
return frame::card(ui, body);
}
}
if saves.missing {
body = body.push(note(ui, texts.saves_missing_dir()));
return frame::card(ui, body);
}
if saves.nothing_yet() {
body = body.push(note(ui, texts.saves_empty()));
return frame::card(ui, body);
}
// 世界 → 截图,两组各一个小标;某一组为空时只给一句话,不留空小标。
body = body.push(sub_head(ui, texts.saves_worlds()));
if saves.worlds.is_empty() {
body = body.push(note(ui, texts.saves_no_worlds()));
} else {
for item in &saves.worlds {
body = body.push(entry_row(ui, item));
}
}
body = body.push(sub_head(ui, texts.saves_screenshots()));
if saves.screenshots.is_empty() {
body = body.push(note(ui, texts.saves_no_screenshots()));
} else {
for item in &saves.screenshots {
body = body.push(entry_row(ui, item));
}
}
if saves.skipped > 0 {
body = body.push(note(ui, &saves.skipped_note));
}
frame::card(ui, body)
}
/// 小标行:`SAVES` + 计数 + 右侧「打开存档文件夹」。
fn head<'a>(
ui: &'a Ui,
saves: &'a Saves,
forced: Option<Interaction>,
texts: Texts,
) -> Element<'a, Message> {
// 左半是「存档 + 计数」,右半是「打开存档文件夹」:左半用一个吃满宽度的容器顶开,
// 按钮才会落在卡片右边缘(与 `head` 之外那些卡片的动作行同一套摆法)。
let mut left = row![frame::text_role(
ui,
TypeRole::LabelMedium,
false,
texts.saves_title(),
ui.color(Role::OnSurfaceVariant),
)]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center);
// 计数只在"真有一份可用快照"时画:读盘中 / 读失败 / 空目录画 0·0 会像"这里什么都没有"。
let show_count = saves.has_view
&& saves.error.is_none()
&& !saves.missing
&& !(saves.worlds.is_empty() && saves.screenshots.is_empty());
if show_count {
left = left.push(frame::text_role(
ui,
TypeRole::BodySmall,
false,
saves.count.as_str(),
ui.color(Role::OnSurfaceVariant),
));
}
// 「打开文件夹」始终可点:目录还没有时 host 会退到运行目录,失败也有就地说明
// (见 `host::saves::open_target`)。
let open = buttons::text(
ui,
forced_state(
Button::new(
texts.saves_open_folder(),
Message::Instances(PageMessage::OpenSavesFolder),
),
forced,
),
);
row![
container(left)
.width(Length::Fill)
.align_y(Alignment::Center),
open
]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center)
.width(Length::Fill)
.into()
}
fn sub_head<'a>(ui: &'a Ui, text: &'a str) -> Element<'a, Message> {
frame::text_role(
ui,
TypeRole::LabelSmall,
false,
text,
ui.color(Role::OnSurfaceVariant),
)
}
/// 一行:名字 + 副文案(只读,所以没有开关、没有按钮)。
fn entry_row<'a>(ui: &'a Ui, item: &'a Row) -> Element<'a, Message> {
column![
frame::text_role(
ui,
TypeRole::BodyMedium,
false,
item.name.as_str(),
ui.color(Role::OnSurface),
),
frame::text_role(
ui,
TypeRole::BodySmall,
false,
item.detail.as_str(),
ui.color(Role::OnSurfaceVariant),
),
]
.spacing(ui.space(Space::X1))
.width(Length::Fill)
.into()
}
fn note<'a>(ui: &'a Ui, text: &'a str) -> Element<'a, Message> {
frame::text_role(
ui,
TypeRole::BodySmall,
false,
text,
ui.color(Role::OnSurfaceVariant),
)
}
@@ -163,6 +163,13 @@ pub enum Message {
/// 期望的启用状态
enabled: bool,
},
/// 重新列一次存档(M235):读盘失败时的「重试」,也用于用户手动刷新。
///
/// 只发一条 `SavesCommand::List`——没有别的状态要复位(失败不清绑定,见 presenter 的
/// `SavesState::failed`)。
ReloadSaves,
/// 打开存档文件夹(M235):拉起哪个目录由 host 决定(`saves/` → `screenshots/` → 运行目录)。
OpenSavesFolder,
}
#[cfg(test)]
@@ -7,6 +7,9 @@ mod i18n;
/// M157 的门限判据(三态 chips 在版本不够时谁可点)。
mod launch_args;
/// M235 的存档区模型判据(快照 → 行 / 计数 / 失败线 / UTC 时间)。
mod saves;
use super::super::layout::source;
use super::super::layout::{frame_metrics, measure_cap_px, PaneKit, WidthClass};
@@ -29,14 +29,24 @@ use super::super::Texts;
/// - `Java`:运行时品牌名(`JAVA` 那格的值域里也全是 `Temurin 21` 这类品牌串),翻成「爪哇」是错的;
/// - `pid`:进程号的技术标识。用户拿它去任务管理器里搜进程,翻成「进程」就丢了这条可操作性。
const ALLOWED: &[&str] = &[
"Java", "pid", "JVM", "OpenGL", "Vulkan", "MiB",
"Java",
"pid",
"JVM",
"OpenGL",
"Vulkan",
"MiB",
// M224:`mods` 是**目录名**(文件系统事实)。中文界面上写成「模组目录」会让用户找不到那个
// 文件夹——`mods/` 就是他要打开的那个东西,所以两套语言都原样写它。
"mods",
// M235:`saves` / `screenshots` 同样是**目录名**(文件系统事实,玩家要照着去找)。
// 「正在读取 saves/…」「还没有 saves/ 或 screenshots/ 目录」里的这两个词不许翻——
// 翻成「存档目录」用户就对不上那两层文件夹了。
"saves",
"screenshots",
];
/// 页面取词的六个源码文件(死 key 扫描与旧串扫描共用)。
const PAGE_SOURCES: [(&str, &str); 7] = [
const PAGE_SOURCES: [(&str, &str); 8] = [
("instances.rs", include_str!("../../instances.rs")),
("instances/detail.rs", include_str!("../detail.rs")),
("instances/demo.rs", include_str!("../demo.rs")),
@@ -51,6 +61,8 @@ const PAGE_SOURCES: [(&str, &str); 7] = [
),
// M224:MODS 区(本地 mod 列表 + 启停开关)
("instances/mods.rs", include_str!("../mods.rs")),
// M235:SAVES 区(存档 / 截图的只读列表 + 打开文件夹)
("instances/saves.rs", include_str!("../saves.rs")),
];
/// 页面五件套里**允许出现的字符串字面量**(凡是带 ASCII 单词的都必须在这张表里)。
@@ -68,7 +80,7 @@ const PAGE_SOURCES: [(&str, &str); 7] = [
/// | `isolated` / `shared` | core 的隔离档位**稳定 id**(`IsolationMode::as_str`),`isolation()` 只认这两档,其余原样上屏 |
/// | `pid` / `reason` | `Catalog::render` 的**占位符名**(与 catalog 里的 `{pid}` / `{reason}` 对应) |
/// | `Instances` | `Texts::keys()` 的**族名前缀**(`Key::ALL` 的过滤条件,不是文案) |
const ALLOWED_LITERALS: [&str; 28] = [
const ALLOWED_LITERALS: [&str; 30] = [
"{date} {time} UTC",
"isolated",
"shared",
@@ -110,6 +122,9 @@ const ALLOWED_LITERALS: [&str; 28] = [
"disabled",
"count",
"file",
// M235:存档区计数那条的**占位符名**(`{worlds}` / `{shots}`),同样不是文案。
"worlds",
"shots",
];
/// 抽出源码里的字符串字面量(够用的实现:本仓这五个文件没有 `\"` 转义、没有多行字符串)。
@@ -0,0 +1,236 @@
//! 「存档区」(M235)的**模型**判据:快照 → 页面要画的东西。
//!
//! 为什么只测一个纯函数:iced 0.14 的 `Element` 没有内省接口(与 `tests/launch_args.rs`
//! 同一取舍),画出来的行在测试里拿不到。所以把"快照 → 行/计数/失败线"全部压在
//! [`saves::from_state`] 里,在这里逐条钉住——页面那一支只负责把这些字段画出来。
//!
//! 守门形状:`detail.rs` 必须调 `saves::section(ui, saves, …)`(`instances/tests.rs` 的源码
//! 结构断言盯着 `detail_column` 里 MODS 与 SAVES 的先后;这里守模型)。
use unlucky_presenter::dto::{
SavesArea, SavesFailureKind, SavesFailureView, SavesListView, SavesSkipKind, SavesSkipView,
ScreenshotView, WorldView,
};
use unlucky_presenter::state::SavesState;
use super::super::saves::{failure_line, from_state};
use super::super::Texts;
use unlucky_i18n::Lang;
/// 2024-05-01 10:00 UTC(UTC 毫秒)。
const STAMP: u64 = 1_714_557_600_000;
fn view(instance: &str) -> SavesListView {
SavesListView {
instance: instance.to_string(),
dir: "C:/mc/saves".to_string(),
exists: true,
worlds: vec![WorldView {
dir: "Better World".to_string(),
icon: true,
modified_ms: Some(STAMP),
}],
screenshots_dir: "C:/mc/screenshots".to_string(),
screenshots_exists: true,
screenshots: vec![ScreenshotView {
file: "2024-05-01_10.00.00.png".to_string(),
bytes: 2_048,
modified_ms: None,
}],
skipped: vec![SavesSkipView {
area: SavesArea::Screenshots,
file: "notes.txt".to_string(),
reason: SavesSkipKind::NotAnImage,
}],
}
}
/// 已绑定实例的视图态(`bind` 是 presenter 内部的 `pub(super)`,这里直接写字段——
/// 它本来就是公开状态的一部分,与 `AppState::bind_saves` 走的是同一条路)。
fn bound_state(instance: &str) -> SavesState {
SavesState {
bound: Some(instance.to_string()),
..SavesState::default()
}
}
/// 快照 → 行:世界带「有图标」,截图带大小;时间**按 UTC 切**,取不到就说"时间未知"。
#[test]
fn the_snapshot_becomes_rows_with_stamps_and_badges() {
let mut state = bound_state("a");
state.changed(view("a"));
let saves = from_state(&state, Lang::ZhCn.catalog());
assert!(!saves.loading);
assert!(!saves.retryable, "成功的快照不该带重试");
assert_eq!(saves.worlds.len(), 1);
assert_eq!(saves.worlds[0].name, "Better World");
let zh_icon = Texts::of(Lang::ZhCn.catalog()).saves_world_icon();
assert!(
saves.worlds[0].detail.contains("2024-05-01 10:00 UTC"),
"世界的副文案要带 UTC 时间:{:?}",
saves.worlds[0].detail
);
assert!(
saves.worlds[0].detail.contains(zh_icon),
"有 icon.png 的世界要带标记:{:?}",
saves.worlds[0].detail
);
assert_eq!(saves.screenshots.len(), 1);
assert!(
saves.screenshots[0].detail.contains("2.0 KB"),
"截图的副文案要带大小:{:?}",
saves.screenshots[0].detail
);
assert!(
saves.screenshots[0]
.detail
.contains(Texts::of(Lang::ZhCn.catalog()).saves_time_unknown()),
"mtime 缺失时要说『时间未知』,不许拿 0 或当前时间糊:{:?}",
saves.screenshots[0].detail
);
// 计数与跳过:3 个世界 / 12 张截图这种句子在这一层拼好。
assert_eq!(saves.count, "1 个世界 · 1 张截图");
assert_eq!(saves.skipped, 1);
assert_eq!(saves.skipped_note, "跳过 1 项");
}
/// 首帧(已绑定、还没有快照)是 `loading`;没有绑定则什么都不算"已加载"。
#[test]
fn a_bound_instance_without_a_snapshot_is_loading() {
let state = bound_state("a");
let saves = from_state(&state, Lang::En.catalog());
assert!(saves.loading);
assert!(!saves.loaded());
let idle = from_state(&SavesState::default(), Lang::En.catalog());
assert!(!idle.loading, "不在详情页不该画『正在读取』");
assert!(!idle.loaded());
}
/// 两个目录都不存在 ⇒ `missing`(画"还没有目录"那句,而不是"这里空空的")。
/// 目录在但里面什么都没有 ⇒ `nothing_yet`。
#[test]
fn missing_directories_and_empty_ones_are_different_states() {
let mut state = bound_state("a");
let mut gone = view("a");
gone.exists = false;
gone.screenshots_exists = false;
gone.worlds.clear();
gone.screenshots.clear();
gone.skipped.clear();
state.changed(gone);
let saves = from_state(&state, Lang::En.catalog());
assert!(saves.missing);
assert!(!saves.nothing_yet(), "缺目录不该被当成『目录在但是空的』");
let mut state = bound_state("a");
let mut empty = view("a");
empty.worlds.clear();
empty.screenshots.clear();
empty.skipped.clear();
state.changed(empty);
let saves = from_state(&state, Lang::En.catalog());
assert!(!saves.missing);
assert!(saves.nothing_yet());
}
/// 失败:读盘失败**可重试**,打开文件夹失败**不可重试**(重试按钮会去重新列目录,那是错的动作)。
///
/// 抓得住:把两种失败合成一条「重试」;也抓得住"失败顺手把列表清掉"(用户看不见上一次读到的东西)。
#[test]
fn only_list_failures_offer_a_retry() {
for (kind, retryable) in [
(SavesFailureKind::DirUnusable, true),
(SavesFailureKind::InstanceUnavailable, true),
(SavesFailureKind::OpenFailed, false),
] {
let mut state = bound_state("a");
state.changed(view("a"));
state.failed(SavesFailureView {
instance: "a".to_string(),
kind,
detail: "Access is denied.".to_string(),
});
let saves = from_state(&state, Lang::En.catalog());
assert!(!saves.loading);
assert_eq!(saves.retryable, retryable, "{kind:?} 的重试口径错了");
assert!(saves.error.is_some(), "{kind:?} 没有画出失败");
assert_eq!(saves.worlds.len(), 1, "{kind:?} 把旧列表清掉了");
assert_eq!(saves.screenshots.len(), 1);
}
}
/// **只拿到失败、一份快照都没有**时,不许被画成"这里还没有世界"。
///
/// 抓得住:`nothing_yet()` 只看"两个列表都空"——那样"读不了"会顶着两句"这里空空的",
/// 用户会把权限问题读成"我没有存档"。
#[test]
fn a_failure_without_a_snapshot_is_not_an_empty_list() {
let mut state = bound_state("a");
state.failed(SavesFailureView {
instance: "a".to_string(),
kind: SavesFailureKind::DirUnusable,
detail: "Access is denied.".to_string(),
});
let saves = from_state(&state, Lang::En.catalog());
assert!(saves.error.is_some());
assert!(!saves.has_view, "没有快照却记成了有");
assert!(!saves.nothing_yet(), "纯失败态被画成了空列表");
assert_eq!(saves.count, "", "没有快照不该画计数");
}
/// 失败主句跟着语言与大类走,OS 原文只进括号(M161 的文案纪律)。
#[test]
fn the_failure_line_follows_the_language_and_keeps_the_detail_in_brackets() {
let failure = SavesFailureView {
instance: "a".to_string(),
kind: SavesFailureKind::DirUnusable,
detail: "Access is denied.".to_string(),
};
let zh = failure_line(&failure, Texts::of(Lang::ZhCn.catalog()));
assert!(zh.starts_with("读取 saves/ 失败:"), "{zh}");
assert!(zh.ends_with("(Access is denied.)"), "{zh}");
let en = failure_line(&failure, Texts::of(Lang::En.catalog()));
assert!(en.starts_with("Could not read saves/:"), "{en}");
assert!(en.contains("Access is denied."), "{en}");
// 没有原文时不留一对空括号。
let bare = SavesFailureView {
detail: String::new(),
..failure
};
let text = failure_line(&bare, Texts::of(Lang::ZhCn.catalog()));
assert!(!text.contains('('), "空原文留下了一对括号:{text}");
}
/// 时间换算:毫秒 → `YYYY-MM-DD HH:MM UTC`(跨年/闰日各钉一条,别只信一条)。
#[test]
fn millisecond_stamps_are_rendered_in_utc() {
let stamp = |ms: u64| {
let mut state = bound_state("a");
let mut view = view("a");
view.worlds[0].modified_ms = Some(ms);
state.changed(view);
from_state(&state, Lang::En.catalog()).worlds[0]
.detail
.clone()
};
// 1970-01-01 00:00 UTC(纪元)
assert!(stamp(0).starts_with("1970-01-01 00:00 UTC"), "{}", stamp(0));
// 2024-05-01 10:00 UTC
assert!(
stamp(STAMP).starts_with("2024-05-01 10:00 UTC"),
"{}",
stamp(STAMP)
);
// 2024-02-29 23:59 UTC(闰日)
assert!(
stamp(1_709_251_140_000).starts_with("2024-02-29 23:59 UTC"),
"{}",
stamp(1_709_251_140_000)
);
}
+77 -2
View File
@@ -26,7 +26,7 @@
//! `Row.status`(「阶段 · 百分比」是一整句),与元信息卡同一层——页面只画模型给的字符串。
use unlucky_i18n::{Catalog, Key};
use unlucky_presenter::dto::ModFailureKind;
use unlucky_presenter::dto::{ModFailureKind, SavesFailureKind};
/// 实例页 + 详情列的文案(一个 `Catalog` 的薄包装,`Copy`)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -212,10 +212,85 @@ impl Texts {
})
}
// ------------------------------------------------------------- 存档区(M235)
pub(super) fn saves_title(self) -> &'static str {
self.text(Key::InstancesSavesTitle)
}
/// 「这一版只读」——U3 的边界固定画出来(见 `catalog/instances.rs` 的那段注释)。
pub(super) fn saves_read_only(self) -> &'static str {
self.text(Key::InstancesSavesReadOnly)
}
/// 「3 个世界 · 12 张截图」。
pub(super) fn saves_count(self, worlds: usize, shots: usize) -> String {
self.0.render(
Key::InstancesSavesCount,
&[
("worlds", &worlds.to_string()),
("shots", &shots.to_string()),
],
)
}
pub(super) fn saves_loading(self) -> &'static str {
self.text(Key::InstancesSavesLoading)
}
pub(super) fn saves_empty(self) -> &'static str {
self.text(Key::InstancesSavesEmpty)
}
pub(super) fn saves_missing_dir(self) -> &'static str {
self.text(Key::InstancesSavesMissingDir)
}
pub(super) fn saves_worlds(self) -> &'static str {
self.text(Key::InstancesSavesWorlds)
}
pub(super) fn saves_screenshots(self) -> &'static str {
self.text(Key::InstancesSavesScreenshots)
}
pub(super) fn saves_no_worlds(self) -> &'static str {
self.text(Key::InstancesSavesNoWorlds)
}
pub(super) fn saves_no_screenshots(self) -> &'static str {
self.text(Key::InstancesSavesNoScreenshots)
}
/// 「有图标」:只判 `icon.png` 存不存在(不解码,缩略图墙不在 U3 范围内)。
pub(super) fn saves_world_icon(self) -> &'static str {
self.text(Key::InstancesSavesWorldIcon)
}
pub(super) fn saves_time_unknown(self) -> &'static str {
self.text(Key::InstancesSavesTimeUnknown)
}
/// 「跳过 3 项」。
pub(super) fn saves_skipped(self, count: usize) -> String {
self.0
.render(Key::InstancesSavesSkipped, &[("count", &count.to_string())])
}
pub(super) fn saves_open_folder(self) -> &'static str {
self.text(Key::InstancesSavesOpenFolder)
}
pub(super) fn saves_retry(self) -> &'static str {
self.text(Key::InstancesSavesRetry)
}
/// 读目录失败的主句:`读取 saves/ 失败:目录不可用`。
pub(super) fn saves_failed(self, reason: &str) -> String {
self.0
.render(Key::InstancesSavesFailed, &[("reason", reason)])
}
/// 打开文件夹失败的主句:`打开文件夹失败:文件管理器没起来`。
pub(super) fn saves_open_failed(self, reason: &str) -> String {
self.0
.render(Key::InstancesSavesOpenFailed, &[("reason", reason)])
}
/// 失败大类的短句(`SavesFailureKind` → 一句话)。
pub(super) fn saves_reason(self, kind: SavesFailureKind) -> &'static str {
self.text(match kind {
SavesFailureKind::DirUnusable => Key::InstancesSavesReasonDir,
SavesFailureKind::InstanceUnavailable => Key::InstancesSavesReasonInstance,
SavesFailureKind::OpenFailed => Key::InstancesSavesReasonOpen,
})
}
fn text(self, key: Key) -> &'static str {
self.0.text(key)
}
/// 这一页的全部 key(守门测试的取样口:**从 `Key::ALL` 过滤出来**,不是手抄一份清单)。
///
/// 域判据是前缀 `Instances`——本族的 key 名是契约(`catalog/instances.rs` 里一条一条写着),
+4 -1
View File
@@ -128,7 +128,10 @@ pub(crate) mod settings;
mod tests;
pub use download::{cancelled_toast, Category, Download, InstallDraft, Loader};
pub(crate) use instances::demo_form as demo_launch_form;
pub use instances::{mods_failure_line, mods_from_state, InstanceMessage, LaunchForm, Mods};
pub use instances::{
mods_failure_line, mods_from_state, saves_failure_line, saves_from_state, InstanceMessage,
LaunchForm, Mods, Saves,
};
pub use layout::{FrameMetrics, PageLayout, PaneKit, WidthClass};
pub use logs::{Confidence, Finding, Level, Line, Logs, Outcome};
pub use message::{Action, Message, UpdateAction};
+6
View File
@@ -145,6 +145,11 @@ pub struct Model {
/// 谁填:`app/view/instances.rs` 的 `mods_of`(presenter 的 `AppState::mods` → 展示字段)。
/// 空态(不在详情页 / 还没请求)是 `Mods::default()`,页面不画这一区。
pub mods: super::instances::Mods,
/// 存档 / 截图(M235):详情页 SAVES 区的只读列表(见 [`super::instances::Saves`])。
///
/// 谁填:`app/view/instances.rs` 的 `saves_of`(presenter 的 `AppState::saves` → 展示字段)。
/// 空态(不在详情页 / 还没请求)是 `Saves::default()`,页面照旧画这一次请求前的样子。
pub saves: super::instances::Saves,
/// 取证用的强制交互态(`--force-state`):`Some` 时页面里的控件按该态画。
///
/// M90 r2(P3-1)之前这个开关只作用到 `shell` 回退路径,产品页是**静默失效**的;
@@ -175,6 +180,7 @@ impl Model {
// 见 `pages/settings/download.rs` 的「没有快照就画演示行」)。
settings: Vec::new(),
mods: super::instances::Mods::default(),
saves: super::instances::Saves::default(),
lang: Lang::En,
forced: None,
}