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
@@ -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