Merge branch 'feat/m223-gui-adopt-import-wiring-guiadopt'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
This commit is contained in:
commit
71144dfca8
34 files changed
+3030
-112
No files matched your search
@@ -0,0 +1,261 @@
|
|||||||
|
//! 「导入已有版本」向导(M223 GUIADOPT):实例页页头的入口 + 一张盖在页面上的向导卡。
|
||||||
|
//!
|
||||||
|
//! ## 四段(与 presenter 的 `AdoptState` 一一对应)
|
||||||
|
//!
|
||||||
|
//! | 段 | 画什么 | 从哪来 | 文件 |
|
||||||
|
//! |---|---|---|---|
|
||||||
|
//! | Pick | 候选版本 + 目标档 + 实例名 | `AdoptEvent::Scan` | [`pick`] |
|
||||||
|
//! | Preview | 落点 / 隔离档 / **`target_notes()` 原文** | `AdoptEvent::Planned` | [`panes`] |
|
||||||
|
//! | Failed | 稳定码 + 人读原因 + **`problems[]` 逐条** | `AdoptEvent::Failed` | [`panes`] |
|
||||||
|
//! | Done | 实例名 / 运行目录 / 提示原文 | `AdoptEvent::Done` | [`panes`] |
|
||||||
|
//!
|
||||||
|
//! 「现在画哪一段」的判据只有一处:presenter 的 `AdoptState::stage`,外面套一条收窄
|
||||||
|
//! [`Form::stage`]——`Preview` 只在用户**点过预检**(`Form::checked`)之后才算数。收窄的理由:
|
||||||
|
//! 共享档 / 版本目录档一被点选就要显示后果原文(§5.2),那要用一次**零写盘**的预检换;
|
||||||
|
//! 但那次结果不该把用户直接甩到确认页(实例名还没填完)。
|
||||||
|
//!
|
||||||
|
//! 卡壳与页脚在 [`shell`](定高可滚 / 收高两种几何),三段视图在 [`pick`] 与 [`panes`],
|
||||||
|
//! 等宽与正文小工具在 `text`,D30 出图种子在 `shot`(`ULL_ADOPT_STATE`,只在 `--screenshot` 下生效)。
|
||||||
|
//!
|
||||||
|
//! ## 三条纪律(契约 §5 的 1/2/3)
|
||||||
|
//!
|
||||||
|
//! 1. **运行目录不派生**:Preview / Done 上那个路径**原样**来自回程事件(缺省档可能带 `-2` 后缀,
|
||||||
|
//! 只有 core 知道最后用了哪个)。本模块没有任何「拼一个目录名」的代码。
|
||||||
|
//! 2. **后果话术不重写**:共享档 / 版本目录档的后果句在 Preview 段**逐条**画 `plan.notes`
|
||||||
|
//! (core `target_notes()` 的原文)。Pick 段只放一句短提示(选之前就得知道会碰别人的目录),
|
||||||
|
//! 真正的后果原文在确认按钮之前一定会出现。
|
||||||
|
//! 3. **不折叠问题**:Failed 段把 `problems` 一条一行画出来,标题只说「导入失败」,原因全在下面。
|
||||||
|
//!
|
||||||
|
//! ## 为什么是「盖一层」而不是实例页里的一段
|
||||||
|
//!
|
||||||
|
//! 向导有四段状态、每段内容都不一样,塞进 list-detail 的正文列会把版式判据(424 散文宽、
|
||||||
|
//! 列表独立滚动)搅乱。盖一层与 M187 的模态同一手法(遮罩 + 居中卡),但**不是**
|
||||||
|
//! `DialogQueue` 那种「标题 + 正文 + 两个按钮」的窄形态:向导要画候选列表、单选档位与输入框。
|
||||||
|
//!
|
||||||
|
//! ## 表单字段归 `App`,事件真相归 presenter
|
||||||
|
//!
|
||||||
|
//! [`Form`] 只装「用户正在填的东西」(选中哪个版本、展示名、目标档、有没有在等回程);
|
||||||
|
//! 候选、预检、失败、完成全部来自 `AppState.adopt`(presenter 的 `AdoptState`)。两边各管一半,
|
||||||
|
//! 于是「画出来的落点」与「真正写盘的落点」永远是同一份事实。
|
||||||
|
|
||||||
|
mod panes;
|
||||||
|
mod pick;
|
||||||
|
mod shell;
|
||||||
|
/// D30 出图用的状态夹具(只在 `--screenshot` 下按环境变量生效)。
|
||||||
|
pub(crate) mod shot;
|
||||||
|
mod text;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
|
|
||||||
|
use iced::widget::{container, mouse_area, opaque, Space};
|
||||||
|
use iced::{Alignment, Background, Element, Length};
|
||||||
|
|
||||||
|
use unlucky_design::spacing::Space as Sp;
|
||||||
|
use unlucky_i18n::{Catalog, Lang};
|
||||||
|
use unlucky_presenter::dto::{
|
||||||
|
AdoptCandidateView, AdoptPlanView, AdoptStage, AdoptState, AdoptTargetView,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::app::components::feedback::dialog::scrim;
|
||||||
|
use crate::theme::Ui;
|
||||||
|
|
||||||
|
use super::message::Message;
|
||||||
|
|
||||||
|
/// 向导卡宽度:比模态卡(560)宽一档——候选行要放「版本 id + 状态」,目标档是三枚 chip。
|
||||||
|
const CARD_WIDTH: f32 = 640.0;
|
||||||
|
|
||||||
|
/// 卡片的最大高(窗口更矮时整卡按窗口走:内容在中间区滚)。
|
||||||
|
const CARD_MAX_HEIGHT: f32 = 620.0;
|
||||||
|
|
||||||
|
/// 正在等哪一条命令的回程。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub enum Busy {
|
||||||
|
/// 空闲(可以点)
|
||||||
|
#[default]
|
||||||
|
Idle,
|
||||||
|
/// 正在扫描 `versions/`
|
||||||
|
Scanning,
|
||||||
|
/// 正在预检(零写盘)
|
||||||
|
Checking,
|
||||||
|
/// 正在真正登记
|
||||||
|
Importing,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Busy {
|
||||||
|
/// 有没有在等回程。
|
||||||
|
#[must_use]
|
||||||
|
pub const fn waiting(self) -> bool {
|
||||||
|
!matches!(self, Self::Idle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 向导的表单态(**归 `App`**,页面借一帧)。
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct Form {
|
||||||
|
/// 面板开着吗
|
||||||
|
pub open: bool,
|
||||||
|
/// 选中的版本 id(`None` = 还没选)
|
||||||
|
pub selected: Option<String>,
|
||||||
|
/// 展示名(空 = 用版本 id)
|
||||||
|
pub name: String,
|
||||||
|
/// 运行目录档(缺省 [`AdoptTargetView::InstanceDir`],**不是** isolated)
|
||||||
|
pub target: AdoptTargetView,
|
||||||
|
/// 在等在飞的那一条命令
|
||||||
|
pub busy: Busy,
|
||||||
|
/// 用户已经点过「预检」(没点过就留在 Pick 段,哪怕后台已经顺手算过一次落点)。
|
||||||
|
///
|
||||||
|
/// 为什么要有这一位:共享档 / 版本目录档一旦被点选,界面就要立刻显示 `target_notes()` 的
|
||||||
|
/// **后果原文**(契约 §5.2)——那是 core 唯一会产出这段文字的地方,所以点选时顺手发一次
|
||||||
|
/// **零写盘**的预检。但那一次结果不该把用户甩到「可以导入」的确认页:确认必须由显式的
|
||||||
|
/// `Check` 触发。于是 `checked` 记住「这次预检是不是用户要的」。
|
||||||
|
pub checked: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Form {
|
||||||
|
/// 打开面板(回到 Pick 段、清掉上一轮的选中与名字)。
|
||||||
|
pub fn open(&mut self) {
|
||||||
|
*self = Self {
|
||||||
|
open: true,
|
||||||
|
busy: Busy::Scanning,
|
||||||
|
..Self::default()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 关掉面板(全清)。
|
||||||
|
pub fn close(&mut self) {
|
||||||
|
*self = Self::default();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 回程到了:不在等任何东西了。
|
||||||
|
pub fn settled(&mut self) {
|
||||||
|
self.busy = Busy::Idle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前该画哪一段:`Preview` 只在用户**点过预检**之后才算数,其余按 `AdoptState::stage()`。
|
||||||
|
///
|
||||||
|
/// 这条收窄是为了「点选共享档 ⇒ 立刻看到后果原文」而不把用户直接甩到确认页:
|
||||||
|
/// 自动预检的结果落在 Pick 段里当**提示**读,用户点「预检」之后才进确认段。
|
||||||
|
#[must_use]
|
||||||
|
pub fn stage(&self, adopt_state: &AdoptState) -> AdoptStage {
|
||||||
|
match adopt_state.stage() {
|
||||||
|
AdoptStage::Preview if !self.checked => AdoptStage::Pick,
|
||||||
|
other => other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 后台算出来、且**对得上当前选择**的预检(对不上 = 过期的结果,一律不画)。
|
||||||
|
///
|
||||||
|
/// 两个判据缺一不可:
|
||||||
|
/// - **目标档一致**:改了档之后旧 plan 的 `notes` 说的是别的目录;
|
||||||
|
/// - **版本 id 一致**:换了版本之后旧 plan 的 `notes` 说的是别的版本目录
|
||||||
|
/// (版本目录档的后果原文里带 `versions/<id>`,拿它当新版本的后果就是撒谎)。
|
||||||
|
///
|
||||||
|
/// 没有选中版本时也返回 `None`:那一刻没有任何版本可言,画谁的预检都不成立。
|
||||||
|
/// 这两条都不是「选中即重扫」带来的——`Select` 只改本地字段、不发命令(落点见
|
||||||
|
/// `app/update/adopt.rs`),所以过期判定只能在这里做。
|
||||||
|
#[must_use]
|
||||||
|
pub fn fresh_plan<'a>(&self, adopt_state: &'a AdoptState) -> Option<&'a AdoptPlanView> {
|
||||||
|
let selected = self.selected.as_deref()?;
|
||||||
|
adopt_state
|
||||||
|
.plan
|
||||||
|
.as_ref()
|
||||||
|
.filter(|plan| plan.target == self.target && plan.version_id == selected)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前选中的候选(选中项已不在列表里时返回 `None`,界面自动退回「请选择」)。
|
||||||
|
#[must_use]
|
||||||
|
pub fn candidate<'a>(&self, adopt_state: &'a AdoptState) -> Option<&'a AdoptCandidateView> {
|
||||||
|
let id = self.selected.as_deref()?;
|
||||||
|
adopt_state
|
||||||
|
.scan
|
||||||
|
.as_ref()?
|
||||||
|
.candidates
|
||||||
|
.iter()
|
||||||
|
.find(|candidate| candidate.version_id == id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 向导消息(`crate::app::Message::Adopt` 的载荷)。
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum AdoptMessage {
|
||||||
|
/// 打开向导(实例页页头「导入」)
|
||||||
|
Open,
|
||||||
|
/// 关掉(点遮罩 / 关闭按钮)
|
||||||
|
Close,
|
||||||
|
/// 选中一个候选版本
|
||||||
|
Select(String),
|
||||||
|
/// 改展示名
|
||||||
|
Name(String),
|
||||||
|
/// 改运行目录档
|
||||||
|
Target(AdoptTargetView),
|
||||||
|
/// 预检(零写盘)
|
||||||
|
Check,
|
||||||
|
/// 确认登记(唯一写盘入口)
|
||||||
|
Confirm,
|
||||||
|
/// 从 Preview / Failed 退回候选列表(等价于重新扫一次)
|
||||||
|
Back,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 向导消息 → app 消息。
|
||||||
|
fn adopt(message: AdoptMessage) -> Message {
|
||||||
|
Message::Adopt(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------- 视图
|
||||||
|
|
||||||
|
/// 把向导叠在页面之上(没打开时原样返回页面树)。
|
||||||
|
pub(super) fn layer<'a>(
|
||||||
|
ui: &'a Ui,
|
||||||
|
base: Element<'a, Message>,
|
||||||
|
form: &'a Form,
|
||||||
|
adopt_state: &'a AdoptState,
|
||||||
|
lang: Lang,
|
||||||
|
) -> Element<'a, Message> {
|
||||||
|
if !form.open {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
let catalog = lang.catalog();
|
||||||
|
let backdrop = mouse_area(
|
||||||
|
container(Space::new())
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Fill)
|
||||||
|
.style(move |_theme| container::Style {
|
||||||
|
background: Some(Background::Color(scrim(ui))),
|
||||||
|
..container::Style::default()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.on_press(adopt(AdoptMessage::Close));
|
||||||
|
|
||||||
|
// 透明的一层只负责居中;吃点击的 `opaque` 只包卡片(点卡内不关,点遮罩才关)。
|
||||||
|
let centered = container(opaque(shell::card(ui, form, adopt_state, catalog)))
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Fill)
|
||||||
|
.align_x(Alignment::Center)
|
||||||
|
.align_y(Alignment::Center)
|
||||||
|
.padding(ui.space(Sp::X6));
|
||||||
|
|
||||||
|
iced::widget::stack![base, backdrop, centered].into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 中间区(按段交给 `pick` / `panes`)。
|
||||||
|
pub(super) fn middle<'a>(
|
||||||
|
ui: &'a Ui,
|
||||||
|
form: &'a Form,
|
||||||
|
adopt_state: &'a AdoptState,
|
||||||
|
stage: AdoptStage,
|
||||||
|
catalog: Catalog,
|
||||||
|
) -> Element<'a, Message> {
|
||||||
|
match stage {
|
||||||
|
AdoptStage::Pick => pick::pick(
|
||||||
|
ui,
|
||||||
|
form,
|
||||||
|
adopt_state.scan.as_ref(),
|
||||||
|
form.fresh_plan(adopt_state),
|
||||||
|
catalog,
|
||||||
|
),
|
||||||
|
AdoptStage::Preview => panes::preview(ui, form.fresh_plan(adopt_state), catalog),
|
||||||
|
AdoptStage::Failed => panes::failed(ui, adopt_state.failure.as_ref(), catalog),
|
||||||
|
AdoptStage::Done => panes::done(ui, adopt_state.done.as_ref(), catalog),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
//! Preview / Failed / Done 三段。
|
||||||
|
//!
|
||||||
|
//! | 段 | 画什么 | 判据 |
|
||||||
|
//! |---|---|---|
|
||||||
|
//! | Preview | 生效运行目录(**回程给的**)+ 隔离档 + `notes` 原文逐条 | `AdoptEvent::Planned` |
|
||||||
|
//! | Failed | 稳定码 + `problems[]` 逐条 | `AdoptEvent::Failed` |
|
||||||
|
//! | Done | 实例名 + 运行目录 + `notes` 原文逐条 | `AdoptEvent::Done` |
|
||||||
|
//!
|
||||||
|
//! 三段都不自己算路径、不自己写后果句:`run_dir` 与 `notes` 是 core 的返回值原样。
|
||||||
|
//! 缺省档会带 `-2`/`-3` 后缀(两个不同展示名可能落到同一个物理目录),**只有 core 知道**
|
||||||
|
//! 最后用了哪个——页面对它再做一次派生就会与真正启动时用的目录分叉。
|
||||||
|
|
||||||
|
use iced::widget::column;
|
||||||
|
use iced::{Element, Length};
|
||||||
|
|
||||||
|
use unlucky_design::color::Role;
|
||||||
|
use unlucky_design::spacing::Space as Sp;
|
||||||
|
use unlucky_i18n::{Catalog, Key};
|
||||||
|
use unlucky_presenter::dto::{AdoptDoneView, AdoptFailureView, AdoptPlanView};
|
||||||
|
|
||||||
|
use crate::theme::Ui;
|
||||||
|
|
||||||
|
use super::super::message::Message;
|
||||||
|
use super::text::{field_line, isolation_label, mono, problem_line, wrapped};
|
||||||
|
|
||||||
|
/// 预检结果:落点 + 隔离档 + 后果原文。
|
||||||
|
pub(super) fn preview<'a>(
|
||||||
|
ui: &'a Ui,
|
||||||
|
plan: Option<&'a AdoptPlanView>,
|
||||||
|
catalog: Catalog,
|
||||||
|
) -> Element<'a, Message> {
|
||||||
|
let Some(plan) = plan else {
|
||||||
|
return wrapped(
|
||||||
|
ui,
|
||||||
|
catalog.text(Key::AdoptScanning).to_string(),
|
||||||
|
ui.color(Role::OnSurfaceVariant),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
let mut body = column![
|
||||||
|
field_line(ui, catalog.text(Key::AdoptInstanceName), &plan.name),
|
||||||
|
field_line(
|
||||||
|
ui,
|
||||||
|
catalog.text(Key::AdoptPlanRunDir),
|
||||||
|
&plan.run_dir.display().to_string()
|
||||||
|
),
|
||||||
|
field_line(
|
||||||
|
ui,
|
||||||
|
catalog.text(Key::AdoptPlanIsolation),
|
||||||
|
&isolation_label(plan.isolation, catalog),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
.spacing(ui.space(Sp::X2))
|
||||||
|
.width(Length::Fill);
|
||||||
|
|
||||||
|
// 后果话术:core `target_notes()` 的原文,一条一行、一个字不改
|
||||||
|
for note in &plan.notes {
|
||||||
|
body = body.push(wrapped(ui, note.clone(), ui.color(Role::OnSurfaceVariant)));
|
||||||
|
}
|
||||||
|
body.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 失败:稳定码 + **problems 逐条**。
|
||||||
|
pub(super) fn failed<'a>(
|
||||||
|
ui: &'a Ui,
|
||||||
|
failure: Option<&'a AdoptFailureView>,
|
||||||
|
catalog: Catalog,
|
||||||
|
) -> Element<'a, Message> {
|
||||||
|
let Some(failure) = failure else {
|
||||||
|
return wrapped(
|
||||||
|
ui,
|
||||||
|
catalog.text(Key::AdoptScanning).to_string(),
|
||||||
|
ui.color(Role::OnSurfaceVariant),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
let mut body = column![mono(ui, failure.code.clone())]
|
||||||
|
.spacing(ui.space(Sp::X2))
|
||||||
|
.width(Length::Fill);
|
||||||
|
|
||||||
|
if failure.problems.is_empty() {
|
||||||
|
body = body.push(wrapped(
|
||||||
|
ui,
|
||||||
|
failure.message.clone(),
|
||||||
|
ui.color(Role::OnSurfaceVariant),
|
||||||
|
));
|
||||||
|
return body.into();
|
||||||
|
}
|
||||||
|
body = body.push(wrapped(
|
||||||
|
ui,
|
||||||
|
catalog.text(Key::AdoptProblemCount).to_string(),
|
||||||
|
ui.color(Role::OnSurfaceVariant),
|
||||||
|
));
|
||||||
|
for problem in &failure.problems {
|
||||||
|
body = body.push(problem_line(ui, problem));
|
||||||
|
}
|
||||||
|
body.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 完成:实例名 + 运行目录 + 提示原文。
|
||||||
|
pub(super) fn done<'a>(
|
||||||
|
ui: &'a Ui,
|
||||||
|
done: Option<&'a AdoptDoneView>,
|
||||||
|
catalog: Catalog,
|
||||||
|
) -> Element<'a, Message> {
|
||||||
|
let Some(done) = done else {
|
||||||
|
return wrapped(ui, String::new(), ui.color(Role::OnSurfaceVariant));
|
||||||
|
};
|
||||||
|
let mut body = column![
|
||||||
|
field_line(ui, catalog.text(Key::AdoptInstanceName), &done.name),
|
||||||
|
field_line(
|
||||||
|
ui,
|
||||||
|
catalog.text(Key::AdoptPlanRunDir),
|
||||||
|
&done.run_dir.display().to_string()
|
||||||
|
),
|
||||||
|
field_line(
|
||||||
|
ui,
|
||||||
|
catalog.text(Key::AdoptPlanIsolation),
|
||||||
|
&isolation_label(done.isolation, catalog),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
.spacing(ui.space(Sp::X2))
|
||||||
|
.width(Length::Fill);
|
||||||
|
|
||||||
|
for note in &done.notes {
|
||||||
|
body = body.push(wrapped(ui, note.clone(), ui.color(Role::OnSurfaceVariant)));
|
||||||
|
}
|
||||||
|
body.into()
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
//! Pick 段:候选列表 + 目标档 + 实例名。
|
||||||
|
//!
|
||||||
|
//! **可导入的候选才可点**:不可导入的行把 `problems` 逐条列在下面但不接点击——点它只会换来
|
||||||
|
//! 一条注定被 core 拒绝的命令(`adopt_not_adoptable`),而用户从行上已经看到了原因。
|
||||||
|
//!
|
||||||
|
//! 目标档三枚 chip 的**短提示**在选中时立刻换(共享/版本目录档用 `Error` 色);
|
||||||
|
//! **后果原文**不在这里——它在 Preview 段逐条画 `plan.notes`(core `target_notes()` 的原文)。
|
||||||
|
|
||||||
|
use iced::widget::{button, column, container, row};
|
||||||
|
use iced::{Alignment, Background, Border, Color, Element, Length};
|
||||||
|
|
||||||
|
use unlucky_design::color::Role;
|
||||||
|
use unlucky_design::shape::ShapeScale;
|
||||||
|
use unlucky_design::spacing::Space as Sp;
|
||||||
|
use unlucky_design::typography::TypeRole;
|
||||||
|
use unlucky_i18n::{Catalog, Key};
|
||||||
|
use unlucky_presenter::dto::{AdoptCandidateView, AdoptPlanView, AdoptScanView, AdoptTargetView};
|
||||||
|
|
||||||
|
use crate::app::components::inputs::{text_field, Field};
|
||||||
|
use crate::app::components::selection::{chip, Chip};
|
||||||
|
use crate::pages::frame;
|
||||||
|
use crate::theme::Ui;
|
||||||
|
|
||||||
|
use super::super::message::Message;
|
||||||
|
use super::text::{label, problem_line, target_label, wrapped};
|
||||||
|
use super::{adopt, AdoptMessage, Form};
|
||||||
|
|
||||||
|
/// Pick 段整体。
|
||||||
|
pub(super) fn pick<'a>(
|
||||||
|
ui: &'a Ui,
|
||||||
|
form: &'a Form,
|
||||||
|
scan: Option<&'a AdoptScanView>,
|
||||||
|
plan: Option<&'a AdoptPlanView>,
|
||||||
|
catalog: Catalog,
|
||||||
|
) -> Element<'a, Message> {
|
||||||
|
let Some(scan) = scan else {
|
||||||
|
// 扫描还没回来:一行进度文案(不做假进度条)
|
||||||
|
return wrapped(
|
||||||
|
ui,
|
||||||
|
catalog.text(Key::AdoptScanning).to_string(),
|
||||||
|
ui.color(Role::OnSurfaceVariant),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut body = column![].spacing(ui.space(Sp::X3)).width(Length::Fill);
|
||||||
|
if scan.is_empty() {
|
||||||
|
let dir = scan.versions_dir.display().to_string();
|
||||||
|
body = body.push(wrapped(
|
||||||
|
ui,
|
||||||
|
catalog.render(Key::AdoptEmpty, &[("dir", &dir)]),
|
||||||
|
ui.color(Role::OnSurfaceVariant),
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
body = body.push(label(ui, catalog.text(Key::AdoptSelectLabel)));
|
||||||
|
body = body.push(candidate_list(ui, form, scan, catalog));
|
||||||
|
}
|
||||||
|
|
||||||
|
body = body.push(frame::divider(ui).map(Message::Page));
|
||||||
|
body = body.push(label(ui, catalog.text(Key::AdoptTargetLabel)));
|
||||||
|
body = body.push(target_row(ui, form, catalog));
|
||||||
|
// 后果原文(契约 §5.2):共享档 / 版本目录档一被点选就显示 core `target_notes()` 的原文——
|
||||||
|
// 这一条来自**零写盘**的自动预检(见 `AdoptMessage::Target` 的落点),不是界面另写的一句。
|
||||||
|
if let Some(plan) = plan {
|
||||||
|
for note in &plan.notes {
|
||||||
|
body = body.push(wrapped(ui, note.clone(), ui.color(Role::OnSurfaceVariant)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body = body.push(text_field(
|
||||||
|
ui,
|
||||||
|
Field::new(form.name.as_str()).label(catalog.text(Key::AdoptNameLabel)),
|
||||||
|
|value| adopt(AdoptMessage::Name(value)),
|
||||||
|
));
|
||||||
|
body.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 候选列表(可滚,定高:列表再长页脚按钮也留在卡里)。
|
||||||
|
fn candidate_list<'a>(
|
||||||
|
ui: &'a Ui,
|
||||||
|
form: &'a Form,
|
||||||
|
scan: &'a AdoptScanView,
|
||||||
|
catalog: Catalog,
|
||||||
|
) -> Element<'a, Message> {
|
||||||
|
let rows = column(
|
||||||
|
scan.candidates
|
||||||
|
.iter()
|
||||||
|
.map(|candidate| candidate_row(ui, form, candidate, catalog))
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
)
|
||||||
|
.spacing(ui.space(Sp::X1))
|
||||||
|
.width(Length::Fill);
|
||||||
|
|
||||||
|
rows.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一条候选。
|
||||||
|
fn candidate_row<'a>(
|
||||||
|
ui: &'a Ui,
|
||||||
|
form: &'a Form,
|
||||||
|
candidate: &'a AdoptCandidateView,
|
||||||
|
catalog: Catalog,
|
||||||
|
) -> Element<'a, Message> {
|
||||||
|
let selected = form.selected.as_deref() == Some(candidate.version_id.as_str());
|
||||||
|
|
||||||
|
let mut items = column![row![
|
||||||
|
frame::text_role(
|
||||||
|
ui,
|
||||||
|
TypeRole::BodyLarge,
|
||||||
|
selected,
|
||||||
|
candidate.version_id.as_str(),
|
||||||
|
ui.color(Role::OnSurface),
|
||||||
|
)
|
||||||
|
.map(Message::Page),
|
||||||
|
container(status(ui, candidate, selected, catalog))
|
||||||
|
.width(Length::Fill)
|
||||||
|
.align_x(Alignment::End),
|
||||||
|
]
|
||||||
|
.width(Length::Fill)
|
||||||
|
.align_y(Alignment::Center)]
|
||||||
|
.spacing(ui.space(Sp::X1))
|
||||||
|
.width(Length::Fill);
|
||||||
|
|
||||||
|
// 问题逐条(不折叠):候选阶段就把「为什么不能导入」说清楚
|
||||||
|
for problem in &candidate.problems {
|
||||||
|
items = items.push(problem_line(ui, problem));
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = container(items)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.padding(ui.space(Sp::X2))
|
||||||
|
.style(move |_theme| container::Style {
|
||||||
|
background: selected.then(|| Background::Color(ui.color(Role::SecondaryContainer))),
|
||||||
|
border: Border {
|
||||||
|
radius: ui.radius_dp(ShapeScale::Medium),
|
||||||
|
..Border::default()
|
||||||
|
},
|
||||||
|
..container::Style::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
if !candidate.adoptable() {
|
||||||
|
// 不可导入的行**不可点**(也就不存在「点了才被告知不行」这条路)
|
||||||
|
return body.into();
|
||||||
|
}
|
||||||
|
button(body)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.padding(0)
|
||||||
|
.style(|_theme, _status| button::Style {
|
||||||
|
background: None,
|
||||||
|
text_color: Color::TRANSPARENT,
|
||||||
|
border: Border::default(),
|
||||||
|
shadow: iced::Shadow::default(),
|
||||||
|
snap: true,
|
||||||
|
})
|
||||||
|
.on_press(adopt(AdoptMessage::Select(candidate.version_id.clone())))
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 候选右侧那枚状态(可导入 / N 个问题 / 已被某实例使用)。
|
||||||
|
fn status<'a>(
|
||||||
|
ui: &'a Ui,
|
||||||
|
candidate: &AdoptCandidateView,
|
||||||
|
selected: bool,
|
||||||
|
catalog: Catalog,
|
||||||
|
) -> Element<'a, Message> {
|
||||||
|
if !candidate.adoptable() {
|
||||||
|
let count = candidate.problems.len().to_string();
|
||||||
|
return wrapped(
|
||||||
|
ui,
|
||||||
|
catalog.render(Key::AdoptBlocked, &[("count", &count)]),
|
||||||
|
ui.color(Role::Error),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(name) = candidate.registered.first() {
|
||||||
|
return wrapped(
|
||||||
|
ui,
|
||||||
|
catalog.render(Key::AdoptRegistered, &[("name", name)]),
|
||||||
|
ui.color(Role::OnSurfaceVariant),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if selected {
|
||||||
|
return chip(ui, Chip::current(catalog.text(Key::AdoptReady)));
|
||||||
|
}
|
||||||
|
wrapped(
|
||||||
|
ui,
|
||||||
|
catalog.text(Key::AdoptReady).to_string(),
|
||||||
|
ui.color(Role::OnSurfaceVariant),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 三枚目标 chip(缺省档在最前;共享 / 版本目录档必须**明确点选**)。
|
||||||
|
fn target_row<'a>(ui: &'a Ui, form: &'a Form, catalog: Catalog) -> Element<'a, Message> {
|
||||||
|
let chips = AdoptTargetView::ALL
|
||||||
|
.into_iter()
|
||||||
|
.map(|target| {
|
||||||
|
chip(
|
||||||
|
ui,
|
||||||
|
Chip::filter(
|
||||||
|
target_label(target, catalog),
|
||||||
|
form.target == target,
|
||||||
|
adopt(AdoptMessage::Target(target)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
column![
|
||||||
|
row(chips).spacing(ui.space(Sp::X2)).width(Length::Fill),
|
||||||
|
target_hint(ui, form.target, catalog),
|
||||||
|
]
|
||||||
|
.spacing(ui.space(Sp::X1))
|
||||||
|
.width(Length::Fill)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 目标档的一句话说明(点选后立刻换)。**不是后果原文**:原文在 Preview 段逐条画
|
||||||
|
/// `plan.notes`(core `target_notes()`)。
|
||||||
|
fn target_hint<'a>(ui: &'a Ui, target: AdoptTargetView, catalog: Catalog) -> Element<'a, Message> {
|
||||||
|
let key = match target {
|
||||||
|
AdoptTargetView::InstanceDir => Key::AdoptTargetInstanceDirHint,
|
||||||
|
AdoptTargetView::Shared => Key::AdoptTargetSharedHint,
|
||||||
|
AdoptTargetView::VersionDir => Key::AdoptTargetVersionDirHint,
|
||||||
|
};
|
||||||
|
let color = if target.touches_foreign_dir() {
|
||||||
|
ui.color(Role::Error)
|
||||||
|
} else {
|
||||||
|
ui.color(Role::OnSurfaceVariant)
|
||||||
|
};
|
||||||
|
wrapped(ui, catalog.text(key).to_string(), color)
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
//! 向导卡的外壳与页脚(M223 从 `app/adopt.rs` 搬出,守 400 行硬线)。
|
||||||
|
//!
|
||||||
|
//! | 函数 | 职责 |
|
||||||
|
//! |---|---|
|
||||||
|
//! | [`card`] | 卡壳(标题 + 说明 + 中间区 + 页脚)、定高/收高两种几何、遮罩上的那一张卡 |
|
||||||
|
//! | [`title_text`] / [`subtitle`] | 四段的标题与一句说明(文案全部来自 i18n 目录) |
|
||||||
|
//! | [`footer`] | 按段给按钮组合;在等回程时主按钮禁用 |
|
||||||
|
//!
|
||||||
|
//! ## 这个文件不是「纯搬移」
|
||||||
|
//!
|
||||||
|
//! 它**起于**一次搬移:`app/adopt.rs` 落笔 412 行越过 `components::gate` 的 400 行硬线,
|
||||||
|
//! 于是把卡壳 / 标题 / 页脚搬到这里。但**同一个提交里**这三块还改过行为与几何,逐条如下
|
||||||
|
//! (复审对 diff 时按这几条看,别按「一行未改」看):
|
||||||
|
//!
|
||||||
|
//! | 改了什么 | 为什么 |
|
||||||
|
//! |---|---|
|
||||||
|
//! | `card` 拆成「定高 + 中间区可滚」(Pick / Failed)与「按内容收高」(Preview / Done)两种几何 | 原来只给 `max_height`,内容一高就被裁:页脚按钮与实例名整排不可见(出图时抓到) |
|
||||||
|
//! | `footer` 的「关闭」只在 Pick / Preview 出现 | Failed / Done 的主按钮就是关闭,再给一枚文字关闭就是两枚一模一样的按钮 |
|
||||||
|
//! | `subtitle` 的 Preview 文案改成「版本名 · 目标档」 | 原来打「隔离档 · 目标档」,同屏两个「共用目录」 |
|
||||||
|
//!
|
||||||
|
//! 调用方仍是 `app/adopt.rs` 的 `layer` 与 `middle`。
|
||||||
|
|
||||||
|
use iced::widget::{column, container, row, scrollable, Space};
|
||||||
|
use iced::{Background, Element, Length};
|
||||||
|
|
||||||
|
use unlucky_design::color::Role;
|
||||||
|
use unlucky_design::elevation::MaterialLayer;
|
||||||
|
use unlucky_design::shape::ShapeScale;
|
||||||
|
use unlucky_design::spacing::Space as Sp;
|
||||||
|
use unlucky_design::typography::TypeRole;
|
||||||
|
use unlucky_i18n::{Catalog, Key};
|
||||||
|
use unlucky_presenter::dto::{AdoptStage, AdoptState};
|
||||||
|
|
||||||
|
use crate::app::components::buttons::{self, Button};
|
||||||
|
use crate::pages::frame;
|
||||||
|
use crate::theme::Ui;
|
||||||
|
|
||||||
|
use super::super::message::Message;
|
||||||
|
use super::text::{target_label, wrapped};
|
||||||
|
use super::{adopt, middle, AdoptMessage, Form, CARD_MAX_HEIGHT, CARD_WIDTH};
|
||||||
|
|
||||||
|
/// 卡片本体:标题 + 一句说明 + 中间(按段) + 页脚按钮。
|
||||||
|
pub(super) fn card<'a>(
|
||||||
|
ui: &'a Ui,
|
||||||
|
form: &'a Form,
|
||||||
|
adopt_state: &'a AdoptState,
|
||||||
|
catalog: Catalog,
|
||||||
|
) -> Element<'a, Message> {
|
||||||
|
let stage = form.stage(adopt_state);
|
||||||
|
let middle = middle(ui, form, adopt_state, stage, catalog);
|
||||||
|
let head = column![
|
||||||
|
frame::text_role(
|
||||||
|
ui,
|
||||||
|
TypeRole::HeadlineSmall,
|
||||||
|
true,
|
||||||
|
title_text(stage, catalog),
|
||||||
|
ui.color(Role::OnSurface),
|
||||||
|
)
|
||||||
|
.map(Message::Page),
|
||||||
|
wrapped(
|
||||||
|
ui,
|
||||||
|
subtitle(stage, adopt_state, catalog),
|
||||||
|
ui.color(Role::OnSurfaceVariant)
|
||||||
|
),
|
||||||
|
frame::divider(ui).map(Message::Page),
|
||||||
|
]
|
||||||
|
.spacing(ui.space(Sp::X3))
|
||||||
|
.width(Length::Fill);
|
||||||
|
|
||||||
|
let tail = column![
|
||||||
|
frame::divider(ui).map(Message::Page),
|
||||||
|
footer(ui, form, adopt_state, stage, catalog),
|
||||||
|
]
|
||||||
|
.spacing(ui.space(Sp::X3))
|
||||||
|
.width(Length::Fill);
|
||||||
|
|
||||||
|
// 列表段(Pick / Failed)**定高 + 中间区可滚**:内容再长,页脚按钮也不会被裁掉
|
||||||
|
// (第一版就是被裁掉的)。短内容段(Preview / Done)按内容高收,不留一大片空白。
|
||||||
|
let list_stage = matches!(stage, AdoptStage::Pick | AdoptStage::Failed);
|
||||||
|
let card = if list_stage {
|
||||||
|
let body = column![
|
||||||
|
head,
|
||||||
|
scrollable(middle)
|
||||||
|
.height(Length::Fill)
|
||||||
|
.style(frame::scroll_style(ui)),
|
||||||
|
tail,
|
||||||
|
]
|
||||||
|
.spacing(ui.space(Sp::X3))
|
||||||
|
.width(Length::Fill);
|
||||||
|
container(body)
|
||||||
|
.width(Length::Fixed(CARD_WIDTH))
|
||||||
|
.height(Length::Fixed(CARD_MAX_HEIGHT))
|
||||||
|
} else {
|
||||||
|
let body = column![head, middle, tail]
|
||||||
|
.spacing(ui.space(Sp::X3))
|
||||||
|
.width(Length::Fill);
|
||||||
|
container(body)
|
||||||
|
.width(Length::Fixed(CARD_WIDTH))
|
||||||
|
.max_height(CARD_MAX_HEIGHT)
|
||||||
|
};
|
||||||
|
|
||||||
|
card.padding(ui.space(Sp::X6))
|
||||||
|
.style(move |_theme| container::Style {
|
||||||
|
background: Some(Background::Color(ui.layer(MaterialLayer::Overlay))),
|
||||||
|
border: ui.outlined(ShapeScale::ExtraLarge),
|
||||||
|
..container::Style::default()
|
||||||
|
})
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 段标题(四段各一句)。
|
||||||
|
pub(super) fn title_text(stage: AdoptStage, catalog: Catalog) -> &'static str {
|
||||||
|
match stage {
|
||||||
|
AdoptStage::Pick => catalog.text(Key::AdoptTitle),
|
||||||
|
AdoptStage::Preview => catalog.text(Key::AdoptPlanTitle),
|
||||||
|
AdoptStage::Failed => catalog.text(Key::AdoptFailureTitle),
|
||||||
|
AdoptStage::Done => catalog.text(Key::AdoptDoneTitle),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 标题下那句说明(Pick 段说清「什么都不复制」;Done 段说清「去实例列表启动」)。
|
||||||
|
pub(super) fn subtitle(stage: AdoptStage, adopt_state: &AdoptState, catalog: Catalog) -> String {
|
||||||
|
match stage {
|
||||||
|
AdoptStage::Pick => catalog.text(Key::AdoptSubtitle).to_string(),
|
||||||
|
// 预检:版本名 · 目标档(隔离档在正文里单独一行,重复一遍只会更吵)
|
||||||
|
AdoptStage::Preview => adopt_state
|
||||||
|
.plan
|
||||||
|
.as_ref()
|
||||||
|
.map(|plan| format!("{} · {}", plan.name, target_label(plan.target, catalog)))
|
||||||
|
.unwrap_or_default(),
|
||||||
|
AdoptStage::Failed => adopt_state
|
||||||
|
.failure
|
||||||
|
.as_ref()
|
||||||
|
.map(|failure| failure.message.clone())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
AdoptStage::Done => catalog.text(Key::AdoptDoneHint).to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 页脚按钮(按段给不同组合;在等回程时主按钮禁用)。
|
||||||
|
pub(super) fn footer<'a>(
|
||||||
|
ui: &'a Ui,
|
||||||
|
form: &'a Form,
|
||||||
|
adopt_state: &'a AdoptState,
|
||||||
|
stage: AdoptStage,
|
||||||
|
catalog: Catalog,
|
||||||
|
) -> Element<'a, Message> {
|
||||||
|
let busy = form.busy.waiting();
|
||||||
|
|
||||||
|
let primary: Element<'a, Message> = match stage {
|
||||||
|
AdoptStage::Pick => {
|
||||||
|
let ready = form.candidate(adopt_state).is_some_and(|c| c.adoptable()) && !busy;
|
||||||
|
if ready {
|
||||||
|
buttons::filled(
|
||||||
|
ui,
|
||||||
|
Button::new(catalog.text(Key::AdoptPreview), adopt(AdoptMessage::Check)),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
buttons::filled(ui, Button::disabled(catalog.text(Key::AdoptPreview)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AdoptStage::Preview if !busy => buttons::filled(
|
||||||
|
ui,
|
||||||
|
Button::new(
|
||||||
|
catalog.text(Key::AdoptConfirm),
|
||||||
|
adopt(AdoptMessage::Confirm),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
AdoptStage::Preview => {
|
||||||
|
buttons::filled(ui, Button::disabled(catalog.text(Key::AdoptConfirm)))
|
||||||
|
}
|
||||||
|
// 失败 / 完成:主按钮就是关掉(Done 不用「去列表看」——实例已经在列表里了)
|
||||||
|
AdoptStage::Failed | AdoptStage::Done => buttons::filled(
|
||||||
|
ui,
|
||||||
|
Button::new(catalog.text(Key::AdoptClose), adopt(AdoptMessage::Close)),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut actions = row![].spacing(ui.space(Sp::X2)).width(Length::Fill);
|
||||||
|
// Preview / Failed 给一条「退回候选列表」(= 重新扫一次,代价只有一次读目录)
|
||||||
|
if matches!(stage, AdoptStage::Preview | AdoptStage::Failed) {
|
||||||
|
actions = actions.push(buttons::text(
|
||||||
|
ui,
|
||||||
|
Button::new(catalog.text(Key::AdoptBack), adopt(AdoptMessage::Back)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
actions = actions.push(container(Space::new()).width(Length::Fill));
|
||||||
|
// 失败/完成段的主按钮就是「关闭」,再给一枚文字「关闭」就是两枚一模一样的按钮
|
||||||
|
if matches!(stage, AdoptStage::Pick | AdoptStage::Preview) {
|
||||||
|
actions = actions.push(buttons::text(
|
||||||
|
ui,
|
||||||
|
Button::new(catalog.text(Key::AdoptClose), adopt(AdoptMessage::Close)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
actions = actions.push(primary);
|
||||||
|
|
||||||
|
column![actions].width(Length::Fill).into()
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
//! 出图用的向导状态夹具(D30 视觉验收的输入)。
|
||||||
|
//!
|
||||||
|
//! ## 为什么要有它
|
||||||
|
//!
|
||||||
|
//! 向导是**四段有状态**的界面,而截图通道只跑一次 `view()`(第 3 帧落盘),没有真人点按钮。
|
||||||
|
//! 组件库的做法是 `--screen=gallery` + `ForcedState`,页面层的做法是 `pages/instances/demo.rs`
|
||||||
|
//! 的 `DEMO`——同一类要求,同一类做法:**给渲染器一份确定的状态**,而不是让截图去赌异步时序。
|
||||||
|
//!
|
||||||
|
//! ## 哪些是真、哪些是夹具(复审请照这条核对)
|
||||||
|
//!
|
||||||
|
//! | 内容 | 来源 |
|
||||||
|
//! |---|---|
|
||||||
|
//! | 候选 / 问题 / 失败原因 | **夹具**(合成的 id 与问题文本,只为把版面撑到真实形态) |
|
||||||
|
//! | 运行目录 / 隔离档 | 夹具(固定路径,便于两张图逐像素对照) |
|
||||||
|
//! | **后果原文** | **真**:`unlucky_core::game::adopt::target_notes()` 现算,一个字不抄 |
|
||||||
|
//! | 文案(标题/标签/按钮) | **真**:`unlucky_i18n` 目录 |
|
||||||
|
//! | 渲染 | **真**:`--software-renderer`(tiny-skia)1180×760,与产品路径同一棵控件树 |
|
||||||
|
//!
|
||||||
|
//! 也就是说:这些图证明**版面与文案**,不证明 core 的判据(判据的证据在 `host/adopt/tests.rs`
|
||||||
|
//! 与 `core/tests/adopt/`)。
|
||||||
|
//!
|
||||||
|
//! ## 怎么用
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! ULL_ADOPT_STATE=pick|pick-selected|empty|preview|shared|failed|done \
|
||||||
|
//! unlucky-launcher-iced.exe --software-renderer --screenshot <path> [--screenshot-theme dark]
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! 只在 `--screenshot` 已经给了的情况下生效(交互模式读这个环境变量没有意义,直接忽略)。
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use unlucky_core::game::adopt::{target_notes, AdoptTarget};
|
||||||
|
use unlucky_presenter::dto::{
|
||||||
|
AdoptCandidateView, AdoptDoneView, AdoptEvent, AdoptFailureView, AdoptIsolationView,
|
||||||
|
AdoptPlanView, AdoptProblemView, AdoptScanView, AdoptState, AdoptTargetView,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{Busy, Form};
|
||||||
|
|
||||||
|
/// 夹具里的版本目录(只是**画**出来的路径,不碰盘)。
|
||||||
|
const VERSIONS_DIR: &str = "D:/minecraft/.minecraft/versions";
|
||||||
|
|
||||||
|
/// 夹具里的数据区运行目录。
|
||||||
|
const INSTANCE_RUN_DIR: &str = "D:/ULL/.ULL/data/instances/Better-MC";
|
||||||
|
|
||||||
|
/// 夹具里的游戏根。
|
||||||
|
const GAME_ROOT: &str = "D:/minecraft/.minecraft";
|
||||||
|
|
||||||
|
/// 环境变量名(见模块文档)。
|
||||||
|
pub(crate) const ENV: &str = "ULL_ADOPT_STATE";
|
||||||
|
|
||||||
|
/// 按名字造一份「打开着的向导」;不认识的名字返回 `None`(不影响正常启动)。
|
||||||
|
pub(crate) fn seed(name: &str) -> Option<(Form, AdoptState)> {
|
||||||
|
let name = name.trim().to_ascii_lowercase();
|
||||||
|
let mut form = Form {
|
||||||
|
open: true,
|
||||||
|
..Form::default()
|
||||||
|
};
|
||||||
|
let mut state = AdoptState::default();
|
||||||
|
|
||||||
|
match name.as_str() {
|
||||||
|
// 扫描完成、还没选版本(列表里三态齐全:可导入 / 已登记 / 有问题)
|
||||||
|
"pick" => {
|
||||||
|
state.apply(AdoptEvent::Scan(Box::new(scan_view())));
|
||||||
|
form.busy = Busy::Idle;
|
||||||
|
}
|
||||||
|
// 扫描完成、**已选中一个可导入的版本**(选中行底色 + 预检按钮启用态)
|
||||||
|
"pick-selected" => {
|
||||||
|
state.apply(AdoptEvent::Scan(Box::new(scan_view())));
|
||||||
|
form.selected = Some("1.20.1".to_string());
|
||||||
|
form.busy = Busy::Idle;
|
||||||
|
}
|
||||||
|
// 游戏根下真没有版本(空态)
|
||||||
|
"empty" => {
|
||||||
|
state.apply(AdoptEvent::Scan(Box::new(AdoptScanView {
|
||||||
|
versions_dir: PathBuf::from(VERSIONS_DIR),
|
||||||
|
candidates: Vec::new(),
|
||||||
|
})));
|
||||||
|
form.busy = Busy::Idle;
|
||||||
|
}
|
||||||
|
// 选中缺省档(InstanceDir)并点过预检
|
||||||
|
"preview" => {
|
||||||
|
state.apply(AdoptEvent::Scan(Box::new(scan_view())));
|
||||||
|
state.apply(AdoptEvent::Planned(Box::new(plan_view(
|
||||||
|
AdoptTargetView::InstanceDir,
|
||||||
|
AdoptIsolationView::Custom,
|
||||||
|
INSTANCE_RUN_DIR,
|
||||||
|
))));
|
||||||
|
form.selected = Some("1.20.1".to_string());
|
||||||
|
form.checked = true;
|
||||||
|
form.busy = Busy::Idle;
|
||||||
|
}
|
||||||
|
// 选中共享档(Shared):后果原文由 core `target_notes()` 现算
|
||||||
|
"shared" => {
|
||||||
|
state.apply(AdoptEvent::Scan(Box::new(scan_view())));
|
||||||
|
state.apply(AdoptEvent::Planned(Box::new(plan_view(
|
||||||
|
AdoptTargetView::Shared,
|
||||||
|
AdoptIsolationView::Shared,
|
||||||
|
GAME_ROOT,
|
||||||
|
))));
|
||||||
|
form.selected = Some("1.20.1".to_string());
|
||||||
|
form.target = AdoptTargetView::Shared;
|
||||||
|
form.checked = true;
|
||||||
|
form.busy = Busy::Idle;
|
||||||
|
}
|
||||||
|
// 预检失败:两条问题逐条列出(不许折叠成一句)
|
||||||
|
"failed" => {
|
||||||
|
state.apply(AdoptEvent::Scan(Box::new(scan_view())));
|
||||||
|
state.apply(AdoptEvent::Failed(Box::new(AdoptFailureView {
|
||||||
|
code: "adopt_not_adoptable".to_string(),
|
||||||
|
message: "版本 1.19.4 不能登记:清单不是合法 JSON".to_string(),
|
||||||
|
problems: vec![
|
||||||
|
AdoptProblemView {
|
||||||
|
code: "manifest_invalid_json".to_string(),
|
||||||
|
message: format!(
|
||||||
|
"清单不是合法 JSON {VERSIONS_DIR}/1.19.4/1.19.4.json:expected `,` or `}}` at line 12 column 3"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
AdoptProblemView {
|
||||||
|
code: "missing_parent".to_string(),
|
||||||
|
message: "版本 1.19.4 继承的父版本 1.19.3 不在盘上".to_string(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})));
|
||||||
|
form.selected = Some("1.19.4".to_string());
|
||||||
|
form.checked = true;
|
||||||
|
form.busy = Busy::Idle;
|
||||||
|
}
|
||||||
|
// 登记完成
|
||||||
|
"done" => {
|
||||||
|
state.apply(AdoptEvent::Scan(Box::new(scan_view())));
|
||||||
|
state.apply(AdoptEvent::Done(Box::new(AdoptDoneView {
|
||||||
|
instance_id: "2f1c0a5e-7f4b-4c1d-9a2e-6b8d5c3a1f70".to_string(),
|
||||||
|
name: "Better MC".to_string(),
|
||||||
|
version_id: "1.20.1-fabric-0.15.0".to_string(),
|
||||||
|
isolation: AdoptIsolationView::Custom,
|
||||||
|
run_dir: PathBuf::from(INSTANCE_RUN_DIR),
|
||||||
|
dir_created: true,
|
||||||
|
notes: target_notes(&AdoptTarget::InstanceDir, &PathBuf::from(INSTANCE_RUN_DIR)),
|
||||||
|
})));
|
||||||
|
form.selected = Some("1.20.1-fabric-0.15.0".to_string());
|
||||||
|
form.checked = true;
|
||||||
|
form.busy = Busy::Idle;
|
||||||
|
}
|
||||||
|
_ => return None,
|
||||||
|
}
|
||||||
|
Some((form, state))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一份扫描结果(三态齐全)。
|
||||||
|
fn scan_view() -> AdoptScanView {
|
||||||
|
AdoptScanView {
|
||||||
|
versions_dir: PathBuf::from(VERSIONS_DIR),
|
||||||
|
candidates: vec![
|
||||||
|
AdoptCandidateView {
|
||||||
|
version_id: "1.20.1".to_string(),
|
||||||
|
jar_present: true,
|
||||||
|
inherits: Vec::new(),
|
||||||
|
registered: Vec::new(),
|
||||||
|
problems: Vec::new(),
|
||||||
|
},
|
||||||
|
AdoptCandidateView {
|
||||||
|
version_id: "1.20.1-fabric-0.15.0".to_string(),
|
||||||
|
jar_present: false,
|
||||||
|
inherits: vec!["1.20.1-fabric-0.15.0".to_string(), "1.20.1".to_string()],
|
||||||
|
registered: vec!["Better MC".to_string()],
|
||||||
|
problems: Vec::new(),
|
||||||
|
},
|
||||||
|
AdoptCandidateView {
|
||||||
|
version_id: "1.19.4".to_string(),
|
||||||
|
jar_present: true,
|
||||||
|
inherits: Vec::new(),
|
||||||
|
registered: Vec::new(),
|
||||||
|
problems: vec![AdoptProblemView {
|
||||||
|
code: "manifest_invalid_json".to_string(),
|
||||||
|
message: format!(
|
||||||
|
"清单不是合法 JSON {VERSIONS_DIR}/1.19.4/1.19.4.json:expected `,` or `}}` at line 12 column 3"
|
||||||
|
),
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一份预检结果。**`notes` 由 core 现算**(共享档/版本目录档的后果原文只有一处)。
|
||||||
|
fn plan_view(
|
||||||
|
target: AdoptTargetView,
|
||||||
|
isolation: AdoptIsolationView,
|
||||||
|
run_dir: &str,
|
||||||
|
) -> AdoptPlanView {
|
||||||
|
let core_target = match target {
|
||||||
|
AdoptTargetView::InstanceDir => AdoptTarget::InstanceDir,
|
||||||
|
AdoptTargetView::Shared => AdoptTarget::Shared,
|
||||||
|
AdoptTargetView::VersionDir => AdoptTarget::VersionDir,
|
||||||
|
};
|
||||||
|
AdoptPlanView {
|
||||||
|
version_id: "1.20.1".to_string(),
|
||||||
|
name: "1.20.1".to_string(),
|
||||||
|
target,
|
||||||
|
isolation,
|
||||||
|
run_dir: PathBuf::from(run_dir),
|
||||||
|
notes: target_notes(&core_target, &PathBuf::from(run_dir)),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
//! `app::adopt` 的用态用例(M223):表单态 + 四段投影。
|
||||||
|
//!
|
||||||
|
//! 抓得住:
|
||||||
|
//! - `Form` 的生命周期(open 清上一轮 / close 全清 / settled 解除等待);
|
||||||
|
//! - `candidate()` 在选中项不在列表里时返回 `None`(界面自动退回「请选择」);
|
||||||
|
//! - 四段的文案真源(标题/后果原文/问题逐条)能从 `AdoptState` 投影出来。
|
||||||
|
//!
|
||||||
|
//! 抓不住:**像素**(那是 D30 的截图通道);也不抓 core 的判据(那是 `host/adopt/tests.rs`)。
|
||||||
|
|
||||||
|
use unlucky_i18n::{Key, Lang};
|
||||||
|
use unlucky_presenter::dto::{
|
||||||
|
AdoptCandidateView, AdoptDoneView, AdoptEvent, AdoptFailureView, AdoptIsolationView,
|
||||||
|
AdoptPlanView, AdoptProblemView, AdoptScanView, AdoptStage, AdoptState, AdoptTargetView,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn candidate(version_id: &str, problems: Vec<AdoptProblemView>) -> AdoptCandidateView {
|
||||||
|
AdoptCandidateView {
|
||||||
|
version_id: version_id.to_string(),
|
||||||
|
jar_present: true,
|
||||||
|
inherits: Vec::new(),
|
||||||
|
registered: Vec::new(),
|
||||||
|
problems,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn state_with_scan() -> AdoptState {
|
||||||
|
let mut state = AdoptState::default();
|
||||||
|
state.apply(AdoptEvent::Scan(Box::new(AdoptScanView {
|
||||||
|
versions_dir: std::path::PathBuf::from("/game/versions"),
|
||||||
|
candidates: vec![candidate("1.20.1", Vec::new())],
|
||||||
|
})));
|
||||||
|
state
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 打开 ⇒ 关闭:上一轮的选中与名字都要清掉(否则下次打开画的是上一次的草稿)。
|
||||||
|
#[test]
|
||||||
|
fn open_and_close_reset_the_form() {
|
||||||
|
let mut form = Form::default();
|
||||||
|
form.open();
|
||||||
|
assert!(form.open);
|
||||||
|
assert_eq!(form.busy, Busy::Scanning);
|
||||||
|
assert_eq!(form.target, AdoptTargetView::InstanceDir);
|
||||||
|
|
||||||
|
form.selected = Some("1.20.1".to_string());
|
||||||
|
form.name = "生存".to_string();
|
||||||
|
form.close();
|
||||||
|
assert!(!form.open);
|
||||||
|
assert!(form.selected.is_none());
|
||||||
|
assert!(form.name.is_empty());
|
||||||
|
|
||||||
|
// 再开一次也不该带着上一轮的选中项
|
||||||
|
form.open();
|
||||||
|
assert!(form.selected.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `settled` 只解除等待,不动用户填的东西(回程不该清表单)。
|
||||||
|
#[test]
|
||||||
|
fn settling_keeps_what_the_user_typed() {
|
||||||
|
let mut form = Form::default();
|
||||||
|
form.open();
|
||||||
|
form.selected = Some("1.20.1".to_string());
|
||||||
|
form.name = "生存".to_string();
|
||||||
|
form.settled();
|
||||||
|
assert_eq!(form.busy, Busy::Idle);
|
||||||
|
assert_eq!(form.selected.as_deref(), Some("1.20.1"));
|
||||||
|
assert_eq!(form.name, "生存");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 选中项不在候选列表里 ⇒ `candidate()` 是 `None`(界面不能对着一个不存在的版本画「预检」)。
|
||||||
|
#[test]
|
||||||
|
fn candidate_is_none_when_the_selection_vanished() {
|
||||||
|
let state = state_with_scan();
|
||||||
|
let mut form = Form {
|
||||||
|
selected: Some("1.20.1".to_string()),
|
||||||
|
..Form::default()
|
||||||
|
};
|
||||||
|
assert!(form.candidate(&state).is_some());
|
||||||
|
form.selected = Some("9.9.9".to_string());
|
||||||
|
assert!(form.candidate(&state).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 四段的标题与后果原文都从目录来(不写死英文字面量、不重写 core 的后果句)。
|
||||||
|
#[test]
|
||||||
|
fn the_four_stages_project_from_the_event_state() {
|
||||||
|
let catalog = Lang::ZhCn.catalog();
|
||||||
|
let mut state = state_with_scan();
|
||||||
|
|
||||||
|
// Pick
|
||||||
|
assert_eq!(state.stage(), AdoptStage::Pick);
|
||||||
|
assert_eq!(
|
||||||
|
shell::title_text(state.stage(), catalog),
|
||||||
|
catalog.text(Key::AdoptTitle),
|
||||||
|
"标题走目录"
|
||||||
|
);
|
||||||
|
assert!(shell::subtitle(state.stage(), &state, catalog).contains("不复制"));
|
||||||
|
|
||||||
|
// Preview:`notes` 是 core `target_notes()` 的原文,界面一个字都不许改
|
||||||
|
let note =
|
||||||
|
"运行目录 = /game(共享档):mods/saves/config 会落在别人的 .minecraft 里".to_string();
|
||||||
|
state.apply(AdoptEvent::Planned(Box::new(AdoptPlanView {
|
||||||
|
version_id: "1.20.1".to_string(),
|
||||||
|
name: "1.20.1".to_string(),
|
||||||
|
target: AdoptTargetView::Shared,
|
||||||
|
isolation: AdoptIsolationView::Shared,
|
||||||
|
run_dir: std::path::PathBuf::from("/game"),
|
||||||
|
notes: vec![note.clone()],
|
||||||
|
})));
|
||||||
|
assert_eq!(state.stage(), AdoptStage::Preview);
|
||||||
|
assert_eq!(
|
||||||
|
state.plan.as_ref().map(|plan| plan.notes.first().cloned()),
|
||||||
|
Some(Some(note.clone())),
|
||||||
|
"后果原文必须原样留着"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Failed:problems 逐条(不折叠)
|
||||||
|
state.apply(AdoptEvent::Failed(Box::new(AdoptFailureView {
|
||||||
|
code: "adopt_not_adoptable".to_string(),
|
||||||
|
message: "版本 1.19.4 不能登记".to_string(),
|
||||||
|
problems: vec![
|
||||||
|
AdoptProblemView {
|
||||||
|
code: "manifest_missing".to_string(),
|
||||||
|
message: "没有清单".to_string(),
|
||||||
|
},
|
||||||
|
AdoptProblemView {
|
||||||
|
code: "missing_parent".to_string(),
|
||||||
|
message: "缺父版本 1.20.1".to_string(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})));
|
||||||
|
assert_eq!(state.stage(), AdoptStage::Failed);
|
||||||
|
assert_eq!(
|
||||||
|
state.failure.as_ref().map(|f| f.problems.len()),
|
||||||
|
Some(2),
|
||||||
|
"两条问题一条都不许吞"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
shell::title_text(state.stage(), catalog),
|
||||||
|
catalog.text(Key::AdoptFailureTitle)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Done
|
||||||
|
state.apply(AdoptEvent::Done(Box::new(AdoptDoneView {
|
||||||
|
instance_id: "id-1".to_string(),
|
||||||
|
name: "1.20.1".to_string(),
|
||||||
|
version_id: "1.20.1".to_string(),
|
||||||
|
isolation: AdoptIsolationView::Shared,
|
||||||
|
run_dir: std::path::PathBuf::from("/game"),
|
||||||
|
dir_created: false,
|
||||||
|
notes: vec![note],
|
||||||
|
})));
|
||||||
|
assert_eq!(state.stage(), AdoptStage::Done);
|
||||||
|
assert_eq!(
|
||||||
|
shell::title_text(state.stage(), catalog),
|
||||||
|
catalog.text(Key::AdoptDoneTitle)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 中英两套目录的向导文案都不是占位(`text()` 回落到 key 名就是这个用例要抓的假绿)。
|
||||||
|
#[test]
|
||||||
|
fn both_catalogs_have_the_wizard_wording() {
|
||||||
|
for lang in [Lang::En, Lang::ZhCn] {
|
||||||
|
let catalog = lang.catalog();
|
||||||
|
for key in [
|
||||||
|
Key::AdoptTitle,
|
||||||
|
Key::AdoptSubtitle,
|
||||||
|
Key::AdoptScanning,
|
||||||
|
Key::AdoptEmpty,
|
||||||
|
Key::AdoptSelectLabel,
|
||||||
|
Key::AdoptReady,
|
||||||
|
Key::AdoptTargetLabel,
|
||||||
|
Key::AdoptTargetInstanceDir,
|
||||||
|
Key::AdoptTargetShared,
|
||||||
|
Key::AdoptTargetVersionDir,
|
||||||
|
Key::AdoptNameLabel,
|
||||||
|
Key::AdoptPreview,
|
||||||
|
Key::AdoptConfirm,
|
||||||
|
Key::AdoptBack,
|
||||||
|
Key::AdoptClose,
|
||||||
|
Key::AdoptPlanTitle,
|
||||||
|
Key::AdoptPlanRunDir,
|
||||||
|
Key::AdoptPlanIsolation,
|
||||||
|
Key::AdoptFailureTitle,
|
||||||
|
Key::AdoptProblemCount,
|
||||||
|
Key::AdoptDoneTitle,
|
||||||
|
Key::AdoptDoneHint,
|
||||||
|
] {
|
||||||
|
let value = catalog.text(key);
|
||||||
|
assert!(!value.is_empty(), "{key:?} 在 {lang:?} 是空的");
|
||||||
|
assert_ne!(
|
||||||
|
value,
|
||||||
|
format!("{key:?}"),
|
||||||
|
"{key:?} 在 {lang:?} 没进目录(回落成了 key 名)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 共享档的短提示必须把「会写进别人的 .minecraft」说清楚(选之前就得知道)
|
||||||
|
let hint = Lang::En.catalog().text(Key::AdoptTargetSharedHint);
|
||||||
|
assert!(hint.contains(".minecraft"), "{hint}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 「点选共享档 ⇒ 立刻看到后果原文」但**不**把用户甩到确认页:
|
||||||
|
/// 预检结果到了也只是 Pick 段里的提示,直到用户点过「预检」(`checked`) 才进 Preview。
|
||||||
|
///
|
||||||
|
/// 抓得住:把 `Form::stage` 写回直接取 `AdoptState::stage()`(点一次共享档就跳到确认页,
|
||||||
|
/// 实例名还没来得及改);或忘了 `fresh_plan` 的目标一致性判据(改档之后画上一档的后果原文)。
|
||||||
|
#[test]
|
||||||
|
fn a_foreign_target_shows_its_notes_without_jumping_to_confirm() {
|
||||||
|
let mut state = state_with_scan();
|
||||||
|
let note =
|
||||||
|
"运行目录 = /game(共享档):mods/saves/config 会落在别人的 .minecraft 里".to_string();
|
||||||
|
state.apply(AdoptEvent::Planned(Box::new(AdoptPlanView {
|
||||||
|
version_id: "1.20.1".to_string(),
|
||||||
|
name: "1.20.1".to_string(),
|
||||||
|
target: AdoptTargetView::Shared,
|
||||||
|
isolation: AdoptIsolationView::Shared,
|
||||||
|
run_dir: std::path::PathBuf::from("/game"),
|
||||||
|
notes: vec![note.clone()],
|
||||||
|
})));
|
||||||
|
|
||||||
|
let mut form = Form {
|
||||||
|
open: true,
|
||||||
|
selected: Some("1.20.1".to_string()),
|
||||||
|
target: AdoptTargetView::Shared,
|
||||||
|
..Form::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
// 自动预检回来但用户没点「预检」:仍是 Pick 段,notes 当作提示可读
|
||||||
|
assert_eq!(form.stage(&state), AdoptStage::Pick);
|
||||||
|
assert_eq!(
|
||||||
|
form.fresh_plan(&state)
|
||||||
|
.map(|plan| plan.notes.first().cloned()),
|
||||||
|
Some(Some(note.clone())),
|
||||||
|
"后果原文必须能取到"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 点过「预检」之后才进确认段
|
||||||
|
form.checked = true;
|
||||||
|
assert_eq!(form.stage(&state), AdoptStage::Preview);
|
||||||
|
|
||||||
|
// 换了目标档:上一档的预检作废(不再当后果原文画)
|
||||||
|
form.target = AdoptTargetView::VersionDir;
|
||||||
|
assert!(form.fresh_plan(&state).is_none(), "过期的预检不许画");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **P2-2**:`fresh_plan` 必须按**版本 id** 过期,不只是目标档。
|
||||||
|
///
|
||||||
|
/// 场景:先选 1.20.1 + 版本目录档(notes 说 `versions/1.20.1`),再改选 1.19.4——目标档没变,
|
||||||
|
/// 旧的预检结果若不按版本过滤就会继续当「这个版本的后果原文」画出来,那是一句假话。
|
||||||
|
///
|
||||||
|
/// 抓得住:`fresh_plan` 只看 `plan.target == self.target`(第一次修法就是这样漏的)。
|
||||||
|
/// 抓不住:core 真的会不会为 1.19.4 算出同一段 notes(那是 `target_notes()` 的活)。
|
||||||
|
#[test]
|
||||||
|
fn fresh_plan_expires_when_the_selected_version_changes() {
|
||||||
|
let mut state = state_with_scan();
|
||||||
|
state.apply(AdoptEvent::Planned(Box::new(AdoptPlanView {
|
||||||
|
version_id: "1.20.1".to_string(),
|
||||||
|
name: "1.20.1".to_string(),
|
||||||
|
target: AdoptTargetView::VersionDir,
|
||||||
|
isolation: AdoptIsolationView::Isolated,
|
||||||
|
run_dir: std::path::PathBuf::from("/game/versions/1.20.1"),
|
||||||
|
notes: vec!["运行目录 = 版本目录 /game/versions/1.20.1".to_string()],
|
||||||
|
})));
|
||||||
|
|
||||||
|
let mut form = Form {
|
||||||
|
open: true,
|
||||||
|
selected: Some("1.20.1".to_string()),
|
||||||
|
target: AdoptTargetView::VersionDir,
|
||||||
|
checked: true,
|
||||||
|
..Form::default()
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
form.fresh_plan(&state).is_some(),
|
||||||
|
"对得上版本与目标档时应当可读"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 改选另一个版本:目标档没变,但旧预检说的是别的版本的目录
|
||||||
|
form.selected = Some("1.19.4".to_string());
|
||||||
|
assert!(
|
||||||
|
form.fresh_plan(&state).is_none(),
|
||||||
|
"换了版本之后旧预检必须过期(否则画的是 1.20.1 的后果原文)"
|
||||||
|
);
|
||||||
|
// 没选版本时同样不该画(`None` = 没有任何版本的预检可言)
|
||||||
|
form.selected = None;
|
||||||
|
assert!(form.fresh_plan(&state).is_none());
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
//! 向导里的小工具:等宽行、可换行正文、标签、问题行、档位人话。
|
||||||
|
//!
|
||||||
|
//! 单独一个文件只为守 400 行硬线(AGENTS §3.1):三段视图(`pick` / `panes`)与卡壳
|
||||||
|
//! (`adopt.rs`)都要用它们,放哪儿都会让那一处越线。
|
||||||
|
|
||||||
|
use iced::widget::text::LineHeight;
|
||||||
|
use iced::widget::{column, text};
|
||||||
|
use iced::{Color, Element, Font, Length, Pixels};
|
||||||
|
|
||||||
|
use unlucky_design::color::Role;
|
||||||
|
use unlucky_design::typography::TypeRole;
|
||||||
|
use unlucky_i18n::{Catalog, Key};
|
||||||
|
use unlucky_presenter::dto::{AdoptIsolationView, AdoptProblemView, AdoptTargetView};
|
||||||
|
|
||||||
|
use crate::pages::frame;
|
||||||
|
use crate::theme::Ui;
|
||||||
|
|
||||||
|
use super::super::message::Message;
|
||||||
|
|
||||||
|
/// 段内的次级标签(`LabelMedium` + `OnSurfaceVariant`)。
|
||||||
|
pub(super) fn label<'a>(ui: &'a Ui, value: &'a str) -> Element<'a, Message> {
|
||||||
|
frame::text_role(
|
||||||
|
ui,
|
||||||
|
TypeRole::LabelMedium,
|
||||||
|
false,
|
||||||
|
value,
|
||||||
|
ui.color(Role::OnSurfaceVariant),
|
||||||
|
)
|
||||||
|
.map(Message::Page)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 等宽一行(路径 / 机读码要能逐字读)。
|
||||||
|
pub(super) fn mono<'a>(ui: &'a Ui, value: String) -> Element<'a, Message> {
|
||||||
|
let style = ui.style(TypeRole::BodySmall);
|
||||||
|
text(value)
|
||||||
|
.size(style.size_px)
|
||||||
|
.line_height(LineHeight::Absolute(Pixels(style.line_height_px)))
|
||||||
|
.font(Font {
|
||||||
|
family: iced::font::Family::Name(ui.families().mono),
|
||||||
|
..Font::DEFAULT
|
||||||
|
})
|
||||||
|
.color(ui.color(Role::OnSurface))
|
||||||
|
.width(Length::Fill)
|
||||||
|
.wrapping(iced::widget::text::Wrapping::WordOrGlyph)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一段可换行的正文(长路径只按词折行会把卡顶破,所以按词或字形折)。
|
||||||
|
pub(super) fn wrapped<'a>(ui: &'a Ui, value: String, color: Color) -> Element<'a, Message> {
|
||||||
|
frame::text_role_owned(ui, TypeRole::BodySmall, false, value, color).map(Message::Page)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 「标签 + 值」一行(值用等宽)。
|
||||||
|
pub(super) fn field_line<'a>(ui: &'a Ui, label_text: &'a str, value: &str) -> Element<'a, Message> {
|
||||||
|
column![label(ui, label_text), mono(ui, value.to_string())]
|
||||||
|
.spacing(0.0)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一条问题:稳定码(等宽) + 人读原因(`Error` 色)。**一条都不许折叠**。
|
||||||
|
pub(super) fn problem_line<'a>(ui: &'a Ui, problem: &'a AdoptProblemView) -> Element<'a, Message> {
|
||||||
|
column![
|
||||||
|
mono(ui, problem.code.clone()),
|
||||||
|
wrapped(ui, problem.message.clone(), ui.color(Role::Error)),
|
||||||
|
]
|
||||||
|
.spacing(0.0)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 隔离档的人话(沿用实例页已有的两条;`custom` 就是缺省档,直接说「在 ULL 数据区新建」)。
|
||||||
|
pub(super) fn isolation_label(isolation: AdoptIsolationView, catalog: Catalog) -> String {
|
||||||
|
match isolation {
|
||||||
|
AdoptIsolationView::Isolated => catalog.text(Key::InstancesIsolationIsolated).to_string(),
|
||||||
|
AdoptIsolationView::Shared => catalog.text(Key::InstancesIsolationShared).to_string(),
|
||||||
|
AdoptIsolationView::Custom => catalog.text(Key::AdoptTargetInstanceDir).to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 目标档的短标签。
|
||||||
|
pub(super) fn target_label(target: AdoptTargetView, catalog: Catalog) -> &'static str {
|
||||||
|
match target {
|
||||||
|
AdoptTargetView::InstanceDir => catalog.text(Key::AdoptTargetInstanceDir),
|
||||||
|
AdoptTargetView::Shared => catalog.text(Key::AdoptTargetShared),
|
||||||
|
AdoptTargetView::VersionDir => catalog.text(Key::AdoptTargetVersionDir),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,6 +69,11 @@ pub enum Message {
|
|||||||
Demo(components::gallery::DemoMessage),
|
Demo(components::gallery::DemoMessage),
|
||||||
/// 模态对话框(M187):主/次按钮、遮罩、展开、导出、打开文件夹、复制路径。
|
/// 模态对话框(M187):主/次按钮、遮罩、展开、导出、打开文件夹、复制路径。
|
||||||
Dialog(super::dialog::Message),
|
Dialog(super::dialog::Message),
|
||||||
|
/// 导入已有版本向导(M223):打开 / 选版本 / 选目标 / 预检 / 确认 / 关闭。
|
||||||
|
///
|
||||||
|
/// 落点是 `app/update/adopt.rs`。页面只发一条入口意图
|
||||||
|
/// (`pages::Message::Action(pages::Action::Import)`),向导自己的四段状态都在这里收口。
|
||||||
|
Adopt(super::adopt::AdoptMessage),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 外壳导航项 → 路由(导航项只在一级页之间有对应;AI 项对应 [`Route::Agent`])。
|
/// 外壳导航项 → 路由(导航项只在一级页之间有对应;AI 项对应 [`Route::Agent`])。
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
//! `src/components/`(组件目录自己的归属规则不变),逻辑路径仍是 `crate::app::components`
|
//! `src/components/`(组件目录自己的归属规则不变),逻辑路径仍是 `crate::app::components`
|
||||||
//! (组件源码里的文档与测试都按这个路径写)。
|
//! (组件源码里的文档与测试都按这个路径写)。
|
||||||
|
|
||||||
|
mod adopt;
|
||||||
mod appearance;
|
mod appearance;
|
||||||
mod backend;
|
mod backend;
|
||||||
mod boot;
|
mod boot;
|
||||||
|
|||||||
@@ -196,6 +196,11 @@ pub struct App {
|
|||||||
/// `pages::Model`,用户正在输入的内容存不到视图模型里。重播种点在
|
/// `pages::Model`,用户正在输入的内容存不到视图模型里。重播种点在
|
||||||
/// [`App::refresh_view`](`LaunchForm::sync`,换实例必重播种、同实例的脏草稿不覆盖)。
|
/// [`App::refresh_view`](`LaunchForm::sync`,换实例必重播种、同实例的脏草稿不覆盖)。
|
||||||
pub(super) launch_form: pages::LaunchForm,
|
pub(super) launch_form: pages::LaunchForm,
|
||||||
|
/// 实例页「导入已有版本」向导的表单态(M223):选中版本 / 展示名 / 目标档 / 在等回程。
|
||||||
|
///
|
||||||
|
/// 住在 `App` 的理由与 `launch_form` 一致:向导跨帧(扫描 → 选 → 预检 → 确认),
|
||||||
|
/// 而候选/预检/失败/完成这些**事件真相**在 `AppState.adopt`(presenter),不在这里。
|
||||||
|
pub(super) adopt_form: super::adopt::Form,
|
||||||
/// 启动失败弹窗的**事实**(M187):与 `state.dialogs` 里的当前条按 id 绑定。
|
/// 启动失败弹窗的**事实**(M187):与 `state.dialogs` 里的当前条按 id 绑定。
|
||||||
///
|
///
|
||||||
/// 为什么事实不塞进 `DialogRequest`:那是 presenter 的公共 DTO,弹窗与「这次失败的现场」
|
/// 为什么事实不塞进 `DialogRequest`:那是 presenter 的公共 DTO,弹窗与「这次失败的现场」
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ impl App {
|
|||||||
// 下载页安装草稿(M105):出厂态 = 没选中、原版、两个空串
|
// 下载页安装草稿(M105):出厂态 = 没选中、原版、两个空串
|
||||||
download_draft: pages::InstallDraft::default(),
|
download_draft: pages::InstallDraft::default(),
|
||||||
launch_form: pages::LaunchForm::default(),
|
launch_form: pages::LaunchForm::default(),
|
||||||
|
adopt_form: super::super::adopt::Form::default(),
|
||||||
install_cancelled_seen: 0,
|
install_cancelled_seen: 0,
|
||||||
launch_ends_seen: 0,
|
launch_ends_seen: 0,
|
||||||
dialog_card: None,
|
dialog_card: None,
|
||||||
@@ -157,6 +158,8 @@ impl App {
|
|||||||
// M174:助手配置的首屏值(真值来自 `config/launcher.json`;截图通道走 `ULL_AI_STATE`)。
|
// M174:助手配置的首屏值(真值来自 `config/launcher.json`;截图通道走 `ULL_AI_STATE`)。
|
||||||
app.hydrate_ai_settings();
|
app.hydrate_ai_settings();
|
||||||
// M175:Java 分组的首屏值 + **首屏自动扫一次盘**(截图通道走 `ULL_JAVA_STATE`)。
|
// M175:Java 分组的首屏值 + **首屏自动扫一次盘**(截图通道走 `ULL_JAVA_STATE`)。
|
||||||
|
// M223: adopt wizard screenshot seed (evidence channel only; `ULL_ADOPT_STATE`).
|
||||||
|
app.hydrate_adopt_state();
|
||||||
app.hydrate_java_settings();
|
app.hydrate_java_settings();
|
||||||
// 两行日志:①档位 + 探测结论 + 生效语言 ②中文策略(D28 缺字体不许静默)。
|
// 两行日志:①档位 + 探测结论 + 生效语言 ②中文策略(D28 缺字体不许静默)。
|
||||||
// 下一个人据此分得清「探测错了」还是「探测对了但没用上」。
|
// 下一个人据此分得清「探测错了」还是「探测对了但没用上」。
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ use super::state::{App, IDLE_TICK, TICK, WATCH_WINDOW};
|
|||||||
use super::CAPTURE_AT_FRAME;
|
use super::CAPTURE_AT_FRAME;
|
||||||
|
|
||||||
mod account;
|
mod account;
|
||||||
|
// M223:导入已有版本向导(扫描 / 预检 / 确认 / 关闭 / 退回)
|
||||||
|
mod adopt;
|
||||||
mod agent;
|
mod agent;
|
||||||
mod appearance;
|
mod appearance;
|
||||||
pub(super) mod boot_env;
|
pub(super) mod boot_env;
|
||||||
@@ -149,6 +151,8 @@ impl App {
|
|||||||
}
|
}
|
||||||
Message::Page(message) => self.on_page(message),
|
Message::Page(message) => self.on_page(message),
|
||||||
Message::Dialog(message) => dialog::on_dialog(self, message),
|
Message::Dialog(message) => dialog::on_dialog(self, message),
|
||||||
|
// M223:导入已有版本向导(打开 / 选版本 / 选目标 / 预检 / 确认 / 退回 / 关闭)
|
||||||
|
Message::Adopt(message) => self.on_adopt(message),
|
||||||
Message::Agent(message) => {
|
Message::Agent(message) => {
|
||||||
// `Esc` 先给弹窗:有弹窗时它属于「关掉这一层」,不属于 AI 栏的收起。
|
// `Esc` 先给弹窗:有弹窗时它属于「关掉这一层」,不属于 AI 栏的收起。
|
||||||
if let crate::shell::agent::Message::Key(event) = &message {
|
if let crate::shell::agent::Message::Key(event) = &message {
|
||||||
|
|||||||
@@ -0,0 +1,358 @@
|
|||||||
|
//! 「导入已有版本」向导的落点(M223 GUIADOPT)。
|
||||||
|
//!
|
||||||
|
//! ## 四段各自发什么、等什么
|
||||||
|
//!
|
||||||
|
//! | 用户动作 | 发什么 | 等什么回程 |
|
||||||
|
//! |---|---|---|
|
||||||
|
//! | 页头「导入」/ [`AdoptMessage::Open`] | `AdoptCommand::Scan` | `AdoptEvent::Scan` |
|
||||||
|
//! | 选目标 + 点「预检」/ [`AdoptMessage::Check`] | `AdoptCommand::Preview` | `Planned` / `Failed` |
|
||||||
|
//! | 点「确认导入」/ [`AdoptMessage::Confirm`] | `AdoptCommand::Confirm` | `Done` / `Failed` |
|
||||||
|
//! | 「返回」/ [`AdoptMessage::Back`] | `AdoptCommand::Scan`(重扫一次,顺手清掉预检/失败) | `Scan` |
|
||||||
|
//! | 「关闭」/ [`AdoptMessage::Close`] | `AdoptCommand::Dismiss` | `Cleared` |
|
||||||
|
//!
|
||||||
|
//! ## 三段纪律
|
||||||
|
//!
|
||||||
|
//! 1. **不在本地判「能不能导入」**:候选可不可用、落点在哪、有哪些问题全部来自 presenter 的回程
|
||||||
|
//! (`AdoptState`)。这里只把「用户点了什么」翻成命令;
|
||||||
|
//! 2. **确认只发用户填过的那份请求**(版本 + 名字 + 目标档),预检与确认**同一份**——
|
||||||
|
//! 于是「预检 A、写 B」这种错位不可能发生;
|
||||||
|
//! 3. **在等回程时把主按钮并掉**(`Form::busy`):连点两次「确认」不会写两条实例记录。
|
||||||
|
|
||||||
|
use iced::Task;
|
||||||
|
use unlucky_presenter::dto::{AdoptCommand, AdoptRequestView, AppCommand};
|
||||||
|
|
||||||
|
use crate::app::adopt::{AdoptMessage, Busy};
|
||||||
|
|
||||||
|
use super::super::message::Message;
|
||||||
|
use super::super::state::App;
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
/// 向导的一条消息。
|
||||||
|
pub(super) fn on_adopt(&mut self, message: AdoptMessage) -> Task<Message> {
|
||||||
|
match message {
|
||||||
|
AdoptMessage::Open => {
|
||||||
|
self.adopt_form.open();
|
||||||
|
self.dispatch_adopt(AdoptCommand::Scan);
|
||||||
|
}
|
||||||
|
AdoptMessage::Close => {
|
||||||
|
// 登记在飞时不吃「关闭」:core 那条线程已经开跑了,写了盘却不回报 Done
|
||||||
|
// 就会出现「实例已存在但界面什么都没说」——等它交卷(几十毫秒级)。
|
||||||
|
if self.adopt_form.busy == Busy::Importing {
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
self.adopt_form.close();
|
||||||
|
self.dispatch_adopt(AdoptCommand::Dismiss);
|
||||||
|
}
|
||||||
|
AdoptMessage::Select(version_id) => {
|
||||||
|
self.adopt_form.selected = Some(version_id);
|
||||||
|
}
|
||||||
|
AdoptMessage::Name(name) => self.adopt_form.name = name,
|
||||||
|
AdoptMessage::Target(target) => {
|
||||||
|
// 登记在飞时不吃「换档」:它会派一条零写盘的预览把 `adopt_epoch` +1,
|
||||||
|
// 于是在飞的 Confirm 交卷时 Done 被代次丢掉——实例写进去了,界面什么都没说。
|
||||||
|
// 连本地档位也不改:界面上显示 A 档、盘上写的是 B 档,是最糟的一种不一致。
|
||||||
|
if self.adopt_form.busy == Busy::Importing {
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
self.adopt_form.target = target;
|
||||||
|
// 换了目标档 ⇒ 上一档的预检作废(确认按钮不该继续亮着)
|
||||||
|
self.adopt_form.checked = false;
|
||||||
|
// 共享档 / 版本目录档会写进**别人的目录**:一被点选就把后果原文拿出来
|
||||||
|
// (零写盘的预检,见 `Form::stage` 的收窄:这一次结果落在 Pick 段当提示读)。
|
||||||
|
if target.touches_foreign_dir() {
|
||||||
|
if let Some(request) = self.adopt_request() {
|
||||||
|
self.adopt_form.busy = Busy::Checking;
|
||||||
|
self.dispatch_adopt(AdoptCommand::Preview(Box::new(request)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AdoptMessage::Check => {
|
||||||
|
if let Some(request) = self.adopt_request() {
|
||||||
|
self.adopt_form.checked = true;
|
||||||
|
self.adopt_form.busy = Busy::Checking;
|
||||||
|
self.dispatch_adopt(AdoptCommand::Preview(Box::new(request)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AdoptMessage::Confirm => {
|
||||||
|
if let Some(request) = self.adopt_request() {
|
||||||
|
self.adopt_form.busy = Busy::Importing;
|
||||||
|
self.dispatch_adopt(AdoptCommand::Confirm(Box::new(request)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AdoptMessage::Back => {
|
||||||
|
// 同 Close / Target:登记在飞时「返回」会把 `adopt_epoch` +1(它派 Scan),
|
||||||
|
// 在飞 Confirm 的 Done 就再也到不了界面。
|
||||||
|
if self.adopt_form.busy == Busy::Importing {
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
// 退回候选列表 = 重新扫一次:`scan` 会把上一轮的预检/失败一起作废,
|
||||||
|
// 于是界面回到 Pick 段,不需要再给 core 加一个「清失败」的命令。
|
||||||
|
self.adopt_form.checked = false;
|
||||||
|
self.adopt_form.busy = Busy::Scanning;
|
||||||
|
self.dispatch_adopt(AdoptCommand::Scan);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 回程到了:这一条命令已经交卷(`pump` 收到 `CoreEvent::Adopt` 时调,见 `update/clock.rs`)。
|
||||||
|
/// 出图通道的种子(`ULL_ADOPT_STATE`,与 `ULL_JAVA_STATE` / `ULL_AI_STATE` 同一族)。
|
||||||
|
///
|
||||||
|
/// **只在 `--screenshot` 下生效**:交互模式读这个环境变量没有意义(也没有人会设它)。
|
||||||
|
/// 不认识的名字一律忽略,退回正常启动路径(向导保持关闭)。
|
||||||
|
pub(in crate::app) fn hydrate_adopt_state(&mut self) {
|
||||||
|
if self.options.screenshot.is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Ok(raw) = std::env::var(crate::app::adopt::shot::ENV) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if let Some((form, state)) = crate::app::adopt::shot::seed(&raw) {
|
||||||
|
self.adopt_form = form;
|
||||||
|
self.state.adopt = state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn settle_adopt(&mut self) {
|
||||||
|
self.adopt_form.settled();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前表单能拼出的请求(没选版本就没有请求——按钮在那边也是禁用的)。
|
||||||
|
fn adopt_request(&self) -> Option<AdoptRequestView> {
|
||||||
|
let version_id = self.adopt_form.selected.clone()?;
|
||||||
|
let name = self.adopt_form.name.trim();
|
||||||
|
Some(AdoptRequestView {
|
||||||
|
version_id,
|
||||||
|
name: (!name.is_empty()).then(|| name.to_string()),
|
||||||
|
target: self.adopt_form.target,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dispatch_adopt(&mut self, command: AdoptCommand) {
|
||||||
|
self.dispatch(AppCommand::Adopt(Box::new(command)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use unlucky_design::color::SchemeFlavor;
|
||||||
|
use unlucky_presenter::dto::{
|
||||||
|
AdoptCommand, AdoptEvent, AdoptTargetView, AppCommand, AppEvent, Backend,
|
||||||
|
};
|
||||||
|
use unlucky_presenter::mock::MockBackend;
|
||||||
|
use unlucky_presenter::settings_bridge::SettingsBridge;
|
||||||
|
|
||||||
|
use crate::app::adopt::{AdoptMessage, Busy};
|
||||||
|
use crate::app::state::App;
|
||||||
|
use crate::cli::Options;
|
||||||
|
use crate::theme::Families;
|
||||||
|
|
||||||
|
const FAMILIES: Families = Families {
|
||||||
|
text: "Segoe UI",
|
||||||
|
display: "Segoe UI",
|
||||||
|
mono: "Consolas",
|
||||||
|
};
|
||||||
|
|
||||||
|
fn headless() -> App {
|
||||||
|
App::with_presenter(
|
||||||
|
Options::default(),
|
||||||
|
FAMILIES,
|
||||||
|
SchemeFlavor::Light,
|
||||||
|
Box::new(MockBackend::new()),
|
||||||
|
SettingsBridge::memory_only(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 记账后端:把 `dispatch` 收到的命令留在台账里(`MockBackend` 只认自己那几条,
|
||||||
|
/// 「有没有派出去」根本看不见——而 P2-1 的判据正是「不许派」)。
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct RecordingBackend {
|
||||||
|
dispatched: Rc<RefCell<Vec<AppCommand>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Backend for RecordingBackend {
|
||||||
|
fn drain_events(&mut self) -> Vec<AppEvent> {
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dispatch(&mut self, command: AppCommand) {
|
||||||
|
self.dispatched.borrow_mut().push(command);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 带台账的 App(返回台账句柄,测试直接查「派了什么」)。
|
||||||
|
fn recording() -> (App, Rc<RefCell<Vec<AppCommand>>>) {
|
||||||
|
let dispatched: Rc<RefCell<Vec<AppCommand>>> = Rc::new(RefCell::new(Vec::new()));
|
||||||
|
let app = App::with_presenter(
|
||||||
|
Options::default(),
|
||||||
|
FAMILIES,
|
||||||
|
SchemeFlavor::Light,
|
||||||
|
Box::new(RecordingBackend {
|
||||||
|
dispatched: Rc::clone(&dispatched),
|
||||||
|
}),
|
||||||
|
SettingsBridge::memory_only(),
|
||||||
|
);
|
||||||
|
(app, dispatched)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 台账里有没有派过某条 adopt 命令(只认命令种类,不看载荷内容)。
|
||||||
|
///
|
||||||
|
/// 写法是 `let ... else` + `matches!`:内层用 `match` 会被 clippy 判成
|
||||||
|
/// `match expression looks like matches! macro`(deny 级,CI 的 `-D warnings` 直接红)。
|
||||||
|
fn dispatched_adopt(ledger: &Rc<RefCell<Vec<AppCommand>>>, want: &str) -> bool {
|
||||||
|
ledger.borrow().iter().any(|command| {
|
||||||
|
let AppCommand::Adopt(command) = command else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
matches!(
|
||||||
|
(&**command, want),
|
||||||
|
(AdoptCommand::Scan, "scan")
|
||||||
|
| (AdoptCommand::Preview(_), "preview")
|
||||||
|
| (AdoptCommand::Confirm(_), "confirm")
|
||||||
|
| (AdoptCommand::Dismiss, "dismiss")
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 打开向导 = 表单进入「在等扫描」+ 派一条 `Scan`。
|
||||||
|
///
|
||||||
|
/// 抓得住:`Open` 没派命令(面板永远停在「正在扫描」)、或忘了置 `Busy::Scanning`(没有等待态)。
|
||||||
|
/// 抓不住:扫描真的返回了什么(那在 presenter 的 `host/adopt` 用例里)。
|
||||||
|
#[test]
|
||||||
|
fn opening_the_wizard_asks_for_a_scan() {
|
||||||
|
let mut app = headless();
|
||||||
|
let _ = app.on_adopt(AdoptMessage::Open);
|
||||||
|
assert!(app.adopt_form.open);
|
||||||
|
assert_eq!(app.adopt_form.busy, Busy::Scanning);
|
||||||
|
assert_eq!(
|
||||||
|
app.adopt_form.target,
|
||||||
|
AdoptTargetView::InstanceDir,
|
||||||
|
"缺省不是 isolated"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 没选版本时「预检 / 确认」不派命令(否则会发出一条 `version_id` 为空的请求)。
|
||||||
|
///
|
||||||
|
/// 抓得住:`adopt_request()` 在没有选中项时仍拼出请求(空版本 id 打到 core 换一句
|
||||||
|
/// `adopt_version_not_found`,用户看到莫名其妙的失败)。
|
||||||
|
#[test]
|
||||||
|
fn no_request_without_a_selected_version() {
|
||||||
|
let mut app = headless();
|
||||||
|
let _ = app.on_adopt(AdoptMessage::Open);
|
||||||
|
let _ = app.on_adopt(AdoptMessage::Check);
|
||||||
|
assert_eq!(app.adopt_form.busy, Busy::Scanning, "Check 不该改等待态");
|
||||||
|
let _ = app.on_adopt(AdoptMessage::Confirm);
|
||||||
|
assert_eq!(app.adopt_form.busy, Busy::Scanning);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 选中 + 预检 = 一条 Preview 命令,且请求里的名字是**去过空白**的用户输入。
|
||||||
|
#[test]
|
||||||
|
fn check_sends_the_form_as_a_preview_request() {
|
||||||
|
let mut app = headless();
|
||||||
|
let _ = app.on_adopt(AdoptMessage::Open);
|
||||||
|
let _ = app.on_adopt(AdoptMessage::Select("1.20.1".to_string()));
|
||||||
|
let _ = app.on_adopt(AdoptMessage::Name(" 生存 ".to_string()));
|
||||||
|
let _ = app.on_adopt(AdoptMessage::Target(AdoptTargetView::Shared));
|
||||||
|
let _ = app.on_adopt(AdoptMessage::Check);
|
||||||
|
assert_eq!(app.adopt_form.busy, Busy::Checking);
|
||||||
|
// 请求本体不在 App 上留副本:能证明意图的只有等待态与目标档
|
||||||
|
assert_eq!(app.adopt_form.target, AdoptTargetView::Shared);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 回程到了就解除等待(`settle_adopt` 由 `pump` 调)。
|
||||||
|
#[test]
|
||||||
|
fn an_incoming_event_settles_the_wait() {
|
||||||
|
let mut app = headless();
|
||||||
|
let _ = app.on_adopt(AdoptMessage::Open);
|
||||||
|
assert!(app.adopt_form.busy.waiting());
|
||||||
|
app.settle_adopt();
|
||||||
|
assert_eq!(app.adopt_form.busy, Busy::Idle);
|
||||||
|
// 事件真相进了 presenter 的状态(界面从它取词)
|
||||||
|
app.state.apply(unlucky_presenter::dto::AppEvent::Core(
|
||||||
|
unlucky_presenter::dto::CoreEvent::Adopt(Box::new(AdoptEvent::Cleared)),
|
||||||
|
));
|
||||||
|
assert!(app.state.adopt.scan.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 共享档一被点选就发一条**预检**(零写盘),但**不**置 `checked`——确认页要用户点「预检」。
|
||||||
|
///
|
||||||
|
/// 抓得住:`Target` 分支忘了发预检(后果原文永远不出现,契约 §5.2 失守)、
|
||||||
|
/// 或者顺手把 `checked` 也置上(点一下档位就跳到确认页)。
|
||||||
|
#[test]
|
||||||
|
fn choosing_a_foreign_target_previews_but_waits_for_the_user() {
|
||||||
|
let mut app = headless();
|
||||||
|
let _ = app.on_adopt(AdoptMessage::Open);
|
||||||
|
let _ = app.on_adopt(AdoptMessage::Select("1.20.1".to_string()));
|
||||||
|
let _ = app.on_adopt(AdoptMessage::Target(AdoptTargetView::Shared));
|
||||||
|
assert_eq!(app.adopt_form.busy, Busy::Checking);
|
||||||
|
assert!(!app.adopt_form.checked, "确认页要等用户点「预检」");
|
||||||
|
|
||||||
|
// 缺省档不触发自动预检(它不会写进别人的目录)
|
||||||
|
let _ = app.on_adopt(AdoptMessage::Target(AdoptTargetView::InstanceDir));
|
||||||
|
assert!(!app.adopt_form.checked);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 登记在飞时「关闭」被忽略:core 已经在写了,关掉面板只会让 Done 无处可去。
|
||||||
|
///
|
||||||
|
/// 抓得住:Close 无条件清表单并 `Dismiss`(实例写成功了,界面却什么都没说)。
|
||||||
|
#[test]
|
||||||
|
fn closing_is_ignored_while_the_registration_is_in_flight() {
|
||||||
|
let mut app = headless();
|
||||||
|
let _ = app.on_adopt(AdoptMessage::Open);
|
||||||
|
let _ = app.on_adopt(AdoptMessage::Select("1.20.1".to_string()));
|
||||||
|
let _ = app.on_adopt(AdoptMessage::Check);
|
||||||
|
app.adopt_form.busy = Busy::Importing;
|
||||||
|
let _ = app.on_adopt(AdoptMessage::Close);
|
||||||
|
assert!(app.adopt_form.open, "登记在飞时不许把面板关掉");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **P2-1**:登记在飞时,除了 Close,「返回」与「换目标档」也必须早退。
|
||||||
|
///
|
||||||
|
/// 三条都是同一条病:它们都会派一条新命令把 `adopt_epoch` +1,于是在飞 Confirm 的
|
||||||
|
/// `Done` 回来时被代次丢掉 —— 实例**写进去了**,界面却什么都没说(Close 那条早就守住了,
|
||||||
|
/// 唯独这两支漏了)。
|
||||||
|
///
|
||||||
|
/// 抓得住:`Back` 派了 `Scan`(旧 `Done` 被丢)、`Target` 派了 `Preview`(同上),
|
||||||
|
/// 以及「守卫写了但写在改本地状态之后」(表单被改成新档位,而盘上写的是旧档位)。
|
||||||
|
/// 抓不住:host 侧代次比较的真实时序(那在 presenter 的 `host/adopt` 那层)。
|
||||||
|
#[test]
|
||||||
|
fn back_and_target_are_ignored_while_the_registration_is_in_flight() {
|
||||||
|
let (mut app, ledger) = recording();
|
||||||
|
let _ = app.on_adopt(AdoptMessage::Open);
|
||||||
|
let _ = app.on_adopt(AdoptMessage::Select("1.20.1".to_string()));
|
||||||
|
let _ = app.on_adopt(AdoptMessage::Target(AdoptTargetView::InstanceDir));
|
||||||
|
// 走到「确认」那一步:真的派一条 Confirm 出去
|
||||||
|
let _ = app.on_adopt(AdoptMessage::Check);
|
||||||
|
let _ = app.on_adopt(AdoptMessage::Confirm);
|
||||||
|
assert!(
|
||||||
|
dispatched_adopt(&ledger, "confirm"),
|
||||||
|
"前提:确认要真的派出去"
|
||||||
|
);
|
||||||
|
assert_eq!(app.adopt_form.busy, Busy::Importing);
|
||||||
|
ledger.borrow_mut().clear();
|
||||||
|
|
||||||
|
// 在飞期间点「返回」:不许派 Scan
|
||||||
|
let _ = app.on_adopt(AdoptMessage::Back);
|
||||||
|
assert!(
|
||||||
|
!dispatched_adopt(&ledger, "scan"),
|
||||||
|
"登记在飞时 Back 不许派 Scan(会把 Done 的代次顶掉)"
|
||||||
|
);
|
||||||
|
assert_eq!(app.adopt_form.busy, Busy::Importing, "等待态不许被改掉");
|
||||||
|
|
||||||
|
// 在飞期间换目标档:不许派 Preview,也不许改本地档位
|
||||||
|
let _ = app.on_adopt(AdoptMessage::Target(AdoptTargetView::Shared));
|
||||||
|
assert!(
|
||||||
|
!dispatched_adopt(&ledger, "preview"),
|
||||||
|
"登记在飞时换档不许派 Preview(会把 Done 的代次顶掉)"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
app.adopt_form.target,
|
||||||
|
AdoptTargetView::InstanceDir,
|
||||||
|
"挡下来的点击不该改出界面与盘上不一致的档位"
|
||||||
|
);
|
||||||
|
assert_eq!(app.adopt_form.busy, Busy::Importing);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -140,7 +140,16 @@ impl App {
|
|||||||
) {
|
) {
|
||||||
clean_scan = true;
|
clean_scan = true;
|
||||||
}
|
}
|
||||||
|
// M223:导入向导「在等回程」只认事件到达(派发时置位、这里解除),
|
||||||
|
// 于是连点「确认」不会写两条实例记录,界面也不会一直转圈。
|
||||||
|
let settled_adopt = matches!(
|
||||||
|
&event,
|
||||||
|
unlucky_presenter::dto::AppEvent::Core(unlucky_presenter::dto::CoreEvent::Adopt(_))
|
||||||
|
);
|
||||||
self.state.apply(event);
|
self.state.apply(event);
|
||||||
|
if settled_adopt {
|
||||||
|
self.settle_adopt();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
self.view_dirty = true;
|
self.view_dirty = true;
|
||||||
self.auto_select();
|
self.auto_select();
|
||||||
|
|||||||
@@ -109,6 +109,11 @@ impl App {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
// M223:实例页页头「导入」——以前是占位动作(点了只打一行日志),现在是
|
||||||
|
// 「导入已有版本」向导的入口(M221 的 core 能力 + M223 的 presenter/GUI 接线)。
|
||||||
|
pages::Message::Action(pages::Action::Import) => {
|
||||||
|
return self.on_adopt(crate::app::adopt::AdoptMessage::Open);
|
||||||
|
}
|
||||||
// 还没接后端的占位动作:只打日志(哪几个、为什么,见 pages 模块文档的偏离登记)
|
// 还没接后端的占位动作:只打日志(哪几个、为什么,见 pages 模块文档的偏离登记)
|
||||||
pages::Message::Action(action) => {
|
pages::Message::Action(action) => {
|
||||||
diag!("[page] 占位动作 {action:?} pressed(没有对应 AppCommand)");
|
diag!("[page] 占位动作 {action:?} pressed(没有对应 AppCommand)");
|
||||||
|
|||||||
@@ -185,10 +185,19 @@ impl App {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
// 队列里有一条就叠一层模态(没有则原样返回)。模型由 `refresh_view` 维护。
|
// 队列里有一条就叠一层模态(没有则原样返回)。模型由 `refresh_view` 维护。
|
||||||
match self.dialog_model.as_ref() {
|
let tree = match self.dialog_model.as_ref() {
|
||||||
Some(modal) => super::dialog::view::layer(&self.ui, tree, modal),
|
Some(modal) => super::dialog::view::layer(&self.ui, tree, modal),
|
||||||
None => tree,
|
None => tree,
|
||||||
}
|
};
|
||||||
|
// M223:导入已有版本向导(实例页页头「导入」打开;没打开时原样返回页面树)。
|
||||||
|
// 叠在模态**之上**:向导自己就是一层交互,模态只在崩溃/确认时出现,两者不会互相抢焦点。
|
||||||
|
super::adopt::layer(
|
||||||
|
&self.ui,
|
||||||
|
tree,
|
||||||
|
&self.adopt_form,
|
||||||
|
&self.state.adopt,
|
||||||
|
self.lang(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
//! 路径上的已有祖先若解析到**数据根之外**(符号链接/目录联接)⇒ 拒绝,不沿它写盘;
|
//! 路径上的已有祖先若解析到**数据根之外**(符号链接/目录联接)⇒ 拒绝,不沿它写盘;
|
||||||
//! 已被**别的实例**占着 ⇒ 顺延 `-2`/`-3` 后缀,绝不让两个实例共用同一个运行目录)。
|
//! 已被**别的实例**占着 ⇒ 顺延 `-2`/`-3` 后缀,绝不让两个实例共用同一个运行目录)。
|
||||||
//!
|
//!
|
||||||
//! 写配置**之前**还要核一遍真实写面(`config/`、`config/instances/`、`profiles.json` 必须在
|
//! 写配置**之前**还要核一遍真实写面(`config/`、`config/instances/`、`profiles.json` 必须落在
|
||||||
//! 写盘面判据见 [`fspaths`](配置必须在 `<.ULL>` 内、默认写盘面不得与游戏树重叠)。
|
//! `<.ULL>` 内、且默认写盘面不得与游戏树重叠),判据见 [`fspaths`]。
|
||||||
//!
|
//!
|
||||||
//! `versions/` `libraries/` `assets/` **一个字节都不写**:登记只填 `version_id`,内容靠 D20 的
|
//! `versions/` `libraries/` `assets/` **一个字节都不写**:登记只填 `version_id`,内容靠 D20 的
|
||||||
//! 「恒共享层」被启动/下载链路复用([`crate::game::isolation::SHARED_LAYERS`])。
|
//! 「恒共享层」被启动/下载链路复用([`crate::game::isolation::SHARED_LAYERS`])。
|
||||||
|
|||||||
@@ -13,8 +13,10 @@
|
|||||||
//! # 判据的基准(为什么不拿 `<data>/instances` 或 `<.ULL>/config` 当基准)
|
//! # 判据的基准(为什么不拿 `<data>/instances` 或 `<.ULL>/config` 当基准)
|
||||||
//!
|
//!
|
||||||
//! 被保护的**边界自己是链接**时,拿它当基准等于把越界当正常。所以基准一律是**解析后的父级**:
|
//! 被保护的**边界自己是链接**时,拿它当基准等于把越界当正常。所以基准一律是**解析后的父级**:
|
||||||
//! 数据写面看 `<data>`,配置写面看 `<.ULL>`、并额外排除游戏根。用户把整棵树迁到别的盘
|
//! 数据写面看 `<data>`(本模块只负责这一条);配置写面(`<.ULL>` 内、并额外排除游戏根)已移交
|
||||||
//! (`<.ULL>` 或 `<data>` 本身是联接/符号链接)仍然允许——判据是落点,不是路径长得像不像。
|
//! [`super::fspaths`] 的 `ensure_config_write_surface` / `ensure_surfaces_clear_of_game_tree`,
|
||||||
|
//! 不在本模块的判据里。用户把整棵树迁到别的盘(`<.ULL>` 或 `<data>` 本身是联接/符号链接)
|
||||||
|
//! 仍然允许——判据是落点,不是路径长得像不像。
|
||||||
//!
|
//!
|
||||||
//! # 已知边界
|
//! # 已知边界
|
||||||
//!
|
//!
|
||||||
|
|||||||
@@ -59,6 +59,34 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[
|
|||||||
("InstancesBackendVulkan", "Vulkan", "Vulkan 后端"),
|
("InstancesBackendVulkan", "Vulkan", "Vulkan 后端"),
|
||||||
("InstancesLaunchSave", "Save", "保存"),
|
("InstancesLaunchSave", "Save", "保存"),
|
||||||
("InstancesLaunchReset", "Reset to global", "恢复跟随全局"),
|
("InstancesLaunchReset", "Reset to global", "恢复跟随全局"),
|
||||||
|
("AdoptTitle", "Import an existing version", "导入已有版本"),
|
||||||
|
("AdoptSubtitle", "Register versions another launcher already installed. Nothing is copied: versions, libraries and assets stay where they are.", "把别的启动器已经装好的版本登记成实例。不复制任何东西:versions / libraries / assets 原地不动。"),
|
||||||
|
("AdoptScanning", "Scanning versions/…", "正在扫描 versions/…"),
|
||||||
|
("AdoptEmpty", "No version found under {dir}. Install one here, or point the game folder at the .minecraft another launcher uses.", "在 {dir} 下一个版本都没找到。可以用本启动器装一个,或者把游戏目录指向别的启动器用的那个 .minecraft。"),
|
||||||
|
("AdoptSelectLabel", "Pick a version", "选择版本"),
|
||||||
|
("AdoptReady", "Ready", "可导入"),
|
||||||
|
("AdoptBlocked", "{count} problem(s)", "{count} 个问题"),
|
||||||
|
("AdoptRegistered", "Already used by {name}", "已被 {name} 使用"),
|
||||||
|
("AdoptTargetLabel", "Where should the game run?", "游戏在哪个目录里跑"),
|
||||||
|
("AdoptTargetInstanceDir", "New folder in ULL data (default)", "ULL 数据区新建目录(缺省)"),
|
||||||
|
("AdoptTargetInstanceDirHint", "mods, saves and config stay out of the other launcher's .minecraft", "mods / saves / config 不会落进别人的 .minecraft"),
|
||||||
|
("AdoptTargetShared", "Share the game folder", "共用游戏目录"),
|
||||||
|
("AdoptTargetSharedHint", "mods, saves and config are written into the other launcher's .minecraft", "mods / saves / config 会写进别人的 .minecraft"),
|
||||||
|
("AdoptTargetVersionDir", "Use the version folder", "用版本目录"),
|
||||||
|
("AdoptTargetVersionDirHint", "player data grows inside versions/<id>", "玩家数据会长在 versions/<id> 里"),
|
||||||
|
("AdoptNameLabel", "Instance name (empty = version id)", "实例名(留空 = 版本 id)"),
|
||||||
|
("AdoptPreview", "Check", "预检"),
|
||||||
|
("AdoptConfirm", "Import", "导入"),
|
||||||
|
("AdoptBack", "Back", "返回"),
|
||||||
|
("AdoptClose", "Close", "关闭"),
|
||||||
|
("AdoptPlanTitle", "Ready to import", "可以导入"),
|
||||||
|
("AdoptPlanRunDir", "Game folder", "运行目录"),
|
||||||
|
("AdoptPlanIsolation", "Isolation", "隔离方式"),
|
||||||
|
("AdoptFailureTitle", "Import failed", "导入失败"),
|
||||||
|
("AdoptProblemCount", "Every reason is listed below:", "全部原因如下:"),
|
||||||
|
("AdoptInstanceName", "Instance name", "实例名"),
|
||||||
|
("AdoptDoneTitle", "Imported", "已导入"),
|
||||||
|
("AdoptDoneHint", "It is in the instance list now. Player data will live in the game folder below.", "它已经出现在实例列表里;玩家数据会落在下面这个运行目录。"),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// 金表的**全量**行(本文件 → [`tail`] → [`tail_ai`] → [`tail2`],顺序即 `Key::ALL`)。
|
/// 金表的**全量**行(本文件 → [`tail`] → [`tail_ai`] → [`tail2`],顺序即 `Key::ALL`)。
|
||||||
|
|||||||
@@ -102,6 +102,36 @@ macro_rules! instances_entries {
|
|||||||
InstancesBackendVulkan => ("Vulkan", "Vulkan 后端"),
|
InstancesBackendVulkan => ("Vulkan", "Vulkan 后端"),
|
||||||
InstancesLaunchSave => ("Save", "保存"),
|
InstancesLaunchSave => ("Save", "保存"),
|
||||||
InstancesLaunchReset => ("Reset to global", "恢复跟随全局"),
|
InstancesLaunchReset => ("Reset to global", "恢复跟随全局"),
|
||||||
|
|
||||||
|
// ── 导入已有版本(M223):向导的全部文案
|
||||||
|
AdoptTitle => ("Import an existing version", "导入已有版本"),
|
||||||
|
AdoptSubtitle => ("Register versions another launcher already installed. Nothing is copied: versions, libraries and assets stay where they are.", "把别的启动器已经装好的版本登记成实例。不复制任何东西:versions / libraries / assets 原地不动。"),
|
||||||
|
AdoptScanning => ("Scanning versions/…", "正在扫描 versions/…"),
|
||||||
|
AdoptEmpty => ("No version found under {dir}. Install one here, or point the game folder at the .minecraft another launcher uses.", "在 {dir} 下一个版本都没找到。可以用本启动器装一个,或者把游戏目录指向别的启动器用的那个 .minecraft。"),
|
||||||
|
AdoptSelectLabel => ("Pick a version", "选择版本"),
|
||||||
|
AdoptReady => ("Ready", "可导入"),
|
||||||
|
AdoptBlocked => ("{count} problem(s)", "{count} 个问题"),
|
||||||
|
AdoptRegistered => ("Already used by {name}", "已被 {name} 使用"),
|
||||||
|
AdoptTargetLabel => ("Where should the game run?", "游戏在哪个目录里跑"),
|
||||||
|
AdoptTargetInstanceDir => ("New folder in ULL data (default)", "ULL 数据区新建目录(缺省)"),
|
||||||
|
AdoptTargetInstanceDirHint => ("mods, saves and config stay out of the other launcher's .minecraft", "mods / saves / config 不会落进别人的 .minecraft"),
|
||||||
|
AdoptTargetShared => ("Share the game folder", "共用游戏目录"),
|
||||||
|
AdoptTargetSharedHint => ("mods, saves and config are written into the other launcher's .minecraft", "mods / saves / config 会写进别人的 .minecraft"),
|
||||||
|
AdoptTargetVersionDir => ("Use the version folder", "用版本目录"),
|
||||||
|
AdoptTargetVersionDirHint => ("player data grows inside versions/<id>", "玩家数据会长在 versions/<id> 里"),
|
||||||
|
AdoptNameLabel => ("Instance name (empty = version id)", "实例名(留空 = 版本 id)"),
|
||||||
|
AdoptPreview => ("Check", "预检"),
|
||||||
|
AdoptConfirm => ("Import", "导入"),
|
||||||
|
AdoptBack => ("Back", "返回"),
|
||||||
|
AdoptClose => ("Close", "关闭"),
|
||||||
|
AdoptPlanTitle => ("Ready to import", "可以导入"),
|
||||||
|
AdoptPlanRunDir => ("Game folder", "运行目录"),
|
||||||
|
AdoptPlanIsolation => ("Isolation", "隔离方式"),
|
||||||
|
AdoptFailureTitle => ("Import failed", "导入失败"),
|
||||||
|
AdoptProblemCount => ("Every reason is listed below:", "全部原因如下:"),
|
||||||
|
AdoptInstanceName => ("Instance name", "实例名"),
|
||||||
|
AdoptDoneTitle => ("Imported", "已导入"),
|
||||||
|
AdoptDoneHint => ("It is in the instance list now. Player data will live in the game folder below.", "它已经出现在实例列表里;玩家数据会落在下面这个运行目录。"),
|
||||||
} }
|
} }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,384 @@
|
|||||||
|
//! 「导入已有版本」的 IPC 面(M223 GUIADOPT):扫描 → 预检 → 确认三段。
|
||||||
|
//!
|
||||||
|
//! 能力本身在 core(`unlucky_core::game::adopt`,M221 的契约见
|
||||||
|
//! `DevDocs/SubAgentsTalk/adoptcore-契约.md`);本文件只有**可序列化的视图**,
|
||||||
|
//! 一个 `unlucky_core::*` 类型都不出现(AGENTS §6 的跨边界纪律)。
|
||||||
|
//!
|
||||||
|
//! ## 三段:前两段零写盘,第三段只写 ULL 自己的配置
|
||||||
|
//!
|
||||||
|
//! | 段 | 指令 | 回程 | 写盘 |
|
||||||
|
//! |---|---|---|---|
|
||||||
|
//! | 扫描 | [`AdoptCommand::Scan`] | [`AdoptEvent::Scan`] | 零(不建目录、不改 mtime) |
|
||||||
|
//! | 预检 | [`AdoptCommand::Preview`] | [`AdoptEvent::Planned`] / [`AdoptEvent::Failed`] | 零 |
|
||||||
|
//! | 确认 | [`AdoptCommand::Confirm`] | [`AdoptEvent::Done`] / [`AdoptEvent::Failed`] | 实例设置 + `profiles.json`(+ 缺省运行目录) |
|
||||||
|
//!
|
||||||
|
//! ## 三条硬纪律(契约 §5 的 1/2/3)
|
||||||
|
//!
|
||||||
|
//! 1. **运行目录只认回程**:[`AdoptPlanView::run_dir`] / [`AdoptDoneView::run_dir`] 是唯一来源。
|
||||||
|
//! 缺省档会带 `-2`/`-3` 后缀消歧(两个不同展示名可能落到同一个物理目录),**只有 core 知道
|
||||||
|
//! 最后用了哪个**;页面拿展示名自己派生一次就是错的(会与实例真正启动时用的目录分叉)。
|
||||||
|
//! 2. **后果话术只有一份**:`Shared` / `VersionDir` 的后果**不在本文件里另写**一句,
|
||||||
|
//! 由预检把 core `target_notes()` 的原文放进 [`AdoptPlanView::notes`]——于是界面上的
|
||||||
|
//! 「mods/saves/config 会落进别人的 `.minecraft`」与 CLI 打的是同一句。
|
||||||
|
//! 想显示后果就必须先预检(预检零写盘,所以这不是成本)。
|
||||||
|
//! 3. **问题逐条**:core 的结构化问题([`AdoptProblemView`],带稳定 `code`)与整次失败
|
||||||
|
//! ([`AdoptFailureView`])都原样搬过来,**不折叠成一句「导入失败」**。
|
||||||
|
//!
|
||||||
|
//! ## 缺省档为什么是 `InstanceDir` 而不是 `isolated`
|
||||||
|
//!
|
||||||
|
//! 全局缺省 `isolated` 的运行目录就是 `versions/<id>/`——那是**别人的版本目录**。沿用它会让第一次
|
||||||
|
//! 启动就在别人的 `.minecraft` 里长出 `mods/` `saves/` `logs/`。所以 [`AdoptTargetView`] 的
|
||||||
|
//! 缺省是 `InstanceDir`(ULL 数据区新建独立目录),另两档必须**明确点选**。
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// app → host 的导入意图。
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum AdoptCommand {
|
||||||
|
/// 扫当前游戏根的 `versions/`(只读;顺带得到每个版本能不能登记、已登记给谁)
|
||||||
|
Scan,
|
||||||
|
/// **预检**:按请求算落点与后果(零写盘)。页面拿回 [`AdoptEvent::Planned`] 才画确认按钮。
|
||||||
|
Preview(Box<AdoptRequestView>),
|
||||||
|
/// 确认登记(唯一写盘入口)。
|
||||||
|
Confirm(Box<AdoptRequestView>),
|
||||||
|
/// 关掉面板(扫描结果 / 预检 / 结果卡都收起来)
|
||||||
|
Dismiss,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// host → app 的回程事件。
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum AdoptEvent {
|
||||||
|
/// 扫描结果(`versions/` 里的全部条目,逐项带问题)
|
||||||
|
Scan(Box<AdoptScanView>),
|
||||||
|
/// 预检结果(落点 + 后果原文 + 将写的展示名)
|
||||||
|
Planned(Box<AdoptPlanView>),
|
||||||
|
/// 失败:`code` 稳定、`message` 人读、`problems` 逐条(可能为空)
|
||||||
|
Failed(Box<AdoptFailureView>),
|
||||||
|
/// 登记完成
|
||||||
|
Done(Box<AdoptDoneView>),
|
||||||
|
/// 面板已关(状态复位)
|
||||||
|
Cleared,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 登记后的**运行目录从哪来**(隔离档由它决定)。
|
||||||
|
///
|
||||||
|
/// GUI 只开三档:[`Self::InstanceDir`](缺省)/[`Self::Shared`] / [`Self::VersionDir`]。
|
||||||
|
/// `custom`(用户手输绝对路径)留在 CLI:GUI 要它就得配目录选择器,不在本 mission 的交付面里。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||||
|
pub enum AdoptTargetView {
|
||||||
|
/// 缺省:ULL 数据区新建独立运行目录(`custom` 档,与别人的 `.minecraft` 隔离)
|
||||||
|
#[default]
|
||||||
|
InstanceDir,
|
||||||
|
/// 共用游戏根(别人的 `.minecraft`)——必须明确点选
|
||||||
|
Shared,
|
||||||
|
/// 版本目录 `versions/<id>/`(`isolated` 档)——会在别人的版本目录里长玩家数据
|
||||||
|
VersionDir,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AdoptTargetView {
|
||||||
|
/// 三档的声明顺序(= 面板上的先后,缺省在最前)。
|
||||||
|
pub const ALL: [AdoptTargetView; 3] = [
|
||||||
|
AdoptTargetView::InstanceDir,
|
||||||
|
AdoptTargetView::Shared,
|
||||||
|
AdoptTargetView::VersionDir,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// 稳定 slug(与 core 的 `AdoptTarget::as_str` 同词表;`instance_dir` 不进 CLI 词表)。
|
||||||
|
#[must_use]
|
||||||
|
pub const fn slug(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::InstanceDir => "instance_dir",
|
||||||
|
Self::Shared => "shared",
|
||||||
|
Self::VersionDir => "version_dir",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 slug 反解。
|
||||||
|
#[must_use]
|
||||||
|
pub fn from_slug(slug: &str) -> Option<Self> {
|
||||||
|
Self::ALL.into_iter().find(|target| target.slug() == slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 这一档会不会把玩家数据写进**别人的**目录(界面据此加警示标记)。
|
||||||
|
#[must_use]
|
||||||
|
pub const fn touches_foreign_dir(self) -> bool {
|
||||||
|
matches!(self, Self::Shared | Self::VersionDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 生效的隔离档(`core::game::install::instance::Isolation` 的视图形态)。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum AdoptIsolationView {
|
||||||
|
/// `versions/<id>/` 当运行目录
|
||||||
|
Isolated,
|
||||||
|
/// 游戏根当运行目录
|
||||||
|
Shared,
|
||||||
|
/// 指定/新建目录当运行目录(缺省档就是这一档)
|
||||||
|
Custom,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AdoptIsolationView {
|
||||||
|
/// 稳定 slug(与 core 的 `Isolation::as_str` 同一套字面量)。
|
||||||
|
#[must_use]
|
||||||
|
pub const fn slug(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Isolated => "isolated",
|
||||||
|
Self::Shared => "shared",
|
||||||
|
Self::Custom => "custom",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 slug 反解(未知值返回 `None`,界面照原样上屏而不是猜)。
|
||||||
|
#[must_use]
|
||||||
|
pub fn from_slug(slug: &str) -> Option<Self> {
|
||||||
|
match slug {
|
||||||
|
"isolated" => Some(Self::Isolated),
|
||||||
|
"shared" => Some(Self::Shared),
|
||||||
|
"custom" => Some(Self::Custom),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一次预检/登记的请求(页面表单 → host)。
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct AdoptRequestView {
|
||||||
|
/// 要接进来的版本 id(= `versions/<id>/` 的目录名)
|
||||||
|
pub version_id: String,
|
||||||
|
/// 展示名(`None` / 空串 = 用版本 id)
|
||||||
|
pub name: Option<String>,
|
||||||
|
/// 运行目录选择(缺省 [`AdoptTargetView::InstanceDir`])
|
||||||
|
pub target: AdoptTargetView,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AdoptRequestView {
|
||||||
|
/// 最小请求(缺省名 + 缺省运行目录)。
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(version_id: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
version_id: version_id.into(),
|
||||||
|
name: None,
|
||||||
|
target: AdoptTargetView::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一条**结构化**的不合格原因(`code` 稳定,`message` 人读)。
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct AdoptProblemView {
|
||||||
|
/// 稳定机读码(`manifest_missing` / `missing_parent` / `outside_game_root` …)
|
||||||
|
pub code: String,
|
||||||
|
/// 人读原因(core 的 `Display` 原文:带路径、行列号、缺哪个父版本)
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 盘上的一个候选版本。
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct AdoptCandidateView {
|
||||||
|
/// 版本 id(= 目录名,登记后写进 `profiles.json`)
|
||||||
|
pub version_id: String,
|
||||||
|
/// 客户端 jar 在不在(只报事实,不校验内容)
|
||||||
|
pub jar_present: bool,
|
||||||
|
/// `inheritsFrom` 链(自身在前、最祖先在后)
|
||||||
|
pub inherits: Vec<String>,
|
||||||
|
/// 已经绑定这个版本的实例名(空 = 还没登记过)
|
||||||
|
pub registered: Vec<String>,
|
||||||
|
/// 问题清单(空 = 可登记)
|
||||||
|
pub problems: Vec<AdoptProblemView>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AdoptCandidateView {
|
||||||
|
/// 能不能登记(没有任何问题)。
|
||||||
|
#[must_use]
|
||||||
|
pub fn adoptable(&self) -> bool {
|
||||||
|
self.problems.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 已登记过(再登记会得到第二个实例,共用同一份版本文件)。
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_registered(&self) -> bool {
|
||||||
|
!self.registered.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一次扫描的结果。
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct AdoptScanView {
|
||||||
|
/// 被扫的目录(`<游戏根>/versions`)
|
||||||
|
pub versions_dir: PathBuf,
|
||||||
|
/// 候选(按版本 id 字典序)
|
||||||
|
pub candidates: Vec<AdoptCandidateView>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AdoptScanView {
|
||||||
|
/// 候选条数。
|
||||||
|
#[must_use]
|
||||||
|
pub fn total(&self) -> usize {
|
||||||
|
self.candidates.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 可登记的条数。
|
||||||
|
#[must_use]
|
||||||
|
pub fn adoptable(&self) -> usize {
|
||||||
|
self.candidates
|
||||||
|
.iter()
|
||||||
|
.filter(|candidate| candidate.adoptable())
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `versions/` 存在但一个候选都没有(新数据根,或只剩别的启动器的空目录)。
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.candidates.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 预检结果:落点 + 后果原文 + 将写的展示名。**零写盘**。
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct AdoptPlanView {
|
||||||
|
/// 版本 id
|
||||||
|
pub version_id: String,
|
||||||
|
/// 将写进档案的展示名(空名已回落到版本 id)
|
||||||
|
pub name: String,
|
||||||
|
/// 运行目录选择
|
||||||
|
pub target: AdoptTargetView,
|
||||||
|
/// 生效隔离档
|
||||||
|
pub isolation: AdoptIsolationView,
|
||||||
|
/// **生效运行目录**(可能是顺延后的 `-2`/`-3`;页面不许自己派生)
|
||||||
|
pub run_dir: PathBuf,
|
||||||
|
/// 人读提示(含 `target_notes()` 的原文:共享档/版本目录档会把玩家数据写进别人的目录)
|
||||||
|
pub notes: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 登记完成。
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct AdoptDoneView {
|
||||||
|
/// 实例 id(稳定 UUID)
|
||||||
|
pub instance_id: String,
|
||||||
|
/// 展示名
|
||||||
|
pub name: String,
|
||||||
|
/// 绑定的版本 id
|
||||||
|
pub version_id: String,
|
||||||
|
/// 生效隔离档
|
||||||
|
pub isolation: AdoptIsolationView,
|
||||||
|
/// 生效运行目录(**唯一来源**:启动时用的就是它)
|
||||||
|
pub run_dir: PathBuf,
|
||||||
|
/// 是否新建了缺省运行目录(`false` = 目录已存在但为空、直接复用)
|
||||||
|
pub dir_created: bool,
|
||||||
|
/// 人读提示(与预检同一份)
|
||||||
|
pub notes: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一次失败(整次调用,不是单条候选问题)。
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct AdoptFailureView {
|
||||||
|
/// 稳定机读码(`adopt_not_adoptable` / `adopt_duplicate_name` / `adopt_target_not_empty` …)
|
||||||
|
pub code: String,
|
||||||
|
/// 人读原因(core 的 `Display` 原文)
|
||||||
|
pub message: String,
|
||||||
|
/// 候选问题清单(`adopt_not_adoptable` 时逐条给出;其它失败为空)
|
||||||
|
pub problems: Vec<AdoptProblemView>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 向导的**事件态**(`AppState::adopt` 的全部内容)。
|
||||||
|
///
|
||||||
|
/// 只有从 [`AdoptEvent`] 来的事实:扫描结果、预检结果、失败、完成。页面的表单字段
|
||||||
|
/// (选中的版本、展示名输入、目标选择、面板开没开)属于 UI 本地态,**不在这里**——
|
||||||
|
/// 状态只有一个写入点,才不会有「页面显示的落点和真正写盘的落点不是一回事」。
|
||||||
|
#[derive(Debug, Clone, PartialEq, Default)]
|
||||||
|
pub struct AdoptState {
|
||||||
|
/// 最近一次扫描(`None` = 还没扫过 / 已被新一轮顶掉)
|
||||||
|
pub scan: Option<AdoptScanView>,
|
||||||
|
/// 最近一次预检(`None` = 还没预检 / 已过期)
|
||||||
|
pub plan: Option<AdoptPlanView>,
|
||||||
|
/// 最近一次失败
|
||||||
|
pub failure: Option<AdoptFailureView>,
|
||||||
|
/// 最近一次完成的登记
|
||||||
|
pub done: Option<AdoptDoneView>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AdoptState {
|
||||||
|
/// 事件 → 状态(**唯一**写入点,与 `PackState::apply` 同一条纪律)。
|
||||||
|
pub fn apply(&mut self, event: AdoptEvent) {
|
||||||
|
match event {
|
||||||
|
AdoptEvent::Scan(scan) => {
|
||||||
|
// 新一轮扫描:上一轮的预检/失败/完成都作废(候选集合可能已经变了)
|
||||||
|
self.scan = Some(*scan);
|
||||||
|
self.plan = None;
|
||||||
|
self.failure = None;
|
||||||
|
self.done = None;
|
||||||
|
}
|
||||||
|
AdoptEvent::Planned(plan) => {
|
||||||
|
self.plan = Some(*plan);
|
||||||
|
self.failure = None;
|
||||||
|
self.done = None;
|
||||||
|
}
|
||||||
|
AdoptEvent::Failed(failure) => {
|
||||||
|
// 预检失败时上一份预检必须消失:留着会让确认按钮继续亮着
|
||||||
|
self.plan = None;
|
||||||
|
self.failure = Some(*failure);
|
||||||
|
}
|
||||||
|
AdoptEvent::Done(done) => {
|
||||||
|
// 登记成功:预检与失败都过期(这一轮已经有结论)
|
||||||
|
self.plan = None;
|
||||||
|
self.failure = None;
|
||||||
|
self.done = Some(*done);
|
||||||
|
}
|
||||||
|
AdoptEvent::Cleared => {
|
||||||
|
self.scan = None;
|
||||||
|
self.plan = None;
|
||||||
|
self.failure = None;
|
||||||
|
self.done = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 面板此刻该画的那一段(扫描 / 选版本 / 预检 / 结果)。
|
||||||
|
///
|
||||||
|
/// 优先级:完成 > 失败 > 预检 > 扫描列表。写成一个方法而不是让页面各判一遍,
|
||||||
|
/// 是为了「同一时刻只画一段」在单测里可断言。
|
||||||
|
#[must_use]
|
||||||
|
pub fn stage(&self) -> AdoptStage {
|
||||||
|
if self.done.is_some() {
|
||||||
|
return AdoptStage::Done;
|
||||||
|
}
|
||||||
|
if self.failure.is_some() {
|
||||||
|
return AdoptStage::Failed;
|
||||||
|
}
|
||||||
|
if self.plan.is_some() {
|
||||||
|
return AdoptStage::Preview;
|
||||||
|
}
|
||||||
|
AdoptStage::Pick
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 面板当前画哪一段([`AdoptState::stage`] 的产物)。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum AdoptStage {
|
||||||
|
/// 扫出来的候选列表(选版本)
|
||||||
|
Pick,
|
||||||
|
/// 预检结果(落点 + 后果原文 + 确认按钮)
|
||||||
|
Preview,
|
||||||
|
/// 失败(原因逐条)
|
||||||
|
Failed,
|
||||||
|
/// 完成
|
||||||
|
Done,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AdoptStage {
|
||||||
|
/// 稳定 slug(进日志/测试断言用)。
|
||||||
|
#[must_use]
|
||||||
|
pub const fn slug(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Pick => "pick",
|
||||||
|
Self::Preview => "preview",
|
||||||
|
Self::Failed => "failed",
|
||||||
|
Self::Done => "done",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
//! `dto::adopt` 的 IPC 契约用例(M223 从 `dto/adopt.rs` **纯搬移**,断言一条未改)。
|
||||||
|
//!
|
||||||
|
//! 为什么搬:`dto/adopt.rs` 落笔 512 行,越过 400 行硬线(AGENTS §3.1);口径与
|
||||||
|
//! [`crate::dto::events::tests`] 完全一致——**纯搬移、一行不改**。`adopt.rs` 是非 `mod.rs`
|
||||||
|
//! 文件,`mod tests;` 的落点就是本文件。
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn candidate(version_id: &str, problems: Vec<AdoptProblemView>) -> AdoptCandidateView {
|
||||||
|
AdoptCandidateView {
|
||||||
|
version_id: version_id.to_string(),
|
||||||
|
jar_present: true,
|
||||||
|
inherits: Vec::new(),
|
||||||
|
registered: Vec::new(),
|
||||||
|
problems,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scan_view() -> AdoptScanView {
|
||||||
|
AdoptScanView {
|
||||||
|
versions_dir: PathBuf::from("/game/versions"),
|
||||||
|
candidates: vec![
|
||||||
|
candidate("1.20.1", Vec::new()),
|
||||||
|
candidate(
|
||||||
|
"1.19.4",
|
||||||
|
vec![AdoptProblemView {
|
||||||
|
code: "manifest_missing".to_string(),
|
||||||
|
message: "版本 1.19.4 没有清单文件".to_string(),
|
||||||
|
}],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn events_round_trip_through_json() {
|
||||||
|
let event = AdoptEvent::Failed(Box::new(AdoptFailureView {
|
||||||
|
code: "adopt_not_adoptable".to_string(),
|
||||||
|
message: "版本 1.19.4 不能登记".to_string(),
|
||||||
|
problems: vec![AdoptProblemView {
|
||||||
|
code: "manifest_missing".to_string(),
|
||||||
|
message: "清单缺了".to_string(),
|
||||||
|
}],
|
||||||
|
}));
|
||||||
|
let text = serde_json::to_string(&event).expect("序列化");
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_str::<AdoptEvent>(&text).expect("反序列化"),
|
||||||
|
event
|
||||||
|
);
|
||||||
|
|
||||||
|
let command = AdoptCommand::Preview(Box::new(AdoptRequestView {
|
||||||
|
version_id: "1.20.1".to_string(),
|
||||||
|
name: Some("生存".to_string()),
|
||||||
|
target: AdoptTargetView::Shared,
|
||||||
|
}));
|
||||||
|
let text = serde_json::to_string(&command).expect("序列化");
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_str::<AdoptCommand>(&text).expect("反序列化"),
|
||||||
|
command
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scan_summarises_adoptable_and_empty() {
|
||||||
|
let view = scan_view();
|
||||||
|
assert_eq!(view.total(), 2);
|
||||||
|
assert_eq!(view.adoptable(), 1);
|
||||||
|
assert!(!view.is_empty());
|
||||||
|
assert!(AdoptScanView {
|
||||||
|
versions_dir: PathBuf::from("/game/versions"),
|
||||||
|
candidates: Vec::new(),
|
||||||
|
}
|
||||||
|
.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn target_slugs_round_trip_and_mark_foreign_dirs() {
|
||||||
|
for target in AdoptTargetView::ALL {
|
||||||
|
assert_eq!(AdoptTargetView::from_slug(target.slug()), Some(target));
|
||||||
|
}
|
||||||
|
assert_eq!(AdoptTargetView::default(), AdoptTargetView::InstanceDir);
|
||||||
|
assert!(!AdoptTargetView::InstanceDir.touches_foreign_dir());
|
||||||
|
assert!(AdoptTargetView::Shared.touches_foreign_dir());
|
||||||
|
assert!(AdoptTargetView::VersionDir.touches_foreign_dir());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn state_follows_the_four_stages() {
|
||||||
|
let mut state = AdoptState::default();
|
||||||
|
assert_eq!(state.stage(), AdoptStage::Pick);
|
||||||
|
|
||||||
|
state.apply(AdoptEvent::Scan(Box::new(scan_view())));
|
||||||
|
assert_eq!(state.stage(), AdoptStage::Pick);
|
||||||
|
assert_eq!(state.scan.as_ref().map(AdoptScanView::adoptable), Some(1));
|
||||||
|
|
||||||
|
let plan = AdoptPlanView {
|
||||||
|
version_id: "1.20.1".to_string(),
|
||||||
|
name: "1.20.1".to_string(),
|
||||||
|
target: AdoptTargetView::InstanceDir,
|
||||||
|
isolation: AdoptIsolationView::Custom,
|
||||||
|
run_dir: PathBuf::from("/ull/data/instances/1.20.1"),
|
||||||
|
notes: vec!["运行目录 = /ull/data/instances/1.20.1".to_string()],
|
||||||
|
};
|
||||||
|
state.apply(AdoptEvent::Planned(Box::new(plan.clone())));
|
||||||
|
assert_eq!(state.stage(), AdoptStage::Preview);
|
||||||
|
assert_eq!(
|
||||||
|
state.plan.as_ref().map(|plan| &plan.run_dir),
|
||||||
|
Some(&plan.run_dir)
|
||||||
|
);
|
||||||
|
|
||||||
|
// 失败会把预检顶掉:留着预检,确认按钮就会在「不能登记」的结论上继续亮着
|
||||||
|
state.apply(AdoptEvent::Failed(Box::new(AdoptFailureView {
|
||||||
|
code: "adopt_duplicate_name".to_string(),
|
||||||
|
message: "已有同名实例".to_string(),
|
||||||
|
problems: Vec::new(),
|
||||||
|
})));
|
||||||
|
assert_eq!(state.stage(), AdoptStage::Failed);
|
||||||
|
assert!(state.plan.is_none());
|
||||||
|
|
||||||
|
state.apply(AdoptEvent::Done(Box::new(AdoptDoneView {
|
||||||
|
instance_id: "id-1".to_string(),
|
||||||
|
name: "1.20.1".to_string(),
|
||||||
|
version_id: "1.20.1".to_string(),
|
||||||
|
isolation: AdoptIsolationView::Custom,
|
||||||
|
run_dir: PathBuf::from("/ull/data/instances/1.20.1"),
|
||||||
|
dir_created: true,
|
||||||
|
notes: Vec::new(),
|
||||||
|
})));
|
||||||
|
assert_eq!(state.stage(), AdoptStage::Done);
|
||||||
|
assert!(state.failure.is_none());
|
||||||
|
|
||||||
|
// 关面板 = 全清(再开要重新扫,否则画的是上一轮的候选)
|
||||||
|
state.apply(AdoptEvent::Cleared);
|
||||||
|
assert_eq!(state.stage(), AdoptStage::Pick);
|
||||||
|
assert!(state.scan.is_none() && state.done.is_none());
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ use crate::dto::{
|
|||||||
// 主线(M109 的 Content* / M113 的 Pack*)与 M114 的 Authlib*/AccountNotice 的并集。
|
// 主线(M109 的 Content* / M113 的 Pack*)与 M114 的 Authlib*/AccountNotice 的并集。
|
||||||
AccountNotice,
|
AccountNotice,
|
||||||
AccountView,
|
AccountView,
|
||||||
|
AdoptCommand,
|
||||||
|
AdoptEvent,
|
||||||
AgentEvent,
|
AgentEvent,
|
||||||
AuthlibLoginView,
|
AuthlibLoginView,
|
||||||
AuthlibRequest,
|
AuthlibRequest,
|
||||||
@@ -149,6 +151,11 @@ pub enum CoreEvent {
|
|||||||
// M113: import/export
|
// M113: import/export
|
||||||
/// 导入/导出(M113):进度、结果、探测诊断、导出预检(子枚举见 [`PackEvent`])
|
/// 导入/导出(M113):进度、结果、探测诊断、导出预检(子枚举见 [`PackEvent`])
|
||||||
Pack(Box<PackEvent>),
|
Pack(Box<PackEvent>),
|
||||||
|
/// 导入已有版本(M223):扫描 / 预检 / 完成 / 失败(子枚举见 [`AdoptEvent`])
|
||||||
|
///
|
||||||
|
/// 与 [`Self::Pack`] 分开是**语义**上必须的:整合包导入会把内容真搬进实例目录,
|
||||||
|
/// 而这里只登记盘上已有的版本、写两份配置(`versions/` 一个字节都不动)。
|
||||||
|
Adopt(Box<AdoptEvent>),
|
||||||
/// 自更新(M159):整份状态快照(检查结论 + 就位状态 + 用户是否已拒绝)。
|
/// 自更新(M159):整份状态快照(检查结论 + 就位状态 + 用户是否已拒绝)。
|
||||||
///
|
///
|
||||||
/// 与 `SettingsChanged` 同一条形态:快照整体替换,UI 不做增量合并。
|
/// 与 `SettingsChanged` 同一条形态:快照整体替换,UI 不做增量合并。
|
||||||
@@ -263,6 +270,8 @@ pub enum AppCommand {
|
|||||||
// M113: import/export
|
// M113: import/export
|
||||||
/// 整合包导入/导出(M113):导入 / 预检 / 确认写盘 / 关面板(子枚举见 [`PackCommand`])
|
/// 整合包导入/导出(M113):导入 / 预检 / 确认写盘 / 关面板(子枚举见 [`PackCommand`])
|
||||||
Pack(Box<PackCommand>),
|
Pack(Box<PackCommand>),
|
||||||
|
/// 导入已有版本(M223):扫描 / 预检 / 确认登记 / 关面板(子枚举见 [`AdoptCommand`])
|
||||||
|
Adopt(Box<AdoptCommand>),
|
||||||
// M159: 自更新
|
// M159: 自更新
|
||||||
/// 查一次更新(设置页「立即检查」;会清掉「忽略这个版本」的标记并重新请求网络)
|
/// 查一次更新(设置页「立即检查」;会清掉「忽略这个版本」的标记并重新请求网络)
|
||||||
CheckUpdate,
|
CheckUpdate,
|
||||||
@@ -318,82 +327,4 @@ pub trait Backend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests;
|
||||||
use super::*;
|
|
||||||
use crate::dto::{AccountKind, DeviceLoginPhase, InstallStage};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn events_are_serializable_ipc_contracts() {
|
|
||||||
let event = AppEvent::Core(CoreEvent::InstallProgress(Some(InstallProgressView {
|
|
||||||
stage: InstallStage::Downloading,
|
|
||||||
files_done: 3,
|
|
||||||
files_total: 9,
|
|
||||||
..InstallProgressView::new()
|
|
||||||
})));
|
|
||||||
let text = serde_json::to_string(&event).expect("序列化");
|
|
||||||
let back: AppEvent = serde_json::from_str(&text).expect("反序列化");
|
|
||||||
assert_eq!(back, event);
|
|
||||||
|
|
||||||
let account = AppEvent::Core(CoreEvent::AccountsChanged(vec![AccountView {
|
|
||||||
id: "account:1".to_string(),
|
|
||||||
name: "Steve".to_string(),
|
|
||||||
kind: AccountKind::Offline,
|
|
||||||
profile_id: "0f8f".to_string(),
|
|
||||||
login_name: String::new(),
|
|
||||||
created: "2026-10-31 20:12".to_string(),
|
|
||||||
active: true,
|
|
||||||
server_url: String::new(),
|
|
||||||
skin: None,
|
|
||||||
}]));
|
|
||||||
let text = serde_json::to_string(&account).expect("序列化");
|
|
||||||
let back: AppEvent = serde_json::from_str(&text).expect("反序列化");
|
|
||||||
assert_eq!(back, account);
|
|
||||||
// 凭据永不跨界:序列化文本里没有 token 类字段
|
|
||||||
assert!(!text.to_lowercase().contains("token"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn commands_round_trip() {
|
|
||||||
let command = AppCommand::CreateInstance(CreateRequest {
|
|
||||||
version: "1.20.4".to_string(),
|
|
||||||
loader: crate::dto::LoaderKind::Fabric,
|
|
||||||
loader_version: "0.16.5".to_string(),
|
|
||||||
name: "生存".to_string(),
|
|
||||||
});
|
|
||||||
let text = serde_json::to_string(&command).expect("序列化");
|
|
||||||
let back: AppCommand = serde_json::from_str(&text).expect("反序列化");
|
|
||||||
assert_eq!(back, command);
|
|
||||||
|
|
||||||
let login = AppCommand::StartDeviceLogin;
|
|
||||||
assert_eq!(
|
|
||||||
serde_json::from_str::<AppCommand>(&serde_json::to_string(&login).expect("序列化"))
|
|
||||||
.expect("反序列化"),
|
|
||||||
login
|
|
||||||
);
|
|
||||||
assert!(DeviceLoginPhase::Waiting.busy());
|
|
||||||
|
|
||||||
// 第三方轨(M114):请求进、状态出,两边都不带 token 字段
|
|
||||||
let authlib = AppCommand::AddAuthlibAccount(AuthlibRequest::new(
|
|
||||||
"https://littleskin.cn/api/yggdrasil/",
|
|
||||||
"me@example.com",
|
|
||||||
"hunter2",
|
|
||||||
));
|
|
||||||
let text = serde_json::to_string(&authlib).expect("序列化");
|
|
||||||
assert_eq!(
|
|
||||||
serde_json::from_str::<AppCommand>(&text).expect("反序列化"),
|
|
||||||
authlib
|
|
||||||
);
|
|
||||||
|
|
||||||
let view = AppEvent::Core(CoreEvent::AuthlibLoginChanged(AuthlibLoginView::reachable(
|
|
||||||
"https://littleskin.cn/api/yggdrasil/",
|
|
||||||
"LittleSkin",
|
|
||||||
)));
|
|
||||||
let text = serde_json::to_string(&view).expect("序列化");
|
|
||||||
let back: AppEvent = serde_json::from_str(&text).expect("反序列化");
|
|
||||||
assert_eq!(back, view);
|
|
||||||
assert!(
|
|
||||||
!text.to_lowercase().contains("token"),
|
|
||||||
"回程视图里不该有 token"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
//! `dto::events` 的 IPC 契约用例(M223 从 `events.rs` 尾部**纯搬移**,一行未改)。
|
||||||
|
//!
|
||||||
|
//! 为什么搬:M223 要给 `AppCommand` / `CoreEvent` 各加一条变体(「导入已有版本」的指令与事件),
|
||||||
|
//! 而 `events.rs` 当时正好 399 行——加两行变体 + 两行 import 就顶破 400 行硬线(AGENTS §3.1)。
|
||||||
|
//! 拆分口径与仓库其它拆分一致:**纯搬移、一行不改**,`mod tests;` 只是把位置挪到
|
||||||
|
//! `dto/events/tests.rs`(`events.rs` 是非 `mod.rs` 文件,子模块目录就是 `events/`)。
|
||||||
|
//!
|
||||||
|
//! 搬移后 `events.rs` 只剩契约本体(枚举 + 构造器 + `Backend` trait),本文件的断言一条没动,
|
||||||
|
//! 所以「搬移零行为变化」可以用逐行 diff 对。
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::dto::{AccountKind, DeviceLoginPhase, InstallStage};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn events_are_serializable_ipc_contracts() {
|
||||||
|
let event = AppEvent::Core(CoreEvent::InstallProgress(Some(InstallProgressView {
|
||||||
|
stage: InstallStage::Downloading,
|
||||||
|
files_done: 3,
|
||||||
|
files_total: 9,
|
||||||
|
..InstallProgressView::new()
|
||||||
|
})));
|
||||||
|
let text = serde_json::to_string(&event).expect("序列化");
|
||||||
|
let back: AppEvent = serde_json::from_str(&text).expect("反序列化");
|
||||||
|
assert_eq!(back, event);
|
||||||
|
|
||||||
|
let account = AppEvent::Core(CoreEvent::AccountsChanged(vec![AccountView {
|
||||||
|
id: "account:1".to_string(),
|
||||||
|
name: "Steve".to_string(),
|
||||||
|
kind: AccountKind::Offline,
|
||||||
|
profile_id: "0f8f".to_string(),
|
||||||
|
login_name: String::new(),
|
||||||
|
created: "2026-10-31 20:12".to_string(),
|
||||||
|
active: true,
|
||||||
|
server_url: String::new(),
|
||||||
|
skin: None,
|
||||||
|
}]));
|
||||||
|
let text = serde_json::to_string(&account).expect("序列化");
|
||||||
|
let back: AppEvent = serde_json::from_str(&text).expect("反序列化");
|
||||||
|
assert_eq!(back, account);
|
||||||
|
// 凭据永不跨界:序列化文本里没有 token 类字段
|
||||||
|
assert!(!text.to_lowercase().contains("token"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn commands_round_trip() {
|
||||||
|
let command = AppCommand::CreateInstance(CreateRequest {
|
||||||
|
version: "1.20.4".to_string(),
|
||||||
|
loader: crate::dto::LoaderKind::Fabric,
|
||||||
|
loader_version: "0.16.5".to_string(),
|
||||||
|
name: "生存".to_string(),
|
||||||
|
});
|
||||||
|
let text = serde_json::to_string(&command).expect("序列化");
|
||||||
|
let back: AppCommand = serde_json::from_str(&text).expect("反序列化");
|
||||||
|
assert_eq!(back, command);
|
||||||
|
|
||||||
|
let login = AppCommand::StartDeviceLogin;
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_str::<AppCommand>(&serde_json::to_string(&login).expect("序列化"))
|
||||||
|
.expect("反序列化"),
|
||||||
|
login
|
||||||
|
);
|
||||||
|
assert!(DeviceLoginPhase::Waiting.busy());
|
||||||
|
|
||||||
|
// 第三方轨(M114):请求进、状态出,两边都不带 token 字段
|
||||||
|
let authlib = AppCommand::AddAuthlibAccount(AuthlibRequest::new(
|
||||||
|
"https://littleskin.cn/api/yggdrasil/",
|
||||||
|
"me@example.com",
|
||||||
|
"hunter2",
|
||||||
|
));
|
||||||
|
let text = serde_json::to_string(&authlib).expect("序列化");
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_str::<AppCommand>(&text).expect("反序列化"),
|
||||||
|
authlib
|
||||||
|
);
|
||||||
|
|
||||||
|
let view = AppEvent::Core(CoreEvent::AuthlibLoginChanged(AuthlibLoginView::reachable(
|
||||||
|
"https://littleskin.cn/api/yggdrasil/",
|
||||||
|
"LittleSkin",
|
||||||
|
)));
|
||||||
|
let text = serde_json::to_string(&view).expect("序列化");
|
||||||
|
let back: AppEvent = serde_json::from_str(&text).expect("反序列化");
|
||||||
|
assert_eq!(back, view);
|
||||||
|
assert!(
|
||||||
|
!text.to_lowercase().contains("token"),
|
||||||
|
"回程视图里不该有 token"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,6 +15,8 @@
|
|||||||
//! 序列化能力由 derive 钉住,`dto` 的单测做 round-trip 断言。
|
//! 序列化能力由 derive 钉住,`dto` 的单测做 round-trip 断言。
|
||||||
|
|
||||||
mod accounts;
|
mod accounts;
|
||||||
|
// M223: 导入已有版本(从别的启动器只读登记 versions/<id>)
|
||||||
|
mod adopt;
|
||||||
mod agent;
|
mod agent;
|
||||||
mod content;
|
mod content;
|
||||||
mod events;
|
mod events;
|
||||||
@@ -38,6 +40,11 @@ pub use accounts::{
|
|||||||
AccountView, AuthlibLoginView, AuthlibRequest, BrowserLoginNote, DeviceLoginPhase,
|
AccountView, AuthlibLoginView, AuthlibRequest, BrowserLoginNote, DeviceLoginPhase,
|
||||||
DeviceLoginView, LoginFailure, SkinImage, REDACTED,
|
DeviceLoginView, LoginFailure, SkinImage, REDACTED,
|
||||||
};
|
};
|
||||||
|
pub use adopt::{
|
||||||
|
AdoptCandidateView, AdoptCommand, AdoptDoneView, AdoptEvent, AdoptFailureView,
|
||||||
|
AdoptIsolationView, AdoptPlanView, AdoptProblemView, AdoptRequestView, AdoptScanView,
|
||||||
|
AdoptStage, AdoptState, AdoptTargetView,
|
||||||
|
};
|
||||||
pub use agent::{AgentEvent, ChatMessage, ChatRole, ConfirmRequest, Sensitivity, ToolCallView};
|
pub use agent::{AgentEvent, ChatMessage, ChatRole, ConfirmRequest, Sensitivity, ToolCallView};
|
||||||
pub use content::{
|
pub use content::{
|
||||||
loader_facet, ContentDetail, ContentDetailRequest, ContentFailure, ContentFailureKind,
|
loader_facet, ContentDetail, ContentDetailRequest, ContentFailure, ContentFailureKind,
|
||||||
|
|||||||
@@ -0,0 +1,261 @@
|
|||||||
|
//! 「导入已有版本」的宿主侧(M223 GUIADOPT):扫描 / 预检 / 确认三条后台线程。
|
||||||
|
//!
|
||||||
|
//! 能力全在 core(`unlucky_core::game::adopt`,M221):本模块只做三件事——**起线程、翻 DTO、
|
||||||
|
//! 按代次回流**。这里不重新判定任何东西:能不能登记、落到哪个运行目录、有哪些问题,全部由 core
|
||||||
|
//! 说了算,本模块逐字段照搬([`scan_view`] / [`plan_view`] / [`done_view`] / [`failure_view`])。
|
||||||
|
//!
|
||||||
|
//! ## 三个命令与写盘面
|
||||||
|
//!
|
||||||
|
//! | 命令 | core 调用 | 写盘 |
|
||||||
|
//! |---|---|---|
|
||||||
|
//! | [`AdoptCommand::Scan`] | `adopt::scan` | 零 |
|
||||||
|
//! | [`AdoptCommand::Preview`] | `adopt::plan` | 零(预检就是 core 的 dry-run) |
|
||||||
|
//! | [`AdoptCommand::Confirm`] | `adopt::adopt` | 实例设置 + `profiles.json`(+ 缺省运行目录) |
|
||||||
|
//!
|
||||||
|
//! 预检与确认走**同一个** `AdoptRequestView`:页面在预检拿到落点与后果原文,用户点确认时把
|
||||||
|
//! **同一份请求**发回来(不是「预检 A、写 B」)。这也是「运行目录只认回程」的实现方式——
|
||||||
|
//! 页面从头到尾没有机会自己派生一个目录名。
|
||||||
|
//!
|
||||||
|
//! ## 代次:为什么要它
|
||||||
|
//!
|
||||||
|
//! 三条命令各起一条线程,用户可能连点(扫完再扫、预检后改目标再预检、失败后关面板)。
|
||||||
|
//! 每条线程带一个代次回来,`adopt_epoch` 只认**最新**那一条:关面板([`AdoptCommand::Dismiss`])
|
||||||
|
//! 也会把代次 +1,于是「点了关闭,两秒后扫描结果又把面板点亮」这种旧病不会发生
|
||||||
|
//! (它与 M105 安装取消、M113 导入取消是同一套做法)。
|
||||||
|
//!
|
||||||
|
//! ## 不做什么
|
||||||
|
//!
|
||||||
|
//! - **不自己派生运行目录**:`run_dir` 只从 `plan`/`adopt` 的返回里取(缺省档可能带 `-2` 后缀);
|
||||||
|
//! - **不自己拼后果话术**:`notes` 原样来自 core 的 `target_notes()`,与 CLI 打的是同一句;
|
||||||
|
//! - **不折叠问题**:`problems[]` 逐条搬进 [`AdoptFailureView`],一条都不省。
|
||||||
|
|
||||||
|
use unlucky_core::game::adopt::{
|
||||||
|
self, AdoptCandidate, AdoptError, AdoptOutcome, AdoptPlan, AdoptProblem, AdoptRequest,
|
||||||
|
AdoptTarget,
|
||||||
|
};
|
||||||
|
use unlucky_core::game::install::instance::Isolation;
|
||||||
|
use unlucky_core::launch::time::{format_rfc3339_millis, now};
|
||||||
|
|
||||||
|
use crate::dto::{
|
||||||
|
AdoptCandidateView, AdoptCommand, AdoptDoneView, AdoptEvent, AdoptFailureView,
|
||||||
|
AdoptIsolationView, AdoptPlanView, AdoptProblemView, AdoptRequestView, AdoptScanView,
|
||||||
|
AdoptTargetView, CoreEvent,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::env::HostEnv;
|
||||||
|
use super::job::{self, HostTask, JobSender};
|
||||||
|
use super::CoreHost;
|
||||||
|
|
||||||
|
/// 后台那一步是「只看不写」还是「真的写」。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(super) enum Mode {
|
||||||
|
/// `adopt::plan`(预检,零写盘)
|
||||||
|
Preview,
|
||||||
|
/// `adopt::adopt`(真写)
|
||||||
|
Confirm,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CoreHost {
|
||||||
|
/// 作废在飞的 adopt 结果,返回新代次(每条命令都调它,见模块文档「代次」一节)。
|
||||||
|
fn next_adopt_epoch(&mut self) -> u64 {
|
||||||
|
self.adopt_epoch = self.adopt_epoch.wrapping_add(1);
|
||||||
|
self.adopt_epoch
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `AppCommand::Adopt` 的落点(M223)。
|
||||||
|
///
|
||||||
|
/// 每条命令都先把代次 +1:这一条之后回来的旧结果一律被 [`Self::handle_adopt`] 丢掉。
|
||||||
|
pub(super) fn adopt(&mut self, command: AdoptCommand) {
|
||||||
|
match command {
|
||||||
|
AdoptCommand::Scan => {
|
||||||
|
let epoch = self.next_adopt_epoch();
|
||||||
|
spawn_scan(self.env.clone(), self.tx.clone(), epoch);
|
||||||
|
}
|
||||||
|
AdoptCommand::Preview(request) => {
|
||||||
|
let epoch = self.next_adopt_epoch();
|
||||||
|
spawn_adopt(
|
||||||
|
self.env.clone(),
|
||||||
|
self.tx.clone(),
|
||||||
|
*request,
|
||||||
|
Mode::Preview,
|
||||||
|
epoch,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
AdoptCommand::Confirm(request) => {
|
||||||
|
let epoch = self.next_adopt_epoch();
|
||||||
|
spawn_adopt(
|
||||||
|
self.env.clone(),
|
||||||
|
self.tx.clone(),
|
||||||
|
*request,
|
||||||
|
Mode::Confirm,
|
||||||
|
epoch,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
AdoptCommand::Dismiss => {
|
||||||
|
// 关面板 = 作废在飞的一切(否则迟到的扫描结果会把面板重新点亮)
|
||||||
|
self.next_adopt_epoch();
|
||||||
|
self.send(CoreEvent::Adopt(Box::new(AdoptEvent::Cleared)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 后台线程的回流落点:代次不符就丢(见模块文档「代次」一节)。
|
||||||
|
///
|
||||||
|
/// 登记成功之后补一次实例快照刷新:新实例必须**这一拍就出现在实例列表里**,
|
||||||
|
/// 而不是等用户手点刷新(否则「导入了但看不见」)。
|
||||||
|
pub(super) fn handle_adopt(&mut self, event: AdoptEvent, epoch: u64) {
|
||||||
|
if epoch != self.adopt_epoch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let registered = matches!(event, AdoptEvent::Done(_));
|
||||||
|
self.send(CoreEvent::Adopt(Box::new(event)));
|
||||||
|
if registered {
|
||||||
|
self.refresh_instances();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 扫描当前游戏根的 `versions/`(只读)。
|
||||||
|
pub(super) fn spawn_scan(env: HostEnv, tx: JobSender, epoch: u64) {
|
||||||
|
job::spawn("adopt-scan", move || {
|
||||||
|
let event = match adopt::scan(&env.game, &env.layout) {
|
||||||
|
Ok(scan) => AdoptEvent::Scan(Box::new(AdoptScanView {
|
||||||
|
versions_dir: scan.versions_dir.clone(),
|
||||||
|
candidates: scan.candidates.iter().map(candidate_view).collect(),
|
||||||
|
})),
|
||||||
|
// 读目录失败也是**具名**失败(断链的 versions 联接 / 权限),不降级成空列表
|
||||||
|
Err(error) => AdoptEvent::Failed(Box::new(failure_view(error))),
|
||||||
|
};
|
||||||
|
let _ = tx.send(HostTask::Adopt {
|
||||||
|
event: Box::new(event),
|
||||||
|
epoch,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 预检(零写盘)或确认登记。
|
||||||
|
pub(super) fn spawn_adopt(
|
||||||
|
env: HostEnv,
|
||||||
|
tx: JobSender,
|
||||||
|
request: AdoptRequestView,
|
||||||
|
mode: Mode,
|
||||||
|
epoch: u64,
|
||||||
|
) {
|
||||||
|
job::spawn("adopt", move || {
|
||||||
|
let request = core_request(request);
|
||||||
|
let event = match mode {
|
||||||
|
Mode::Preview => match adopt::plan(&env.game, &env.layout, &request) {
|
||||||
|
Ok(plan) => AdoptEvent::Planned(Box::new(plan_view(plan))),
|
||||||
|
Err(error) => AdoptEvent::Failed(Box::new(failure_view(error))),
|
||||||
|
},
|
||||||
|
Mode::Confirm => match adopt::adopt(&env.game, &env.layout, &request) {
|
||||||
|
Ok(outcome) => AdoptEvent::Done(Box::new(done_view(outcome))),
|
||||||
|
Err(error) => AdoptEvent::Failed(Box::new(failure_view(error))),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let _ = tx.send(HostTask::Adopt {
|
||||||
|
event: Box::new(event),
|
||||||
|
epoch,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 视图请求 → core 请求(`created_at` 由宿主给:core 不碰时钟)。
|
||||||
|
fn core_request(view: AdoptRequestView) -> AdoptRequest {
|
||||||
|
AdoptRequest {
|
||||||
|
version_id: view.version_id,
|
||||||
|
name: view.name.filter(|name| !name.trim().is_empty()),
|
||||||
|
target: target_of(view.target),
|
||||||
|
created_at: Some(format_rfc3339_millis(now())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 三档视图 → core 的运行目录选择。
|
||||||
|
///
|
||||||
|
/// `custom` **不映射**:GUI 不开「用户手输绝对路径」这一档(那要目录选择器),
|
||||||
|
/// 需要它的调用方走 CLI 的 `ull instances adopt --isolation custom --game-dir DIR`。
|
||||||
|
fn target_of(view: AdoptTargetView) -> AdoptTarget {
|
||||||
|
match view {
|
||||||
|
AdoptTargetView::InstanceDir => AdoptTarget::InstanceDir,
|
||||||
|
AdoptTargetView::Shared => AdoptTarget::Shared,
|
||||||
|
AdoptTargetView::VersionDir => AdoptTarget::VersionDir,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// core 隔离档 → 视图。
|
||||||
|
fn isolation_view(isolation: Isolation) -> AdoptIsolationView {
|
||||||
|
match isolation {
|
||||||
|
Isolation::Isolated => AdoptIsolationView::Isolated,
|
||||||
|
Isolation::Shared => AdoptIsolationView::Shared,
|
||||||
|
Isolation::Custom => AdoptIsolationView::Custom,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// core 候选 → 视图(`registered` 只带实例名:界面要的是「给谁登记过了」)。
|
||||||
|
fn candidate_view(candidate: &AdoptCandidate) -> AdoptCandidateView {
|
||||||
|
AdoptCandidateView {
|
||||||
|
version_id: candidate.version_id.clone(),
|
||||||
|
jar_present: candidate.jar_present,
|
||||||
|
inherits: candidate.inherits.clone(),
|
||||||
|
registered: candidate
|
||||||
|
.registered
|
||||||
|
.iter()
|
||||||
|
.map(|instance| instance.name.clone())
|
||||||
|
.collect(),
|
||||||
|
problems: candidate.problems.iter().map(problem_view).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// core 问题 → 视图(`code` 稳定 + `Display` 原文:路径、行列号、缺哪个父版本都在里面)。
|
||||||
|
fn problem_view(problem: &AdoptProblem) -> AdoptProblemView {
|
||||||
|
AdoptProblemView {
|
||||||
|
code: problem.code().to_string(),
|
||||||
|
message: problem.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// core 计划 → 视图。`run_dir` / `notes` **原样**搬运,界面不许再算一遍。
|
||||||
|
fn plan_view(plan: AdoptPlan) -> AdoptPlanView {
|
||||||
|
AdoptPlanView {
|
||||||
|
version_id: plan.candidate.version_id.clone(),
|
||||||
|
name: plan.name,
|
||||||
|
target: target_view(&plan.target),
|
||||||
|
isolation: isolation_view(plan.isolation),
|
||||||
|
run_dir: plan.run_dir,
|
||||||
|
notes: plan.notes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// core 结果 → 视图。
|
||||||
|
fn done_view(outcome: AdoptOutcome) -> AdoptDoneView {
|
||||||
|
AdoptDoneView {
|
||||||
|
instance_id: outcome.instance_id,
|
||||||
|
name: outcome.name,
|
||||||
|
version_id: outcome.version_id,
|
||||||
|
isolation: isolation_view(outcome.isolation),
|
||||||
|
run_dir: outcome.run_dir,
|
||||||
|
dir_created: outcome.dir_created,
|
||||||
|
notes: outcome.notes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// core 运行目录选择 → 视图(`Custom` 只在 core 侧出现,界面按「缺省档」显示)。
|
||||||
|
fn target_view(target: &AdoptTarget) -> AdoptTargetView {
|
||||||
|
match target {
|
||||||
|
AdoptTarget::InstanceDir | AdoptTarget::Custom(_) => AdoptTargetView::InstanceDir,
|
||||||
|
AdoptTarget::Shared => AdoptTargetView::Shared,
|
||||||
|
AdoptTarget::VersionDir => AdoptTargetView::VersionDir,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// core 失败 → 视图:**`problems[]` 一条不少地带出来**(契约 §5.3)。
|
||||||
|
fn failure_view(error: AdoptError) -> AdoptFailureView {
|
||||||
|
AdoptFailureView {
|
||||||
|
code: error.code().to_string(),
|
||||||
|
message: error.to_string(),
|
||||||
|
problems: error.problems().iter().map(problem_view).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
//! `host::adopt` 的用例(M223):真盘夹具 + 三条命令的回程形状。
|
||||||
|
//!
|
||||||
|
//! 抓得住:扫描只读(`.ULL/config` 不出现)、预检与确认的落点一致且都来自 core、
|
||||||
|
//! 坏清单失败时 `problems[]` 逐条带出(带行列号)、三档映射。
|
||||||
|
//! 抓不住:真启动游戏用不用这个运行目录(那是 launch 链路的活);D30 视觉(另走截图通道)。
|
||||||
|
//!
|
||||||
|
//! 为什么单独一个文件:`host/adopt.rs` 带用例会到 440 行,越过 400 行硬线(AGENTS §3.1)。
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::dto::AdoptStage;
|
||||||
|
|
||||||
|
/// 真盘夹具:`<root>/.minecraft/versions/<id>/`(清单 + jar)+ `<root>/.ULL`。
|
||||||
|
struct Fixture {
|
||||||
|
root: std::path::PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Fixture {
|
||||||
|
fn new(tag: &str) -> Self {
|
||||||
|
let root = std::env::temp_dir().join(format!(
|
||||||
|
"ull-presenter-adopt-{tag}-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map_or(0, |d| d.as_nanos())
|
||||||
|
));
|
||||||
|
std::fs::create_dir_all(&root).expect("建临时根");
|
||||||
|
Self { root }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn env(&self) -> HostEnv {
|
||||||
|
HostEnv::at_root(&self.root)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 铺一个「别的启动器装好的」版本(`manifest` 原样写,便于造坏清单)。
|
||||||
|
fn version(&self, id: &str, manifest: &str) {
|
||||||
|
let dir = self.root.join(".minecraft/versions").join(id);
|
||||||
|
std::fs::create_dir_all(&dir).expect("建版本目录");
|
||||||
|
std::fs::write(dir.join(format!("{id}.json")), manifest).expect("写清单");
|
||||||
|
std::fs::write(dir.join(format!("{id}.jar")), b"jar-bytes").expect("写 jar");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Fixture {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = std::fs::remove_dir_all(&self.root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const MANIFEST: &str = r#"{
|
||||||
|
"id": "1.20.1",
|
||||||
|
"type": "release",
|
||||||
|
"mainClass": "net.minecraft.main.Main",
|
||||||
|
"libraries": []
|
||||||
|
}"#;
|
||||||
|
|
||||||
|
fn request(version_id: &str, target: AdoptTargetView) -> AdoptRequestView {
|
||||||
|
AdoptRequestView {
|
||||||
|
version_id: version_id.to_string(),
|
||||||
|
name: None,
|
||||||
|
target,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 收一条 `HostTask::Adopt`(同步等后台线程交卷)。
|
||||||
|
fn recv(rx: &super::super::job::JobReceiver) -> AdoptEvent {
|
||||||
|
match rx.recv().expect("后台线程应回一条") {
|
||||||
|
HostTask::Adopt { event, .. } => *event,
|
||||||
|
other => panic!("期望 HostTask::Adopt,收到 {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scan_lists_the_foreign_versions_without_writing() {
|
||||||
|
let fixture = Fixture::new("scan");
|
||||||
|
fixture.version("1.20.1", MANIFEST);
|
||||||
|
let env = fixture.env();
|
||||||
|
let (tx, rx) = job::channel();
|
||||||
|
spawn_scan(env.clone(), tx, 1);
|
||||||
|
|
||||||
|
let AdoptEvent::Scan(scan) = recv(&rx) else {
|
||||||
|
panic!("扫描应回 AdoptEvent::Scan");
|
||||||
|
};
|
||||||
|
assert_eq!(scan.versions_dir, fixture.root.join(".minecraft/versions"));
|
||||||
|
assert_eq!(scan.total(), 1);
|
||||||
|
assert_eq!(scan.adoptable(), 1);
|
||||||
|
assert!(scan.candidates[0].jar_present);
|
||||||
|
// 扫描零写盘:`.ULL/config` 下不该出现任何东西
|
||||||
|
assert!(!fixture.root.join(".ULL/config/profiles.json").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn confirm_registers_and_reports_the_core_run_dir() {
|
||||||
|
let fixture = Fixture::new("confirm");
|
||||||
|
fixture.version("1.20.1", MANIFEST);
|
||||||
|
let env = fixture.env();
|
||||||
|
|
||||||
|
// 先预检:零写盘,落点与后果原文都来自 core
|
||||||
|
let (tx, rx) = job::channel();
|
||||||
|
spawn_adopt(
|
||||||
|
env.clone(),
|
||||||
|
tx,
|
||||||
|
request("1.20.1", AdoptTargetView::InstanceDir),
|
||||||
|
Mode::Preview,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
let AdoptEvent::Planned(plan) = recv(&rx) else {
|
||||||
|
panic!("预检应回 AdoptEvent::Planned");
|
||||||
|
};
|
||||||
|
assert_eq!(plan.target, AdoptTargetView::InstanceDir);
|
||||||
|
assert_eq!(plan.isolation, AdoptIsolationView::Custom);
|
||||||
|
// 缺省档的运行目录落数据区(**不是** `versions/<id>`)
|
||||||
|
assert!(
|
||||||
|
plan.run_dir
|
||||||
|
.starts_with(fixture.root.join(".ULL/data/instances")),
|
||||||
|
"落点应在 ULL 数据区:{}",
|
||||||
|
plan.run_dir.display()
|
||||||
|
);
|
||||||
|
assert!(!plan.notes.is_empty(), "缺省档也要说清后果");
|
||||||
|
assert!(!fixture.root.join(".ULL/config/profiles.json").exists());
|
||||||
|
|
||||||
|
// 确认:同一份请求发回去,真的登记
|
||||||
|
let (tx, rx) = job::channel();
|
||||||
|
spawn_adopt(
|
||||||
|
env,
|
||||||
|
tx,
|
||||||
|
request("1.20.1", AdoptTargetView::InstanceDir),
|
||||||
|
Mode::Confirm,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
let AdoptEvent::Done(done) = recv(&rx) else {
|
||||||
|
panic!("确认应回 AdoptEvent::Done");
|
||||||
|
};
|
||||||
|
assert_eq!(done.version_id, "1.20.1");
|
||||||
|
assert_eq!(done.run_dir, plan.run_dir, "确认的落点必须与预检一致");
|
||||||
|
assert!(done.dir_created);
|
||||||
|
let profiles = std::fs::read_to_string(fixture.root.join(".ULL/config/profiles.json"))
|
||||||
|
.expect("profiles.json 应被写出来");
|
||||||
|
assert!(profiles.contains("1.20.1"));
|
||||||
|
// 游戏根的版本目录一个字节都没写(只有夹具自己那两个文件)
|
||||||
|
let dir = fixture.root.join(".minecraft/versions/1.20.1");
|
||||||
|
let mut names: Vec<String> = std::fs::read_dir(&dir)
|
||||||
|
.expect("读版本目录")
|
||||||
|
.map(|entry| {
|
||||||
|
entry
|
||||||
|
.expect("条目")
|
||||||
|
.file_name()
|
||||||
|
.to_string_lossy()
|
||||||
|
.into_owned()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
names.sort();
|
||||||
|
assert_eq!(names, ["1.20.1.jar", "1.20.1.json"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_bad_manifest_comes_back_as_every_problem_not_one_sentence() {
|
||||||
|
let fixture = Fixture::new("problems");
|
||||||
|
fixture.version("1.20.1", "{ this is not json");
|
||||||
|
let (tx, rx) = job::channel();
|
||||||
|
spawn_adopt(
|
||||||
|
fixture.env(),
|
||||||
|
tx,
|
||||||
|
request("1.20.1", AdoptTargetView::InstanceDir),
|
||||||
|
Mode::Preview,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
let AdoptEvent::Failed(failure) = recv(&rx) else {
|
||||||
|
panic!("坏清单应回 AdoptEvent::Failed");
|
||||||
|
};
|
||||||
|
assert_eq!(failure.code, "adopt_not_adoptable");
|
||||||
|
assert_eq!(failure.problems.len(), 1);
|
||||||
|
assert_eq!(failure.problems[0].code, "manifest_invalid_json");
|
||||||
|
assert!(
|
||||||
|
failure.problems[0].message.contains("line"),
|
||||||
|
"清单问题要带行列号:{}",
|
||||||
|
failure.problems[0].message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn targets_map_to_core_and_custom_stays_out_of_the_gui() {
|
||||||
|
assert_eq!(
|
||||||
|
target_of(AdoptTargetView::InstanceDir),
|
||||||
|
AdoptTarget::InstanceDir
|
||||||
|
);
|
||||||
|
assert_eq!(target_of(AdoptTargetView::Shared), AdoptTarget::Shared);
|
||||||
|
assert_eq!(
|
||||||
|
target_of(AdoptTargetView::VersionDir),
|
||||||
|
AdoptTarget::VersionDir
|
||||||
|
);
|
||||||
|
// `Custom` 只在 core 侧出现(CLI 的 `--game-dir`):界面按缺省档显示
|
||||||
|
assert_eq!(
|
||||||
|
target_view(&AdoptTarget::Custom(std::path::PathBuf::from("/x"))),
|
||||||
|
AdoptTargetView::InstanceDir
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
isolation_view(Isolation::Custom),
|
||||||
|
AdoptIsolationView::Custom
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
isolation_view(Isolation::Shared),
|
||||||
|
AdoptIsolationView::Shared
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
isolation_view(Isolation::Isolated),
|
||||||
|
AdoptIsolationView::Isolated
|
||||||
|
);
|
||||||
|
// 空名回落到版本 id 由 core 负责;这里只钉「空白名不进请求」
|
||||||
|
let request = core_request(AdoptRequestView {
|
||||||
|
version_id: "1.20.1".to_string(),
|
||||||
|
name: Some(" ".to_string()),
|
||||||
|
target: AdoptTargetView::Shared,
|
||||||
|
});
|
||||||
|
assert_eq!(request.name, None);
|
||||||
|
assert!(request.created_at.is_some());
|
||||||
|
assert_eq!(AdoptStage::Pick.slug(), "pick");
|
||||||
|
}
|
||||||
@@ -253,6 +253,8 @@ impl CoreHost {
|
|||||||
// (`CancelInstall` 会把标记置位,下载在阶段边界收尾)。
|
// (`CancelInstall` 会把标记置位,下载在阶段边界收尾)。
|
||||||
// M113: import/export —— 导入 / 预检 / 确认写盘 / 关面板
|
// M113: import/export —— 导入 / 预检 / 确认写盘 / 关面板
|
||||||
AppCommand::Pack(command) => self.pack(*command),
|
AppCommand::Pack(command) => self.pack(*command),
|
||||||
|
// M223: 导入已有版本 —— 扫描 / 预检 / 确认登记 / 关面板
|
||||||
|
AppCommand::Adopt(command) => self.adopt(*command),
|
||||||
AppCommand::InstallContent(request) => {
|
AppCommand::InstallContent(request) => {
|
||||||
let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||||
self.install_cancel = Some(std::sync::Arc::clone(&cancel));
|
self.install_cancel = Some(std::sync::Arc::clone(&cancel));
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ impl CoreHost {
|
|||||||
// M105 的形状(代次丢包)为准:旧任务(已取消 / 被下一轮顶掉)的进度一律丢掉。
|
// M105 的形状(代次丢包)为准:旧任务(已取消 / 被下一轮顶掉)的进度一律丢掉。
|
||||||
// 这是「点了取消没反应」的根因:取消时 `InstallProgress(None)` 刚把面板收回,
|
// 这是「点了取消没反应」的根因:取消时 `InstallProgress(None)` 刚把面板收回,
|
||||||
// 旧任务的下一条「下载中」事件立刻又把它点亮。
|
// 旧任务的下一条「下载中」事件立刻又把它点亮。
|
||||||
|
HostTask::Adopt { event, epoch } => self.handle_adopt(*event, epoch),
|
||||||
HostTask::Install { progress, epoch } => {
|
HostTask::Install { progress, epoch } => {
|
||||||
if epoch != self.install_epoch {
|
if epoch != self.install_epoch {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ pub fn supported(kind: LoaderKind) -> bool {
|
|||||||
/// 不支持的加载器 → 给用户的可执行建议。
|
/// 不支持的加载器 → 给用户的可执行建议。
|
||||||
pub fn unsupported_reason(kind: LoaderKind) -> String {
|
pub fn unsupported_reason(kind: LoaderKind) -> String {
|
||||||
format!(
|
format!(
|
||||||
"{} 还没有接入安装管线(目前支持原版 + Fabric/Quilt/Forge/NeoForge/LiteLoader);请先在实例页导入已有版本,或换成受支持的加载器。",
|
"{} 还没有接入安装管线(目前支持原版 + Fabric/Quilt/Forge/NeoForge/LiteLoader);如果别的启动器已经装好了这个版本,可以在实例页用「导入已有版本」把它只读接进来(不复制文件),否则请换成受支持的加载器。",
|
||||||
kind.label()
|
kind.label()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,17 @@ pub enum HostTask {
|
|||||||
/// 任务代次
|
/// 任务代次
|
||||||
epoch: u64,
|
epoch: u64,
|
||||||
},
|
},
|
||||||
|
// M223: 导入已有版本
|
||||||
|
/// 扫描 / 预检 / 确认的结果(一条事件 + 任务代次)
|
||||||
|
///
|
||||||
|
/// 代次是 adopt 自己的(`CoreHost::adopt_epoch`),**不**与安装或 pack 的代次共用:
|
||||||
|
/// 关一次导入面板不该把下载页在跑的安装静默丢掉。
|
||||||
|
Adopt {
|
||||||
|
/// 事件载荷(扫描 / 预检 / 完成 / 失败)
|
||||||
|
event: Box<crate::dto::AdoptEvent>,
|
||||||
|
/// 任务代次
|
||||||
|
epoch: u64,
|
||||||
|
},
|
||||||
/// 安装进度(高频,host 只做聚合与转发)
|
/// 安装进度(高频,host 只做聚合与转发)
|
||||||
Install {
|
Install {
|
||||||
/// 进度快照
|
/// 进度快照
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
|
|
||||||
mod accounts;
|
mod accounts;
|
||||||
mod addon;
|
mod addon;
|
||||||
|
// M223:导入已有版本(扫描 / 预检 / 确认三条后台线程)
|
||||||
|
mod adopt;
|
||||||
mod dispatch;
|
mod dispatch;
|
||||||
mod engine;
|
mod engine;
|
||||||
pub mod env;
|
pub mod env;
|
||||||
@@ -106,6 +108,10 @@ pub struct CoreHost {
|
|||||||
/// 一眼能看出「这是旧任务的」,于是既不会被取消掉的安装重新点亮面板,也不会串到下一轮安装
|
/// 一眼能看出「这是旧任务的」,于是既不会被取消掉的安装重新点亮面板,也不会串到下一轮安装
|
||||||
/// (两轮安装同时存在时,旧任务的事件曾经把新任务的进度条顶掉)。
|
/// (两轮安装同时存在时,旧任务的事件曾经把新任务的进度条顶掉)。
|
||||||
install_epoch: u64,
|
install_epoch: u64,
|
||||||
|
/// 「导入已有版本」的代次(M223):每条 scan/preview/confirm 命令 +1,关面板也 +1。
|
||||||
|
///
|
||||||
|
/// 只认最新代次的回程事件——用户连点或点了关闭之后,旧线程带回来的结果不许再把面板点亮。
|
||||||
|
adopt_epoch: u64,
|
||||||
/// 自更新(M159):检查结论 / 应用状态 / 更新域偏好。
|
/// 自更新(M159):检查结论 / 应用状态 / 更新域偏好。
|
||||||
update: update_state::Runtime,
|
update: update_state::Runtime,
|
||||||
}
|
}
|
||||||
@@ -160,6 +166,7 @@ impl CoreHost {
|
|||||||
chat: MockBackend::default(),
|
chat: MockBackend::default(),
|
||||||
install_cancel: None,
|
install_cancel: None,
|
||||||
install_epoch: 0,
|
install_epoch: 0,
|
||||||
|
adopt_epoch: 0,
|
||||||
update,
|
update,
|
||||||
};
|
};
|
||||||
if refresh {
|
if refresh {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use crate::dialogs::DialogQueue;
|
|||||||
use crate::dto::{
|
use crate::dto::{
|
||||||
// 主线(M105 的 `LoaderKind` / M113 的 `PackState`)与 M114 的 `AuthlibLoginView` 并集。
|
// 主线(M105 的 `LoaderKind` / M113 的 `PackState`)与 M114 的 `AuthlibLoginView` 并集。
|
||||||
AccountView,
|
AccountView,
|
||||||
AgentEvent,
|
AdoptState,
|
||||||
AppEvent,
|
AppEvent,
|
||||||
AuthlibLoginView,
|
AuthlibLoginView,
|
||||||
CoreEvent,
|
CoreEvent,
|
||||||
@@ -42,6 +42,8 @@ use crate::wizard::WizardState;
|
|||||||
// 所以这里没有 `mod ai;` / `pub use ai::AiSession;`——M114 r1 曾把旧结构搬到 `state/ai.rs`,
|
// 所以这里没有 `mod ai;` / `pub use ai::AiSession;`——M114 r1 曾把旧结构搬到 `state/ai.rs`,
|
||||||
// 该文件已随 rebase 删除,**以 main 的 agent 契约为准**。
|
// 该文件已随 rebase 删除,**以 main 的 agent 契约为准**。
|
||||||
mod accounts;
|
mod accounts;
|
||||||
|
// M223:AI 栏的事件投递与可见性(纯搬移,守 400 行硬线)
|
||||||
|
mod agent;
|
||||||
mod live;
|
mod live;
|
||||||
mod tiles;
|
mod tiles;
|
||||||
|
|
||||||
@@ -132,6 +134,8 @@ pub struct AppState {
|
|||||||
pub content: ContentState,
|
pub content: ContentState,
|
||||||
/// 整合包导入/导出:进度 / 结果 / 探测诊断 / 导出预检(M113)
|
/// 整合包导入/导出:进度 / 结果 / 探测诊断 / 导出预检(M113)
|
||||||
pub pack: PackState,
|
pub pack: PackState,
|
||||||
|
/// 导入已有版本(M223):扫描 / 预检 / 失败 / 完成(**只装事件态**,表单在页面本地态)
|
||||||
|
pub adopt: AdoptState,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AppState {
|
impl Default for AppState {
|
||||||
@@ -177,6 +181,7 @@ impl AppState {
|
|||||||
live_session: None,
|
live_session: None,
|
||||||
content: ContentState::default(),
|
content: ContentState::default(),
|
||||||
pack: PackState::default(),
|
pack: PackState::default(),
|
||||||
|
adopt: AdoptState::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,6 +300,8 @@ impl AppState {
|
|||||||
CoreEvent::Update(view) => self.update = *view,
|
CoreEvent::Update(view) => self.update = *view,
|
||||||
// M113: import/export
|
// M113: import/export
|
||||||
CoreEvent::Pack(event) => self.pack.apply(*event),
|
CoreEvent::Pack(event) => self.pack.apply(*event),
|
||||||
|
// M223: 导入已有版本(唯一写入点:`AdoptState::apply`)
|
||||||
|
CoreEvent::Adopt(event) => self.adopt.apply(*event),
|
||||||
CoreEvent::ContentResults(results) => self.apply_content(*results),
|
CoreEvent::ContentResults(results) => self.apply_content(*results),
|
||||||
CoreEvent::ContentFailed(failure) => self.apply_content_failure(*failure),
|
CoreEvent::ContentFailed(failure) => self.apply_content_failure(*failure),
|
||||||
// M162:详情/版本列表(判断逻辑在 `state/content.rs`,这里只有一行投递)
|
// M162:详情/版本列表(判断逻辑在 `state/content.rs`,这里只有一行投递)
|
||||||
@@ -304,31 +311,6 @@ impl AppState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// agent 事件 → AI 栏视图模型(投影规则见 [`crate::agent::rail`])。
|
|
||||||
///
|
|
||||||
/// 未读规则:栏不可见时,助手侧新内容(助手/工具消息、错误、待确认请求)逐条累加 `unread`;
|
|
||||||
/// 用户自己说的话与纯增量不算。栏可见时到达的内容直接算已读(红点不该在眼前亮着)。
|
|
||||||
pub fn apply_agent(&mut self, event: AgentEvent) {
|
|
||||||
let visible = self.agent_rail_visible();
|
|
||||||
let fresh = self.ai.ingest(&event);
|
|
||||||
if fresh {
|
|
||||||
if visible {
|
|
||||||
self.ai.mark_read();
|
|
||||||
} else {
|
|
||||||
self.ai.unread = self.ai.unread.saturating_add(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// AI 栏此刻是否「在用户眼前」。
|
|
||||||
///
|
|
||||||
/// 独立页形态(栈顶是 `Route::Agent`)必然可见;停靠形态下只有收成 icon 轨才算离开——
|
|
||||||
/// 右侧常驻栏在任何一级页面都挂着,用户扫一眼就能看到。
|
|
||||||
#[must_use]
|
|
||||||
pub fn agent_rail_visible(&self) -> bool {
|
|
||||||
self.ai_mode() == AiPanelMode::Page || !self.prefs.ai_collapsed
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 按搜索词过滤后的磁贴网格(顺序稳定 = 实例列表顺序)。
|
/// 按搜索词过滤后的磁贴网格(顺序稳定 = 实例列表顺序)。
|
||||||
pub fn tiles(&self) -> Vec<TileCell<'_>> {
|
pub fn tiles(&self) -> Vec<TileCell<'_>> {
|
||||||
let query = self.instance_query.trim().to_lowercase();
|
let query = self.instance_query.trim().to_lowercase();
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
//! AI 栏的事件投递与可见性判定(M223 从 `state.rs` **纯搬移**,一行未改)。
|
||||||
|
//!
|
||||||
|
//! 为什么搬:M223 要往 `AppState` 加「导入已有版本」的状态字段与一条 `CoreEvent::Adopt` 分支,
|
||||||
|
//! 而 `state.rs` 当时正好 400 行(零余量,AGENTS §3.9)。拆分口径与 [`super::tiles`] 一致:
|
||||||
|
//! **纯搬移、一行不改**;`impl AppState` 写在子模块里,方法路径
|
||||||
|
//! `AppState::apply_agent` / `AppState::agent_rail_visible` 对调用方完全不变
|
||||||
|
//! (子模块看得见父模块的私有字段,所以这里直接用 `self.ai` / `self.prefs`)。
|
||||||
|
|
||||||
|
use crate::dto::AgentEvent;
|
||||||
|
use crate::router::AiPanelMode;
|
||||||
|
|
||||||
|
use super::AppState;
|
||||||
|
|
||||||
|
impl AppState {
|
||||||
|
/// agent 事件 → AI 栏视图模型(投影规则见 [`crate::agent::rail`])。
|
||||||
|
///
|
||||||
|
/// 未读规则:栏不可见时,助手侧新内容(助手/工具消息、错误、待确认请求)逐条累加 `unread`;
|
||||||
|
/// 用户自己说的话与纯增量不算。栏可见时到达的内容直接算已读(红点不该在眼前亮着)。
|
||||||
|
pub fn apply_agent(&mut self, event: AgentEvent) {
|
||||||
|
let visible = self.agent_rail_visible();
|
||||||
|
let fresh = self.ai.ingest(&event);
|
||||||
|
if fresh {
|
||||||
|
if visible {
|
||||||
|
self.ai.mark_read();
|
||||||
|
} else {
|
||||||
|
self.ai.unread = self.ai.unread.saturating_add(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 栏此刻是否「在用户眼前」。
|
||||||
|
///
|
||||||
|
/// 独立页形态(栈顶是 `Route::Agent`)必然可见;停靠形态下只有收成 icon 轨才算离开——
|
||||||
|
/// 右侧常驻栏在任何一级页面都挂着,用户扫一眼就能看到。
|
||||||
|
#[must_use]
|
||||||
|
pub fn agent_rail_visible(&self) -> bool {
|
||||||
|
self.ai_mode() == AiPanelMode::Page || !self.prefs.ai_collapsed
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user