diff --git a/CLAUDE.md b/CLAUDE.md index b926161..f786d7f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,7 +74,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - `install.rs`:纯函数 `detect` 决定 `InstallKind`(`MacBundle` / `WindowsSetup` / `AppImage` / `Unsupported(..)`:开发构建、App Translocation、DMG 里运行、没写权限、不是安装程序装的、不是 AppImage);`Installer` trait 的 `stage` 在工作线程上做慢的部分,`apply` 在 UI 线程上只改名或准备一个进程,返回 `Relaunch::{Restart(path), Hand(Launch), Nothing}`。平台实现 `install_macos.rs`(`ditto` 解到 app 旁边同一卷上的 `.ShellRS.app.update-<版本>`,`codesign` 核对 Team ID,两次改名交换,第二步失败就换回去)、`install_windows.rs`(隐藏的 PowerShell 等 ShellRS 退出后静默运行新版本的安装程序,再按需启动 ShellRS)、`install_linux.rs`(复制到 `$APPIMAGE` 旁边,一次改名覆盖;重启用 `$APPIMAGE`,current_exe 是会消失的挂载点)都在 `cfg(test)` 下也编译,Mac 上能跑它们的测试;外部命令在 `MacTools` 之类的接缝后面。 - `updater.rs`:`Updater` 实体,阶段 `Phase::{Off, Idle, Checking, UpToDate, Available, Downloading, Verifying, Ready, Installing, Failed{stage, error}}`。每个任务一个 `shellrs-update` 线程,只在有任务时每 100 ms 轮询事件(同端口转发),新任务取消旧任务、丢掉旧任务的事件。`start()`(只在生产路径调用):报告上一次更新(读 `applied.json`)、30 秒后清理并第一次检查、之后每 6 小时(带抖动)、`on_app_quit` 时把 Ready 的版本装好。下载进度只 `notify`(设置页和对话框观察 `Updater`),阶段变化才发 `UpdaterEvent::Changed`,工作区只订阅它来重画标题栏。 - `status.rs`(纯函数):设置页的状态行、标题栏按钮 `UpdateBadge`、哪些按钮可用、设置页和对话框共用的下一步 `UpdateStep`(`UpdateSnapshot::step`),以及 `restart_note(RestartImpact)`。`update_dialog.rs`:更新对话框。 -- `shared/` — 多个功能共用的展示片段(`ClosableTabTitle`、`HostMark`、远程终端和 SFTP 标签栏右侧的延迟 `LatencyLabel`、`close_tab_items`,表单里「几选一」的分段控件 `SegmentedControl` / `Segment`,表单对话框的「取消 + 提交」页脚 `commit_footer`、错误行 `form_error`(主机对话框不用它,校验错误由 `form_error_notification` 弹成错误通知:同一种通知新的替换旧的,不叠;对话框的 `on_close` 用 `dismiss_form_error` 收掉)、端口字段的 `parse_port` 和删除确认 `confirm_delete`,以及远程终端和 SFTP 标签共用的「重命名标签」对话框 `open_rename_tab_dialog`,面板实现 `RenamableTab` 即可接入)。 +- `shared/` — 多个功能共用的展示片段(`ClosableTabTitle`、`HostMark`、远程终端和 SFTP 标签栏右侧的延迟 `LatencyLabel`、`close_tab_items`,表单里「几选一」的分段控件 `SegmentedControl` / `Segment`,表单对话框的「取消 + 提交」页脚 `commit_footer`、错误行 `form_error`(主机对话框不用它,校验错误由 `form_error_notification` 弹成错误通知:同一种通知新的替换旧的,不叠;点它就收起,对话框的 `on_close` 也用 `dismiss_form_error` 收掉)、端口字段的 `parse_port` 和删除确认 `confirm_delete`,以及远程终端和 SFTP 标签共用的「重命名标签」对话框 `open_rename_tab_dialog`,面板实现 `RenamableTab` 即可接入)。 关键流程与不变量: @@ -168,7 +168,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - **对话框点外面一律不关。** 点到对话框外面(半透明背景)就把填了一半的内容丢掉不合适,所以每个 `window.open_dialog` 都要 `.overlay_closable(false)`(默认是 `true`,新加对话框别漏):只能用「取消」、右上角的关闭按钮或 Esc 关(`a_click_beside_a_dialog_does_not_close_it` 查主机、分组、端口转发和凭据四个)。`open_alert_dialog` 开的确认框本来就不会被点外面关掉。 - **下拉框攥着焦点时,提交会把它弹开。** 对话框的提交按钮(`DialogAction`)和回车都是 `window.dispatch_action(Confirm)`,先到焦点元素;gpui-base 的 `Select` 在收起状态下收到 `Confirm` 会打开下拉并继续冒泡,所以表单照常校验 / 提交,但校验不过时下拉会跟着弹开,盖住下面的字段,下一次点击只是把它收起。这是 gpui-kit 的行为。转发对话框的「经由主机」、主机对话框的「凭据」「分组」都没有绕开(`ForwardForm::focus_bind_port` 只在打开新建对话框时聚焦一次,代码里没有订阅 `SelectEvent`;「分组」是最后一个字段,没有下一个字段可去)。UI 测试里选完这些下拉之后要先点一下别的字段再点 `commit`,否则后面第一次点击会被吃掉。 - **对话框关闭后焦点会丢。** 这是 gpui-component 的既有行为,不是本仓库引入的:关掉对话框后 `window.dispatch_action` 没有落点,要先点一下面板里的东西。UI 测试里同理(见 `groups_and_hosts_are_read_back_from_the_database`)。 -- **通知层要 `deferred` 并高过对话框。** gpui-base 的对话框(连同半透明背景)是用 `deferred`(优先级 0)画的,开始页也是;通知层若作为普通子元素,就会画在对话框背景底下,只能隐约看到。所以工作区把 `Root::render_notification_layer` 包成 `deferred(..).with_priority(NOTIFICATION_PRIORITY)`(= 1,低于菜单等弹出层的 `POPUP_PRIORITY` = 100)。无头测试看不到绘制顺序(被盖住的元素照样能点中),这条只能靠肉眼确认。 +- **通知层要 `deferred` 并高过对话框。** gpui-base 的对话框(连同半透明背景)是用 `deferred` 画的,优先级是 `10 + 对话框的层号`(`gpui-base` 的 `dialog.rs`;开始页是 0);通知层的优先级比它低就会画在对话框背景底下,被压暗,点它点到的是背景。所以工作区把 `Root::render_notification_layer` 包成 `deferred(..).with_priority(NOTIFICATION_PRIORITY)`(= `POPUP_PRIORITY - 1` = 99:高过任意层叠的对话框,低于菜单等弹出层的 `POPUP_PRIORITY` = 100 和 tooltip 的 200)。以前这里写成 1,以为对话框是 0,通知一直在对话框背景底下。无头测试里点击走真实的命中测试,被背景盖住的元素收不到点击:`a_form_error_sits_above_the_dialog_and_a_click_puts_it_away` 点主机对话框的错误通知,它在背景底下时这个测试会超时。 - **要彩色就不能用 `Icon`。** GPUI 的 `svg()` 走 `Window::paint_svg`,签名只收一个颜色,整张图当遮罩上色,所以彩色 SVG 塞进 `Icon` 会变成一坨纯色。真要彩色只能走 `img()`(`ImageSource`),那条路把 SVG 光栅化成 RGBA,颜色能留住,代价是变位图。系统徽章因此是「主题色的圆底 + 单色遮罩 logo」,不是一张彩图。 - **渲染回调不得读取实体。** 树行渲染闭包和 `render_td` 运行在所属实体的 render 过程中,只能传入普通快照(例如主机面板里各主机系统的 `Rc>`)。 - `px(..)` 只允许出现在 API 要求 `Pixels` 的地方(窗口 / Dock / 分栏尺寸、表格列宽、缩放字号),其余一律用 rem 助手和 `cx.theme()` token。 diff --git a/src/shared/dialog.rs b/src/shared/dialog.rs index 66f14af..596b623 100644 --- a/src/shared/dialog.rs +++ b/src/shared/dialog.rs @@ -37,13 +37,15 @@ struct FormErrorNotification; /// Why a form was not accepted, as an error notification over the dialog /// instead of a line under the form. The element keeps the `form-error` id -/// the line had, with the message as its label. The dialog takes it away -/// when it closes (`dismiss_form_error`). +/// the line had, with the message as its label. A click puts it away, and +/// so does the dialog when it closes (`dismiss_form_error`). pub fn form_error_notification(error: impl Into) -> Notification { let error = error.into(); Notification::new() .with_type(NotificationType::Error) .id::() + // Clicking a notification dismisses it once it has a click handler. + .on_click(|_, _, _| {}) .content(move |_, _, _| { div() .id("form-error") diff --git a/src/workspace/workspace_view.rs b/src/workspace/workspace_view.rs index a941638..28c72b1 100644 --- a/src/workspace/workspace_view.rs +++ b/src/workspace/workspace_view.rs @@ -73,12 +73,12 @@ const FONT_SIZE_MIN: f32 = 12.; const FONT_SIZE_MAX: f32 = 20.; const FONT_SIZE_DEFAULT: f32 = 16.; const FONT_SIZE_STEP: f32 = 2.; -/// Paint order of the notification layer. gpui-base draws a dialog deferred -/// at priority 0, and so does the dock skin's start page; a plain child would -/// land under the dialog's backdrop and show through it only faintly. One step -/// above them, and below popups (`POPUP_PRIORITY`), keeps a notification on -/// top of dialogs without covering an open menu. -const NOTIFICATION_PRIORITY: usize = 1; +/// Paint order of the notification layer. gpui-base draws a dialog, backdrop +/// and all, deferred at priority `10 + its layer` (the dock skin's start page +/// at 0); anything lower lands under the backdrop, dimmed, and a click on it +/// hits the backdrop. Just below popups (`POPUP_PRIORITY`) keeps a +/// notification above any stack of dialogs without covering an open menu. +const NOTIFICATION_PRIORITY: usize = gpui_kit::base::POPUP_PRIORITY - 1; /// How often the window asks whether ShellRS was opened again; see /// [`CliServer::take_activation`]. const ACTIVATION_POLL: Duration = Duration::from_millis(200); diff --git a/tests/ui/host_dialog.rs b/tests/ui/host_dialog.rs index a1fa501..f82e5e1 100644 --- a/tests/ui/host_dialog.rs +++ b/tests/ui/host_dialog.rs @@ -451,6 +451,34 @@ async fn connection_edits_reconnect_once_but_display_edits_do_not(cx: &mut TestA assert_eq!(factory.starts(), 2); } +/// The error is painted above the dialog and its backdrop, not under them: +/// a click on it reaches it, and puts it away, instead of landing on the +/// backdrop. +#[gpui_kit::test] +async fn a_form_error_sits_above_the_dialog_and_a_click_puts_it_away(cx: &mut TestAppContext) { + let (handle, _) = open_workspace(cx); + in_frame(cx, handle, |window, cx| window.click("new-host", cx)); + in_frame(cx, handle, |window, cx| { + window.click("host-name", cx); + window.click("commit", cx); + }); + cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { + window.render_frame(cx); + window.try_find("form-error").is_some() + }) + .await; + + in_frame(cx, handle, |window, cx| window.click("form-error", cx)); + cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { + window.render_frame(cx); + window.notifications(cx).is_empty() + }) + .await; + in_frame(cx, handle, |window, _| { + assert!(window.find("commit").visible(), "the dialog stays open"); + }); +} + #[gpui_kit::test] async fn new_host_dialog_validates_then_inserts(cx: &mut TestAppContext) { let (handle, workspace) = open_workspace(cx);