复审 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 张,全部重出)
428 lines
17 KiB
Rust
428 lines
17 KiB
Rust
//! 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;
|