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:
AskaEth committed 2026-11-12 13:13:53 +08:00
1 parent 751ae80f28
commit 5479f25068
11 files changed
+574 -46

No files matched your search

+15 -6
View File
@@ -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>(
+30 -4
View File
@@ -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)
}