M201: 接通 iced 设置页的数据通路(下载组:真值 + 真控件 + 写回闭环)

M193 的用户投诉「设置页里改了不生效」的结构性根因是 iced 侧没有设置数据通路:
`CoreEvent::SettingsChanged` 在 `crates/app-iced/src` 零命中、`pages::Model` 没有设置快照、
`download::State` 是空 struct。presenter 那套设置组早就是真值来源(`host::settings::groups`
由 `refresh_all` 与 `dispatch` 的 `SetSetting` 分支发成 `SettingsChanged`),只是没有接到 iced。

这条线现在通了:

    host::settings::groups → SettingsChanged → AppState::settings_groups
      → pages::Model.settings → settings::States.settings → 下载组控件
      → AppCommand::SetSetting → 写盘 + 重新发 SettingsChanged(闭环)

主要改动
- presenter `host/settings/download.rs`:并发/重试两行改成画**引擎生效值**(`DownloadTuning`),
  文件原值与生效值不一致时写明「原值 X 已按出厂 N 生效」(P3-5,两个数都现读);
  gh-proxy 行补「当前生效:跟随内置默认/已关闭/覆盖为 X」。
- app-iced `pages/settings/download.rs`:`GROUP.body = Some(body)`,按 `SettingControl`
  画 choice(filter chip)/ text(输入框 + 应用)/ action(按钮)/ toggle / readonly;写回走
  `app/update/settings/download.rs` → `AppCommand::SetSetting`(不做乐观回显)。
- `pages::Model.settings` + `settings::States.settings`:快照进页面的唯一通道。
- i18n:新增 `SettingsDownloadsApply`(金表锚点追加,`split_keeps_every_translation` 绿)。
- 取证:`ULL_SCREENSHOT_REAL_HOST=1` 让 `--screenshot` 接真 host(截图通道默认强制 mock,
  而 mock 不发 `SettingsChanged`,真值屏拍不出来)。

测试(新增 11 条)
- presenter `download_tests.rs`:P3-5 的越界回落 / 0→1 / 无差异不出提示 + P3-3 的
  gh-proxy 文件→三态端到端链。
- app-iced `app/update/settings/download_tests.rs`:假后端闭环 ×4 + **真 `CoreHost` 端到端**
  (点 16 ⇒ 磁盘上 `launcher.json` 真的有 16)。
- `pages/settings/tests/download_section.rs`:`body.is_none()` 绊线翻成 `is_some()` +
  控件形状 / 空快照不编值。
This commit is contained in:
AskaEth committed 2026-11-09 10:51:16 +08:00
1 parent 0cc1a35420
commit ab573838f3
18 files changed
+1255 -72

No files matched your search

+16
View File
@@ -214,3 +214,19 @@ pub struct App {
/// [`crate::platform::win32::corner::CornerApplied`]。
pub(super) corner_applied: Option<crate::platform::win32::corner::CornerApplied>,
}
impl App {
/// iced 侧的**设置组快照**(M201):presenter 的 `CoreEvent::SettingsChanged` 回流进来的那一份。
///
/// 真值只有一份——presenter 的 `AppState::settings_groups`(`apply_core` 是它唯一的写入口,
/// 由 host 的 `refresh_all` / `SetSetting` 分支发出来)。这里**不另存一个字段**:那会变成第二份
/// 真相,两份在 `pump` 之间必然有一步不一致(而「界面上是旧值、盘上是新值」正是本 mission 要
/// 消灭的形状)。要画的那一份是 [`pages::Model::settings`](`refresh_view` 里从同一个字段克隆)。
///
/// `#[cfg(test)]`:产品路径不需要这个入口(页面拿的是 `Model.settings`,同一份数据的投影),
/// 断言需要它——「App 认的值变了吗」必须能直接从 App 上问,不能去问页面模型(那是下游)。
#[cfg(test)]
pub(crate) fn settings_snapshot(&self) -> &[unlucky_presenter::dto::SettingGroupView] {
&self.state.settings_groups
}
}
+21 -1
View File
@@ -23,10 +23,30 @@ pub(super) fn app_options(options: &Options) -> AppOptions {
// `--data-dir`:M90 起**真的消费**它(此前是「接受并忽略」的翻牌兼容参数)。
// 截图通道强制 mock,所以 CI 冒烟那条 `--data-dir <tmp>` 不会往那个目录写东西。
data_root: options.data_dir.clone(),
mock: options.screenshot.is_some(),
mock: options.screenshot.is_some() && !real_host_in_screenshot(),
}
}
/// 取证开关 `ULL_SCREENSHOT_REAL_HOST=1`:让**截图通道**接真 host(默认关)。
///
/// ## 为什么需要它(M201)
///
/// 截图通道恒 `mock: true`(D30:同一份代码出同一张图),而 `MockBackend` **不发**
/// `CoreEvent::SettingsChanged` ⇒ 设置页拿不到任何真值,只能拍演示行。可「设置页里改了就生效」
/// 这件事的正脸图**必须带真值**:M193 用户的投诉就是「图上有 8 threads,追下去是假的」。
/// 这条开关把出图的**数据源**换成真 host(`--data-dir` 指一个临时根),渲染通道与落盘一字不改。
///
/// ## 边界(别把它当产品行为)
///
/// - 默认关;开了它就不再满足 D30 的可复现(数据来自磁盘,两台机器会不同),所以**只用于取证**;
/// - 它只改 `mock` 这一位,不碰任何业务分支(`build_backend` 的其余两条路仍然照旧);
/// - 与 `ULL_START_ROUTE` / `ULL_SETTINGS_GROUP` 同一族:只影响这一次进程。
fn real_host_in_screenshot() -> bool {
std::env::var("ULL_SCREENSHOT_REAL_HOST")
.ok()
.is_some_and(|raw| raw.trim() == "1")
}
/// 首帧耗时日志(只在第一次 `view()` 打一行)。
pub(crate) fn log_first_frame() {
if FIRST_FRAME_LOGGED.swap(true, Ordering::Relaxed) {
@@ -1,18 +1,75 @@
//! Downloads 分组消息的落点(M101 `SETTINGSMSG`):**只归 download 线**。
//! Downloads 分组消息的落点(M201):**设置写回**的那一段(M101 `SETTINGSMSG` 的漏斗不变)。
//!
//! 消息口在 `pages/settings/download.rs`(`pages::settings::download::Message`)。与下载页
//! (`pages/download*` 与 `app/update/download.rs`)无关:那是「装哪个版本」,这一组是「怎么下」。
//! 消息口在 `pages/settings/download.rs`(`pages::settings::download::Message`)。本文件只有一件事:
//! 把「用户改了哪个键」翻成 `AppCommand::SetSetting` —— 那是 presenter 已有的写回闭环
//! (`host/dispatch.rs` 的 `SetSetting` 分支:`settings::set` 落盘 → `apply_tuning` → 重新发
//! `CoreEvent::SettingsChanged`)。
//!
//! 首版是空枚举上的 `match message {}`:这是**穷尽**的写法(不是兜底),加了变体不接线就报 E0004。
//! ## 不做乐观回显
//!
//! 这里**不**改任何「当前值」:界面上的值是 presenter 快照(`pages::Model.settings`)的投影,
//! 只有 host 把 `SettingsChanged` 发回来才会变。理由与下载页的安装/启动同一条纪律——本地改一份、
//! host 回来再改一次,两边打架时界面就会各说一套(尤其是写盘失败:乐观回显会让用户以为改成了)。
//!
//! ## 文本行为什么要「应用」才提交
//!
//! 见 `pages/settings/download.rs` 的文件头:每敲一个键写一次盘会把半截值落进去。所以
//! `Edit` 只改本地草稿(`view_dirty` 都不用翻:草稿由 `States` 直接读),`Commit` 才派发。
//! 提交之后草稿当场丢掉——快照随即成为唯一真值,写失败时行上会回到文件里的值 + presenter 的
//! 错误 Toast 说清原因(比留着一个没生效的草稿更诚实)。
use super::super::*;
use unlucky_presenter::dto::{find_row, AppCommand};
impl App {
/// Downloads 分组的一条消息。首版没有可处理的东西(空枚举)。
/// Downloads 分组的一条消息。
pub(super) fn on_settings_download(
&mut self,
message: pages::settings::download::Message,
) -> Task<Message> {
match message {}
use pages::settings::download::Message;
match message {
Message::Set { key, value } => {
self.dispatch(AppCommand::SetSetting { key, value });
Task::none()
}
Message::Edit { key, value } => {
// 只改草稿:`body` 每一帧都从 `Groups.download` 读它,所以这里不必重建视图模型。
self.settings.download.edit(&key, value);
Task::none()
}
Message::Commit { key } => {
let value = self.pending_text(&key);
self.settings.download.clear(&key);
self.dispatch(AppCommand::SetSetting { key, value });
Task::none()
}
// 动作行(「重新检测地区」):键名与动作 id 都由 presenter 的快照给,取值被
// `settings::set` 忽略(那条分支先认键、再解析值)。
Message::Action { key } => {
self.dispatch(AppCommand::SetSetting {
key,
value: String::new(),
});
Task::none()
}
}
}
/// 这一行要提交的文本:本地草稿优先,否则用快照里的当前值(= 文件里的值)。
///
/// 空串是合法提交(「留空 = 跟随默认」是这一组真有的语义),所以只有**两者都缺**才返回空串。
fn pending_text(&self, key: &str) -> String {
self.settings
.download
.pending(key)
.or_else(|| find_row(&self.view.settings, key).map(|row| row.value_text.clone()))
.unwrap_or_default()
}
}
#[cfg(test)]
#[path = "download_tests.rs"]
mod tests;
@@ -0,0 +1,332 @@
//! Downloads 组的**写回闭环**(M201):`控件 → Message → AppCommand::SetSetting → 真 host 写盘
//! → CoreEvent::SettingsChanged → App 的快照与页面模型`。
//!
//! 为什么这条断言必须在 app 层:链子的两头都是「有状态的东西」——中间断在任何一环,页面层测试
//! 都看不见(它只拿到一份已经画好的模型)。要问的是「用户点了以后,**盘上和 App 认的值**变了吗」,
//! 而不是「按钮的 `on_press` 里有没有构造消息」。
//!
//! 两个后端都测,因为它们证明的事不同:
//!
//! | 用例 | 后端 | 证明 |
//! |---|---|---|
//! | 前四条 | 假后端(按 host 形状回快照) | 搬运:消息 → 命令、命令 → 快照、快照 → 模型 |
//! | 最后一条 | **真 `CoreHost` + 临时数据根** | 整条链:命令 → `settings::set` → `launcher.json` 真的变了 |
use std::cell::RefCell;
use std::rc::Rc;
use unlucky_design::color::SchemeFlavor;
use unlucky_presenter::dto::{
AppCommand, AppEvent, Backend, CoreEvent, SettingControl, SettingGroupView, SettingRowView,
};
use unlucky_presenter::settings_bridge::SettingsBridge;
use crate::cli::Options;
use crate::pages;
use crate::pages::settings::download;
use crate::theme::Families;
use super::*;
/// 这一组要断言的那个键(与 presenter 快照里的键同名)。
const CONCURRENCY: &str = "download.concurrency";
/// 一个文本键(代理地址)。
const PROXY_URL: &str = "download.proxy_url";
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
/// 一份只有下载组的快照:并发 = `concurrency`,代理 = `proxy`。
///
/// 行形状照 presenter 的 `host::settings::download::group` 抄(choice + 档位选项,text + 原文),
/// 只保留本文件断言要用的那两行——这里守的是 app 侧的搬运,不是 presenter 的行文案。
fn snapshot(concurrency: &str, proxy: &str) -> Vec<SettingGroupView> {
let options = ["4", "8", "16"];
let selected = i32::try_from(
options
.iter()
.position(|item| *item == concurrency)
.unwrap_or(1),
)
.expect("装得下");
vec![SettingGroupView::new("download", "下载与镜像(D7)")
.with(SettingRowView::choice(
CONCURRENCY,
"并发数",
"同时下载的文件数",
&options,
selected,
))
.with(SettingRowView::text(
PROXY_URL,
"HTTP 代理",
"manual 模式生效",
proxy,
))]
}
/// 记下令、并按 host 的形状回一条 `SettingsChanged` 的后端。
struct Hostish {
commands: Rc<RefCell<Vec<AppCommand>>>,
queued: Vec<AppEvent>,
/// 回事件时用的快照(`SetSetting` 之后换成「值真的变了」的那一份)。
state: RefCell<(String, String)>,
}
impl Backend for Hostish {
fn drain_events(&mut self) -> Vec<AppEvent> {
self.queued.drain(..).collect()
}
fn dispatch(&mut self, command: AppCommand) {
self.commands.borrow_mut().push(command.clone());
if let AppCommand::SetSetting { key, value } = command {
let mut state = self.state.borrow_mut();
if key == CONCURRENCY {
state.0 = value;
} else if key == PROXY_URL {
state.1 = value;
}
// host 写盘之后重新发一份快照(`dispatch.rs` 的 `SetSetting` 分支就这一行)。
self.queued
.push(AppEvent::Core(CoreEvent::SettingsChanged(snapshot(
&state.0, &state.1,
))));
}
}
}
/// 一个装着 [`Hostish`] 的 App + 那本命令账。
fn app() -> (App, Rc<RefCell<Vec<AppCommand>>>) {
let commands = Rc::new(RefCell::new(Vec::new()));
let backend = Hostish {
commands: Rc::clone(&commands),
queued: Vec::new(),
state: RefCell::new(("8".to_string(), "http://127.0.0.1:7890".to_string())),
};
let mut app = App::with_presenter(
Options::default(),
FAMILIES,
SchemeFlavor::Light,
Box::new(backend),
SettingsBridge::memory_only(),
);
// 首屏:收一份 host 的快照(产品路径在 `on_boot` 的 `RefreshAll` 之后就是这样)。
app.state
.apply(AppEvent::Core(CoreEvent::SettingsChanged(snapshot(
"8",
"http://127.0.0.1:7890",
))));
app.refresh_view();
(app, commands)
}
/// 一条设置页消息走完漏斗。
fn send(app: &mut App, message: pages::settings::download::Message) {
let message = pages::Message::Settings(pages::settings::GroupMessage::Download(message));
let _ = app.on_page(message);
}
/// **主用例**:点一个档位 ⇒ 命令发出去了 ⇒ host 回的快照进了 App ⇒ 页面模型跟着换。
///
/// 抓得住:
/// 1. handler 不派发 `AppCommand::SetSetting`(写回那一行被删)——这时假后端收不到命令、
/// 也回不出事件,快照停在旧值,红(变异验证见报告);
/// 2. `pages::Model` 不从快照取设置(`view::model` 那行 `settings:` 被删)——`app.view.settings`
/// 是空的,`current_value` 返回 `None`,红;
/// 3. App 侧**乐观回显**(点完就地改本地值、不等事件)——把假后端的回事件删掉后第 2 段仍会绿,
/// 所以本用例的「红」只证明闭合回路;它抓不住「有没有多存一份本地值」这一半。
///
/// 抓不住:真实点击(`Chip` 的 `on_press` 是否被真的按下)、host 是否真的落盘(那是最后一条的活)。
#[test]
fn changing_a_choice_value_lands_in_the_app_snapshot_and_the_page_model() {
let (mut app, commands) = app();
assert_eq!(
download::current_value(app.settings_snapshot(), CONCURRENCY).as_deref(),
Some("8"),
"开局认的是 host 给的那份"
);
send(
&mut app,
pages::settings::download::Message::Set {
key: CONCURRENCY.to_string(),
value: "4".to_string(),
},
);
assert!(
commands.borrow().iter().any(|command| matches!(
command,
AppCommand::SetSetting { key, value } if key == CONCURRENCY && value == "4"
)),
"写回必须走已有的 `AppCommand::SetSetting`(实际下发:{:?})",
commands.borrow()
);
assert_eq!(
download::current_value(app.settings_snapshot(), CONCURRENCY).as_deref(),
Some("4"),
"App 的设置快照必须跟着 host 回来那份变(不是本地 State 变)"
);
assert_eq!(
download::current_value(&app.view.settings, CONCURRENCY).as_deref(),
Some("4"),
"页面模型(这一帧要画的值)也必须是 4:控件画的是 `Model.settings`"
);
}
/// 文本行:`Edit` 只改草稿(**不发指令**),`Commit` 才带着草稿去写盘。
///
/// 抓得住:`Edit` 也派发(每敲一个键写一次盘)、`Commit` 用快照旧值而不是草稿(用户输入丢失)。
/// 抓不住:输入框本身的按键路径(`text_field` 的 `on_input` 是组件既有实现)。
#[test]
fn typing_a_text_value_only_commits_on_demand() {
let (mut app, commands) = app();
send(
&mut app,
pages::settings::download::Message::Edit {
key: PROXY_URL.to_string(),
value: "http://127.0.0.1:1080".to_string(),
},
);
assert!(
commands.borrow().is_empty(),
"输入过程中不许写盘(半截值会落进 launcher.json)"
);
send(
&mut app,
pages::settings::download::Message::Commit {
key: PROXY_URL.to_string(),
},
);
assert!(
commands.borrow().iter().any(|command| matches!(
command,
AppCommand::SetSetting { key, value } if key == PROXY_URL && value == "http://127.0.0.1:1080"
)),
"提交时带的必须是**草稿**里的那一串(不是快照里的旧值):{:?}",
commands.borrow()
);
assert_eq!(
download::current_value(app.settings_snapshot(), PROXY_URL).as_deref(),
Some("http://127.0.0.1:1080"),
"提交后快照换成 host 回来的新值"
);
}
/// 动作行(「重新检测地区」):派发的是那一行的键,值留空(presenter 那条分支先认键、不看值)。
///
/// 抓得住:动作按钮被写成「什么都不发」、或者把键发成按钮文案。
#[test]
fn an_action_row_dispatches_its_key() {
let (mut app, commands) = app();
let key = "download.action.detect_region".to_string();
send(
&mut app,
pages::settings::download::Message::Action { key: key.clone() },
);
assert!(
commands.borrow().iter().any(|command| matches!(
command,
AppCommand::SetSetting { key: sent, .. } if sent == &key
)),
"动作行必须走「按 schema 键写设置」那一条既有通路:{:?}",
commands.borrow()
);
}
/// 快照里没有本组时,页面**不许**自己编一行(`controls_of` 空 = 退回演示行)。
///
/// 抓得住:把演示行当成真值画(M193 用户的投诉形状)。
/// 抓不住:`settings/mod.rs` 那条退回路径本身(那是 `demo_card` 的事)。
#[test]
fn an_empty_snapshot_yields_no_controls() {
let (mut app, _commands) = app();
app.state.settings_groups.clear();
app.refresh_view();
assert!(download::controls_of(app.settings_snapshot()).is_empty());
// 有快照时,本组的控件种类就是 presenter 给的那几行(这里两份:choice + text)。
let kinds = download::controls_of(&snapshot("8", ""));
assert_eq!(
kinds,
vec![SettingControl::Choice, SettingControl::Text],
"控件种类照快照的 `control` 字段画,不许自己另定"
);
}
/// **真 host 端到端**:点「16」⇒ 「设置页里改了就生效」这句话的正面证据。
///
/// 链子全长:`Message::Set` → `AppCommand::SetSetting` → 真 `CoreHost` 的 `dispatch.rs` 分支
/// (`settings::set` 校验 + 落盘 + `apply_tuning` + 重新发 `SettingsChanged`)→ `AppState` →
/// `pages::Model::settings`。断言落在**磁盘上那串字节**上,不是任何中间变量。
///
/// 抓得住:
/// 1. 只改本地、不派发(`Message::Set` 的分支被删)⇒ `launcher.json` 里没有 16,红;
/// 2. 派发了但 handler 用了错误的键/值 ⇒ 文件里那个键还是出厂 8,红;
/// 3. 快照没跟着 host 的回流走(`view::model` 那行 `settings:` 被删)⇒ 页面模型空,红;
/// 4. 写盘校验被绕过(`settings::set` 不再 `save()`)⇒ 文件不存在/没变,红。
///
/// 抓不住:真实鼠标点击(本用例从 `Message` 起跑,`Chip::filter` 的 `on_press` 不在其中)、
/// 引擎是否立刻吃到新值(`apply_tuning` 与 `concurrency_probe` 由 presenter 的
/// `the_concurrency_setting_reaches_the_real_download_engine` 守)、以及界面像素(截图的事)。
#[test]
fn a_choice_change_reaches_the_real_settings_file() {
let dir = std::env::temp_dir().join(format!("ull-m201-writeback-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join(".ULL/config")).expect("config 目录");
let config = dir.join(".ULL/config/launcher.json");
let env = unlucky_presenter::host::HostEnv::at_root(&dir);
let mut app = App::with_presenter(
Options {
data_dir: Some(dir.clone()),
..Options::default()
},
FAMILIES,
SchemeFlavor::Light,
Box::new(unlucky_presenter::host::CoreHost::new(env)),
// 设置桥也指向同一个根:不碰用户的真配置。
SettingsBridge::with_path(unlucky_presenter::backend::config_path(&dir)),
);
// 首屏:产品路径在 `on_boot` 里派的就是这一条(真 host 会发 `SettingsChanged`)。
app.dispatch(AppCommand::RefreshAll);
assert_eq!(
download::current_value(app.settings_snapshot(), CONCURRENCY).as_deref(),
Some("8"),
"临时根里没有这个键 ⇒ 快照给的是出厂 8(也是引擎的生效值)"
);
send(
&mut app,
pages::settings::download::Message::Set {
key: CONCURRENCY.to_string(),
value: "16".to_string(),
},
);
let written = std::fs::read_to_string(&config).expect("写回之后 launcher.json 必须存在");
assert!(
written.contains("\"concurrency\": 16"),
"磁盘上必须真的有 16(这才是「设置页里改了就生效」):{written}"
);
assert_eq!(
download::current_value(app.settings_snapshot(), CONCURRENCY).as_deref(),
Some("16"),
"host 把新快照发回来了,App 认的也是 16"
);
assert_eq!(
download::current_value(&app.view.settings, CONCURRENCY).as_deref(),
Some("16"),
"页面模型也换成了 16(下一帧控件画的就是它)"
);
std::fs::remove_dir_all(&dir).ok();
}
+5
View File
@@ -92,6 +92,8 @@ impl App {
pages::settings::States {
appearance: Some(&self.appearance),
groups: &self.settings,
// M201:下载组画真控件要的那份快照(`Model` 里那份,`refresh_view` 刚重建)。
settings: &self.view.settings,
},
// M106:设置页 General 组要画「当前档位 + 生效语言」。
self.language(),
@@ -190,6 +192,9 @@ pub(super) fn model(
logs: logs_model(state).with_status(lang),
// M159:更新快照 → 页面视图(设置页「更新」区 + 顶部提示条都读它)
update: update_of(&state.update),
// M201:设置组快照(`SettingsChanged` 的回流结果)→ 页面视图。设置页的下载组从这里按
// schema 键取当前值画控件;写回后 host 重新发 `SettingsChanged` ⇒ 下一次重建换新值。
settings: state.settings_groups.clone(),
lang,
forced: forced.map(|state| match state {
// `Rest` = 常态(压掉系统光标 hover);`Interaction::Enabled` 就是静息态,不叠状态层。
+6
View File
@@ -82,6 +82,12 @@ fn main() -> ExitCode {
"验收诊断:ULL_ACCEPTANCE=1([console]/[env]/[backend]/[window]/[font]/[theme]/[perf]/[i18n]/[caps])\
,ULL_WINDOW_SIZE=1520x900 覆盖窗口尺寸"
);
// M201:设置页要出「带真值」的验收图,而截图通道默认强制 mock(mock 不发
// `CoreEvent::SettingsChanged`)。这一条把**数据源**换成真 host,其余一字不改。
eprintln!(
"取证:ULL_SCREENSHOT_REAL_HOST=1 让 --screenshot 接真 host(默认 mock;\
出图将不再可复现,只用于验收真值屏)"
);
ExitCode::from(2)
}
}
+4
View File
@@ -167,6 +167,8 @@ pub fn page<'a>(
settings::States {
appearance: None,
groups: settings::demo_groups(),
// harness 路径没有 presenter 快照:空切片 ⇒ 各组画演示行(`Model::settings` 此时是空的)。
settings: &model.settings,
},
Language::default(),
form,
@@ -193,6 +195,7 @@ pub fn page_with_appearance<'a>(
settings::States {
appearance: Some(appearance),
groups: settings::demo_groups(),
settings: &model.settings,
},
Language::default(),
form,
@@ -291,6 +294,7 @@ pub fn app_with_appearance<'a>(
settings::States {
appearance,
groups: settings::demo_groups(),
settings: &model.settings,
},
Language::default(),
form,
+13
View File
@@ -124,6 +124,16 @@ pub struct Model {
pub content: Content,
/// 自更新的展示状态(M159:设置页「更新」区 + 顶部提示条,见 [`super::update`])。
pub update: Update,
/// **设置组快照**(M201):presenter 的 `CoreEvent::SettingsChanged` 回流的那一份
/// (`AppState::settings_groups`,由 `presenter::host::settings::groups` 产出)。
///
/// 谁填:`app/view.rs` 的 `model()`(`state.settings_groups.clone()`)。用途只有一处——
/// 设置页各组的 `body` 按 schema 键从它里面 `dto::find_row` 取**当前值**,画成可改的控件;
/// 写回走 `AppCommand::SetSetting`,落盘后 host 重新发一次 `SettingsChanged` ⇒ 这里换新的一份。
///
/// 为什么放在 `Model` 而不是别处:它就是「这一帧要画的设置值」,与 `update` 同一条形状
/// (页面只认 `Model`,presenter 的快照不直接进页面层)。
pub settings: Vec<unlucky_presenter::dto::SettingGroupView>,
/// 界面语言(M106):页面文案走 i18n key,语言由 app 层决定后灌进来。
///
/// 谁填:`app/state.rs` 的 `App::refresh_view`(`LangState::lang()` → `view::model(..)`),
@@ -156,6 +166,9 @@ impl Model {
// 演示模型也从这个空壳起步(`demo_model` 再按页面填数据)
content: super::content::demo(unlucky_presenter::dto::ContentKind::Mod, Lang::En),
update: Update::default(),
// 空快照 = 这一帧没有设置数据(harness / `pages_demo`;`demo_model` 也不伪造一份,
// 见 `pages/settings/download.rs` 的「没有快照就画演示行」)。
settings: Vec::new(),
lang: Lang::En,
forced: None,
}
+340 -23
View File
@@ -1,31 +1,76 @@
//! 设置页「Downloads」分组:并发 / 单源尝试次数 / GitHub 加速前缀(M193)。
//! 设置页「Downloads」分组:并发 / 单源尝试次数 / 地区 / 镜像 / 代理 / GitHub 加速前缀(M201)。
//!
//! **这一组归 download 线**:接真控件时在本文件把 `body` 换成 `Some(download::body)`(渲染器也写在本文件),
//! `settings/mod.rs` 的注册表不用动。
//! **这一组归 download 线**:正文渲染器在本文件(`GROUP.body = Some(body)`),`settings/mod.rs`
//! 的注册表不用动。
//!
//! 注意与下载页(`pages/download*`)的区别:那是「装哪个版本」,这里是「怎么下」的默认值。
//!
//! ## 为什么这三行是「说语义」而不是「说当前值」(M193)
//! ## 数据从哪来、写到哪去(M201 接的就是这条通路)
//!
//! 原来这三行是**写死的演示值**(`("Concurrency", "8 threads")` / `("Preferred source",
//! "Automatic (region)")` / `("Verify checksums", "On")`)。用户实测把它们当成真值投诉过
//! (「它默认显示 8,可我改成 2 也没变」)——因为 iced 侧**根本没有设置数据通路**:
//! `pages::Model` 没有设置快照、`grep -rn "SettingsChanged" crates/app-iced/src` 零命中、
//! [`State`] 是个空 struct 且无人填。而这三行里「8 threads」当时还是**假的**:引擎真值曾是 64
//! (schema 说 8),「Verify checksums」更是没有任何校验开关(校验恒开)。
//! ```text
//! presenter::host::settings::groups(&env) ← 真值(core schema 的键 + 生效值)
//! └─ CoreEvent::SettingsChanged(Vec<SettingGroupView>)
//! └─ AppState::settings_groups ← presenter 的唯一写入口(apply_core)
//! └─ pages::Model.settings ← refresh_view:这一帧要画的设置值
//! └─ settings::States.settings ← 本文件按 `id` 找到本组,逐行按 `control` 画
//! └─ 控件(choice / text / action)
//! └─ Message → app/update/settings/download.rs
//! └─ AppCommand::SetSetting → host 写盘 + 重新发 SettingsChanged
//! ```
//!
//! 所以这一轮把假值换成**不会撒谎的语义行**:区间、默认值、「留空 / off / 前缀」三态各是什么。
//! 真值(用户在 `launcher.json` 里填的那个数)不在这里显示——**宁可不说,也不说假的**。
//! 真正的可改控件要等一条 presenter 快照 → app → `Groups.download` 的数据通路,另立 mission。
//! **闭环**:控件不回显自己点的结果(不做乐观回显)。点了以后界面变,只能是因为 host 把
//! `SettingsChanged` 发回来了——那也正是「设置页里改了就生效」这句话的证据链。
//!
//! **生效保证**:这三条设置真的会被引擎读走(`core::download::shared::set_tuning`,宿主侧
//! 翻译在 `presenter::host::settings`),所以「行里的语义」与「引擎行为」是同一份契约。
//! ## 三件事分开画(这一组是「设置页里改不了」的修复落点)
//!
//! 1. **当前值**(choice 的选中 chip / text 的输入框)——来自快照,不来自任何字面量;
//! 2. **这一行在讲什么**(标题 + 说明)——说明里带着 presenter 算出来的「引擎生效值」,
//! 文件原值与生效值不一致时那句「原值 X 已按出厂 N 生效」也在里面(P3-5);
//! 3. **怎么改**(点 chip / 输入 + 应用 / 点动作按钮)——写回走 `AppCommand::SetSetting`。
//!
//! **没有快照**(harness / `pages_demo` / 首帧)时 `body` 返回 `None` ⇒ 退回去画 `GROUP.rows`
//! 那几行语义行(`frame::demo_card`)。那几行**说的不是值**(「出厂 8 · 可调 1~64」),
//! 所以不会再把演示值当成真值(M193 用户投诉的形状)。
//!
//! ## 文本行为什么要点「应用」
//!
//! 每敲一个键就写一次盘会把半截值落进 `launcher.json`(`off` 敲到 `of` 时 schema 会让它通过),
//! 而且 `store.set` 失败会弹一串 Toast。所以输入只改本地草稿([`State::edit`]),点「应用」
//! 才走 [`Message::Commit`] → `AppCommand::SetSetting`。
//!
//! **为什么不是回车提交**:本仓的 `text_field`(`app/components/inputs/field.rs`)只暴露 `on_input`,
//! 没有 `on_submit` 的口子;给它加一个口子要动组件库(组件库有自己的验收线),所以这一版只有
//! 「应用」这一颗按钮。回车提交登记为后续(见报告「没验/没做」)。
use std::collections::BTreeMap;
use iced::widget::{column, row};
use iced::{Alignment, Element, Length};
use unlucky_design::color::Role;
use unlucky_design::icons::IconKind;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::{Key, Lang};
use unlucky_presenter::dto::{SettingControl, SettingGroupView, SettingRowView};
// `find_row` 只被 `#[cfg(test)]` 的探针用(产品路径按 `GROUP_ID` 取组、逐行画)。
#[cfg(test)]
use unlucky_presenter::dto::find_row;
use super::Group;
use crate::app::components::buttons::{self, Button};
use crate::app::components::inputs::{text_field, Field, FieldVariant};
use crate::app::components::selection::{chip, switch, Chip, Switch};
use crate::pages::frame::text_role_owned;
use crate::pages::Message as PageMessage;
use crate::theme::Ui;
use super::frame::{card, divider};
use super::{Group, States};
/// Downloads 分组。
///
/// `rows` 只在**没有快照**时画(harness / 首帧):它们是语义行(出厂值 / 区间 / 三态),
/// 不是当前值——M193 用户的投诉正是「把演示值当成了真值」。
pub(super) const GROUP: Group = Group {
name: "Downloads",
icon: IconKind::Download,
@@ -36,20 +81,292 @@ pub(super) const GROUP: Group = Group {
("关掉它", "填 off = 关闭"),
("换掉它", "填 URL 前缀 = 覆盖"),
],
body: None,
body: Some(body),
};
/// 这一组在 presenter 快照里的 id(`setting::groups` 建组时给的那个)。
const GROUP_ID: &str = "download";
/// Downloads 分组的运行期状态(M101 r2:**只归 download 线**)。
///
/// 加字段只改本文件:`Groups` 只认 `Groups.download` 这一个字段。
/// 现在仍然是空的——真值的数据通路还没接(见文件头「为什么这三行是说语义」)。
/// 只装「用户正在编辑、还没提交」的文本草稿。**当前值不在这里**——它来自 presenter 快照
/// (`States::settings`),这里存一份就成了第二份真相。
#[derive(Debug, Default)]
pub struct State {}
pub struct State {
/// schema 键 → 正在输入但还没提交的文本。
drafts: BTreeMap<String, String>,
}
impl State {
/// 这一行此刻要显示的文本:有草稿用草稿,否则用快照里那个值(= 文件里的值)。
pub(crate) fn text_of<'a>(&'a self, row: &'a SettingRowView) -> &'a str {
self.drafts
.get(&row.key)
.map_or(row.value_text.as_str(), String::as_str)
}
/// 记一条草稿(`text_field` 的 `on_input` 落点;**不派发**任何指令)。
pub(crate) fn edit(&mut self, key: &str, value: String) {
self.drafts.insert(key.to_string(), value);
}
/// 这一行要提交的值(草稿优先)。`None` = 没草稿,调用方回落到快照里的值。
pub(crate) fn pending(&self, key: &str) -> Option<String> {
self.drafts.get(key).cloned()
}
/// 丢掉一行的草稿(提交之后用:快照重新成为唯一真值)。
pub(crate) fn clear(&mut self, key: &str) {
self.drafts.remove(key);
}
}
/// Downloads 分组的消息(M101:**只归 download 线**,加变体只改本文件)。
///
/// 落点是 `app/update/settings/download.rs`;`pages/mod.rs` 与 `page_messages.rs` 都不用动(它们只认
/// [`GroupMessage`](super::GroupMessage))。异步结果(例如「测这个源可用」)在自己那条落点里构造
/// `Message::Page(pages::Message::Settings(GroupMessage::Download(…)))` 回来即可。
/// [`GroupMessage`](super::GroupMessage))。
#[derive(Debug, Clone)]
pub enum Message {}
pub enum Message {
/// `choice` 行:选了一个选项 —— 直接提交(选择本身就是意图,没有「半截」状态)。
Set {
/// schema 键(`download.concurrency`)
key: String,
/// 选项原文(`8` / `cn` / `off` 这类,写回时按 schema 的类型转换)
value: String,
},
/// `text` 行:正在输入 —— **只改本地草稿**,不派发。
Edit {
/// schema 键
key: String,
/// 输入框里的整段文本
value: String,
},
/// `text` 行:提交(「应用」按钮 / 回车)。
Commit {
/// schema 键
key: String,
},
/// `action` 行:动作按钮(例如「重新检测地区」)。
Action {
/// 快照里那一行的键(动作 id 由 host 在 `settings::set` 里认)
key: String,
},
}
/// 把本组消息包进设置页那条**唯一**投递口(`pages::Message::Settings(GroupMessage::Download)`)。
///
/// 为什么必须有它:本模块里 `Message` 是本组自己的枚举,而 `Element` 的消息类型是
/// `crate::pages::Message`(别名 [`PageMessage`])。不包这一层,控件会带上本组的消息类型,
/// 于是 `settings/mod.rs` 那个 `and_then` 与页面树就对不上(编译期就红,不会静默丢消息)。
fn msg(message: Message) -> PageMessage {
PageMessage::Settings(super::GroupMessage::Download(message))
}
// --------------------------------------------------------------- 正文
/// 整组正文:快照里按 [`GROUP_ID`] 找到本组,一行一个控件。
///
/// 返回 `None` = 这一帧没有本组的快照(harness / 首帧)⇒ `settings/mod.rs` 退回 `GROUP.rows`。
fn body<'a>(ui: &'a Ui, states: States<'a>, lang: Lang) -> Option<Element<'a, PageMessage>> {
let group = state_group(&states)?;
let drafts = &states.groups.download;
let catalog = lang.catalog();
let mut stacked = column![].spacing(0.0).width(Length::Fill);
for (index, item) in group.rows.iter().enumerate() {
if index > 0 {
stacked = stacked.push(divider(ui));
}
stacked = stacked.push(match item.control {
SettingControl::Toggle => toggle_row(ui, item),
SettingControl::Choice => choice_row(ui, item),
SettingControl::Text => {
text_row(ui, item, drafts, catalog.text(Key::SettingsDownloadsApply))
}
SettingControl::ReadOnly => read_only_row(ui, item),
SettingControl::Action => action_row(ui, item),
});
}
Some(card(ui, stacked))
}
/// 快照里的本组(`States::settings` 是 `Model.settings` 那一份)。
fn state_group<'a>(states: &States<'a>) -> Option<&'a SettingGroupView> {
states.settings.iter().find(|group| group.id == GROUP_ID)
}
/// 一行的标题(`BodyMedium`)。
fn heading<'a>(ui: &'a Ui, item: &'a SettingRowView) -> Element<'a, PageMessage> {
text_role_owned(
ui,
TypeRole::BodyMedium,
false,
item.label.clone(),
ui.color(Role::OnSurface),
)
}
/// 一行的说明(`BodySmall`;P3-5 那句「原值 X 已按出厂 N 生效」就在这段文字里)。
fn hint<'a>(ui: &'a Ui, item: &'a SettingRowView) -> Element<'a, PageMessage> {
text_role_owned(
ui,
TypeRole::BodySmall,
false,
item.description.clone(),
ui.color(Role::OnSurfaceVariant),
)
}
/// 标题 + 控件 + 说明(三行一组的常规排法)。
fn block<'a>(
ui: &'a Ui,
item: &'a SettingRowView,
control: Element<'a, PageMessage>,
) -> Element<'a, PageMessage> {
column![heading(ui, item), control, hint(ui, item)]
.spacing(ui.space(Space::X1))
.width(Length::Fill)
.into()
}
/// 开 / 关两态(本组暂时没有 Toggle 行,但 `SettingControl` 是穷尽枚举,画法在这里定下来)。
fn toggle_row<'a>(ui: &'a Ui, item: &'a SettingRowView) -> Element<'a, PageMessage> {
let key = item.key.clone();
let toggle = switch(
ui,
Switch::new(
&item.label,
item.flag,
msg(Message::Set {
key,
// schema 的 Bool 键认 `true` / `false`(`settings::parse_value` 也认 on/off/1/0)。
value: (!item.flag).to_string(),
}),
),
);
column![toggle, hint(ui, item)]
.spacing(ui.space(Space::X1))
.width(Length::Fill)
.into()
}
/// 分段单选:一个选项一颗 filter chip,选中的那颗是当前值。
fn choice_row<'a>(ui: &'a Ui, item: &'a SettingRowView) -> Element<'a, PageMessage> {
let mut options = row![]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center);
for (index, option) in item.options.iter().enumerate() {
let selected = i32::try_from(index)
.map(|index| index == item.selected)
.unwrap_or(false);
let key = item.key.clone();
let value = option.clone();
options = options.push(chip(
ui,
Chip::filter(option, selected, msg(Message::Set { key, value })),
));
}
block(ui, item, options.into())
}
/// 单行文本 + 「应用」按钮(`on_input` 只改草稿,点按钮 / 回车才写盘)。
fn text_row<'a>(
ui: &'a Ui,
item: &'a SettingRowView,
drafts: &'a State,
apply_label: &'a str,
) -> Element<'a, PageMessage> {
let key = item.key.clone();
let field = Field::new(drafts.text_of(item)).variant(FieldVariant::Outlined);
let input = text_field(ui, field, move |value| {
msg(Message::Edit {
key: key.clone(),
value,
})
});
let apply = buttons::text(
ui,
Button::new(
apply_label,
msg(Message::Commit {
key: item.key.clone(),
}),
),
);
let controls = row![input, apply]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center)
.width(Length::Fill);
block(ui, item, controls.into())
}
/// 只读一行(本组没有;`SettingControl` 是穷尽枚举)。
fn read_only_row<'a>(ui: &'a Ui, item: &'a SettingRowView) -> Element<'a, PageMessage> {
block(
ui,
item,
text_role_owned(
ui,
TypeRole::BodySmall,
false,
item.value_text.clone(),
ui.color(Role::OnSurfaceVariant),
),
)
}
/// 动作按钮(「重新检测地区」)。
fn action_row<'a>(ui: &'a Ui, item: &'a SettingRowView) -> Element<'a, PageMessage> {
let button = buttons::tonal(
ui,
Button::new(
&item.label,
msg(Message::Action {
key: item.key.clone(),
}),
),
);
column![button, hint(ui, item)]
.spacing(ui.space(Space::X1))
.width(Length::Fill)
.into()
}
// --------------------------------------------------------------- 测试探针
/// 这一帧本组要画的**控件种类**(顺序 = 快照里的行序)。
///
/// 存在的理由:`Element` 没有内省接口,「页面画了哪几种控件」只能从行模型上问。测试用它区分
/// 「画了真控件」与「退回了演示行」——这是 `body.is_some()` 之外唯一能看的东西。
///
/// `#[cfg(test)]`:产品路径不需要它(`body` 自己就在画),它只为断言存在。
#[cfg(test)]
pub(crate) fn controls_of(settings: &[SettingGroupView]) -> Vec<SettingControl> {
settings
.iter()
.find(|group| group.id == GROUP_ID)
.map(|group| group.rows.iter().map(|item| item.control).collect())
.unwrap_or_default()
}
/// 快照里本组某一行的**当前值**(choice 取选中的选项、text 取 `value_text`)。
///
/// 与 [`controls_of`] 同一条理由:页面把控件画出来之后,「它认为当前值是几」只能从这个入口问,
/// 不能从 `Element` 里读。
#[cfg(test)]
pub(crate) fn current_value(settings: &[SettingGroupView], key: &str) -> Option<String> {
let row = find_row(settings, key)?;
Some(match row.control {
SettingControl::Choice => row
.options
.get(usize::try_from(row.selected).ok()?)
.cloned()
.unwrap_or_default(),
// 其余三种控件的「当前值」就是 `value_text`(开关那一种用它表示 true/false)。
SettingControl::Toggle
| SettingControl::Text
| SettingControl::ReadOnly
| SettingControl::Action => row.value_text.clone(),
})
}
@@ -303,6 +303,7 @@ mod no_stray_empty_state {
States {
appearance: None,
groups: demo_groups(),
settings: &[],
}
}
+13 -1
View File
@@ -36,6 +36,8 @@
use std::sync::LazyLock;
use unlucky_presenter::dto::SettingGroupView;
use crate::pages::appearance;
use super::{ai_about, download, java, language_appearance};
@@ -55,7 +57,8 @@ pub struct Groups {
pub ai_about: ai_about::State,
}
/// 画一帧时**按值**递给各组渲染器的只读视图:组状态 + 外观运行期状态(两个引用,`Copy`)。
/// 画一帧时**按值**递给各组渲染器的只读视图:组状态 + 外观运行期状态 + presenter 的设置快照
/// (三个引用,`Copy`)。
///
/// 为什么按值而不是按引用:`Settings::view` 返回的 `Element<'a>` 会借走它,按引用传会逼着
/// harness 那条路径([`super::demo_groups`] 撑着的 `page()` / `page_with_appearance()`)为一份
@@ -67,6 +70,15 @@ pub struct States<'a> {
pub appearance: Option<&'a appearance::State>,
/// 各组自己的状态(见 [`Groups`])。
pub groups: &'a Groups,
/// **presenter 的设置组快照**(M201):`CoreEvent::SettingsChanged` 的回流结果,
/// `pages::Model` 那份(`Model::settings`)。空切片 = 这一帧没有快照(harness / 首帧),
/// 组自己的 `body` 据此退回演示行。
///
/// 为什么走这里而不是给 `settings::view` 加形参:`Group.body` 的槽位签名是
/// `fn(&Ui, States, Lang) -> Option<Element>`,它**只看得到 `States`**——下载组要画真控件
/// (`GROUP.body = Some(..)`,见 `settings/download.rs`)就必须从这里拿到快照。加形参那条路
/// 与 `GROUP.body` 互斥,而且要把 `settings/mod.rs`(399 行)再撑出 10 行。
pub settings: &'a [SettingGroupView],
}
/// 演示 / harness 路径的默认组状态(`pages::page` 与 `pages::page_with_appearance` 用它)。
@@ -154,6 +154,7 @@ fn appearance_texts(lang: Lang) -> Vec<(Key, &'static str)> {
States {
appearance: Some(&state),
groups: demo_groups(),
settings: &[],
},
Language {
choice: LangChoice::System,
@@ -1,19 +1,65 @@
//! 「Downloads」分组自己的断言(M100 **新增**:`settings/tests.rs` 那 7 条全是跨 section 的,
//! 一条 section 断言都没有;这 3 条是新增,不是搬移)。
//! 一条 section 断言都没有;这几条是新增,不是搬移)。
//!
//! **归 download 线**。接真控件那天要改**两个**文件(M101 r2 删掉了原先的第 3 处跨 section 绊线):
//! `settings/download.rs` + 本文件。
//! **归 download 线**。M201 之前这一组只有演示行;现在它接了真控件(`GROUP.body = Some(body)`),
//! 数据来自 presenter 的设置快照(`States::settings` ← `pages::Model::settings`)。
//!
//! 别把它和下载页(`pages/download*`)搞混:那是「装哪个版本」,这一组是「怎么下」的默认值。
//!
//! ## 这一轮换了什么(M201)
//!
//! | 旧绊线 | 现在 |
//! |---|---|
//! | `body.is_none()`(「这一组还没接真控件」) | `body.is_some()`(「接了,别再退回去」) |
//! | 只有 `rows` 的语义行断言 | 保留 + 三条**控件形状**断言(按 `SettingControl` 分发) |
//!
//! 写回闭环(点了以后盘上真的变了)不在本文件:那是 `app/update/settings/download_tests.rs`,
//! 它要一个假后端 + `App`,页面层的测试拿不到。
use super::super::download;
use super::super::States;
use unlucky_design::icons::IconKind;
use unlucky_presenter::dto::{SettingControl, SettingGroupView, SettingRowView};
/// 这一组的行集合(标签 + 值)。
/// 一份照 presenter 的 `host::settings::download::group` 形状搭的快照(键与控件种类同序)。
///
/// 为什么自己搭而不是问 presenter 要:那个函数要 `HostEnv`(读盘)且不在 `host` 的公开面上;
/// 本文件守的是**页面按 `control` 字段分发**这件事,不是 presenter 的行文案。
fn snapshot() -> Vec<SettingGroupView> {
vec![SettingGroupView::new("download", "下载与镜像(D7)")
.with(SettingRowView::choice(
"download.concurrency",
"并发数",
"同时下载的文件数",
&["4", "8", "16"],
1,
))
.with(SettingRowView::choice(
"download.retry_limit",
"单源尝试次数",
"同一个源最多试几次",
&["0", "1", "3"],
2,
))
.with(SettingRowView::text(
"download.proxy_url",
"HTTP 代理",
"manual 模式生效",
"",
))
.with(SettingRowView::action(
"download.action.detect_region",
"重新检测地区",
"清掉 24h 地区缓存",
"detect_region",
))]
}
/// 这一组的行集合(标签 + 值)——**没有快照时**画的语义行。
///
/// 抓得住:① 四条线并行合并时把别人的行粘到本文件(六份 `GROUP` 长得几乎一样,最容易整块贴错);
/// ② 漏掉/多出一条行;③ 标签与值错位。
/// 抓不住:值对不对——这三行**本来就不是值**(M193 起改成语义行,见下一条用例)。
/// 抓不住:值对不对(这几行本来就不是值,是语义),也抓不住有快照时画什么(下一条用例管)。
#[test]
fn the_downloads_group_shows_concurrency_retries_and_the_gh_proxy_key() {
let rows: Vec<(&str, &str)> = download::GROUP.rows.to_vec();
@@ -35,29 +81,76 @@ fn the_downloads_group_keeps_its_icon() {
assert_eq!(download::GROUP.icon, IconKind::Download);
}
/// **M193 换了绊线的内容,但没撤绊线**。
/// **M201 翻了这条绊线**:这一组现在有真控件渲染器。
///
/// 原绊线是 `the_downloads_group_is_still_demo_rows`:断言 `body.is_none()`(这一组还没接真控件)。
/// 它存在的意义是「防止演示值回潮」。M193 撤掉的只是**演示值**(`("Concurrency", "8 threads")`
/// 这类):那个 8 与引擎真实的 64 对不上,用户按它调设置自然没效果。
/// 旧绊线 `the_downloads_group_is_still_demo_rows`(`body.is_none()`)存在的意义是「防止演示值
/// 回潮」。M201 之后演示值已经不是问题——有快照就画真控件,没快照才退回语义行——所以绊线翻面:
///
/// 现在改成两条更硬的断言:
/// 抓得住:① 谁把 `GROUP.body` 改回 `None`(真控件当场消失,页面又只剩静态语义行 ——
/// 那正是用户投诉的形态);② 谁把渲染器挪走却忘了同步这里。
/// 抓不住:渲染器**画得对不对**(`Element` 没有内省接口,形状断言在下一条),也抓不住
/// 「有快照时真的走了 body」(`settings/mod.rs` 的 `and_then` 那一层;那一层断了会退回语义行,
/// 界面上看得出来,但没有断言)。
#[test]
fn the_downloads_group_carries_a_real_control_body() {
assert!(
download::GROUP.body.is_some(),
"Downloads 必须挂着真控件渲染器:`body = None` 就是「设置页里改不了」的回归"
);
}
/// **控件形状**:快照里有几种 `SettingControl`,页面就画几种(choice / text / action)。
///
/// 1. **仍然没有真控件**(`body.is_none()`)——真值的通路还没接(`State` 是空 struct、
/// iced 侧没有设置快照),所以这一组还是静态行;接上那天把这条改成 `is_some()` 并接数据。
/// 2. **行里不许再出现「当前值」形态的字面量**:三行必须把「出厂值 / 区间 / 三态」讲清楚,
/// 且 gh-proxy 那一行必须**同时**说清「留空 = 跟随内置默认」与「填 off = 关闭」——
/// 这两者最容易被混(一个「我没决定」,一个是「我明确不要」)。
/// 抓得住:① 渲染器只认 choice(把 text / action 也画成chip 或干脆不画)——`controls_of` 是从
/// 同一份行模型上读的,但**分发**发生在 `body` 里,改成 `match` 单臂会让别的行整段消失;
/// ② 谁把 `SettingControl::Text` 落到 `read_only_row` 上(那时输入框没了,用户改不了文本键)。
/// 抓不住:画出来的像素(那是截图的事),也抓不住每种控件是否真的连了消息(那是 app 层的
/// `download_tests.rs` 与真机截图)。
#[test]
fn the_downloads_body_follows_the_control_kind() {
let groups = snapshot();
assert_eq!(
download::controls_of(&groups),
vec![
SettingControl::Choice,
SettingControl::Choice,
SettingControl::Text,
SettingControl::Action,
],
"控件种类必须照快照的 `control` 字段画"
);
}
/// **没有快照就不许编值**:`controls_of` 空 ⇒ 页面退回 [`download::GROUP.rows`] 的语义行。
///
/// 变异证明:把 `download.rs` 的 gh_proxy 行改回 `("Preferred source", "Automatic (region)")`
/// ⇒ 第 2 条红;把 `enabled` 语义写成「留空 = 关闭」⇒ 第 2 条红。
/// 抓得住:谁为了「让页面好看」在渲染器里塞一份写死的默认快照(那就是 M193 被投诉的演示值,
/// 只不过换了个藏身处)。
/// 抓不住:「空快照时确实走了 `demo_card`」这一步(`settings/mod.rs` 的退回逻辑,它有自己的
/// `frame.rs` 守门)。
#[test]
fn an_empty_snapshot_lists_no_controls() {
assert!(download::controls_of(&[]).is_empty());
// 别的组在场、本组缺席,同样一条控件都不该画(不许拿别人的行凑数)。
let other = vec![
SettingGroupView::new("java", "Java(D13)").with(SettingRowView::toggle(
"java.auto_install",
"缺 Java 时自动下载",
"",
true,
)),
];
assert!(download::controls_of(&other).is_empty());
}
/// 语义行里的「三态 / 区间 / 出厂值」必须还在——它们是**没有快照时**唯一说得清的说明。
///
/// 这一条是 M193 留下的,M201 原样保留:即便有了真控件,兜底那一屏也不许出现假的当前值。
///
/// 抓得住:把 gh_proxy 那两行合并成一句(「留空 = 关闭」这种混法)、或者把区间 / 0-1 语义删掉。
/// 抓不住:有快照时行文案对不对(那时文案来自 presenter,见 `host/settings/download_tests.rs`)。
#[test]
fn the_downloads_rows_state_semantics_and_never_fake_a_current_value() {
assert!(
download::GROUP.body.is_none(),
"Downloads 还没接真控件:接了就改成 is_some() 并接数据通路"
);
let joined = download::GROUP
.rows
.iter()
@@ -93,3 +186,22 @@ fn the_downloads_rows_state_semantics_and_never_fake_a_current_value() {
);
}
}
/// 本组是**有快照就不退回演示行**的那一类:`States` 里带了快照时,`body` 一定拿得到组。
///
/// 站位提醒:`States` 是 `pages::settings::States`(三条线共享的只读视图),本文件只验证
/// 「本组能从它里面找到自己」——找不到就等于退回演示行。
#[test]
fn the_group_finds_itself_in_the_states_snapshot() {
let groups = snapshot();
let groups_state = super::super::demo_groups();
let states = States {
appearance: None,
groups: groups_state,
settings: &groups,
};
assert!(
states.settings.iter().any(|group| group.id == "download"),
"本组的 id 必须在快照里:`download`(presenter 的 `SettingGroupView::new(\"download\", ..)`)"
);
}
+2
View File
@@ -209,4 +209,6 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[
("SettingsJavaPrefsSaved", "Saved and read back from disk: all three keys are in the settings file.", "已保存并从磁盘读回:三个键都进配置文件了。"),
("SettingsJavaPrefsUnsaved", "There are unsaved changes in this section.", "这一段有未保存的改动。"),
("SettingsJavaReasonInstallRootTooLong", "the folder path is too long", "目录路径太长"),
// M201:设置页 Downloads 组的文本行提交按钮(装配链里本域紧随 `settings_java_entries`)。
("SettingsDownloadsApply", "Apply", "应用"),
];
+15 -3
View File
@@ -1,11 +1,23 @@
//! 设置页「Downloads」分组文案:**预留**(页面侧的真控件还没接)。
//! 设置页「Downloads」分组文案(M201 起有内容:这一组接了真控件)。
//!
//! 加 key 时把条目写进 `$($acc)*` 后面即可(形状见 [`crate::catalog`] 的「域装配」一节)。
//! 现在没有条目,所以本宏是**空转移器**:只把累积表原样交给下一个域。
//! 本域在装配链里排在 `settings_java_entries` 之后、`settings_ai_entries` 之前,所以金表那几行
//! 也落在 `SettingsJavaReasonInstallRootTooLong` 与 `SettingsGroupAssistant` 之间。
//!
//! ## 只有一条 key 的理由
//!
//! 下载组的**行文案**(`并发数` / `单源尝试次数` / 「原值 9999 已按出厂 8 生效」这些)不在这里:
//! 它们由 presenter 按 schema 键现算(`dto::SettingRowView::label` / `description`),因为那些句子
//! 里带着**生效值**——把数字搬进静态目录只会再长出一份会说谎的文案。这一域只放「页面自己的
//! 按钮词」:提交文本框那一下的「应用」。
macro_rules! settings_download_entries {
($next:ident, $($rest:ident,)* { $($acc:tt)* }) => {
$next! { $($rest,)* { $($acc)* } }
$next! { $($rest,)* {
$($acc)*
// ── 文本行的提交按钮(`pages/settings/download.rs` 的 text 行)
SettingsDownloadsApply => ("Apply", "应用"),
} }
};
}
+1 -1
View File
@@ -67,7 +67,7 @@ pub use pack::{
ImportState, ImportStatusView, ImportedFileView, MissingFileView, MissingReasonView,
PackCommand, PackEvent, PackFormatView, PackState, ProbeView,
};
pub use settings::{SettingControl, SettingGroupView, SettingRowView};
pub use settings::{find_row, SettingControl, SettingGroupView, SettingRowView};
// M159: 自更新
pub use update::{
ReadyUpdate, UpdateBanner, UpdateCandidateView, UpdateFailureView, UpdateOutcome, UpdateView,
+79 -15
View File
@@ -22,14 +22,15 @@
//! 「读起来是纯读、实际改全局状态」正是本仓吃过亏的那类形状。
use unlucky_core::download::config::{
DownloadTuning, DEFAULT_MAX_ATTEMPTS_PER_SOURCE, DEFAULT_MAX_CONCURRENCY, MAX_CONCURRENCY,
DownloadTuning, GhProxyOverride, DEFAULT_MAX_ATTEMPTS_PER_SOURCE, DEFAULT_MAX_CONCURRENCY,
MAX_CONCURRENCY,
};
use unlucky_core::download::shared;
use crate::dto::{SettingGroupView, SettingRowView};
use super::super::env::HostEnv;
use super::{int, selected, text};
use super::{selected, text};
/// 并发数的推荐档位(`selected()` 找不到时回 0;手改出来的非档位值会被插进选项表,见 [`group`])。
const CONCURRENCY_LADDER: [&str; 5] = ["4", "8", "16", "32", "64"];
@@ -37,19 +38,28 @@ const CONCURRENCY_LADDER: [&str; 5] = ["4", "8", "16", "32", "64"];
const RETRY_LADDER: [&str; 6] = ["0", "1", "2", "3", "5", "10"];
/// 下载(D7):并发/重试/地区/镜像偏好/代理/GitHub 加速前缀。
///
/// ## P3-5:这两行显示的是**引擎生效值**,不是文件原值
///
/// `download.concurrency` / `download.retry_limit` 的生效判定在 core([`DownloadTuning`]):
/// 越界、负数、手改坏值一律**回落出厂值**(不夹到边界——「想 9999 给 64」仍然是界面说谎)。
/// 所以这一组必须画 [`tuning_of`] 的结论;文件里的原值只在**两者不一致**时作为一句解释出现
/// (「原值 9999 已按出厂 8 生效」)。判定的两个输入都是**现读**的:文件原值取自
/// `doc().get(..)`(显式写过的值),生效值取自 core 的翻译层 —— 没有一个数字来自常量或参数。
pub(super) fn group(env: &HostEnv) -> SettingGroupView {
let concurrency = int(
env,
unlucky_core::download::config::KEY_CONCURRENCY,
let tuning = tuning_of(env);
let concurrency = (tuning.max_concurrency as i64).to_string();
let retry = i64::from(tuning.max_attempts_per_source).to_string();
let concurrency_note = applied_note(
raw_int(env, unlucky_core::download::config::KEY_CONCURRENCY),
tuning.max_concurrency as i64,
DEFAULT_MAX_CONCURRENCY as i64,
)
.to_string();
let retry = int(
env,
unlucky_core::download::config::KEY_RETRY_LIMIT,
);
let retry_note = applied_note(
raw_int(env, unlucky_core::download::config::KEY_RETRY_LIMIT),
i64::from(tuning.max_attempts_per_source),
i64::from(DEFAULT_MAX_ATTEMPTS_PER_SOURCE),
)
.to_string();
);
let concurrency_options = ladder(&CONCURRENCY_LADDER, &concurrency);
let retry_options = ladder(&RETRY_LADDER, &retry);
let concurrency_options: Vec<&str> = concurrency_options.iter().map(String::as_str).collect();
@@ -64,18 +74,24 @@ pub(super) fn group(env: &HostEnv) -> SettingGroupView {
.with(SettingRowView::choice(
unlucky_core::download::config::KEY_CONCURRENCY,
"并发数",
&format!(
&with_note(
format!(
"同时下载的文件数:出厂 {DEFAULT_MAX_CONCURRENCY},可调 1~{MAX_CONCURRENCY}(引擎硬上限,防止填 9999 把机器拖死);越界或手改坏值按出厂值生效"
),
concurrency_note.as_deref(),
),
&concurrency_options,
selected(&concurrency_options, &concurrency),
))
.with(SettingRowView::choice(
unlucky_core::download::config::KEY_RETRY_LIMIT,
"单源尝试次数",
&format!(
&with_note(
format!(
"同一个源最多试几次(含首次;出厂 {DEFAULT_MAX_ATTEMPTS_PER_SOURCE})。0 与 1 都等于「只试一次、不重试」——0 是旧版提供的档位,不代表「不下载」。换源不受此限"
),
retry_note.as_deref(),
),
&retry_options,
selected(&retry_options, &retry),
))
@@ -129,10 +145,15 @@ pub(super) fn group(env: &HostEnv) -> SettingGroupView {
))
// M193:gh-proxy 三态。**留空与 off 是两件事**,描述里必须都写出来——用户最容易把
// 「我还没决定」当成「我不要加速」,于是以为自己关掉了、实际还在套前缀。
//
// P3-5:末尾那半句是**引擎生效档位**(`tuning.gh_proxy` 的翻译结论),不是文件原文。
.with(SettingRowView::text(
unlucky_core::download::config::KEY_GH_PROXY,
"GitHub 加速前缀(gh-proxy)",
"留空 = 跟随内置默认(镜像优先/国内判定时自动套 gh-proxy.com);填 off = 关闭(不生成任何 gh-proxy 候选);填一个 URL 前缀 = 覆盖它",
&format!(
"留空 = 跟随内置默认(镜像优先/国内判定时自动套 gh-proxy.com);填 off = 关闭(不生成任何 gh-proxy 候选);填一个 URL 前缀 = 覆盖它;当前生效:{}",
gh_proxy_state(&tuning.gh_proxy)
),
&text(env, unlucky_core::download::config::KEY_GH_PROXY),
))
.with(SettingRowView::action(
@@ -153,6 +174,45 @@ fn ladder(ladder: &[&str], current: &str) -> Vec<String> {
options
}
/// 文件里**显式写过**的原值(`None` = 文件没写这个键)。
///
/// 与 `super::int` 的区别:那个走 `effective()`(文件值 → schema 默认值),本函数只问文件本身。
/// 「原值 X 已按出厂 N 生效」这句话的两个数必须现读(一个来自文件、一个来自 core 的翻译层),
/// **不许**拿常量或请求参数冒充——那是 U1 明令禁止的假证据。
fn raw_int(env: &HostEnv, key: &str) -> Option<i64> {
env.store().doc().get(key).and_then(|value| value.as_i64())
}
/// 文件原值 ≠ 生效值时的说明;`None` = 没什么可说的(没写过,或写的就是生效值)。
fn applied_note(raw: Option<i64>, effective: i64, factory: i64) -> Option<String> {
let raw = raw?;
if raw == effective {
return None;
}
Some(if effective == factory {
format!("原值 {raw} 已按出厂 {factory} 生效")
} else {
format!("原值 {raw} 已按 {effective} 生效")
})
}
/// 说明 +(有差异时)那一句「原值 … 生效」。
fn with_note(base: String, note: Option<&str>) -> String {
match note {
Some(note) => format!("{base};{note}"),
None => base,
}
}
/// gh-proxy 三态的上屏短句(与 [`GhProxyOverride`] 一一对应)。
fn gh_proxy_state(state: &GhProxyOverride) -> String {
match state {
GhProxyOverride::Builtin => "跟随内置默认".to_string(),
GhProxyOverride::Off => "已关闭(off)".to_string(),
GhProxyOverride::Prefix(prefix) => format!("覆盖为 {prefix}"),
}
}
/// 从 `launcher.json` 读出下载调优(默认值与夹取口径在 core,本函数只做「取原始值」)。
#[must_use]
pub(crate) fn tuning_of(env: &HostEnv) -> DownloadTuning {
@@ -176,3 +236,7 @@ pub(crate) fn tuning_of(env: &HostEnv) -> DownloadTuning {
pub(crate) fn apply_tuning(env: &HostEnv) -> bool {
shared::set_tuning(env.layout.cache(), tuning_of(env))
}
#[cfg(test)]
#[path = "download_tests.rs"]
mod tests;
@@ -0,0 +1,209 @@
//! 下载设置组的「**生效值**」守门(P3-5 / P3-3)。
//!
//! 这一组画的是引擎真的会用的那个数,不是文件里那串字节。两者的判定在 core
//! (`DownloadTuning::from_settings`:越界/坏值回落出厂,`0` 落成「只试一次」),这里守的是
//! 「宿主把哪个数交给页面」以及「文件原值与生效值不一致时那句话有没有出现」。
//!
//! 构造真文件:手改坏值绕不过 `store.set` 的 schema 校验,所以测试**直接写 `launcher.json`**
//! ——这正是用户手改文件的现场(`document::load` 对坏值只记 `LoadProblem`、原样保留)。
use super::*;
use crate::dto::find_row;
use std::path::PathBuf;
/// 一个装好「手写 `launcher.json`」的数据根。`json` 是整个文件的顶层对象。
fn env_with(tag: &str, json: serde_json::Value) -> (PathBuf, HostEnv) {
let dir = std::env::temp_dir().join(format!("ull-download-group-{}-{tag}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join(".ULL/config")).expect("config 目录");
std::fs::write(
dir.join(".ULL/config/launcher.json"),
serde_json::to_string_pretty(&json).expect("序列化"),
)
.expect("写 launcher.json");
let env = HostEnv::at_root(&dir);
(dir, env)
}
/// 某一行(走页面同一条 `find_row`)。
fn row<'a>(group: &'a SettingGroupView, key: &str) -> &'a SettingRowView {
find_row(std::slice::from_ref(group), key).unwrap_or_else(|| panic!("{key} 这一行不在组里"))
}
/// 选项表里被选中的那一项(页面正是按 `selected` 下标画选中的 chip)。
fn chosen<'a>(row: &'a SettingRowView) -> &'a str {
row.options
.get(usize::try_from(row.selected).expect("下标非负"))
.map_or("<越界下标>", String::as_str)
}
/// **P3-5 主用例**:文件里是坏值 9999,行上画的是引擎真在用的 8,并把原值写明白。
///
/// 抓得住:① 宿主把文件值当生效值画(改回 `int(env, ..)`)⇒ 选中的 chip 变成 `9999`,红;
/// ② 那句「原值 9999 已按出厂 8 生效」被删掉或写成常量 ⇒ 文案断言红;
/// ③ core 的回落口径被改成「夹到边界 64」⇒ 选中项变成 64,红。
/// 抓不住:界面上这行是否真的画出来(那是 `pages/settings/download.rs` 的 body 与截图的事),
/// 也抓不住用户是否看得懂那句话。
#[test]
fn an_out_of_range_file_value_shows_the_engine_value_and_names_the_raw_one() {
let (dir, env) = env_with(
"concurrency",
serde_json::json!({ "download": { "concurrency": 9999 } }),
);
let group = group(&env);
let concurrency = row(&group, unlucky_core::download::config::KEY_CONCURRENCY);
assert_eq!(
group.id, "download",
"设置页按这个 id 找组(app-iced `pages/settings/download.rs` 的 GROUP_ID);改名会静默退回演示行"
);
assert_eq!(
chosen(concurrency),
"8",
"行上画的必须是引擎生效值(出厂 8),不是文件里的 9999"
);
assert!(
concurrency
.description
.contains("原值 9999 已按出厂 8 生效"),
"文件值与生效值不一致时必须写明原值:{}",
concurrency.description
);
assert_eq!(
tuning_of(&env).max_concurrency,
8,
"同一份文件经 core 的翻译层也必须是 8(两处口径不许漂)"
);
std::fs::remove_dir_all(&dir).ok();
}
/// 文件里写的就是生效值 ⇒ **不许**冒出「原值 …」那句话(否则它是常量而不是判决)。
///
/// 抓得住:`applied_note` 不管三七二十一都返回 `Some`(把那句话变成装饰)。
/// 抓不住:句子出现的**位置**(埋在哪一段文案里)。
#[test]
fn a_file_value_that_matches_the_engine_value_gets_no_note() {
let (dir, env) = env_with(
"in-range",
serde_json::json!({ "download": { "concurrency": 2, "retry_limit": 5 } }),
);
let group = group(&env);
let concurrency = row(&group, unlucky_core::download::config::KEY_CONCURRENCY);
let retry = row(&group, unlucky_core::download::config::KEY_RETRY_LIMIT);
assert_eq!(chosen(concurrency), "2");
assert_eq!(chosen(retry), "5");
for row in [concurrency, retry] {
assert!(
!row.description.contains("原值"),
"没有差异就不该有那句话:{}",
row.description
);
}
std::fs::remove_dir_all(&dir).ok();
}
/// `retry_limit = 0`(旧版档位)落成「只试一次」:行上画 1,并说清原值是 0。
///
/// 抓得住:把 `0` 原样画上屏(那时引擎按 1 跑,界面说 0 —— 又一个「设置项说假话」);
/// 或者把这句话写成「按出厂 3 生效」(实际生效值是 1,不是出厂 3)。
/// 抓不住:用户是否理解 0/1 同义(那是文案的事,本行 base 段已有说明)。
#[test]
fn a_zero_retry_in_the_file_is_shown_as_one_and_says_so() {
let (dir, env) = env_with(
"retry-zero",
serde_json::json!({ "download": { "retry_limit": 0 } }),
);
let group = group(&env);
let retry = row(&group, unlucky_core::download::config::KEY_RETRY_LIMIT);
assert_eq!(chosen(retry), "1", "0 生效为 1(只试一次)");
assert!(
retry.description.contains("原值 0 已按 1 生效"),
"必须写明 0 落成了 1(不是出厂 3):{}",
retry.description
);
assert!(
!retry.description.contains("原值 0 已按出厂"),
"1 不是出厂值(出厂 3),措辞不许混"
);
std::fs::remove_dir_all(&dir).ok();
}
/// **P3-3(选了「在下载设置页补一条 gh_proxy 端到端链」这一支)**:
/// 文件里的 `download.gh_proxy` → `store` → [`tuning_of`] → core 的三态 → 行上的值与那句
/// 「当前生效:…」。三档各走一遍。
///
/// 抓得住:① 键名读错(例如 `download.gh-proxy`)⇒ 三档全部退回 `Builtin`,`off`/`prefix` 两档红;
/// ② 三态翻译被拍平(`off` 也当 `Builtin`)⇒ 第二档红;③ 行上只画文件原文、不画生效档位
/// ⇒ 那一句文案红(页面上「填了 off 却没关」的那种谎正是它兜的)。
/// 抓不住:候选链里到底生成了什么(那是 `shared.rs` 的 `expand()`,core 侧已有用例),
/// 也抓不住真下载时是否走那条链(要网络)。
///
/// 为什么选这一支而不是拆 core 的 `gh_proxy_override_reaches_the_profile_and_the_chain`:
/// 那个文件不在本 mission 的 scope(`crates/core/**`),拆它得由 core 线另立 mission;
/// 而「设置文件 → 宿主 → 三态」这一段的缺口正是本 mission 要接的路,补在这里能证明整条链
/// 从用户手写的那串字节开始就是通的。
#[test]
fn the_gh_proxy_key_travels_from_the_file_to_the_core_tri_state() {
use unlucky_core::download::config::KEY_GH_PROXY;
// ① 留空 = 跟随内置默认(空串不是「关」)。
let (empty_dir, empty_env) = env_with(
"gh-empty",
serde_json::json!({ "download": { "gh_proxy": "" } }),
);
let empty_group = group(&empty_env);
let empty_row = row(&empty_group, KEY_GH_PROXY);
assert_eq!(tuning_of(&empty_env).gh_proxy, GhProxyOverride::Builtin);
assert_eq!(empty_row.value_text, "", "文件里的原文照旧回填");
assert!(
empty_row.description.contains("当前生效:跟随内置默认"),
"空串必须说成「跟随内置默认」而不是「关闭」:{}",
empty_row.description
);
std::fs::remove_dir_all(&empty_dir).ok();
// ② 填 off = 关(不生成任何 gh-proxy 候选)。
let (off_dir, off_env) = env_with(
"gh-off",
serde_json::json!({ "download": { "gh_proxy": "off" } }),
);
let off_group = group(&off_env);
let off_row = row(&off_group, KEY_GH_PROXY);
assert_eq!(tuning_of(&off_env).gh_proxy, GhProxyOverride::Off);
assert_eq!(off_row.value_text, "off");
assert!(
off_row.description.contains("当前生效:已关闭(off)"),
"填了 off 就必须说「已关闭」:{}",
off_row.description
);
std::fs::remove_dir_all(&off_dir).ok();
// ③ 填 URL 前缀 = 覆盖它。
let (prefix_dir, prefix_env) = env_with(
"gh-prefix",
serde_json::json!({ "download": { "gh_proxy": "https://gh.example/" } }),
);
let prefix_group = group(&prefix_env);
let prefix_row = row(&prefix_group, KEY_GH_PROXY);
assert_eq!(
tuning_of(&prefix_env).gh_proxy,
GhProxyOverride::Prefix("https://gh.example/".to_string())
);
assert_eq!(prefix_row.value_text, "https://gh.example/");
assert!(
prefix_row
.description
.contains("当前生效:覆盖为 https://gh.example/"),
"覆盖档必须把生效的前缀写出来:{}",
prefix_row.description
);
std::fs::remove_dir_all(&prefix_dir).ok();
}