Files
Unlucky-Launcher/crates/app-iced/src/app/update/dialog.rs
T
AskaEth 32384b952e chore(presenter,app-iced): 复审 r2 三项(文档对齐 / 拆 dialog.rs / 收窄关闭路径)
1. 文档自相矛盾(复审 r2 第 1 项):`SavesState::apply` 的文档还写着「无论成败都清『在飞』与
   『确认中』」——改完之后这是假的,而且与模块文档「安全判据第 3 条」互相打脸。现在逐支写清:
   `Ok` 清 draft/pending、`confirm` 只在被确认的世界已不在快照里时清并登记 `closed_confirm`;
   `Err` 只清 pending。生命周期表里的 `dismissed` 改成真字段名 `closed_confirm`(消费点是
   `app/state/refresh.rs::settle_saves_confirm`)。

2. 拆 `app/update/dialog.rs`(复审裁决):397 行里 262 行是内联 `mod tests`,而它装的是
   主按钮 / Esc / 次按钮三条不可逆分支。照仓内先例(`app/dialog.rs` + `app/dialog/tests.rs`)
   把测试体搬到 `app/update/dialog/tests.rs`,**纯搬移**:`dialog.rs` 397 → 138,
   `dialog/tests.rs` 262;从 `6c87e2a` 的 `dialog.rs` 里抽出 `mod tests` 去一层缩进后与
   新文件**逐行比对 0 处差异**(262 = 262)。

3. 收窄关闭路径(复审 r2 加固):`SavesState::close_confirm(current)` 只在 `current` 等于登记的
   那个框时才清;`clear_confirm()` 保持无条件(换实例/复位用)。`escape()` 与 `Secondary|Dismiss`
   改成传当前框 id。新用例 `closing_another_dialog_leaves_our_target_alone`(别人关框不清我们的
   目标;自己关框才清)。
2026-11-11 14:25:26 +08:00

139 lines
6.3 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 弹窗消息的落点:关闭 / 展开 / 导出 / 打开文件夹 / 复制路径。
//!
//! 只有两类副作用:**剪贴板**(返回 `Task`,必须交回 runtime)与**弹 Toast**(就地推)。
//! 导出与打开文件夹是同步的本地动作(写一个 zip / 拉起一个 `explorer`),耗时都在毫秒级,
//! 不为它们单开线程——真要慢(网络盘)也是用户能感知的一次点击。
use iced::Task;
use unlucky_presenter::toast::ToastKind;
use crate::app::dialog::{export, Message as DialogMessage};
use crate::app::state::App;
pub(super) fn on_dialog(app: &mut App, message: DialogMessage) -> Task<crate::app::Message> {
match message {
DialogMessage::Primary => {
// M247:删除存档的二次确认——主按钮就是**真删**(这是本页唯一不可逆的动作)。
// 目标记在 presenter 的状态里(`SavesState::confirm`):模态队列只画框,不记"要删谁"。
//
// **认框不认人**(复审 r1 P1-1):只核销"当前这个框"登记的目标。哪怕某条关闭路径漏清,
// 崩溃卡片的「导出诊断包」也拿不走它——否则就是一次静默删世界。
let current = app.state.dialogs.current().map(|request| request.id);
if let Some(dir) = current.and_then(|id| app.state.saves.take_confirm(id)) {
app.close_dialog();
if let Some(instance) = app.selected_instance_id() {
app.state
.saves
.mark_pending(unlucky_presenter::state::SavesOp::Delete {
dir: dir.clone(),
});
app.dispatch(unlucky_presenter::dto::AppCommand::Saves(Box::new(
unlucky_presenter::dto::SavesCommand::DeleteWorld { id: instance, dir },
)));
}
app.refresh_view();
return Task::none();
}
// 崩溃卡片的主按钮是「导出诊断包」;普通确认框的主按钮就是「关掉」。
let Some(card) = app.dialog_card.clone() else {
app.close_dialog();
return Task::none();
};
let done = export::export(&card.report, app.lang());
// 结论**画在卡片里**:导出这种「必须看见落到哪」的结果不能只靠 Toast
// (app-iced 的 Toast 至今没有渲染点)。成败按 `write_bundle` 的真实结果判,
// 不看「launcher_logs 有没有值」——那只是入参齐全度(r1 复审 P3-2)。
app.state.toasts.push(
if done.ok {
ToastKind::Success
} else {
ToastKind::Error
},
done.text.clone(),
);
if let Some(state) = &mut app.dialog_card {
state.outcome = Some(done.text);
}
app.view_dirty = true;
app.refresh_view();
Task::none()
}
DialogMessage::Secondary | DialogMessage::Dismiss => {
// M247:取消/关掉删除确认框 ⇒ 忘掉那个目标(不然下一次点确定会删上一个)。
// **只核销当前这个框的目标**(复审 r2 加固):别人的框关掉时不该动我们的。
if let Some(current) = app.state.dialogs.current().map(|request| request.id) {
app.state.saves.close_confirm(current);
}
app.close_dialog();
// 立即重画:空闲态只有 1 Hz 心跳,只置 `view_dirty` 会让弹窗再留最多 1 秒
// (r1 复审 P3-1)。
app.refresh_view();
Task::none()
}
DialogMessage::Toggle => {
app.toggle_dialog_detail();
Task::none()
}
DialogMessage::OpenFolder => {
let Some(card) = app.dialog_card.clone() else {
return Task::none();
};
let problem = export::open_folder(&card.report, app.lang());
if !problem.is_empty() {
if let Some(state) = &mut app.dialog_card {
state.outcome = Some(problem.clone());
}
app.state.toasts.push(ToastKind::Error, problem);
app.view_dirty = true;
app.refresh_view();
}
Task::none()
}
DialogMessage::CopyPaths => {
let Some(card) = app.dialog_card.clone() else {
return Task::none();
};
app.state.toasts.push(
ToastKind::Info,
app.lang()
.catalog()
.text(unlucky_i18n::catalog::Key::CrashDialogCopied),
);
iced::clipboard::write(export::paths_text(&card.report))
}
DialogMessage::CopyPath(index) => {
let Some(card) = app.dialog_card.clone() else {
return Task::none();
};
let Some((_, path)) = card.report.path_lines().into_iter().nth(index) else {
return Task::none();
};
app.state.toasts.push(
ToastKind::Info,
app.lang()
.catalog()
.text(unlucky_i18n::catalog::Key::CrashDialogCopied),
);
iced::clipboard::write(path.display().to_string())
}
}
}
/// `Esc` 关弹窗(有弹窗时**先吃这一条**,不让它落到 AI 栏的 Esc 收起上)。
pub(super) fn escape(app: &mut App) -> bool {
let Some(current) = app.state.dialogs.current().map(|request| request.id) else {
return false;
};
// M247(复审 r1 P1-1):**三条关闭路径都要清**——次按钮/遮罩清了,`Esc` 这条以前漏了,
// 于是"删存档 → Esc → 崩溃卡片 → 导出诊断包"会把那个世界静默删掉。
// 现在还有 id 绑定兜一道(`take_confirm(current)`),这里是纵深防御的第二层。
// 复审 r2 加固:清的时候**只清这个框自己的目标**(`close_confirm`),不碰别人的。
app.state.saves.close_confirm(current);
app.close_dialog();
app.refresh_view();
true
}
#[cfg(test)]
mod tests;