feat(presenter): 实例清单改并集(档案 ∪ versions/*)+ 空态接导入向导(M273)
用户实测:HMCL 能看见 `.minecraft/versions/樱落怡然客户端`,ULL 的实例页是空的 (日志 `instances=0`)。根因不是路径找错,而是 `host::game::instances()` 只列 `profiles.json` 里的档案,第三方启动器装好的版本在界面上根本不存在。 ## 列表改并集(presenter/src/host/game.rs) - `versions/*` 里**能解析出清单**(`<id>/<id>.json` 的 id 与目录名一致、继承链走到底、 符号链接不越界)且没有被任何档案绑定的版本,就地合成一条实例视图: 显示名 = 目录名、`version_id` = 目录名。扫描复用 `core::game::adopt::scan`, 没有另写一套目录遍历。 - 只读:合成视图不建目录、不写 profiles.json、不碰 versions/ 一个字节。 - 档案优先:某版本已被档案绑定时只按档案出一条(名字/隔离档/启动参数都保留),不重复列。 - 坏目录(无清单 / JSON 坏 / id 不符 / 缺父 / 越界)不当实例;`versions/` 读不出来也只是 退回「只有档案」的旧行为,不让实例页报错。 - 外部实例的视图 id 用 `ext-<fnv64>`:`is_valid_instance_id` 只收 `[A-Za-z0-9_-]`, 而版本目录名可以是中文(现场就是),直接沿用版本 id 会让**启动**在读实例设置时就失败。 ## 懒创建档案(只有第一次改它才写盘) `set_isolation()` 与 `apply_launch_args()` 的「找不到实例」改成 `game::ensure_profile()`: 外部实例第一次改隔离档 / 启动参数时才写 profiles.json(幂等,id 与视图 id 相同)。 ## 空态可见入口 + 文案(app-iced / i18n) - 空态分两种:列表里有东西但没选中(原「未选择实例」)与**一个实例都没有** (新:「还没有实例」+ 实心主行动「导入已有版本」)。用户现场看不到任何入口正是前一种文案 在误导。 - `adopt_action()` 成为「导入已有版本」意图的唯一产地,页头按钮与空态主行动共用它。 ## 门禁 `cargo test -p unlucky-app-iced --lib` 971 passed / `cargo test -p unlucky-presenter --all-targets` 384 passed / `cargo test -p unlucky-i18n` 36 passed / `cargo fmt --all --check` 0 / `cargo clippy -p unlucky-presenter -p unlucky-app-iced --all-targets -- -D warnings` 0。
This commit is contained in:
1 parent
751ae80f28
commit
5479f25068
11 files changed
+574
-46
No files matched your search
@@ -161,10 +161,7 @@ pub fn view<'a>(
|
||||
))
|
||||
.action(buttons::text(
|
||||
ui,
|
||||
forced_state(
|
||||
Button::new(texts.import(), Message::Action(Action::Import)),
|
||||
forced,
|
||||
),
|
||||
forced_state(Button::new(texts.import(), adopt_action()), forced),
|
||||
));
|
||||
|
||||
let body = match metrics.layout {
|
||||
@@ -182,7 +179,7 @@ pub fn view<'a>(
|
||||
forced,
|
||||
texts,
|
||||
),
|
||||
None => detail::detail_empty(ui, texts),
|
||||
None => detail::detail_empty(ui, texts, !block.rows.is_empty()),
|
||||
},
|
||||
),
|
||||
_ => match &block.detail {
|
||||
@@ -196,13 +193,25 @@ pub fn view<'a>(
|
||||
forced,
|
||||
texts,
|
||||
),
|
||||
None => frame::body(metrics, detail::detail_empty(ui, texts)),
|
||||
None => frame::body(
|
||||
metrics,
|
||||
detail::detail_empty(ui, texts, !block.rows.is_empty()),
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
frame::page(ui, metrics, header, body)
|
||||
}
|
||||
|
||||
/// 「导入已有版本」向导的入口意图——**页面里唯一产地**。
|
||||
///
|
||||
/// 两个入口共用它:页头的「导入」与空态的主行动(一个实例都没有时用户只看得到后一个)。
|
||||
/// 落点是 `app/update/page_messages.rs` 的 `Action::Import` 臂(→ `AdoptMessage::Open`),
|
||||
/// 所以中间任何一段被拆掉,向导就再也打不开——`instances/tests/empty_entry.rs` 专门钉这条链。
|
||||
pub(crate) fn adopt_action() -> Message {
|
||||
Message::Action(Action::Import)
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- 列表列
|
||||
|
||||
fn list_column<'a>(
|
||||
|
||||
@@ -292,12 +292,38 @@ fn action_row<'a>(ui: &'a Ui, forced: Option<Interaction>, texts: Texts) -> Elem
|
||||
.into()
|
||||
}
|
||||
|
||||
pub(super) fn detail_empty<'a>(ui: &'a Ui, texts: Texts) -> Element<'a, Message> {
|
||||
/// 详情列的**空态**,两种含义分得很清楚:
|
||||
///
|
||||
/// | `has_instances` | 标题 / 说明 | 主行动 |
|
||||
/// |---|---|---|
|
||||
/// | `true`(列表有东西,但没选中) | 「未选择实例」+ 「在左侧选一个」 | 无 |
|
||||
/// | `false`(一个实例都没有) | 「还没有实例」+ 「导入一个已经装在盘上的版本」 | **导入已有版本**(实心按钮) |
|
||||
///
|
||||
/// 为什么要分开(M273):用户现场 `instances=0`,却看到「未选择实例」——那句话在暗示
|
||||
/// 「你已经有的东西只是没点」,于是他找不到任何入口。一个都没有时必须给一条**看得见**的出路:
|
||||
/// 接管盘上已经装好的版本(向导的另一半是「检测到了 N 个外部版本」的候选列表)。
|
||||
pub(super) fn detail_empty<'a>(
|
||||
ui: &'a Ui,
|
||||
texts: Texts,
|
||||
has_instances: bool,
|
||||
) -> Element<'a, Message> {
|
||||
if has_instances {
|
||||
return frame::empty_state(
|
||||
ui,
|
||||
IconKind::Instances,
|
||||
texts.empty_title(),
|
||||
texts.empty_body(),
|
||||
Vec::new(),
|
||||
);
|
||||
}
|
||||
frame::empty_state(
|
||||
ui,
|
||||
IconKind::Instances,
|
||||
texts.empty_title(),
|
||||
texts.empty_body(),
|
||||
Vec::new(),
|
||||
texts.empty_none_title(),
|
||||
texts.empty_none_body(),
|
||||
vec![buttons::filled(
|
||||
ui,
|
||||
Button::new(texts.adopt_cta(), super::adopt_action()),
|
||||
)],
|
||||
)
|
||||
}
|
||||
@@ -13,6 +13,9 @@ mod saves;
|
||||
/// M252 / P18 的**回车**接线(行内重命名:回车 = 点「改名」)。
|
||||
mod rename;
|
||||
|
||||
/// M273 的**入口接线**(空态的「导入已有版本」⇒ `AdoptMessage::Open`)。
|
||||
mod empty_entry;
|
||||
|
||||
use super::super::layout::source;
|
||||
use super::super::layout::{frame_metrics, measure_cap_px, PaneKit, WidthClass};
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
//! M273 · 「导入已有版本」向导的**入口接线**(空态那一半)。
|
||||
//!
|
||||
//! ## 为什么要单独一组
|
||||
//!
|
||||
//! 这条功能的失败形态不是「算错了」,而是「界面上没有入口」(M223 之后用户实测:
|
||||
//! `instances=0` 的空态只有一句「未选择实例」,他找不到任何能接管 `.minecraft` 的地方)。
|
||||
//! 逻辑测试抓不住这种洞——`on_adopt(Open)` 的用例一直是绿的,因为**没有人从界面派发它**。
|
||||
//!
|
||||
//! | 用例 | 抓得住 | 抓不住 |
|
||||
//! |---|---|---|
|
||||
//! | [`the_empty_state_hands_out_the_adopt_intent`] | 空态主行动发的是**哪一条**意图(被换成新建 / 删除 / 复制就红) | 那条意图真的从按钮里发出来(`Element` 没有内省接口,见 `pages::layout::source`) |
|
||||
//! | [`the_empty_state_wires_the_cta_to_that_intent`] | 空态里那枚按钮**被删掉**、被注释掉、或接到别的消息上 | 按钮画出来好不好看 / 真的可点(出图 + 人工) |
|
||||
//! | [`the_intent_still_reaches_the_adopt_wizard`] | `pages::Action::Import` → `AdoptMessage::Open` 这一跳被拆掉(那正是「UI 存在但没人派发」的复发形态) | `Open` 之后向导自己的四段(`app/update/adopt.rs` 的用例覆盖) |
|
||||
//!
|
||||
//! 三条合起来是一条链:空态按钮 ⇒ [`adopt_action`] ⇒ `Action::Import` ⇒ `AdoptMessage::Open`。
|
||||
//! 任何一段断开,这里至少红一条。
|
||||
|
||||
use crate::pages::layout::source;
|
||||
|
||||
use super::super::adopt_action;
|
||||
use super::super::Texts;
|
||||
use crate::pages::{Action, Message};
|
||||
use unlucky_i18n::Lang;
|
||||
|
||||
/// 空态主行动的发的是**导入**意图(不是新建、不是删除、不是占位动作)。
|
||||
#[test]
|
||||
fn the_empty_state_hands_out_the_adopt_intent() {
|
||||
assert_eq!(
|
||||
format!("{:?}", adopt_action()),
|
||||
format!("{:?}", Message::Action(Action::Import)),
|
||||
"空态主行动必须发「导入已有版本」,否则向导依旧打不开"
|
||||
);
|
||||
let text = format!("{:?}", adopt_action());
|
||||
for wrong in ["NewInstance", "Delete", "Duplicate", "OpenFolder"] {
|
||||
assert!(!text.contains(wrong), "空态主行动接到了 {wrong} 上:{text}");
|
||||
}
|
||||
}
|
||||
|
||||
/// 空态的按钮**在代码里**接 `adopt_action()`(判据是代码行:注释掉等于没有)。
|
||||
///
|
||||
/// 两种空态还必须分开:一个实例都没有时用 [`Texts::empty_none_title`],不能再拿
|
||||
/// 「未选择实例」敷衍——那句话在告诉用户「你已经有的东西只是没点」。
|
||||
#[test]
|
||||
fn the_empty_state_wires_the_cta_to_that_intent() {
|
||||
let body = source::function_body(include_str!("../detail.rs"), "fn detail_empty");
|
||||
let cta = source::code_line(body, "Button::new(texts.adopt_cta()")
|
||||
.unwrap_or_else(|| panic!("空态里没有一行**代码**建那枚「导入已有版本」按钮:\n{body}"));
|
||||
assert!(
|
||||
cta.contains("adopt_action()"),
|
||||
"空态按钮没接 `adopt_action()`(接成别的消息 = 入口又断了):{cta}"
|
||||
);
|
||||
|
||||
let en = Texts::of(Lang::En.catalog());
|
||||
assert_ne!(
|
||||
en.empty_none_title(),
|
||||
en.empty_title(),
|
||||
"「一个实例都没有」与「没选中」必须是两句话,否则空态又变成误导"
|
||||
);
|
||||
assert!(
|
||||
!en.adopt_cta().is_empty(),
|
||||
"空态主行动必须有可读标签(空标签 = 看不见的按钮)"
|
||||
);
|
||||
}
|
||||
|
||||
/// `pages::Action::Import` → `AdoptMessage::Open`:跨层的这一跳不许被拆。
|
||||
///
|
||||
/// 判据走 `code_text`(注释已剥):把那条 match 臂注释掉、或改成只打一行日志,这里就红——
|
||||
/// 那正是「功能做了但没接线」的原始形态(M223 之前 `Action::Import` 就是一条占位动作)。
|
||||
#[test]
|
||||
fn the_intent_still_reaches_the_adopt_wizard() {
|
||||
let router = source::code_text(include_str!("../../../app/update/page_messages.rs"));
|
||||
assert!(
|
||||
source::has_code_line(
|
||||
include_str!("../../../app/update/page_messages.rs"),
|
||||
"pages::Message::Action(pages::Action::Import) =>"
|
||||
),
|
||||
"`Action::Import` 在页面消息路由里没有分支了"
|
||||
);
|
||||
assert!(
|
||||
router.contains("self.on_adopt(crate::app::adopt::AdoptMessage::Open)"),
|
||||
"`Action::Import` 不再派发 `AdoptMessage::Open`(向导从界面上又变得不可达)"
|
||||
);
|
||||
}
|
||||
@@ -85,6 +85,18 @@ impl Texts {
|
||||
pub(super) fn empty_body(self) -> &'static str {
|
||||
self.text(Key::InstancesEmptyBody)
|
||||
}
|
||||
/// 一个实例都没有时的标题(与「有实例但没选」分开,见目录里 M273 那条注释)。
|
||||
pub(super) fn empty_none_title(self) -> &'static str {
|
||||
self.text(Key::InstancesEmptyNoneTitle)
|
||||
}
|
||||
/// 一个实例都没有时的说明。
|
||||
pub(super) fn empty_none_body(self) -> &'static str {
|
||||
self.text(Key::InstancesEmptyNoneBody)
|
||||
}
|
||||
/// 空态的主行动:「导入已有版本」。
|
||||
pub(super) fn adopt_cta(self) -> &'static str {
|
||||
self.text(Key::InstancesAdoptCta)
|
||||
}
|
||||
pub(super) fn narrow_note(self) -> &'static str {
|
||||
self.text(Key::InstancesNarrowNote)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user