feat(core,presenter,app-iced): 存档三个写操作(重命名 / 删除 / 备份)+ 行操作区(M247 SAVESOPS)

core::game::saves 新增 write 模块,三个入口的第一句都是 boundary::guard:

- rename_world:saves/<old> → saves/<new>,撞名/同名/非法名字各自具名拒绝
- delete_world:递归删(不跟随符号链接、层级上限 64),先过边界校验
- backup_world:流式 zip(限额预检 → 临时文件 → 原子改名),
  <世界名>-YYYYMMDD-HHMMSS.zip,撞名顺延,绝不覆盖上一份

guard 三道判据(每次写前重做,不信任调用方):名字必须是单段裸名字;
saves/ 必须是运行目录的真实直属子目录;世界目录必须是 saves/ 下的真目录
(解析后比父级,不比字符串前缀)。落点目录要求绝对路径(相对路径会落到进程 CWD,
实测过一次)。

presenter:SavesCommand 增 RenameWorld/DeleteWorld/BackupWorld,host 三个后台入口
写成功后重新列一遍目录并把「回执」挂在快照上(SavesOped:改名两端 / 被删的名字 /
备份落点+字节数+跳过的链接数)。SavesFailureView 增 file 与 op,写失败八类一一对应。
SavesState 增 draft / pending / confirm 三样交互态(草稿是行内输入缓冲,
pending 与 ModsState::pending 同一条先例,confirm 是删除确认框在等谁)。

app-iced:世界行右侧重命名/备份/删除;行内输入框改名(现成模态队列没有「输入变了」
这一条消息,用输入模态等于重写一套);删除走现成 modal 的 Confirm,主按钮落点接在
app/update/dialog.rs。回执行画在卡片里(备份落点看不见就是静默失败)。

i18n:新增 25 条中英成对 key + 金表同步;删掉 M235 的「这一版只读」那条(写操作接上
之后它是假的)。出图夹具 ULL_SAVES_FIXTURE=rename|confirm|backed-up 三种形态。
This commit is contained in:
AskaEth committed 2026-11-11 13:29:56 +08:00
1 parent 3e852331f4
commit c921b9c185
28 files changed
+3016 -216

No files matched your search

+2 -2
View File
@@ -88,8 +88,8 @@ pub use settings::{
};
// M235: 实例存档 / 截图的只读列表
pub use saves::{
SavesArea, SavesCommand, SavesFailureKind, SavesFailureView, SavesListView, SavesSkipKind,
SavesSkipView, ScreenshotView, WorldView,
SavesArea, SavesCommand, SavesFailureKind, SavesFailureOp, SavesFailureView, SavesListView,
SavesOped, SavesSkipKind, SavesSkipView, ScreenshotView, WorldView,
};
// M159: 自更新
pub use update::{
+126 -88
View File
@@ -1,26 +1,33 @@
//! 实例**存档 / 截图的只读快照**与「打开文件夹」的 IPC 面(M235 · STOREMVP)。
//! 实例**存档 / 截图的快照**与写操作的 IPC 面(M235 · STOREMVP,M247 · SAVESOPS 加写路径)。
//!
//! # 三层里的第二层
//!
//! 塔裁决 U3 把这一版钉成「只读列表 + 打开文件夹」,不做缩略图墙。三层是:
//! `core::game::saves`(读盘,唯一的真相)→ 本模块(IPC 形状)→
//! `crates/app-iced/src/pages/instances/saves.rs`(画)。
//! 这一版是「只读列表 + 打开文件夹」**加上**三个写操作:`core::game::saves`(读盘/写盘,唯一的
//! 真相)→ 本模块(IPC 形状)→ `crates/app-iced/src/pages/instances/saves.rs`(画)。
//!
//! | 方向 | 类型 |
//! |---|---|
//! | app → host | [`SavesCommand`](列目录 / 打开文件夹) |
//! | host → app | [`SavesListView`](整份快照)或 [`SavesFailureView`](具名原因) |
//! | app → host | [`SavesCommand`](列目录 / 打开文件夹 / 重命名 / 删除 / 备份) |
//! | host → app | [`SavesListView`](整份快照 + 上一次写操作的[回执](SavesOped))或 [`SavesFailureView`] |
//!
//! # 写操作为什么走同一条回程
//!
//! 三个写操作的结果都是**一份新快照**(`Ok`)+ 一个[回执](SavesOped):写完之后磁盘变了,
//! 界面必须跟着变;而回执是「这次动的是哪一条、动成了什么」。失败则走同一个事件的 `Err` 一支,
//! 带上一个**具名类别**(重命名撞名 / 删除越界 / 备份超限……),见 [`SavesFailureKind`]。
//! 两条都不静默:成功有回执、失败有类别 + OS 原文。
//!
//! # 为什么另开一族,不并进 `ModListView`
//!
//! 两者形状相近(都是"列一个实例的某个目录"),但语义不同:mod 有启停(会写盘、要看游戏在不在跑),
//! 存档这一版**没有任何写路径**。合并会让 mod 那边的 `pending` / `running` 字段在存档页变成
//! 永远为假的分支(M224 拒绝给内容页加第五个 `ContentKind` 是同一条理由:别让穷尽 match 为不相干
//! 的字段服务)。
//! 两者形状相近(都是"列一个实例的某个目录"),但语义不同:mod 有启停(看游戏在不在跑),
//! 存档这边有三个写操作(重命名 / 删除 / 备份)。合并会让两边的 `pending` 字段互相变成
//! 永远为假的分支(M224 拒绝给内容页加第五个 `ContentKind` 是同一条理由:别让穷尽 match 为
//! 不相干的字段服务)。
//!
//! # 文案纪律(M161)
//!
//! 本模块**不带人读散文**:失败给的是结构([`SavesFailureKind`] + 底层 `detail` 串),
//! 本模块**不带人读散文**:失败给的是结构([`SavesFailureKind`] + 底层 `detail` 串)、
//! 回执给的是事实(谁改成什么、备份落到哪个路径、几个字节、跳过几个链接),
//! 时间戳给的是**毫秒数**(UTC 毫秒,UI 自己格式化)。文案一律由 app 层按 i18n key 现求。
//!
//! # 时间戳为什么是 `Option<u64>` 的毫秒
@@ -45,6 +52,76 @@ pub enum SavesCommand {
/// 实例 id
id: InstanceId,
},
/// 重命名一个世界(M247):`saves/<dir>` → `saves/<to>`。
///
/// 名字规则、撞名、边界校验全在 core(host 只转发):UI 传坏了也不会落到 `saves/` 之外。
RenameWorld {
/// 实例 id
id: InstanceId,
/// 现在的磁盘目录名
dir: String,
/// 新名字(单段;不许带 `/` `\` `:` 与 `..`)
to: String,
},
/// 删除一个世界(M247):递归删 `saves/<dir>`。
///
/// **UI 必须先二次确认**:这是本页唯一不可逆的动作。host 侧再走一遍边界校验
/// (越界 / 符号链接一律拒),不靠确认框把调用方「教好」。
DeleteWorld {
/// 实例 id
id: InstanceId,
/// 磁盘目录名
dir: String,
},
/// 备份一个世界(M247):把一个世界导出成 zip。
///
/// `dest` 是**父目录**(不是文件):`None` = 启动器自己的 `<data>/exports/saves/`。
/// 文件名由 core 按「世界名 + UTC 时戳」生成,撞名顺延,**绝不覆盖**上一份。
BackupWorld {
/// 实例 id
id: InstanceId,
/// 磁盘目录名
dir: String,
/// 导出到哪个父目录(`None` = 启动器自己的 exports)
dest: Option<String>,
},
}
/// 一次写操作成功后的事实(**不含人读文案**:文案由 app 按 i18n key 现求)。
///
/// 为什么成功也要有回执:重命名 / 删除的结果在列表上自己会说话(行改名、行消失),
/// 而**备份成功没有话可说**——zip 落到了文件系统某处,用户必须知道完整路径 + 字节数,
/// 否则「点了备份然后什么也没发生」就是一次静默失败。
///
/// 回执挂在快照上([`SavesListView::last_op`]):下一次普通 `List` 会让它消失,
/// 所以它表达的是「最近这一次操作」,不是一段历史。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum SavesOped {
/// 改名成功。
Renamed {
/// 原磁盘名
from: String,
/// 新磁盘名
to: String,
},
/// 删除成功。
Deleted {
/// 被删掉的磁盘名
dir: String,
},
/// 备份成功。
BackedUp {
/// 被备份的世界(磁盘名)
dir: String,
/// 落点文件名
file: String,
/// 落点完整路径
path: String,
/// zip 的字节数
bytes: u64,
/// 被跳过的符号链接数(不跟随;0 = 一个都没跳)
skipped: usize,
},
}
/// 一个世界目录。
@@ -125,6 +202,8 @@ pub struct SavesListView {
pub screenshots: Vec<ScreenshotView>,
/// 跳过的条目(UI 只需计数 + 一句"为什么")。
pub skipped: Vec<SavesSkipView>,
/// 最近一次写操作的回执(`None` = 这一拍只是列举,没写东西)。
pub last_op: Option<SavesOped>,
}
impl SavesListView {
@@ -158,13 +237,31 @@ impl SavesListView {
pub struct SavesFailureView {
/// 实例 id(迟到/串台的结果靠它判)。
pub instance: InstanceId,
/// 涉及的那个世界(写操作才有;读路径给空串)。
///
/// 为什么要这一格:失败那句话是「重命名 **A** 失败:同名世界已存在」——没有名字,
/// 用户不知道该改哪一行(与 `ModFailureView::file` 同一条理由)。
pub file: String,
/// 这是哪一类**写**操作(`None` = 读路径 / 打开文件夹的失败)。
pub op: Option<SavesFailureOp>,
/// 失败大类。
pub kind: SavesFailureKind,
/// 底层原文(OS 错误 / 路径细节);只进括号,不进主句。
pub detail: String,
}
/// 失败大类。
/// 失败出自哪一类写操作(决定失败那句主句用哪个动词)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SavesFailureOp {
/// 重命名。
Rename,
/// 删除。
Delete,
/// 备份。
Backup,
}
/// 失败大类(**每一种在 UI 上都有一句话**,没有静默失败)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SavesFailureKind {
/// 实例不在了(快照里找不到),或隔离档位派生不出运行目录。
@@ -173,82 +270,23 @@ pub enum SavesFailureKind {
DirUnusable,
/// 「打开文件夹」失败(目录不存在 / 拉不起文件管理器)。
OpenFailed,
/// 写操作:名字过不了规则(空、`.`、`..`、含 `/` `\` `:` NUL)。
WriteUnsafeName,
/// 写操作:目标或路径上的一段是符号链接(不跟随)。
WriteSymlink,
/// 写操作:那个世界(或 `saves/`)已经不在了。
WriteMissing,
/// 写操作:那个名字底下不是目录。
WriteNotADirectory,
/// 写操作:同名世界已存在 / 备份文件名撞满。
WriteConflict,
/// 写操作:路径落在 `saves/` 之外(含层级异常深)。
WriteBoundary,
/// 写操作:备份超过体积或条目上限。
WriteTooLarge,
/// 写操作:其它系统错误(权限 / 被别的进程占用),OS 原文进 `detail`。
WriteFailed,
}
#[cfg(test)]
mod tests {
use super::*;
fn view() -> SavesListView {
SavesListView {
instance: "inst".to_string(),
dir: "/tmp/run/saves".to_string(),
exists: true,
worlds: vec![WorldView {
dir: "World".to_string(),
icon: true,
modified_ms: Some(1_700_000_000_000),
}],
screenshots_dir: "/tmp/run/screenshots".to_string(),
screenshots_exists: true,
screenshots: vec![ScreenshotView {
file: "2024-05-01_10.00.00.png".to_string(),
bytes: 1024,
modified_ms: None,
}],
skipped: vec![SavesSkipView {
area: SavesArea::Screenshots,
file: "notes.txt".to_string(),
reason: SavesSkipKind::NotAnImage,
}],
}
}
/// 计数与 IPC round-trip(AGENTS §6:跨界类型必须能序列化)。
#[test]
fn view_counts_and_round_trips() {
let view = view();
assert_eq!(view.world_count(), 1);
assert_eq!(view.screenshot_count(), 1);
assert_eq!(view.skipped_count(), 1);
assert!(!view.is_empty());
let text = serde_json::to_string(&view).expect("序列化");
assert_eq!(
serde_json::from_str::<SavesListView>(&text).expect("反序列化"),
view
);
}
/// 两条指令都要能过 IPC(含「打开文件夹」——它的载荷只有实例 id)。
#[test]
fn commands_round_trip() {
for command in [
SavesCommand::List("inst".to_string()),
SavesCommand::OpenFolder {
id: "inst".to_string(),
},
] {
let text = serde_json::to_string(&command).expect("序列化");
assert_eq!(
serde_json::from_str::<SavesCommand>(&text).expect("反序列化"),
command
);
}
}
/// 两个目录都不存在 ⇒ 空(`is_empty` 只看内容,不看 `exists`:一个目录有空世界也算非空)。
#[test]
fn empty_means_no_worlds_and_no_screenshots() {
let mut view = view();
view.worlds.clear();
view.screenshots.clear();
assert!(view.is_empty(), "没有世界也没有截图就该算空");
view.screenshots.push(ScreenshotView {
file: "a.png".to_string(),
bytes: 0,
modified_ms: None,
});
assert!(!view.is_empty());
}
}
mod tests;
+126
View File
@@ -0,0 +1,126 @@
//! 存档 IPC 面的用例:**IPC round-trip**(生成物必须能反序列化回来)。
use super::*;
fn view() -> SavesListView {
SavesListView {
instance: "inst".to_string(),
dir: "/tmp/run/saves".to_string(),
exists: true,
worlds: vec![WorldView {
dir: "World".to_string(),
icon: true,
modified_ms: Some(1_700_000_000_000),
}],
screenshots_dir: "/tmp/run/screenshots".to_string(),
screenshots_exists: true,
screenshots: vec![ScreenshotView {
file: "2024-05-01_10.00.00.png".to_string(),
bytes: 1024,
modified_ms: None,
}],
skipped: vec![SavesSkipView {
area: SavesArea::Screenshots,
file: "notes.txt".to_string(),
reason: SavesSkipKind::NotAnImage,
}],
last_op: None,
}
}
/// 计数与 IPC round-trip(AGENTS §6:跨界类型必须能序列化)。
#[test]
fn view_counts_and_round_trips() {
let view = view();
assert_eq!(view.world_count(), 1);
assert_eq!(view.screenshot_count(), 1);
assert_eq!(view.skipped_count(), 1);
assert!(!view.is_empty());
let text = serde_json::to_string(&view).expect("序列化");
assert_eq!(
serde_json::from_str::<SavesListView>(&text).expect("反序列化"),
view
);
}
/// 五条指令都要能过 IPC(含写操作三条——它们带世界名,名字里可能有空格与中文)。
#[test]
fn commands_round_trip() {
for command in [
SavesCommand::List("inst".to_string()),
SavesCommand::OpenFolder {
id: "inst".to_string(),
},
SavesCommand::RenameWorld {
id: "inst".to_string(),
dir: "Better World".to_string(),
to: "更好的世界".to_string(),
},
SavesCommand::DeleteWorld {
id: "inst".to_string(),
dir: "Better World".to_string(),
},
SavesCommand::BackupWorld {
id: "inst".to_string(),
dir: "Better World".to_string(),
dest: Some("D:/backups".to_string()),
},
SavesCommand::BackupWorld {
id: "inst".to_string(),
dir: "Better World".to_string(),
dest: None,
},
] {
let text = serde_json::to_string(&command).expect("序列化");
assert_eq!(
serde_json::from_str::<SavesCommand>(&text).expect("反序列化"),
command
);
}
}
/// 成功回执也要能过 IPC(备份那一支字段最多:路径 / 字节 / 跳过数)。
#[test]
fn op_receipts_round_trip() {
let cases = [
SavesOped::Renamed {
from: "Old".to_string(),
to: "New".to_string(),
},
SavesOped::Deleted {
dir: "Old".to_string(),
},
SavesOped::BackedUp {
dir: "Old".to_string(),
file: "Old-20240501-100000.zip".to_string(),
path: "D:/exports/Old-20240501-100000.zip".to_string(),
bytes: 4096,
skipped: 2,
},
];
for op in cases {
let mut view = view();
view.last_op = Some(op);
let text = serde_json::to_string(&view).expect("序列化");
assert_eq!(
serde_json::from_str::<SavesListView>(&text).expect("反序列化"),
view
);
}
}
/// 两个目录都不存在 ⇒ 空(`is_empty` 只看内容,不看 `exists`:一个目录有空世界也算非空)。
#[test]
fn empty_means_no_worlds_and_no_screenshots() {
let mut view = view();
view.worlds.clear();
view.screenshots.clear();
assert!(view.is_empty(), "没有世界也没有截图就该算空");
view.screenshots.push(ScreenshotView {
file: "a.png".to_string(),
bytes: 0,
modified_ms: None,
});
assert!(!view.is_empty());
}
+187 -13
View File
@@ -1,12 +1,24 @@
//! 实例存档 / 截图的宿主实现(M235 · STOREMVP)。
//! 实例存档 / 截图的宿主实现(M235 · STOREMVP 读路径 + M247 · SAVESOPS 写路径)。
//!
//! ## 三条纪律
//! ## 四条纪律
//!
//! | 纪律 | 落点 |
//! |---|---|
//! | 磁盘只在后台线程碰 | [`spawn_list`] / [`spawn_open_folder`] 走 `job::spawn`,UI 只收快照 |
//! | 磁盘只在后台线程碰 | [`spawn_list`] / [`spawn_open_folder`] / [`spawn_rename`] / [`spawn_delete`] / [`spawn_backup`] 全走 `job::spawn`,UI 只收快照 |
//! | 单根语义(D20) | 运行目录来自 `game::isolation::derive_game_dir`——**与启动同一个答案**,不另派生、不做双根合并 |
//! | 只读 | 本模块一次写盘都没有;`saves/` 不存在也不替玩家建(core 的 `saves::scan` 同理) |
//! | 读路径只读 | [`list`] 一次写盘都没有;`saves/` 不存在也不替玩家建(core 的 `saves::scan` 同理) |
//! | 写路径自己重校边界 | 三个写操作**不在 host 这边信任自己**:core 的 `saves::write` 每次重新校验 `run_dir` 与目标路径(越界 / 符号链接 / 撞名都在那里拒) |
//!
//! ## 三个写操作的回程形状
//!
//! | 操作 | 成功 | 失败 |
//! |---|---|---|
//! | 重命名 | 新快照 + [`SavesOped::Renamed`] | 具名 [`SavesFailureKind`](撞名 / 名字不可用 / 越界 / 链接 / IO) |
//! | 删除 | 新快照 + [`SavesOped::Deleted`] | 同上 |
//! | 备份 | 新快照 + [`SavesOped::BackedUp`](落点路径 / 字节数 / 跳过的链接数) | 同上 + `TooLarge` |
//!
//! **写成功后一律重新列一遍**(`snapshot`):界面画的永远是磁盘上的真相,不是"我以为我改成了什么"。
//! 回执只是"这次动的是哪一条",与快照一起回去,所以 UI 不会出现「列表变了但不知道谁变的」。
//!
//! ## 为什么不复用启动路径的 `run_dir()`
//!
@@ -22,21 +34,30 @@
//! 真正拉起文件管理器的是 [`unlucky_core::platform::opener::open_dir`](`AGENTS.md` §6:
//! 平台差异只许收敛在 `core::platform`,本文件一个 `cfg` 都没有,也没有一次裸 spawn)。
//!
//! ## 备份落点为什么是启动器自己的 `exports/`
//!
//! 本仓没有 `rfd` 这类原生文件选择框依赖(`core::launch::crash_bundle::default_target_dir` 的
//! 文档里记着同一条理由),所以「用户选定目录」先退化成:`<data>/exports/saves/`。
//! 命令带一个可选的 `dest`,将来接了选择框、或 CLI 想指定别处,都不用改 core。
//! 成功的回执里带**完整路径 + 字节数**,用户不用猜落点。
//!
//! ## 失败口径
//!
//! 一律走 [`crate::dto::SavesFailureView`](结构 + OS 原文),**不拼中文**:文案由 app 层按
//! i18n key 现求(M161 的纪律)。三类失败见 [`crate::dto::SavesFailureKind`]。
//! i18n key 现求(M161 的纪律)。读路径三类失败见 [`crate::dto::SavesFailureKind`],
//! 写路径八类见同一个枚举的 `Write*` 那一族——每一种在 UI 上都有一句话。
use std::path::PathBuf;
use unlucky_core::game::install::instance::read_instance_config;
use unlucky_core::game::isolation::derive_game_dir;
use unlucky_core::game::saves::{self, SavesError, SavesScan};
use unlucky_core::game::saves::{self, SavesError, SavesScan, SavesWriteError, SavesWriteKind};
use unlucky_core::platform::opener;
use crate::dto::{
locate, InstanceId, InstanceSummary, SavesArea, SavesCommand, SavesFailureKind,
SavesFailureView, SavesListView, SavesSkipKind, SavesSkipView, ScreenshotView, WorldView,
locate, InstanceId, InstanceSummary, SavesArea, SavesCommand, SavesFailureKind, SavesFailureOp,
SavesFailureView, SavesListView, SavesOped, SavesSkipKind, SavesSkipView, ScreenshotView,
WorldView,
};
use super::env::HostEnv;
@@ -81,14 +102,135 @@ pub fn dispatch(env: HostEnv, tx: JobSender, command: SavesCommand) {
match command {
SavesCommand::List(id) => spawn_list(env, tx, id),
SavesCommand::OpenFolder { id } => spawn_open_folder(env, tx, id),
SavesCommand::RenameWorld { id, dir, to } => spawn_rename(env, tx, id, dir, to),
SavesCommand::DeleteWorld { id, dir } => spawn_delete(env, tx, id, dir),
SavesCommand::BackupWorld { id, dir, dest } => spawn_backup(env, tx, id, dir, dest),
}
}
/// 后台重命名一个世界,**并把刷新后的快照与回执一起带回来**。
pub fn spawn_rename(env: HostEnv, tx: JobSender, id: InstanceId, dir: String, to: String) {
spawn("saves-rename", move || {
let report = Report {
instance: id.clone(),
list: rename(&env, &id, &dir, &to),
};
let _ = tx.send(super::HostTask::Saves(Box::new(report)));
});
}
/// 后台删除一个世界(**UI 已经二次确认过**;host 侧照样重新过边界校验)。
pub fn spawn_delete(env: HostEnv, tx: JobSender, id: InstanceId, dir: String) {
spawn("saves-delete", move || {
let report = Report {
instance: id.clone(),
list: delete(&env, &id, &dir),
};
let _ = tx.send(super::HostTask::Saves(Box::new(report)));
});
}
/// 后台把一个世界备份成 zip(落点见 [`export_dir`]),并带回新快照。
pub fn spawn_backup(
env: HostEnv,
tx: JobSender,
id: InstanceId,
dir: String,
dest: Option<String>,
) {
spawn("saves-backup", move || {
let report = Report {
instance: id.clone(),
list: backup(&env, &id, &dir, dest.as_deref()),
};
let _ = tx.send(super::HostTask::Saves(Box::new(report)));
});
}
/// 列目录(后台线程里调)。
fn list(env: &HostEnv, id: &str) -> Result<SavesListView, SavesFailureView> {
let (instance, run_dir) = target(env, id)?;
let scan = saves::scan(&run_dir).map_err(|error| unusable(&instance.id, &error))?;
Ok(view(&instance, &scan))
snapshot(&instance, &run_dir, None)
}
/// 重新扫一遍目录 → 快照(可挂一条写操作的回执)。
///
/// 写成功后**一律**走这里:界面画的是磁盘上的真相,不是"我以为我改成了什么"。
fn snapshot(
instance: &InstanceSummary,
run_dir: &std::path::Path,
last_op: Option<SavesOped>,
) -> Result<SavesListView, SavesFailureView> {
let scan = saves::scan(run_dir).map_err(|error| unusable(&instance.id, &error))?;
Ok(view(&instance.id, &scan, last_op))
}
/// 重命名 → 再列一遍(回执里记「谁改成了什么」)。
fn rename(
env: &HostEnv,
id: &str,
from: &str,
to: &str,
) -> Result<SavesListView, SavesFailureView> {
let (instance, run_dir) = target(env, id)?;
saves::rename_world(&run_dir, from, to)
.map_err(|error| write_failure(&instance.id, SavesFailureOp::Rename, from, &error))?;
snapshot(
&instance,
&run_dir,
Some(SavesOped::Renamed {
from: from.to_string(),
to: to.to_string(),
}),
)
}
/// 删除 → 再列一遍(回执里记被删掉的那个名字)。
fn delete(env: &HostEnv, id: &str, dir: &str) -> Result<SavesListView, SavesFailureView> {
let (instance, run_dir) = target(env, id)?;
saves::delete_world(&run_dir, dir)
.map_err(|error| write_failure(&instance.id, SavesFailureOp::Delete, dir, &error))?;
snapshot(
&instance,
&run_dir,
Some(SavesOped::Deleted {
dir: dir.to_string(),
}),
)
}
/// 备份 → 再列一遍(回执里记落点路径 / 字节数 / 被跳过的链接数)。
fn backup(
env: &HostEnv,
id: &str,
dir: &str,
dest: Option<&str>,
) -> Result<SavesListView, SavesFailureView> {
let (instance, run_dir) = target(env, id)?;
let dest = dest.map_or_else(|| export_dir(env), PathBuf::from);
let done = saves::backup_world(&run_dir, dir, &dest)
.map_err(|error| write_failure(&instance.id, SavesFailureOp::Backup, dir, &error))?;
let file = done
.path
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_default();
snapshot(
&instance,
&run_dir,
Some(SavesOped::BackedUp {
dir: dir.to_string(),
file,
path: done.path.display().to_string(),
bytes: done.bytes,
skipped: done.skipped,
}),
)
}
/// 备份的默认落点:`<data>/exports/saves/`(见模块文档「备份落点」一节)。
fn export_dir(env: &HostEnv) -> PathBuf {
env.layout.data().join("exports").join("saves")
}
/// 打开存档目录 → 再列一遍(失败只说"打不开",原文进括号)。
@@ -98,7 +240,7 @@ fn open_folder(env: &HostEnv, id: &str) -> Result<SavesListView, SavesFailureVie
let target = open_target(&scan, &run_dir);
opener::open_dir(&target)
.map_err(|detail| failure(&instance.id, SavesFailureKind::OpenFailed, detail))?;
Ok(view(&instance, &scan))
Ok(view(&instance.id, &scan, None))
}
/// 打开哪一个目录:`saves/` → `screenshots/` → 运行目录(取第一个存在的)。
@@ -144,9 +286,9 @@ fn run_dir(env: &HostEnv, instance: &InstanceSummary) -> Result<PathBuf, String>
}
/// 快照映射:core 的扫描结果 → DTO(路径串在 host 这一侧才成形)。
fn view(instance: &InstanceSummary, scan: &SavesScan) -> SavesListView {
fn view(instance: &str, scan: &SavesScan, last_op: Option<SavesOped>) -> SavesListView {
SavesListView {
instance: instance.id.clone(),
instance: instance.to_string(),
dir: scan.dir.display().to_string(),
exists: scan.exists,
worlds: scan
@@ -188,12 +330,15 @@ fn view(instance: &InstanceSummary, scan: &SavesScan) -> SavesListView {
},
})
.collect(),
last_op,
}
}
fn failure(instance: &str, kind: SavesFailureKind, detail: impl Into<String>) -> SavesFailureView {
SavesFailureView {
instance: instance.to_string(),
file: String::new(),
op: None,
kind,
detail: detail.into(),
}
@@ -208,5 +353,34 @@ fn unusable(instance: &str, error: &SavesError) -> SavesFailureView {
)
}
/// core 的写失败 → 具名类别 + 「路径:原文」(**不翻译**,翻译在 app 层)。
///
/// 八个类别一一对应,没有一个落到「未知」兜底里:兜底会变成静默(用户只看到"失败了")。
fn write_failure(
instance: &str,
op: SavesFailureOp,
file: &str,
error: &SavesWriteError,
) -> SavesFailureView {
let kind = match error.kind {
SavesWriteKind::UnsafeName => SavesFailureKind::WriteUnsafeName,
SavesWriteKind::SymbolicLink => SavesFailureKind::WriteSymlink,
SavesWriteKind::NotFound => SavesFailureKind::WriteMissing,
SavesWriteKind::NotADirectory => SavesFailureKind::WriteNotADirectory,
SavesWriteKind::Conflict => SavesFailureKind::WriteConflict,
SavesWriteKind::Boundary => SavesFailureKind::WriteBoundary,
SavesWriteKind::TooLarge => SavesFailureKind::WriteTooLarge,
SavesWriteKind::Io => SavesFailureKind::WriteFailed,
};
let mut failure = failure(
instance,
kind,
format!("{}:{}", error.path.display(), error.detail),
);
failure.file = file.to_string();
failure.op = Some(op);
failure
}
#[cfg(test)]
mod tests;
+180 -1
View File
@@ -73,6 +73,12 @@ fn instance() -> InstanceSummary {
}
}
/// 一个真世界(`saves/<name>/level.dat`)。
fn world(run: &std::path::Path, name: &str) {
std::fs::create_dir_all(run.join("saves").join(name)).expect("建世界");
std::fs::write(run.join("saves").join(name).join("level.dat"), b"nbt").expect("写 level.dat");
}
/// **端到端**(真盘 + 真派生):世界 / 截图 / 跳过原因都过桥,且快照里的路径就是派生目录。
///
/// 抓得住:派生用错目录、`icon.png` 被当成"读到了"、跳过原因被折叠、字节数丢了。
@@ -194,6 +200,179 @@ fn every_core_skip_reason_keeps_its_own_kind() {
reason,
}],
};
assert_eq!(view(&instance(), &scan).skipped[0].reason, expected);
assert_eq!(view(ID, &scan, None).skipped[0].reason, expected);
}
}
/// **端到端**:重命名 → 真盘改名 + 新快照 + 回执。
///
/// 抓得住:host 用错运行目录、写完成却忘了重新列(界面还画旧名字)、回执与快照对不上。
#[test]
fn rename_round_trips_on_a_real_root() {
let (_root, env, run) = fixture("saves-rename");
world(&run, "Old World");
world(&run, "Other");
let listed = rename(&env, ID, "Old World", "New World").expect("改名");
assert!(!run.join("saves/Old World").exists(), "旧目录还在");
assert!(
run.join("saves/New World/level.dat").exists(),
"新目录里没有内容"
);
let names: Vec<&str> = listed
.worlds
.iter()
.map(|world| world.dir.as_str())
.collect();
assert_eq!(names, ["New World", "Other"], "快照没跟着刷新");
assert_eq!(
listed.last_op,
Some(SavesOped::Renamed {
from: "Old World".to_string(),
to: "New World".to_string()
})
);
}
/// **端到端**:删除 → 真盘没了 + 新快照 + 回执。
#[test]
fn delete_round_trips_on_a_real_root() {
let (_root, env, run) = fixture("saves-delete");
world(&run, "Doomed");
world(&run, "Kept");
let listed = delete(&env, ID, "Doomed").expect("删除");
assert!(!run.join("saves/Doomed").exists(), "目录还在");
let names: Vec<&str> = listed
.worlds
.iter()
.map(|world| world.dir.as_str())
.collect();
assert_eq!(names, ["Kept"], "别的世界被连带删了 / 快照没刷新");
assert_eq!(
listed.last_op,
Some(SavesOped::Deleted {
dir: "Doomed".to_string()
})
);
}
/// **端到端**:备份 → zip 落在启动器自己的 `exports/saves/`,回执带路径与字节数。
#[test]
fn backup_round_trips_to_the_exports_dir() {
let (_root, env, run) = fixture("saves-backup");
world(&run, "My World");
let listed = backup(&env, ID, "My World", None).expect("备份");
let exports = env.layout.data().join("exports").join("saves");
let Some(SavesOped::BackedUp {
dir,
file,
path,
bytes,
skipped,
}) = listed.last_op.clone()
else {
panic!("备份成功却没有回执:{:?}", listed.last_op);
};
assert_eq!(dir, "My World");
assert!(
file.starts_with("My World-"),
"文件名要带世界名与时戳:{file}"
);
assert!(file.ends_with(".zip"), "文件名要带 .zip:{file}");
assert_eq!(std::path::PathBuf::from(&path), exports.join(&file));
assert!(std::path::Path::new(&path).exists(), "zip 没真的落盘");
assert!(bytes > 0);
assert_eq!(skipped, 0);
// 备份不改世界本身:列表还是一行。
assert_eq!(listed.world_count(), 1);
// 显式落点也要照用(将来接了目录选择框就走这条)。
let custom = backup(
&env,
ID,
"My World",
Some(&run.join("elsewhere").display().to_string()),
)
.expect("备份到指定目录");
assert!(matches!(custom.last_op, Some(SavesOped::BackedUp { .. })));
assert!(run.join("elsewhere").is_dir(), "显式落点没被采用");
}
/// 八种写失败各给**自己的**类别(没有一个落到「未知」兜底)。
#[test]
fn every_write_failure_keeps_its_own_kind() {
let (_root, env, run) = fixture("saves-write-kinds");
world(&run, "Taken");
world(&run, "Other");
let cases = [
("Other", "Taken", SavesFailureKind::WriteConflict),
("Other", "..", SavesFailureKind::WriteUnsafeName),
("Nope", "Fresh", SavesFailureKind::WriteMissing),
];
for (from, to, expected) in cases {
let failure = rename(&env, ID, from, to).expect_err("改成功了");
assert_eq!(failure.kind, expected, "{from} → {to}:{}", failure.detail);
}
let failure = delete(&env, ID, "Nope").expect_err("删成功了");
assert_eq!(failure.kind, SavesFailureKind::WriteMissing);
// 空 / 相对的落点目录:会落到进程当前目录,所以是"越界"而不是"IO 失败"。
let failure = backup(&env, ID, "Taken", Some("")).expect_err("备份到空目录居然成功了");
assert_eq!(
failure.kind,
SavesFailureKind::WriteBoundary,
"{}",
failure.detail
);
// core 的八个类别 → DTO 八个类别(一一对应,不折叠)。
let pairs = [
(
SavesWriteKind::UnsafeName,
SavesFailureKind::WriteUnsafeName,
),
(SavesWriteKind::SymbolicLink, SavesFailureKind::WriteSymlink),
(SavesWriteKind::NotFound, SavesFailureKind::WriteMissing),
(
SavesWriteKind::NotADirectory,
SavesFailureKind::WriteNotADirectory,
),
(SavesWriteKind::Conflict, SavesFailureKind::WriteConflict),
(SavesWriteKind::Boundary, SavesFailureKind::WriteBoundary),
(SavesWriteKind::TooLarge, SavesFailureKind::WriteTooLarge),
(SavesWriteKind::Io, SavesFailureKind::WriteFailed),
];
for (core, expected) in pairs {
let error = SavesWriteError {
path: PathBuf::from("C:/mc/saves/x"),
kind: core,
detail: "原文".to_string(),
};
let failure = write_failure(ID, SavesFailureOp::Backup, "Taken", &error);
assert_eq!(failure.kind, expected, "{core:?}");
assert!(
failure.detail.contains("saves"),
"原文丢了:{}",
failure.detail
);
assert_eq!(failure.instance, ID);
assert_eq!(failure.file, "Taken", "失败那句话要指名是哪一行");
assert_eq!(failure.op, Some(SavesFailureOp::Backup));
}
}
/// 找不到实例的写操作 ⇒ 同一个具名失败(不是"写成功了个空的")。
#[test]
fn write_ops_on_a_vanished_instance_are_named() {
let (_root, env, _run) = fixture("saves-write-unknown");
for failure in [
rename(&env, "gone", "A", "B").expect_err("改名成功了"),
delete(&env, "gone", "A").expect_err("删除成功了"),
backup(&env, "gone", "A", None).expect_err("备份成功了"),
] {
assert_eq!(failure.kind, SavesFailureKind::InstanceUnavailable);
assert_eq!(failure.instance, "gone");
}
}
+1 -1
View File
@@ -53,7 +53,7 @@ mod tiles;
pub use live::MAX_LIVE_LINES;
pub use mods::ModsState;
pub use saves::SavesState;
pub use saves::{RenameDraft, SavesOp, SavesState};
pub use tiles::{grid_positions, TileCell};
/// 一份加载器版本清单查询的结果(`Ok` = 最新在前;`Err` = 人读原因)。
+209 -1
View File
@@ -1,8 +1,16 @@
//! 实例存档 / 截图快照的落地(M235 · STOREMVP)。
//! 实例存档 / 截图快照的落地(M235 · STOREMVP)+ 写操作的三样交互态(M247 · SAVESOPS)。
//!
//! 从 `state.rs` 拆出(那个文件贴着 400 行硬线),形状与 [`super::mods`] 一致:
//! **另一个 `impl AppState` 块**,路径与可见性都不变,`apply_core` 只有一行投递。
//!
//! # 写操作的三样交互态(M247)
//!
//! | 字段 | 是什么 | 为什么必须住在这里 |
//! |---|---|---|
//! | [`SavesState::draft`] | 行内重命名的输入缓冲 | 页面模型每帧从快照重建(`from_state`),打字过程中的内容不在快照里 ⇒ 不留在这里就会每打一个字被事件冲掉 |
//! | [`SavesState::pending`] | 哪个世界的哪个写操作在飞 | 与 [`super::mods::ModsState::pending`] **同一条先例**:只用来把那一行的按钮置灰,**不做乐观回显**(列表只跟快照走) |
//! | [`SavesState::confirm`] | 删除确认框**正在等谁** | 模态队列只画框、不记得"确认之后要删哪个世界";而点主按钮的那一刻是 `app/update/dialog.rs`,那里只能从状态里取 |
//!
//! # 为什么快照住在这里而不是页面
//!
//! 「列表画的永远是磁盘上的真相」是本模块唯一要保证的事,所以:
@@ -22,6 +30,45 @@ use crate::dto::{SavesFailureView, SavesListView};
use super::AppState;
/// 行内重命名的输入缓冲(见模块文档的三样交互态)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RenameDraft {
/// 哪个世界(磁盘名,提交时原样带回去当「源」)。
pub dir: String,
/// 输入框里的内容(**原样**:合不合法由 core 判,页面只管显示,不做前端过滤)。
pub text: String,
}
/// 一次在飞的写操作(只用来置灰那一行,列表仍然只跟快照)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SavesOp {
/// 重命名中。
Rename {
/// 磁盘名
dir: String,
},
/// 删除中。
Delete {
/// 磁盘名
dir: String,
},
/// 备份中。
Backup {
/// 磁盘名
dir: String,
},
}
impl SavesOp {
/// 这个操作动的是哪个世界。
#[must_use]
pub fn dir(&self) -> &str {
match self {
Self::Rename { dir } | Self::Delete { dir } | Self::Backup { dir } => dir,
}
}
}
/// 存档区的视图态。
#[derive(Debug, Clone, Default, PartialEq)]
pub struct SavesState {
@@ -31,10 +78,19 @@ pub struct SavesState {
pub view: Option<SavesListView>,
/// 最近一条失败(具名原因;成功一次就清掉)。
pub failed: Option<SavesFailureView>,
/// 行内重命名的输入缓冲(`None` = 没有在改名)。
pub draft: Option<RenameDraft>,
/// 在飞的写操作(`None` = 空闲)。
pub pending: Option<SavesOp>,
/// 删除确认框正在等哪个世界(点「确定」时才真的删)。
pub confirm: Option<String>,
}
impl SavesState {
/// 切到另一个实例(换实例必须清空:A 的存档不能画在 B 的详情页上)。
///
/// 三样交互态一起清:换实例之后还留着「A 的行内草稿 / A 的删除确认」是错位状态
/// (点确定会去删 B 页上的 A,或者把 A 的草稿提交成 B 的改名)。
pub(super) fn bind(&mut self, instance: Option<&str>) {
if self.bound.as_deref() == instance {
return;
@@ -42,9 +98,19 @@ impl SavesState {
self.bound = instance.map(str::to_string);
self.view = None;
self.failed = None;
self.reset_interaction();
}
/// 清掉三样交互态(换实例 / 操作回程时用)。
fn reset_interaction(&mut self) {
self.draft = None;
self.pending = None;
self.confirm = None;
}
/// 一条结果落地(`Ok` = 快照整体替换,`Err` = 记一条具名失败;迟到/串台的丢掉)。
///
/// **无论成败都清「在飞」与「确认中」**:这是一次后台往返的收尾,操作已经不在飞了。
pub fn apply(&mut self, result: Result<SavesListView, SavesFailureView>) {
match result {
Ok(view) => self.changed(view),
@@ -59,6 +125,8 @@ impl SavesState {
}
self.failed = None;
self.view = Some(view);
// 写操作成功之后草稿与在飞标记都该没了:行已经按新名字画出来了。
self.reset_interaction();
}
/// 失败落地(只记**当前绑定实例**的;old 列表保留——用户还能看见旧列表,只是多一条红字)。
@@ -67,6 +135,61 @@ impl SavesState {
return;
}
self.failed = Some(failure);
// 失败也收尾:按钮不能永远灰着(用户要能直接再点一次重试)。
self.pending = None;
self.confirm = None;
}
/// 开一个行内重命名(输入框预填当前磁盘名:改个后缀比从零打更常见)。
pub fn start_rename(&mut self, dir: &str) {
self.draft = Some(RenameDraft {
dir: dir.to_string(),
text: dir.to_string(),
});
}
/// 输入框变了(**照原样存**,不做前端过滤:非法名字由 core 拒并给出具名原因)。
pub fn edit_rename(&mut self, text: String) {
if let Some(draft) = &mut self.draft {
draft.text = text;
}
}
/// 取走草稿并收起来(提交时用;`None` = 当前没有草稿)。
pub fn take_rename(&mut self) -> Option<RenameDraft> {
self.draft.take()
}
/// 取消行内重命名。
pub fn cancel_rename(&mut self) {
self.draft = None;
}
/// 记下「删除确认框正在等谁」。
pub fn ask_delete(&mut self, dir: &str) {
self.confirm = Some(dir.to_string());
}
/// 取走确认目标(点「确定」时用;取走即清,避免同一次确认被点两次)。
pub fn take_confirm(&mut self) -> Option<String> {
self.confirm.take()
}
/// 确认框被取消/关掉:清掉目标。
pub fn clear_confirm(&mut self) {
self.confirm = None;
}
/// 标记一次写操作在飞(草稿同时收起来:请求已经发出去了)。
pub fn mark_pending(&mut self, op: SavesOp) {
self.pending = Some(op);
self.draft = None;
}
/// 这个世界的写操作在飞吗(用来把那一行置灰)。
#[must_use]
pub fn is_pending(&self, dir: &str) -> bool {
self.pending.as_ref().is_some_and(|op| op.dir() == dir)
}
/// 当前绑定实例的列表是否已经有一份快照。
@@ -102,12 +225,15 @@ mod tests {
screenshots_exists: false,
screenshots: Vec::new(),
skipped: Vec::new(),
last_op: None,
}
}
fn failure(instance: &str) -> SavesFailureView {
SavesFailureView {
instance: instance.to_string(),
file: String::new(),
op: None,
kind: SavesFailureKind::DirUnusable,
detail: "Access is denied.".to_string(),
}
@@ -152,4 +278,86 @@ mod tests {
assert!(!idle.has_view());
assert!(idle.failed.is_none());
}
/// 换实例要把三样交互态一起清掉(草稿 / 在飞 / 确认目标)。
///
/// 抓得住:换到 B 之后「A 的草稿」还留着 ⇒ 用户点确定会去改 B 页上的一个不存在的名字。
#[test]
fn switching_instances_drops_draft_pending_and_confirm() {
let mut saves = SavesState::default();
saves.bind(Some("a"));
saves.changed(view("a"));
saves.start_rename("World");
saves.edit_rename("Renamed".to_string());
saves.ask_delete("World");
saves.mark_pending(SavesOp::Backup {
dir: "World".to_string(),
});
assert!(saves.draft.is_none(), "发请求时草稿该收起来");
assert!(saves.pending.is_some());
saves.bind(Some("b"));
assert!(saves.draft.is_none(), "换实例后草稿还在");
assert!(saves.pending.is_none(), "换实例后在飞标记还在");
assert!(saves.confirm.is_none(), "换实例后删除确认目标还在");
}
/// 回程(成功或失败)都要把「在飞」收掉:按钮不能永远灰着。
#[test]
fn both_outcomes_clear_the_pending_marker() {
let mut saves = SavesState::default();
saves.bind(Some("a"));
saves.changed(view("a"));
saves.mark_pending(SavesOp::Delete {
dir: "World".to_string(),
});
assert!(saves.is_pending("World"));
saves.failed(failure("a"));
assert!(
!saves.is_pending("World"),
"失败后在飞标记没清:重试按钮会一直是灰的"
);
saves.mark_pending(SavesOp::Rename {
dir: "World".to_string(),
});
assert!(saves.is_pending("World"));
saves.changed(view("a"));
assert!(!saves.is_pending("World"), "成功后没清在飞标记");
}
/// 确认目标只发一次(同一次确认连点两下不该发两条删除)。
#[test]
fn the_delete_confirmation_hands_out_once() {
let mut saves = SavesState::default();
saves.bind(Some("a"));
saves.ask_delete("Doomed");
assert_eq!(saves.take_confirm().as_deref(), Some("Doomed"));
assert!(
saves.take_confirm().is_none(),
"第二次取还拿得到:连点会删两次"
);
saves.ask_delete("Other");
saves.clear_confirm();
assert!(saves.take_confirm().is_none(), "取消之后还留着目标");
}
/// 行内草稿照原样存:非法名字不在前端过滤(core 会拒并给出具名原因)。
#[test]
fn the_rename_draft_keeps_the_typed_text_verbatim() {
let mut saves = SavesState::default();
saves.bind(Some("a"));
saves.start_rename("World");
assert_eq!(
saves.draft.as_ref().expect("草稿").text,
"World",
"应预填当前名字"
);
saves.edit_rename("../escape".to_string());
let draft = saves.take_rename().expect("草稿");
assert_eq!(draft.dir, "World");
assert_eq!(draft.text, "../escape", "输入被前端偷偷改了");
assert!(saves.take_rename().is_none(), "取走之后草稿还在");
}
}