Files
Unlucky-Launcher/crates/presenter/src/state.rs
T
AskaEth 42099d4336 fix(app-iced): M109 r1 —— CF 来源装入闸门、has_more 读最近一页、末行切半与文案/图标
复审 p1-2items 的两条 P1 与三条 P2,逐条收口。

`blockers_of` 只看版本/加载器/目标实例,不看来源;而 host 的 `resolve_file` 对 CF 直接
返回「未接通」⇒ 切到 CF 源后每条都必然失败,UI 一声不响。

- 新增 `Blocker::SourceNotWired` + 文案 `ContentSourceNotWired`(中英);
- `blockers_of` 的入参从 `Option<(&[String], &[String])>` 收成 `Option<HitFacts>`(版本 /
  加载器 / **来源**),两处调用点(产品路径、演示数据)一起改;
- 判据同构于整合包的 `NeedsPipeline`:**点之前**禁用并给一条原因;CF 文件端点接通后删掉这条。

DTO 的 `has_more()` 判「本页装满」是对的,但 reducer 在 `offset>0` 时先 extend 再判,
被判定的是**累计条数** ⇒ 首页满 20 条之后恒真:「加载更多」永不消失、「没有更多了」在
真实翻页路径上不可达,且每点一次白发一次网络请求。

- `ContentState` 新增 `last_page_filled`(**先**按这一页记一位,再并进累计列表);
- 页面的 `has_more` 读它(`&& !pending`);换条件(offset 归零)时清位;
- 新增 `crates/presenter/src/state/content_tests.rs` 四条用例(state.rs 贴 400 行上限,拆文件):
  `a_short_last_page_clears_has_more` / `an_empty_last_page_keeps_has_more_false` /
  `changing_the_query_resets_the_page_flag` / `a_stale_batch_does_not_touch_the_page_flag`;
- 变异自证:把判据改回「读累计」后前两条红(`末页不满 ⇒ 不许说还有更多`、`空页 ⇒ 没有更多`),
  改回绿。

- **末行被 footer 切半**(复审定位的真因):`view.rs` 的 body 列漏了 `.spacing(..)`,而列表
  纵向内边距是 0 ⇒ 视口底边正好切在半行上。补 `Space::X2`,并说明为什么必须给。
- **文案去掉开发者术语与内部编号**:`ContentSourceUnavailable` 不再写 `ULL_CF_API_KEY`
  (改「CurseForge 需要在设置里配置密钥才能搜索」);`ContentModpackNotReady` 不再写
  mission 编号(改「整合包安装尚未开放」);host 侧那条 `InstallProgress` 失败文案同步去编号。
- **图标**:四类改为 `Folder` / `Plus` / `Restore` / `Expand`——不借用导航五枚(同屏撞形,
  复审 P2-3)、不用填充字形(`no_hardcoded_tokens` 门禁);偏离表按**实际取值**重写,并补一条
  「CF 的装入也是禁用态」的登记。
- 演示数据加 `ULL_CONTENT_STATE=cf` 一屏,出图证明 P1-1 的禁用态(`12-cf-not-wired-light.png`)。

门禁(逐条落盘退出码,不走管道):fmt 0 / clippy `-D warnings`(app-iced+presenter+i18n)0 /
`cargo test -p unlucky-app-iced --lib` 0(451 passed)/ `cargo test -p unlucky-presenter` 0
(174 passed)/ `cargo test --workspace --exclude unlucky-app` 0。改动文件最大 397 行。

取证:`DevDocs/artifacts/m109/`(12 张,全部重出)
2026-11-05 03:20:22 +08:00

428 lines
17 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! UI 状态与 reducer:UI 框架只负责渲染,所有状态变更都汇聚到 [`AppState::apply`]。
//!
//! 数据流向:mock/host 后端 → [`crate::dto::AppEvent`] 队列 → `apply` → 状态字段 → UI 属性。
//! 偏好项在 [`crate::prefs`] 里登记了持久化键名,core::setting 的 schema 落地后按这些键读写。
use crate::agent::AgentRail;
use crate::dialogs::DialogQueue;
use crate::dto::{
AccountView, AgentEvent, AppEvent, ContentQuery, ContentResults, CoreEvent, DeviceLoginView,
DiagnosisReport, InstallProgressView, InstanceRuntime, InstanceSummary, JavaPageView,
LoaderKind, LogLine, LogSession, NoticeView, Progress, SettingGroupView, VersionSummary,
};
use crate::prefs::UiPrefs;
// 内容库的视图态与其 DTO 放一起(`dto/content.rs`),路径 `state::ContentState` 不变。
pub use crate::dto::ContentState;
use crate::router::{AiPanelMode, Route, Router};
use crate::toast::{ToastCenter, ToastKind};
use crate::wizard::WizardState;
/// 磁贴网格单元:位置由 [`grid_positions`] 算好,UI 侧只消费 row/col。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TileCell<'a> {
/// 实例数据
pub instance: &'a InstanceSummary,
/// 网格行号(0 起)
pub row: usize,
/// 网格列号(0 起)
pub col: usize,
}
/// 纯函数:把 n 个磁贴摊到 `columns` 列的网格上,返回 `(row, col)`。
///
/// 特意放在 Rust 侧:声明式 UI 的表达式里做整数除法容易引出隐式浮点,且这段逻辑可以单测。
pub fn grid_positions(count: usize, columns: usize) -> Vec<(usize, usize)> {
let columns = columns.max(1);
(0..count)
.map(|index| (index / columns, index % columns))
.collect()
}
/// 实时日志最多保留的行数(滚动窗口;更多行由日志页的筛选/历史会话承担)。
pub const MAX_LIVE_LINES: usize = 2_000;
/// 一份加载器版本清单查询的结果(`Ok` = 最新在前;`Err` = 人读原因)。
pub type LoaderVersions = Result<Vec<String>, String>;
/// 应用全局状态。
#[derive(Debug)]
pub struct AppState {
/// UI 偏好(记忆项)
pub prefs: UiPrefs,
/// 导航栈
pub router: Router,
/// 实例列表快照
pub instances: Vec<InstanceSummary>,
/// 当前分类的版本列表
pub versions: Vec<VersionSummary>,
/// 日志会话列表
pub log_sessions: Vec<LogSession>,
/// 当前会话日志行
pub log_lines: Vec<LogLine>,
/// 最近一次诊断结论
pub diagnosis: Option<DiagnosisReport>,
/// 选中的日志会话
pub selected_session: Option<String>,
/// AI 会话状态(右侧栏与独立页共享同一份,切换不丢)
pub ai: AgentRail,
/// 对话框队列(一次一条)
pub dialogs: DialogQueue,
/// Toast 中心
pub toasts: ToastCenter,
/// 实例页搜索词
pub instance_query: String,
/// 下载页「仅已装」过滤
pub installed_only: bool,
/// 下载页版本号筛选词
pub download_query: String,
/// 账户列表(三轨统一视图;凭据不在其中)
pub accounts: Vec<AccountView>,
/// 微软登录状态
pub device_login: DeviceLoginView,
/// Java 页快照(发行版 / 回退链 / 运行时)
pub java: JavaPageView,
/// 安装进度(None = 没有安装任务)
pub install: Option<InstallProgressView>,
/// 用户取消安装的**次数**(M105):每次 `CoreEvent::InstallCancelled` +1。
///
/// 只是信号,不带文案:app 层比对前后值决定「要不要弹一条 i18n Toast」,
/// 于是取消提示和页面用同一种语言(复审 P2-2)。
pub install_cancelled: u64,
/// 加载器版本清单(下载页「安装选项」的下拉;M105)
///
/// 两段键:外层是加载器的**稳定 id**(`LoaderKind::as_str`,`LoaderKind` 没派生 `Hash`,
/// 而 `as_str` 本来就是它在 CLI/GUI 两边的身份),内层是原版版本。
/// **原版版本进键**,所以切到另一个 MC 版本时拿到的是另一条缓存,
/// 不会把 A 版本的 Fabric 列表挂到 B 版本下面(M105 复审的硬要求)。
/// 值是 `Result`:「取不到」与「官方没有版本」是两件事,前者要在页面上说清原因
/// (并禁掉确认),不许显示成空列表。
pub loader_versions:
std::collections::HashMap<&'static str, std::collections::HashMap<String, LoaderVersions>>,
/// 设置分组(设置页右栏)
pub settings_groups: Vec<SettingGroupView>,
/// 当前选中的设置分组(download / java / data / ai / mcp)
pub settings_tab: String,
/// 横幅通知
pub notices: Vec<NoticeView>,
/// 新建实例向导
pub wizard: WizardState,
/// 正在实时尾随的会话
pub live_session: Option<String>,
/// 内容库(M109):条件 / 结果 / 请求态
pub content: ContentState,
}
impl Default for AppState {
fn default() -> Self {
Self::new()
}
}
impl AppState {
/// 空状态(数据由后端事件填充,P0 是 mock)。
pub fn new() -> Self {
Self {
prefs: UiPrefs::default(),
router: Router::new(),
instances: Vec::new(),
versions: Vec::new(),
log_sessions: Vec::new(),
log_lines: Vec::new(),
diagnosis: None,
selected_session: None,
ai: AgentRail::new(),
dialogs: DialogQueue::new(),
toasts: ToastCenter::new(),
instance_query: String::new(),
installed_only: false,
download_query: String::new(),
accounts: Vec::new(),
device_login: DeviceLoginView::new(),
java: JavaPageView::new(),
install: None,
install_cancelled: 0,
loader_versions: std::collections::HashMap::new(),
settings_groups: Vec::new(),
settings_tab: "download".to_string(),
notices: Vec::new(),
wizard: WizardState::new(),
live_session: None,
content: ContentState::default(),
}
}
/// 某 (加载器, 原版版本) 的加载器版本清单。
///
/// `None` = 还没查过(页面据此派一次 `AppCommand::ListLoaderVersions` 并显示「正在获取…」);
/// `Some(Err(..))` = 查过但拿不到(页面显示具名原因并**禁掉确认**)。
#[must_use]
pub fn loader_versions_for(
&self,
kind: LoaderKind,
mc_version: &str,
) -> Option<&LoaderVersions> {
self.loader_versions.get(kind.as_str())?.get(mc_version)
}
/// 当前页面。
pub fn route(&self) -> &Route {
self.router.route()
}
/// 跳转(一级页替换、子页面压栈;去 AI 独立页时自动展开 AI 栏偏好)。
pub fn navigate(&mut self, route: Route) {
if route == Route::Agent {
self.prefs.ai_collapsed = false;
// 用户正在看 AI 页:攒下的未读立刻算已读(红点不该在眼前亮着)
self.ai.mark_read();
}
self.router.navigate(route);
}
/// 回退一层。
pub fn back(&mut self) -> bool {
self.router.back()
}
/// AI 栏当前形态(跟着路由走)。
pub fn ai_mode(&self) -> AiPanelMode {
self.router.ai_mode()
}
/// 应用一条事件(唯一状态变更入口)。
pub fn apply(&mut self, event: AppEvent) {
match event {
AppEvent::Core(core) => self.apply_core(core),
AppEvent::Agent(agent) => self.apply_agent(agent),
AppEvent::DialogRequested(request) => {
self.dialogs.push_request(request);
}
AppEvent::DialogClosed(id) => {
self.dialogs.close(id);
}
AppEvent::Toast(toast) => {
self.toasts.push_request(toast);
}
}
}
/// core 事件。
pub fn apply_core(&mut self, event: CoreEvent) {
match event {
CoreEvent::InstancesChanged(instances) => self.instances = instances,
CoreEvent::InstanceRuntimeChanged { id, runtime } => {
if let Some(instance) = self.instances.iter_mut().find(|item| item.id == id) {
instance.runtime = runtime;
}
}
CoreEvent::VersionListChanged(versions) => self.versions = versions,
CoreEvent::LogSessionsChanged(sessions) => self.log_sessions = sessions,
CoreEvent::LogLinesChanged(lines) => {
self.log_lines = lines;
self.live_session = None;
}
CoreEvent::LogAppended(batch) => self.append_live_lines(&batch),
CoreEvent::LaunchFinished(finished) => {
self.live_session = None;
self.selected_session = Some(finished.session.clone());
let kind = if finished.outcome == crate::dto::LogOutcome::Ok {
ToastKind::Success
} else {
ToastKind::Error
};
self.toasts.push(kind, finished.summary);
}
CoreEvent::DiagnosisReady(report) => self.diagnosis = Some(report),
CoreEvent::AccountsChanged(accounts) => self.accounts = accounts,
CoreEvent::DeviceLoginChanged(view) => {
if view.phase == crate::dto::DeviceLoginPhase::Succeeded {
self.toasts
.push(ToastKind::Success, format!("已登录:{}", view.account_name));
}
if view.phase == crate::dto::DeviceLoginPhase::Failed {
self.toasts.push(ToastKind::Error, view.message.clone());
}
self.device_login = view;
}
CoreEvent::JavaChanged(page) => self.java = page,
CoreEvent::InstallProgress(progress) => match progress {
Some(progress) => {
let terminal = progress.stage.is_terminal();
self.install = Some(progress);
if terminal {
self.wizard.open = false;
self.wizard.submitting = false;
}
}
None => self.install = None,
},
CoreEvent::InstallCancelled => {
self.install_cancelled = self.install_cancelled.saturating_add(1);
}
CoreEvent::SettingsChanged(groups) => self.settings_groups = groups,
CoreEvent::LoaderVersions {
kind,
mc_version,
result,
} => {
self.loader_versions
.entry(kind.as_str())
.or_default()
.insert(mc_version, result);
}
CoreEvent::NoticesChanged(notices) => self.notices = notices,
CoreEvent::ContentResults(results) => self.apply_content(*results),
CoreEvent::ContentFailed(reason) => {
self.content.pending = false;
self.content.failed = Some(reason);
}
}
}
/// 内容库结果落地(M109):同条件换页 ⇒ **追加**;换条件 ⇒ **整批替换**;迟到 ⇒ 丢掉。
///
/// 这三条就是「列表画的永远是当前筛选条件」的全部保证——页面自己没有过滤逻辑,也没有第二份真相。
fn apply_content(&mut self, results: ContentResults) {
self.content.pending = false;
self.content.failed = None;
if !self.content.accepts(&results) {
// 迟到的一批:用户已经改了条件,覆盖上去就会出现「筛选条说 A、列表是 B」。
return;
}
// **先**按「这一页装没装满」记一位,再把它并进累计列表(复审 P1-2:判据不能读累计值)。
self.content.last_page_filled = results.has_more();
match &mut self.content.results {
Some(current) if results.query.offset > 0 => {
current.hits.extend(results.hits);
current.total = results.total;
current.curseforge = results.curseforge;
current.query = results.query;
}
_ => self.content.results = Some(results),
}
}
/// 记下新的检索条件并进入「请求在飞」态(指令由调用方随后 dispatch)。
pub fn set_content_query(&mut self, query: ContentQuery) {
self.content.pending = true;
self.content.failed = None;
if query.offset == 0 {
self.content.last_page_filled = false;
// 换条件(不是换页):先把旧结果清掉,否则加载态会画出上一批命中项。
self.content.results = None;
}
self.content.query = query;
}
/// 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
}
/// 实时日志增量:只追加,封顶 [`MAX_LIVE_LINES`] 行(滚动窗口)。
fn append_live_lines(&mut self, batch: &crate::dto::LiveLogBatch) {
if self
.live_session
.as_deref()
.is_some_and(|session| session != batch.session)
{
// 换了一个会话在跑:清掉上一场的行,避免两场日志混在一起
self.log_lines.clear();
}
self.live_session = batch.running.then(|| batch.session.clone());
self.selected_session = Some(batch.session.clone());
self.log_lines.extend(batch.lines.iter().cloned());
if self.log_lines.len() > MAX_LIVE_LINES {
let drop = self.log_lines.len() - MAX_LIVE_LINES;
self.log_lines.drain(..drop);
}
}
/// 按搜索词过滤后的磁贴网格(顺序稳定 = 实例列表顺序)。
pub fn tiles(&self) -> Vec<TileCell<'_>> {
let query = self.instance_query.trim().to_lowercase();
let filtered: Vec<&InstanceSummary> = self
.instances
.iter()
.filter(|instance| query.is_empty() || instance.name.to_lowercase().contains(&query))
.collect();
let positions = grid_positions(filtered.len(), self.prefs.tile_size.columns());
filtered
.into_iter()
.zip(positions)
.map(|(instance, (row, col))| TileCell { instance, row, col })
.collect()
}
/// 运行中的实例(实例页顶部迷你状态条)。
pub fn running(&self) -> Vec<&InstanceSummary> {
self.instances
.iter()
.filter(|instance| {
matches!(
instance.runtime,
InstanceRuntime::Running { .. } | InstanceRuntime::Busy { .. }
)
})
.collect()
}
/// 覆盖某实例的启动进度(mock / 真实后端共用同一入口)。
pub fn set_progress(&mut self, id: &str, progress: Progress) {
if let Some(instance) = self.instances.iter_mut().find(|item| item.id == id) {
if let InstanceRuntime::Busy {
progress: current, ..
} = &mut instance.runtime
{
*current = progress;
}
}
}
/// 折叠/展开导航栏。
pub fn toggle_nav(&mut self) {
self.prefs.nav_collapsed = !self.prefs.nav_collapsed;
}
/// 折叠/展开 AI 栏;若正处在独立页形态则先收回侧栏。
pub fn toggle_ai(&mut self) {
if self.route() == &Route::Agent {
self.router.back();
self.prefs.ai_collapsed = false;
return;
}
self.prefs.ai_collapsed = !self.prefs.ai_collapsed;
if !self.prefs.ai_collapsed {
// 展开常驻栏 = 用户在看它
self.ai.mark_read();
}
}
}
#[cfg(test)]
#[path = "state_tests.rs"]
mod tests;
#[cfg(test)]
mod content_tests;