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:
1 parent
3e852331f4
commit
c921b9c185
28 files changed
+3016
-216
No files matched your search
@@ -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::{
|
||||
|
||||
@@ -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;
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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` = 人读原因)。
|
||||
|
||||
@@ -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(), "取走之后草稿还在");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user