Skip to content

dev → main: 终端弹层透明度可配置、MCP 工具名净化、SFTP 短读与个人同步等多处修复 - #367

Merged
feigeCode merged 18 commits into
mainfrom
dev
Oct 9, 2026
Merged

feigeCode merged 18 commits into
mainfrom
dev

Conversation

@feigeCode

Copy link
Copy Markdown
Owner

Closes #73
Closes #194
Closes #359
Closes #362
Closes #363

Description

dev → main 集成分支汇总,共 16 个提交(另含一次 main 回合并 fdfe845a7)。
91 files changed, 3700 insertions(+), 634 deletions(-)。

1. 终端(terminal_view)

2. public_mcp —— 线上工具名净化(#194,508e98c0b)

MCP 客户端把工具名里出现 [A-Za-z0-9_-] 之外字符(例如 ssh.command.poll 中的点)
的名字判定为非法并整批丢弃,表现为「Grok build 连上了 MCP 却拿不到任何工具」。
PublicMcpToolRegistry 增加一层对外视图(client_tools / client_tool /
resolve_client_tool_name):tools/list 只暴露净化后的名字,tools/call
同时接受净化名与内部原始 id,registry 内部 id 一律不变。

3. sftp —— 短读不再让整条下载失败(#363,7a4fa5cad + 9bc237cdb)

跳板机场景下 SFTP 一次读只回 32 KiB(请求 61440),原实现按整块长度校验,
短读即判定失败并终止整条下载。改为按实际返回长度推进,并补一条协议层回归测试:
用 tokio::io::duplex() 把真服务端与客户端在同一进程里对接,假服务端一次只回
32 KiB,复现 #363 的短读路径(含对照组,防止用例空转通过)。

4. db_view —— 刷新节点不再先清空子树(#362,bcede3b2c)

过滤状态下点刷新会先清空子树再拉取,导致被过滤掉的表短暂「消失」。

5. personal-sync(4 个提交)

  • 目录 / Git / WebDAV 后端补上真正的跨实例互斥(4c8fe22ce)
  • 本地删除遇上「远端之后又改过」时挂冲突,而不是直接抹掉(30b25a6d0)
  • 本地删除连接 / 分组 / 凭据时清掉遗留的同步冲突(034fb8230)
  • 本地条目消失后冲突不再永远解不开(663a6571e)

6. remote_desktop_view —— macOS 文件剪贴板(3 个提交)

72782fcb4 + ed070f033:改用经典 declareTypes 序列写入 macOS 文件剪贴板,
并发布 NSFilenamesPboardType,让「已安装的文件列表」保持权威,避免远端迟到的
文本通告把它覆写成纯文本;a4007dfe7 消掉 -D warnings 拦下的两处告警。

7. 其他

Screenshot

本次改动以终端 / 表格 UI 行为为主,未附截图,以下为文本对照(非视觉 diff):

Before After
弹层背景固定 0.72 不透明度,下层文字透出 28% 默认 0.96,侧边栏可调 0.5–1.0
文件传输面板开关状态难辨、无「定位到终端目录」 状态可辨 + 新增该入口
Grok 连接 MCP 后工具列表为空 正常返回净化后的工具名
过滤状态下刷新导致表「消失」 不再先清空子树

How to Test

RUSTFLAGS="-D warnings" cargo check --workspace --all-targets
cargo test -p one-core -p one-ui -p db_view -p terminal_view --lib
cargo test -p main

本地实测结果(合并 main 之后重跑):

  • cargo check --workspace --all-targets(-D warnings)通过,无 error / warning
  • one-core 789 passed / one-ui 892 passed(1 ignored)/ db_view 123 /
    terminal_view 583 / main 661 + 1 + 1 —— 全部 0 failed
  • main 回合并无冲突

弹层不透明度一项另做了变异验证(6/6 变异体被测试杀死):helper 退回写死 0.96 /
渲染层绕过 helper / 旧配置缺字段回落 0.0 / normalize 不再夹紧 / from_parts 写死 /
不回写 settings.json。

Checklist

  • I have read the CONTRIBUTING document and followed the guidelines.
  • Reviewed the changes in this PR and confirmed AI generated code (If any) is accurate.
  • Passed cargo run for story tests related to the changes.
    —— navop 本体无 story 测试机制(story 在 gpui-component 仓),本 PR 未涉及;改用
    cargo check --workspace --all-targets + 上述 crate 单测覆盖。
  • Tested macOS, Windows and Linux platforms performance (if the change is platform-specific)
    —— macOS 剪贴板改动只在本机 macOS 上编译与单测验证,Windows / Linux 未实机验证。

AI Assistance

本 PR 是 dev 集成分支的汇总,其中相当一部分修复与测试由 AI(WorkBuddy)辅助产出,
均已逐行人 review 并跑本地测试:

  • AI 产出:#73 的不透明度可配置链路(设置字段 → TerminalSettings 映射 →
    侧边栏滑块 → 事件 → 视图回灌 → 渲染 helper,共 6 段接线)及其源码锚点测试;
    #194 的 client_tools / client_tool / resolve_client_tool_name 与协议级测试;
    #363 的 tokio::io::duplex() 复现测试;#359、#362 的改动与断言。
  • 人工复核:逐行 review diff;调用点用 grep 逐条核实;对新增的对外接口
    (MCP 工具名净化)确认内部 id 未被改动、tools/call 向后兼容。
  • 验证方式:并非「生成即提交」——每个修复都配了会失败的断言,并用变异测试
    反向确认测试确实能抓住坏代码(#73 6/6、#194 6/6)。
  • 76cb55f6f(rustfmt 重排)为机械格式化,无生成式逻辑。

…alled files authoritative

The macOS file clipboard writer published file URLs via writeObjects plus
a plain-text path fallback. GPUI's pasteboard reader only restores
ExternalPaths from the legacy NSFilenamesPboardType, so the reader saw a
plain string: the 500ms local sync echo-pushed the clipboard back to the
remote (rdpclip churn: Lock/Unlock PDUs right after every transfer), and
Finder had no reliable classic representation to paste from, showing the
'already pasted <date>' text file instead of the real file.

Write the legacy filenames property list alongside the modern file URLs
(after writeObjects so it is not cleared), and stop honouring remote text
announcements while installed files remain the authoritative clipboard
content - rdpclip re-announces a copied file's text long after the
transfer (observed ~15s), beyond the previous 3s window.
…ssic declareTypes sequence

writeObjects(NSURL) publishes no public.file-url in practice here: the
pasteboard only ends up with plain-text types, so Finder pastes an
'already pasted <date>' text file and GPUI's reader (which only restores
ExternalPaths from NSFilenamesPboardType) falls back to the path string,
making the 500ms local-clipboard sync echo the freshly installed files
back to the remote as text.

Reproduced with a standalone probe: after writeObjects the only types are
public.utf8-plain-text/NSStringPboardType, and a following
setPropertyList:forType: returns false; with declareTypes +
setPropertyList(NSFilenamesPboardType) the pasteboard carries the full
file representation (NSFilenamesPboardType, public.file-url, Apple URL
pasteboard type). Use that sequence, and log the install at info level so
the host side can be verified from navop.log.
冲突表的主键是 (profile, data_type, 云端 record_id),但解决冲突一直硬解
local_snapshot 里存的那条本地行 id。本地行一旦消失(用户在界面里删掉它,
或它被远端墓碑处理顺手删掉),两个按钮就都废了:

  使用远程版本 -> personal sync io error: Connection 22 not found or
                  credential revision exhausted
  使用本地版本 -> personal sync parse error: connection not found: 22

而且冲突会永远躺在表里(planner 按 paused_record_keys 跳过它),用户只能
手改 sqlite 才出得来。

三处改动:

1. 解析冲突前按 (data_type, cloud_id) 现场重解析本地条目,与 planner / worker
   判定本地条目的口径一致;本地条目已不存在时,「使用远程版本」走插入(远端
   胜出),「使用本地版本」把本地删除意图推到云端(tombstone)。
2. 处理远端墓碑时跳过已挂起冲突的记录 —— 之前会把冲突指向的那条本地行照样
   删掉,冲突瞬间变悬挂;同时补上「本地自上次同步后改过」的
   LocalModifiedRemoteDeleted 分支,连接不再被无条件静默删除(这个枚举以前
   从来没被产生过)。本轮新挂起的记录也一并排除出 planner,避免同一次 pass
   一边记冲突一边把记录推回云端。
3. 本地删除事件清掉该记录遗留的冲突(sink 新增 forget_record)。

新增用例全部做了变异验证:把对应修复改回旧行为,用例即红。

Refs #361
本地条目被删掉之后,它遗留的冲突就再也解不开了(解析要按云端 id 反查本地
条目);更糟的是这时点「使用远程版本」会把刚删掉的条目重新拉回来。

三处删除入口(连接列表 / 分组侧栏 / 凭据保险库)在删成功、发删除事件之前,
按 (data_type, cloud_id) 清掉冲突。用 sqlite 端到端用例锁住:
只清目标记录,同云端 id 的另一种 data_type 和别的云端 id 都不受影响。

Refs #361
dev HEAD(`ed070f033`)在 CI 的警告门禁下编不过,两处都在这个文件的导入语句上,
与本次要发的改动无关,但会让任何 `dev -> main` 的 PR 直接红:

  - `NSFilenamesPboardType` 已 deprecated,而 `#[allow(deprecated)]` 加在函数上
    覆盖不到顶层的 `use`,必须单独给这条导入放行(Finder 与 gpui_macos 的
    pasteboard 读回都只认这个旧类型来还原 ExternalPaths,不能改成新 API)。
  - `NSURL` 只有测试用例在用,非测试构建会报 unused import;改成 `#[cfg(test)]`
    导入。

验证:`RUSTFLAGS="-D warnings" cargo check -p remote_desktop_view --all-targets`
通过,全仓 `cargo test --all --no-run` 亦通过。
`LocalDeletedRemoteModified` 这个冲突类型(UI 文案与默认策略早就写好)从来没被
产生过。原因是本地删除事件(`PersonalSyncEvent::LocalDeleted`)只带
data_type + cloud_id,判不出「远端在我们删除之后是不是又被别的设备改过」,
于是 worker 只能无条件把远端记录推成墓碑 —— 对方刚做的改动就被静默抹掉了。

改动:

1. 删除事件带上 `last_synced_at`:删除前那条本地行记录的同步基线(远端
   `updated_at` 的秒值,与 planner 判定 remote_changed 的口径一致)。三处删除
   入口(连接列表 / 分组侧栏 / 凭据保险库)都在真正删行**之前**把基线读出来,
   同时随 `ConnectionDeleted` / `WorkspaceDeleted` / `CredentialDeleted` 事件上抛。
2. `apply_local_delete_events` 分三种情况:远端已是墓碑 ⇒ 无事可做;远端在基线
   之后被改过 ⇒ 挂 `LocalDeletedRemoteModified` 并计入本轮冲突数;否则照旧推墓碑。
3. 这种冲突没有本地行,所以 `PersonalSyncRecordConflict.local_id` 改成
   `Option<String>`:挂起时只存远端快照,解决时按 (data_type, cloud_id) 现场反查
   本地条目(上一提交已经做到「本地条目不存在也能解」)。

验证(本提交单独成立):`RUSTFLAGS="-D warnings"` 下 one-core 个人同步 106
passed、main personal_sync 25 passed;变异验证两个方向都试过 ——
把判决函数改成恒 false(少挂冲突)只有「远端改过要挂冲突」那条转红,改成恒 true
(多挂冲突)则 5 条「远端没变要照旧推墓碑」的用例全红。

Refs #361
`acquire_lock` 原来是永远成功、且持有者从不释放的空实现(目录后端只是把
`SyncStoreLock` 造出来,WebDAV 后端的 `_lock` 直接丢掉)。但这两类后端都可能被
同一台机器上的两个 navop 实例,甚至被两台机器通过 Dropbox / iCloud 共享同一个
目标目录指向,那时两边会并发做「读记录 → 改 → 写回」,单文件原子写保不住版本。

改动:

1. `SyncStoreLock` 从「只有 owner 字段的公开结构体」改成不透明的锁句柄,两条释放
   路径:本地后端挂 `Drop` 回调(pass 结束、包括提前 `?` 返回都会释放),远端后端
   把凭据放在 `token` 里由 worker 显式调 `PersonalSyncStore::release_lock`(`Drop`
   里 await 不了)。新增 `store.release_lock` 默认实现,worker 的 `run_pass` 拆出
   `run_locked_pass` 以便在 pass 结束后统一释放。
2. 目录后端(Git 后端复用):`create_new` 原子创建锁文件 + nonce + 300s TTL;
   已被占且未过期 ⇒ `LockTimeout` 让路;确认过期才抢占,抢占用「临时文件 + rename」
   再回读 nonce 确认,两个实例同时判断出「已过期」时只有回读到自己的 nonce 的算抢到。
   释放前也比对 nonce,避免 TTL 过期后把别人新拿到的锁删掉。锁文件放在同步包
   **之外**(`.onetcli-sync.lock`),否则 Git 后端会把它 `git add` 提交上去。
3. WebDAV 后端:读现有锁 → 阻塞则让路;PUT 自己的锁后**回读确认 nonce**(服务端
   普遍没有原子创建,只能靠回读裁决);释放用 PUT `released: true` 而不是 DELETE
   (有些服务端对 DELETE 直接 405),且只释放 nonce 还是自己的那一次。
4. 锁基础设施本身失败(读不到 / 写不进)一律降级成「本轮不加锁继续」,不把本来
   能跑的同步变成失败;`LockTimeout` 也不再被状态栏当成存储故障(它只是这一轮
   让路,下一轮自动重试)。

验证:`RUSTFLAGS="-D warnings"` 下 one-core 个人同步 116 passed、main
personal_sync 27 passed;变异验证覆盖三处关键判定(目录后端未过期不得抢占、释放
时必须比对 nonce、WebDAV 回读 nonce 不匹配必须让路、释放不得清掉别人的锁、
`LockTimeout` 不得算存储故障),每个变异体都确认**能编译**、且只有对应用例转红。

顺带(仅排版):`PersonalSyncEvent` 的 `LocalDeleted` 因新增第 3 个字段触发了
rustfmt 的枚举变体展开规则,同枚举里的 `LocalChanged` 一并展开。

Refs #361
流水线下载按固定偏移、固定长度(61440 字节)并发读分片,并要求服务端一次
读满。但 SFTP 协议只保证「不超过请求长度」,短读是合法应答:经堡垒机/隧道
(issue #363 是 JumpServer)时服务端会真的只回一部分(请求 61440、拿到
32768),于是整条下载以 `SFTP short read at offset 368640: received 32768
bytes, expected 61440` 失败——报障者看到的正是这个。

改成在同一个分片内按已读到的偏移续读,直到凑满 expected_len:短读被补齐
而不是被当成错误,下游「长度恰好、连续写入」的约定完全不变(连续写、进度
累计、validate_chunk_len 都不用动)。空读(远端提前结束)与「返回超过请求
长度」仍按错误处理,不会静默错位。

验证:
- cargo test -p sftp --lib:98 passed(含新增 5 个用例)
- RUSTFLAGS="-D warnings" 下无新增告警
- 变异验证 4 个变异体全部被捕获,且都先确认变异体本身能编译:
  M1 砍掉续读循环     -> a_short_read_is_read_again_until_the_chunk_is_complete 转红
  M2 超长响应放行     -> a_read_longer_than_requested_is_rejected 转红
  M3 空读当作读完了   -> an_empty_read_is_reported_as_eof_instead_of_looping 转红
  M4 EOF 状态码不映射 -> an_eof_status_is_reported_as_eof 转红
- 未验证:真实堡垒机链路(需要 JumpServer 环境);本机无法复现短读。
refresh_tree 会先 clear_node_descendants + reset_node_children 把整棵子树从
db_nodes 里删掉,再异步重新拉取。这期间界面上这个节点是空的:被过滤/搜索命中
的表会连带整条命中分支一起「消失」一下,等懒加载回来才重新长出来(报障者的
截图就是过滤 TOP 之后右键刷新,命中的表瞬时不见)。

改成刷新只清掉「已加载」标记(让懒加载重新拉一次),已有子树继续显示;新数据
到达时才由新的 install_loaded_children 整体替换——先递归回收旧后代、再挂上新的,
所以既不闪烁,也不会在 db_nodes 里留下从根走不到的孤儿节点。clear_node_descendants
的其余调用点(切换库/断连/删节点)行为完全不变。

顺带修好同一根因的另一面:刷新一个**收起**的库/模式节点后,旧实现把 children
清空且 children_loaded=false,而 node_has_children 对 Connection/Database/Schema
直接返回 false ⇒ 该节点再也长不出展开箭头;现在旧子节点还在,箭头与展开都正常,
点开时照样重新拉取。

验证:
- cargo test -p db_view --lib:888 passed / 0 failed;RUSTFLAGS="-D warnings" 无新增告警
- 新增 3 个用例:
  refreshing_a_filtered_node_keeps_the_matching_table_visible
    (gpui 测试,直接复现报障场景:过滤后刷新,命中的表与列目录必须还在)
  installing_new_children_recycles_the_replaced_subtree
    (替换要回收旧子树,且不碰其它分支)
  removing_descendants_reaches_grandchildren_and_keeps_the_node_itself
    (递归回收要挖到孙辈,并保留节点自己)
- 变异验证 4 个变异体全部被捕获,且都先确认变异体本身能编译:
  M1 刷新时清空子树 / M2 替换时不回收 / M3 回收不递归 / M4 忘标记已加载
- 未验证:真实 GUI 观感(本机没有可复现的数据库实例,行为由 gpui 测试钉住)
受限设备(华为 S5720 交换机等防火墙/交换机)掐断传输层后不会立刻回收自己
那边的会话记录。原来的降级重连是「失效 transport 后零延迟重连一次」,第二次
往往被同一原因再掐一次,于是错误直接抛到界面上——用户看到的就是
"Connection Lost: ... while a session channel was being opened: Disconnected",
过几秒手动按 Enter 重连又正常(那时设备已经回收了旧会话)。

改动(crates/terminal/src/ssh_backend.rs):
- 退化重连改成有界退避重试:失败后等 1s、3s 再以单个裸交互 channel 重连
  (DEGRADED_RECONNECT_BACKOFFS),预算用尽才如实返回错误,不会无限重连;
- 每次重试打一条 warn(attempt / backoff / probe_killed_transport / invalidated /
  error),下次同类问题从日志即可确认重试是否发生、是否用尽;
- 重试日志统一到一处,探测掐断传输层与 channel open 被拒不再各自打不同文案;
- 修正 add_connect_error_context 的提示:不再只把用户往「VTY 并发上限」上引
  (案发设备自报 10 上限 / 1 在线),改为说明设备会短暂保留已断开的旧会话,
  建议几秒后重试,或对该连接勾选「禁用 Shell 集成」。

验证:
- cargo test -p terminal --lib ssh_backend::tests:39 passed / 0 failed
- 新增 2 个用例:
  establish_channel_retries_degraded_channel_until_device_releases_session
    (探测与紧随其后的重连都被掐断,退避后的重试才拿到交互 channel;
      被掐断的 transport generation 每次都要失效)
  establish_channel_reports_disconnect_after_degraded_retries_are_exhausted
    (重试预算有界:耗尽后如实报错,并仍带可诊断上下文与原始 Disconnected)
- 4 个既有重连用例改在 start_paused 时钟下跑,退避不真实等待(本模块 0.02s 跑完)
- crates/terminal 新增 dev-dependency:tokio test-util(仅为暂停时钟)
- 未验证:真实华为交换机上的首连复现(没有设备,也没有现场日志)
本机 rustfmt 与仓库基线版本不同,`cargo fmt` 在整仓重排了 52 个文件:主要是
`pub mod` / `use` 的排序与少量换行,与本次 SSH 修复无逻辑关联。

已核对:在 HEAD 的临时 worktree 里重跑一遍 rustfmt,它单独改动出来的文件集合
与这 52 个完全一致(两个方向都无差异)⇒ 这些文件相对 HEAD 只差格式化,不含
其它未提交改动。
#363 的修复此前只有「分片续读」这一层的单元测试:`read_chunk_fully` 的入参是
一个闭包,短读是被喂进去的假数据,真实协议往返(open → read → close、并发
分片、按偏移落盘)从未被跑过。这道 e2e 把它补上。

做法:用 `russh_sftp::server` 起一个真的 SFTP 服务端,只把 `read` 覆写成
「一次最多回 32 KiB」(JumpServer 的形态),接在 `tokio::io::duplex` 的另一端,
让 `pipelined_read_into_writer` 走完整协议往返写进临时文件。不开端口、不依赖
网络或外部服务。

- a_short_reading_server_still_yields_a_byte_exact_download
  10 个分片(9 满 + 1 残,>512 KB 的流水线阈值)下载后逐字节相等。
  用例自带三道「场景真的成立」的断言,避免变成空转:必须真的出现过
  「请求 61440 / 只回 32768」、服务端不得回超过请求长度、每个满片只补读一次
  (9*2+1 = 19 次读请求)。
- a_well_behaved_server_still_downloads_without_extra_requests
  对照组:老实服务端不触发续读(10 个分片恰好 10 次请求),既证明修复没有给
  正常链路增加往返,也兜住「假服务端自身是坏的」这种情况。

RED 反证:把 `read_chunk_fully` 的续读退回修复前语义(短读即整条失败)后,
主用例转红并复现报障者那条错
`SFTP short read at offset 0: received 32768 bytes, expected 61440`,
对照组仍绿;已还原。

验证:cargo test -p sftp --lib = 100 passed / 0 failed(新增 2 个);
RUSTFLAGS="-D warnings" 无新增告警;两个文件 rustfmt 零偏差。
工具栏上「自动跟随终端目录」和「隐藏文件」两个开关此前只用 bg(hover)
标出开启状态,而 hover 本身就是鼠标悬停色、图标又恒为次要前景色,开与关
几乎长得一样。新增 toggle_icon_color():开启用强调色,关闭退回次要前景色。

另外补一个「定位到终端目录」按钮,方向与既有的「同步终端目录」相反
(面板当前目录 → 终端 cd),复用已有的 FileManagerPanelEvent::CdToTerminal。
不可用时(未连接 / 跨主机目标)置灰并禁用点击。

验证:cargo test -p terminal_view --lib 579 passed / 0 failed;
6/6 变异体被杀(含 payload 漂移、丢掉两个守卫条件、开关恒同色)。
内部工具 id 用 `.` 分层(`ssh.command.poll`),此前原样暴露在 tools/list
里。Grok 注册函数时只接受 [A-Za-z0-9_-],带点的名字被整批丢弃,于是「连上
了却一个工具都查不到」。

ACP 侧早已由 agent_runtime 的 ToolName/ToolNameAllocator 做过净化,只有公开
MCP 服务端漏了。这里复用同一套规则、同一个「先按内部 id 排序」的分配顺序,
所以同一工具在 ACP 与 MCP 两端名字一致,冲突消解(sample.echo 与
sample_echo → sample_echo / sample_echo_2)也可复现。

内部 id 一律不动,只在 PublicMcpToolRegistry 增加对外视图:
- client_tools():tools/list 用的清单(名字已净化)
- client_tool(name):单个查询
- resolve_client_tool_name(name):回翻成内部 id,同时接受净化名与原始 id,
  让仍允许 `.` 的客户端(Claude 等)照旧调用

验证:public_mcp 全部 24 个 target 绿;6/6 变异体被杀(清单退回带点 id、
调用不回翻、丢掉内部 id 回退、对照表不排序、清单不排序、解析优先级反转);
下游 terminal_view / main 的 --all-targets check 通过(-D warnings)。
命令提示 / cd 补全 / 历史搜索共用同一个下拉弹层,其背景此前按 0.72
透明度绘制,下层终端文字以 28% 的权重透进弹层 —— 与弹层自身文字的对比度
同量级,亮色主题下整层内容几乎不可读(issue #73 截图反解:弹层内透出的
目录蓝 = 0.72×背景 + 0.28×文字,与常数完全吻合)。

0.72 是 issue #5 时代为「不遮挡终端内容」从 0.88 特意调低的,属于同一个
弹层的两个相反诉求。现在取 0.96:透出权重 28% → 4%(降为 1/7),弹层文字
恢复可读,同时保留少量下层内容痕迹,不回到 #5 抱怨的完全遮挡观感。
补源码断言锁住渲染层必须走治理 helper,不允许裸用背景色。

验证:cargo test -p terminal_view --lib 579 passed / 0 failed;
3/3 变异体被杀(退回 0.72、改成 1.0、渲染层绕过 helper)。
命令提示 / cd 补全 / 历史搜索共用同一个下拉弹层,其背景此前是一个写死的
0.72 常量:issue #5 嫌它遮挡终端内容,把它从 0.88 调低到 0.72;而 0.72 会
透出 28% 的下层文字,与弹层自身文字的对比度同量级,亮色主题下整层几乎不
可读(issue #73,截图像素反解与常数完全吻合)。两个诉求相反,任何一个常数
都无法同时满足,因此改成用户在终端侧边栏设置里自己定,默认 0.96(透出 4%)。

链路共 6 段,每段都有源码锚点测试锁住,避免任何一环漏接导致静默失效:
AppSettings::terminal_suggestion_popup_opacity(新字段,默认 0.96、范围
0.5–1.0,normalize 兜住手改 settings.json 的越界与 NaN)
→ TerminalSettings::from_parts
→ 终端侧边栏设置面板「弹层背景不透明度」滑块(拖动中只重绘百分比读数,
松手才写回设置,避免一路拖动把 settings.json 写十几遍;候选词开关关掉时
滑块置灰)
→ SettingsPanelEvent/TerminalSidebarEvent
→ TerminalView::set_suggestion_popup_opacity
→ 回灌视图(apply_settings_snapshot 同步侧边栏与 self)
→ history_prompt_dropdown_background(background, opacity)

验证:cargo test -p terminal_view --lib 583 passed / 0 failed;
cargo test -p one-core --lib 788 passed / 0 failed;
6/6 变异体被杀(helper 退回写死 0.96、渲染层改用写死 0.72、老配置缺字段
回落到 0.0、normalize 不再夹紧、from_parts 写死、不回写 settings.json);
改动文件 rustfmt 零偏差。
面板原先把最多 1000 条记录(每条一个 Popover + Button)全量铺进 element
树,约两万个元素。打开「执行记录」后 SQL 编辑器每敲一个字都要重建它们,
表现为「每打一个字卡一下」,清空记录后立刻消失。

- 列表换成 uniform_list,只渲染可见的十来行;行高抽成固定常量
  EXECUTION_RECORD_ROW_HEIGHT(按钮 88 + 行间距 8)—— uniform_list 要求
  所有行等高,又不支持 item 间距,行间距只能并进行高里。
- 「过滤 + 倒序」的下标映射抽成纯函数 visible_record_indices,渲染回调只按
  原始下标回查记录,不再为整份列表克隆记录(sql/details 都是字符串)。
- render_record 改回 AnyElement:impl IntoElement 会捕获入参生命周期,让
  uniform_list 回调的返回类型收敛不到单一的 R,编译不过。
- 补 4 条契约测试:3 条源码锚点(虚拟化 + 固定行高 + 按下标回查)+ 1 条
  「过滤后仍保留原始下标」的行为测试。变异 6/6 被杀。
@feigeCode
feigeCode merged commit 4e6562f into main Oct 9, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant