diff --git a/AGENTS.md b/AGENTS.md index e34b153..6fde861 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,7 +48,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - **一个命令一个处理器。** 按钮、菜单、右键菜单、快捷键都只派发 `app/actions.rs` 里的动作,由 `Workspace` 处理。新增命令加在那里,不要写临时闭包直接改状态。 - **持久化写穿,内存说了算。** `SessionStore` 带 `Context` 的 mutator 负责「改内存 → 写库 → `notify`」,`*_unnotified` 那一半是纯内存的(给单元测试和加载用)。写库同步发生在 UI 线程:要落盘的都是用户在对话框里确认的单行写入,不是流。写失败时内存里的改动照样生效,store 发 `SessionStoreEvent::PersistFailed`,工作区订阅后弹一个错误通知——**绝不静默吞掉**。`ConnectionState` 是运行时状态,不入库;`last_connected_at` 入库,开始页的「最近连接」因此能跨启动。删除分组靠数据库两个外键的 `ON DELETE CASCADE`,内存里的 `remove_group_unnotified` 必须给出一样的结果,并把被删会话的 id 返回给工作区去关标签页。 -- **主机系统每次连上都重探一遍。** 认证过、shell 起来、`Started` 发出去之后,传输层另开一条 exec 通道跑 `uname -s; cat /etc/os-release`,在主 IO 循环里和 shell 的输出一起轮询,所以终端不会为了探测多等一个往返。POSIX 那条什么都没输出(Windows 的 shell 就是这样)时再问一次 `cmd /c ver`,还是不认就放弃,会话保留原来的标记。结果沿 `TerminalTransportEvent::HostOsDetected` → 引擎 → `TerminalView` → `TerminalPanel` → 工作区 → `SessionStore::set_host_os` 这条链走,和 prompt 是同一套管线。`os` 列由 `set_host_os` 单独写,`update_session` 不碰它(同 `last_connected_at`)。树行前面的标记:探到了用系统 logo,没探到用会话名第一个字(`session_mark`)。 +- **主机系统每次连上都重探一遍。** 认证过、shell 起来、`Started` 发出去之后,传输层另开一条 exec 通道跑 `uname -s; cat /etc/os-release`,在主 IO 循环里和 shell 的输出一起轮询,所以终端不会为了探测多等一个往返。POSIX 那条什么都没输出(Windows 的 shell 就是这样)时再问一次 `cmd /c ver`,还是不认就放弃,会话保留原来的标记。结果沿 `TerminalTransportEvent::HostOsDetected` → 引擎 → `TerminalView` → `TerminalPanel` → 工作区 → `SessionStore::set_host_os` 这条链走,和 prompt 是同一套管线。`os` 列由 `set_host_os` 单独写,`update_session` 不碰它(同 `last_connected_at`)。树行前面的标记是一枚 20px 的圆形徽章(`session_mark`):探到了就用该系统的品牌色打底、白色(浅色品牌用黑色)画 logo,没探到就退回 `muted` 底色加会话名第一个字。徽章沿用 `Avatar` 的画法——主题圆角加一圈 `border` 描边——所以纯黑或纯白的品牌在明暗两个主题下都还看得见;`AlmaLinux` 和 `macOS` 没有单一品牌色,改用 `foreground` / `background`,这也是 Apple 官方的画法。品牌色写在 `host_os!` 表里而不是渲染处,那是设计规范允许的「颜色本身即数据」那一档。分组行和其他行用 `plain_mark` 占同样宽度的槽位,否则标签对不齐。 - **秘密只进钥匙串,不进数据库。** `database.rs` 的 `schema_never_contains_secret_columns` 守着这条线。写入的唯一入口是 `SessionStore::save_secret`:它在后台线程上调钥匙串,失败同样走 `PersistFailed`。会话换了端点或被删掉时,store 用纯函数 `password_in_use` 判断还有没有别的会话在用旧端点,没有才删条目;私钥口令按文件共享,从不自动删。会话对话框里的密码字段是异步预填的,`*_loaded` 标志没置上之前空字段不算「用户清空了」,所以预填还没回来就点保存不会误删。秘密绝不放进 `SessionDraft`,它派生了 `Debug`。 - **id 由内存分配。** `next_session_id` / `next_group_id` 两个独立计数器,`SessionStore::load` 把它们置为库里最大 id + 1。`SessionStore::seed()` 生产环境不再使用,是 `tests/ui.rs` 的夹具。 - **状态经由 store 流动。** 面板和对话框拿到 `Entity` 的克隆,通过它的方法修改(方法内部会 `notify`),消费方用 `cx.observe(&store, ..)`。状态栏得知当前会话的路径是 `BasePanel::set_active` → `store.set_active`;`on_removed` 负责复位状态并发出 `TerminalPanelEvent::Closed` / `ExplorerPanelEvent::Closed`,工作区据此清理注册表。 @@ -57,9 +57,10 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - **焦点规则。** `window.dispatch_action` 只能到达焦点元素路径上的处理器,所以工作区内部必须有东西持有焦点(启动时聚焦会话面板)。永远不要聚焦工作区根节点的 handle:对话框层是它的子元素,祖先持有焦点会让对话框的焦点陷阱拿不到焦点,导致「取消 / 创建」无响应。标题栏和开始页的按钮改用工作区 handle 上的 `FocusHandle::dispatch_action` 派发。对话框打开后焦点停在对话框宿主上,直到用户点击某个字段;延迟或下一帧的聚焦请求不会生效。 - **树的语义。** 树行的左键按下会同时选中并切换展开(无法抑制);双击打开靠 `ListItem` 上的 `ClickEvent::click_count() == 2`。`TreeState::set_items` 会清空选择,`SessionPanel::rebuild_tree` 按 id 恢复选择,展开状态由它自己的 `expanded` 集合维护。对话框新建出来的节点靠 `SessionPanel` 比对 `known_groups` / `known_sessions` 快照来发现,然后展开祖先链并选中——对话框不需要回调面板。 - **对话框关闭后焦点会丢。** 这是 gpui-component 的既有行为,不是本仓库引入的:关掉对话框后 `window.dispatch_action` 没有落点,要先点一下面板里的东西。UI 测试里同理(见 `groups_and_sessions_are_read_back_from_the_database`)。 +- **要彩色就不能用 `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。 ## 测试约定 -UI 测试(`tests/ui.rs`)在无头窗口里渲染真实的 `Workspace`,通过 `gpui_kit::test::TestWindowExt`(`click`、`double_click`、`input`、`press`、`find`、`within`)驱动。`Workspace::new` 要求外部传入 `Entity`:生产环境由 `main.rs` 从数据库加载,测试用 `open_workspace_with_store` 注入 `SessionStore::seed()`(或一个临时文件上的真库)。**不要用右键菜单驱动测试**:菜单项的 ElementId 只是序号,而且 `PopupMenu` 实体会让测试以「leaked handles」失败;直接 `window.dispatch_action` 派发菜单会派发的那个动作。测试模块里要显式导入类型,`use gpui_kit::*` 会遮蔽 `#[test]`。被测试查询的元素带稳定的领域 id,自定义 `div` 还要加 `.test_support()`:`("session-row", id)`、`("session-os", id)`(`aria_label` 是系统名或兜底的首字)、`("group-row", id)`、`("terminal", id)`、`("explorer", id)`、`("local-pane"|"remote-pane", id)`、`("close-terminal", id)`、`("close-explorer", id)`、`recent-sessions`、`("recent-session", id)`、`recent-new-session`、`session-search`、`new-session`、`new-group`、`theme-toggle`、`status-connection`、`commit`、`ok`(确认对话框)、`form-error`、`session-name` / `-host` / `-port` / `-user` / `-auth` / `-key-path` / `-password` / `-passphrase`、`group-name`、`remote-path`。被遮蔽的输入框在测试里读不到值(`ElementSnapshot::value()` 返回 `None`),所以密码相关的断言一律查注入的 `InMemorySecretStore`,不查界面。动作派发和 `set_active` 都是延迟执行的:先退出 `update_window`,调用 `cx.run_until_parked()`,再 `render_frame` 后断言。种子会话 id 按插入顺序(`web-01`=1 … `dev-box`=6),种子分组同理(`生产`=1、`测试`=2、`开发`=3);`web-01` 与 `staging-api` 初始为已连接并已打开终端标签。 +UI 测试(`tests/ui.rs`)在无头窗口里渲染真实的 `Workspace`,通过 `gpui_kit::test::TestWindowExt`(`click`、`double_click`、`input`、`press`、`find`、`within`)驱动。`Workspace::new` 要求外部传入 `Entity`:生产环境由 `main.rs` 从数据库加载,测试用 `open_workspace_with_store` 注入 `SessionStore::seed()`(或一个临时文件上的真库)。**不要用右键菜单驱动测试**:菜单项的 ElementId 只是序号,而且 `PopupMenu` 实体会让测试以「leaked handles」失败;直接 `window.dispatch_action` 派发菜单会派发的那个动作。测试模块里要显式导入类型,`use gpui_kit::*` 会遮蔽 `#[test]`。被测试查询的元素带稳定的领域 id,自定义 `div` 还要加 `.test_support()`:`("session-row", id)`、`("session-os", id)`(`aria_label` 是系统名,未探测时是「未探测到系统」)、`("group-row", id)`、`("terminal", id)`、`("explorer", id)`、`("local-pane"|"remote-pane", id)`、`("close-terminal", id)`、`("close-explorer", id)`、`recent-sessions`、`("recent-session", id)`、`recent-new-session`、`session-search`、`new-session`、`new-group`、`theme-toggle`、`status-connection`、`commit`、`ok`(确认对话框)、`form-error`、`session-name` / `-host` / `-port` / `-user` / `-auth` / `-key-path` / `-password` / `-passphrase`、`group-name`、`remote-path`。被遮蔽的输入框在测试里读不到值(`ElementSnapshot::value()` 返回 `None`),所以密码相关的断言一律查注入的 `InMemorySecretStore`,不查界面。动作派发和 `set_active` 都是延迟执行的:先退出 `update_window`,调用 `cx.run_until_parked()`,再 `render_frame` 后断言。种子会话 id 按插入顺序(`web-01`=1 … `dev-box`=6),种子分组同理(`生产`=1、`测试`=2、`开发`=3);`web-01` 与 `staging-api` 初始为已连接并已打开终端标签。 diff --git a/CLAUDE.md b/CLAUDE.md index 5d6c87e..e835862 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,7 +50,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - **一个命令一个处理器。** 按钮、菜单、右键菜单、快捷键都只派发 `app/actions.rs` 里的动作,由 `Workspace` 处理。新增命令加在那里,不要写临时闭包直接改状态。 - **持久化写穿,内存说了算。** `SessionStore` 带 `Context` 的 mutator 负责「改内存 → 写库 → `notify`」,`*_unnotified` 那一半是纯内存的(给单元测试和加载用)。写库同步发生在 UI 线程:要落盘的都是用户在对话框里确认的单行写入,不是流。写失败时内存里的改动照样生效,store 发 `SessionStoreEvent::PersistFailed`,工作区订阅后弹一个错误通知——**绝不静默吞掉**。`ConnectionState` 是运行时状态,不入库;`last_connected_at` 入库,开始页的「最近连接」因此能跨启动。删除分组靠数据库两个外键的 `ON DELETE CASCADE`,内存里的 `remove_group_unnotified` 必须给出一样的结果,并把被删会话的 id 返回给工作区去关标签页。 -- **主机系统每次连上都重探一遍。** 认证过、shell 起来、`Started` 发出去之后,传输层另开一条 exec 通道跑 `uname -s; cat /etc/os-release`,在主 IO 循环里和 shell 的输出一起轮询,所以终端不会为了探测多等一个往返。POSIX 那条什么都没输出(Windows 的 shell 就是这样)时再问一次 `cmd /c ver`,还是不认就放弃,会话保留原来的标记。结果沿 `TerminalTransportEvent::HostOsDetected` → 引擎 → `TerminalView` → `TerminalPanel` → 工作区 → `SessionStore::set_host_os` 这条链走,和 prompt 是同一套管线。`os` 列由 `set_host_os` 单独写,`update_session` 不碰它(同 `last_connected_at`)。树行前面的标记:探到了用系统 logo,没探到用会话名第一个字(`session_mark`)。 +- **主机系统每次连上都重探一遍。** 认证过、shell 起来、`Started` 发出去之后,传输层另开一条 exec 通道跑 `uname -s; cat /etc/os-release`,在主 IO 循环里和 shell 的输出一起轮询,所以终端不会为了探测多等一个往返。POSIX 那条什么都没输出(Windows 的 shell 就是这样)时再问一次 `cmd /c ver`,还是不认就放弃,会话保留原来的标记。结果沿 `TerminalTransportEvent::HostOsDetected` → 引擎 → `TerminalView` → `TerminalPanel` → 工作区 → `SessionStore::set_host_os` 这条链走,和 prompt 是同一套管线。`os` 列由 `set_host_os` 单独写,`update_session` 不碰它(同 `last_connected_at`)。树行前面的标记是一枚 20px 的圆形徽章(`session_mark`):探到了就用该系统的品牌色打底、白色(浅色品牌用黑色)画 logo,没探到就退回 `muted` 底色加会话名第一个字。徽章沿用 `Avatar` 的画法——主题圆角加一圈 `border` 描边——所以纯黑或纯白的品牌在明暗两个主题下都还看得见;`AlmaLinux` 和 `macOS` 没有单一品牌色,改用 `foreground` / `background`,这也是 Apple 官方的画法。品牌色写在 `host_os!` 表里而不是渲染处,那是设计规范允许的「颜色本身即数据」那一档。分组行和其他行用 `plain_mark` 占同样宽度的槽位,否则标签对不齐。 - **秘密只进钥匙串,不进数据库。** `database.rs` 的 `schema_never_contains_secret_columns` 守着这条线。写入的唯一入口是 `SessionStore::save_secret`:它在后台线程上调钥匙串,失败同样走 `PersistFailed`。会话换了端点或被删掉时,store 用纯函数 `password_in_use` 判断还有没有别的会话在用旧端点,没有才删条目;私钥口令按文件共享,从不自动删。会话对话框里的密码字段是异步预填的,`*_loaded` 标志没置上之前空字段不算「用户清空了」,所以预填还没回来就点保存不会误删。秘密绝不放进 `SessionDraft`,它派生了 `Debug`。 - **id 由内存分配。** `next_session_id` / `next_group_id` 两个独立计数器,`SessionStore::load` 把它们置为库里最大 id + 1。`SessionStore::seed()` 生产环境不再使用,是 `tests/ui.rs` 的夹具。 - **状态经由 store 流动。** 面板和对话框拿到 `Entity` 的克隆,通过它的方法修改(方法内部会 `notify`),消费方用 `cx.observe(&store, ..)`。状态栏得知当前会话的路径是 `BasePanel::set_active` → `store.set_active`;`on_removed` 负责复位状态并发出 `TerminalPanelEvent::Closed` / `ExplorerPanelEvent::Closed`,工作区据此清理注册表。 @@ -60,9 +60,10 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - **树的语义。** 树行的左键按下会同时选中并切换展开(无法抑制);双击打开靠 `ListItem` 上的 `ClickEvent::click_count() == 2`。`TreeState::set_items` 会清空选择,`SessionPanel::rebuild_tree` 按 id 恢复选择,展开状态由它自己的 `expanded` 集合维护。对话框新建出来的节点靠 `SessionPanel` 比对 `known_groups` / `known_sessions` 快照来发现,然后展开祖先链并选中——对话框不需要回调面板。 - **树的右键菜单挂在容器上,不挂在行上。** gpui-component 的 `ContextMenu` 在 `request_layout` 里给弹出菜单抢焦点,而树行是 `uniform_list` 在 prepaint 阶段才渲染的:焦点于是在一帧的中途易主,gpui 的无障碍断言会 panic(`set_focus called more than once in a single frame`,只在开了辅助功能的调试构建里出现)。所以 `SessionPanel` 把 `.context_menu(..)` 挂在 `#session-tree` 容器上,右键命中的节点由行上的右键处理器写进一个 `Rc>>`(容器在捕获阶段先清空它,空白处右键因此得到根菜单)。gpui-kit 自己的 `Table` 也是这么挂的。 - **对话框关闭后焦点会丢。** 这是 gpui-component 的既有行为,不是本仓库引入的:关掉对话框后 `window.dispatch_action` 没有落点,要先点一下面板里的东西。UI 测试里同理(见 `groups_and_sessions_are_read_back_from_the_database`)。 +- **要彩色就不能用 `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。 ## 测试约定 -UI 测试(`tests/ui.rs`)在无头窗口里渲染真实的 `Workspace`,通过 `gpui_kit::test::TestWindowExt`(`click`、`double_click`、`input`、`press`、`find`、`within`)驱动。`Workspace::new` 要求外部传入 `Entity`:生产环境由 `main.rs` 从数据库加载,测试用 `open_workspace_with_store` 注入 `SessionStore::seed()`(或一个临时文件上的真库)。**不要用右键菜单驱动测试**:菜单项的 ElementId 只是序号,而且 `PopupMenu` 实体会让测试以「leaked handles」失败;直接 `window.dispatch_action` 派发菜单会派发的那个动作。测试模块里要显式导入类型,`use gpui_kit::*` 会遮蔽 `#[test]`。被测试查询的元素带稳定的领域 id,自定义 `div` 还要加 `.test_support()`:`("session-row", id)`、`("session-os", id)`(`aria_label` 是系统名或兜底的首字)、`("group-row", id)`、`("terminal", id)`、`("explorer", id)`、`("local-pane"|"remote-pane", id)`、`("close-terminal", id)`、`("close-explorer", id)`、`recent-sessions`、`("recent-session", id)`、`recent-new-session`、`session-search`、`new-session`、`new-group`、`theme-toggle`、`status-connection`、`commit`、`ok`(确认对话框)、`form-error`、`session-name` / `-host` / `-port` / `-user` / `-auth` / `-key-path` / `-password` / `-passphrase`、`group-name`、`remote-path`。被遮蔽的输入框在测试里读不到值(`ElementSnapshot::value()` 返回 `None`),所以密码相关的断言一律查注入的 `InMemorySecretStore`,不查界面。动作派发和 `set_active` 都是延迟执行的:先退出 `update_window`,调用 `cx.run_until_parked()`,再 `render_frame` 后断言。种子会话 id 按插入顺序(`web-01`=1 … `dev-box`=6),种子分组同理(`生产`=1、`测试`=2、`开发`=3);`web-01` 与 `staging-api` 初始为已连接并已打开终端标签。 +UI 测试(`tests/ui.rs`)在无头窗口里渲染真实的 `Workspace`,通过 `gpui_kit::test::TestWindowExt`(`click`、`double_click`、`input`、`press`、`find`、`within`)驱动。`Workspace::new` 要求外部传入 `Entity`:生产环境由 `main.rs` 从数据库加载,测试用 `open_workspace_with_store` 注入 `SessionStore::seed()`(或一个临时文件上的真库)。**不要用右键菜单驱动测试**:菜单项的 ElementId 只是序号,而且 `PopupMenu` 实体会让测试以「leaked handles」失败;直接 `window.dispatch_action` 派发菜单会派发的那个动作。测试模块里要显式导入类型,`use gpui_kit::*` 会遮蔽 `#[test]`。被测试查询的元素带稳定的领域 id,自定义 `div` 还要加 `.test_support()`:`("session-row", id)`、`("session-os", id)`(`aria_label` 是系统名,未探测时是「未探测到系统」)、`("group-row", id)`、`("terminal", id)`、`("explorer", id)`、`("local-pane"|"remote-pane", id)`、`("close-terminal", id)`、`("close-explorer", id)`、`recent-sessions`、`("recent-session", id)`、`recent-new-session`、`session-search`、`new-session`、`new-group`、`theme-toggle`、`status-connection`、`commit`、`ok`(确认对话框)、`form-error`、`session-name` / `-host` / `-port` / `-user` / `-auth` / `-key-path` / `-password` / `-passphrase`、`group-name`、`remote-path`。被遮蔽的输入框在测试里读不到值(`ElementSnapshot::value()` 返回 `None`),所以密码相关的断言一律查注入的 `InMemorySecretStore`,不查界面。动作派发和 `set_active` 都是延迟执行的:先退出 `update_window`,调用 `cx.run_until_parked()`,再 `render_frame` 后断言。种子会话 id 按插入顺序(`web-01`=1 … `dev-box`=6),种子分组同理(`生产`=1、`测试`=2、`开发`=3);`web-01` 与 `staging-api` 初始为已连接并已打开终端标签。 diff --git a/src/session/model.rs b/src/session/model.rs index 44ed80f..f0134e8 100644 --- a/src/session/model.rs +++ b/src/session/model.rs @@ -1,4 +1,4 @@ -use gpui_kit::SharedString; +use gpui_kit::{Rgba, SharedString, rgb}; use serde::Deserialize; use crate::secrets::SecretRef; @@ -57,12 +57,17 @@ impl AuthKind { /// The operating system running on a host, as reported by the session's own /// shell after it connects. /// -/// Each variant owns three strings that must stay in step: the spelling kept -/// in the database, the name shown to a person, and the icon embedded in -/// `app/assets.rs`. Adding a distribution means one line here, one icon file, -/// and one arm in `ssh/probe.rs`. +/// Each variant owns four things that must stay in step: the spelling kept in +/// the database, the name shown to a person, the icon embedded in +/// `app/assets.rs`, and the brand's own colour. Adding a distribution means +/// one line here, one icon file, and one arm in `ssh/probe.rs`. +/// +/// The colours are the published brand values, which is why they are literals +/// rather than theme tokens: here the colour *is* the data, the one case the +/// design guides allow. Keeping them in this table is what stops them from +/// leaking into render code. macro_rules! host_os { - ($($variant:ident => $stored:literal, $label:literal, $icon:literal;)*) => { + ($($variant:ident => $stored:literal, $label:literal, $icon:literal, $brand:expr;)*) => { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum HostOs { $($variant,)* @@ -88,6 +93,30 @@ macro_rules! host_os { match self { $(Self::$variant => concat!("icons/os/", $icon, ".svg"),)* } } + /// The brand's published colour, for the badge behind the mark. + /// + /// `None` means the mark is monochrome and should follow the + /// theme instead. That is how Apple's is meant to be drawn, and + /// the honest answer where a project publishes no single colour. + pub fn brand_color(self) -> Option { + let hex: Option = match self { $(Self::$variant => $brand,)* }; + hex.map(rgb) + } + + /// What to draw the mark itself in, on top of [`Self::brand_color`]. + /// + /// White, the way nearly every brand draws its own mark, except on + /// the light ones where the brand uses a dark mark instead. + pub fn brand_foreground(self) -> Option { + self.brand_color().map(|color| { + if is_light(color) { + rgb(BRAND_DARK_MARK) + } else { + rgb(BRAND_LIGHT_MARK) + } + }) + } + /// Read back a stored spelling. An unknown one means the row was /// written by a newer build, so it is treated as undetected. pub fn from_stored(value: &str) -> Option { @@ -101,26 +130,48 @@ macro_rules! host_os { } host_os! { - Ubuntu => "ubuntu", "Ubuntu", "ubuntu"; - Debian => "debian", "Debian", "debian"; - Fedora => "fedora", "Fedora", "fedora"; - RedHat => "rhel", "Red Hat", "redhat"; - CentOs => "centos", "CentOS", "centos"; - Rocky => "rocky", "Rocky Linux", "rockylinux"; - Alma => "almalinux", "AlmaLinux", "almalinux"; - Arch => "arch", "Arch Linux", "archlinux"; - Alpine => "alpine", "Alpine Linux", "alpinelinux"; - Suse => "suse", "openSUSE", "opensuse"; - Gentoo => "gentoo", "Gentoo", "gentoo"; - Kali => "kali", "Kali Linux", "kalilinux"; - Manjaro => "manjaro", "Manjaro", "manjaro"; - Raspbian => "raspbian", "Raspberry Pi OS", "raspberrypi"; - Linux => "linux", "Linux", "linux"; - MacOs => "macos", "macOS", "apple"; - Windows => "windows", "Windows", "windows"; - FreeBsd => "freebsd", "FreeBSD", "freebsd"; - OpenBsd => "openbsd", "OpenBSD", "openbsd"; - NetBsd => "netbsd", "NetBSD", "netbsd"; + Ubuntu => "ubuntu", "Ubuntu", "ubuntu", Some(0xE9_5420); + Debian => "debian", "Debian", "debian", Some(0xA8_1D33); + Fedora => "fedora", "Fedora", "fedora", Some(0x51_A2DA); + RedHat => "rhel", "Red Hat", "redhat", Some(0xEE_0000); + CentOs => "centos", "CentOS", "centos", Some(0x26_2577); + Rocky => "rocky", "Rocky Linux", "rockylinux", Some(0x10_B981); + // AlmaLinux publishes no single flat colour, so its mark follows the theme. + Alma => "almalinux", "AlmaLinux", "almalinux", None; + Arch => "arch", "Arch Linux", "archlinux", Some(0x17_93D1); + Alpine => "alpine", "Alpine Linux", "alpinelinux", Some(0x0D_597F); + Suse => "suse", "openSUSE", "opensuse", Some(0x73_BA25); + Gentoo => "gentoo", "Gentoo", "gentoo", Some(0x54_487A); + Kali => "kali", "Kali Linux", "kalilinux", Some(0x55_7C94); + Manjaro => "manjaro", "Manjaro", "manjaro", Some(0x35_BFA4); + Raspbian => "raspbian", "Raspberry Pi OS", "raspberrypi", Some(0xA2_2846); + Linux => "linux", "Linux", "linux", Some(0xFC_C624); + MacOs => "macos", "macOS", "apple", None; + Windows => "windows", "Windows", "windows", Some(0x00_78D4); + FreeBsd => "freebsd", "FreeBSD", "freebsd", Some(0xAB_2B28); + OpenBsd => "openbsd", "OpenBSD", "openbsd", Some(0xF2_CA30); + NetBsd => "netbsd", "NetBSD", "netbsd", Some(0xFF_6600); +} + +/// Drawn on top of a light brand colour. +const BRAND_DARK_MARK: u32 = 0x00_0000; +/// Drawn on top of every other brand colour. +const BRAND_LIGHT_MARK: u32 = 0xFF_FFFF; + +/// Whether a brand colour is light enough that the brand puts a dark mark on +/// it. The threshold sits well above the point where white text would fail, +/// because a logo silhouette is not body text and nearly every brand draws its +/// own mark in white; only the yellows go the other way. +fn is_light(color: Rgba) -> bool { + fn linear(channel: f32) -> f32 { + if channel <= 0.03928 { + channel / 12.92 + } else { + ((channel + 0.055) / 1.055).powf(2.4) + } + } + let luminance = 0.2126 * linear(color.r) + 0.7152 * linear(color.g) + 0.0722 * linear(color.b); + luminance > 0.5 } /// Connection state of a session. Runtime only: it is never persisted, so a @@ -305,6 +356,23 @@ mod tests { } } + #[test] + fn the_light_brands_get_a_dark_mark_and_the_rest_get_white() { + // Tux is black on yellow, and OpenBSD's Puffy likewise. + assert_eq!(HostOs::Linux.brand_foreground(), Some(rgb(0x00_0000))); + assert_eq!(HostOs::OpenBsd.brand_foreground(), Some(rgb(0x00_0000))); + // Everything else follows the usual white-on-brand treatment. + assert_eq!(HostOs::Ubuntu.brand_foreground(), Some(rgb(0xFF_FFFF))); + assert_eq!(HostOs::Debian.brand_foreground(), Some(rgb(0xFF_FFFF))); + assert_eq!(HostOs::Windows.brand_foreground(), Some(rgb(0xFF_FFFF))); + } + + #[test] + fn a_monochrome_mark_leaves_the_colour_to_the_theme() { + assert_eq!(HostOs::MacOs.brand_color(), None); + assert_eq!(HostOs::MacOs.brand_foreground(), None); + } + #[test] fn stored_spellings_and_icons_are_unique() { let mut spellings: Vec<_> = HostOs::ALL.iter().map(|os| os.as_str()).collect(); diff --git a/src/session/session_panel.rs b/src/session/session_panel.rs index efb0fdd..dcd1f7a 100644 --- a/src/session/session_panel.rs +++ b/src/session/session_panel.rs @@ -5,13 +5,14 @@ use std::{ }; use gpui_kit::component::{ - ActiveTheme as _, Icon, IconName, Sizable as _, + ActiveTheme as _, Icon, IconName, Sizable as _, ThemeStyled as _, button::Button, dock::{BasePanel, Panel, PanelControl, PanelEvent}, h_flex, input::{Input, InputEvent, InputState}, list::ListItem, menu::{ContextMenuExt as _, PopupMenu}, + tooltip::Tooltip, tree::{TreeEntry, TreeEvent, TreeState, tree}, v_flex, }; @@ -389,44 +390,76 @@ impl Render for SessionPanel { } } -/// The mark in front of a session: the operating system a probe found on the -/// host, or the first character of the session name until one succeeds. +/// The mark in front of a session: the host's operating system on a badge in +/// that project's own colour, or the first character of the session name until +/// a probe succeeds. +/// +/// The badge borrows `Avatar`'s treatment — a bordered circle at the theme's +/// radius — so identity marks look the same wherever the product shows one. +/// The border is what keeps a black or white brand readable against either +/// theme. fn session_mark(id: SessionId, label: &SharedString, os: Option, cx: &App) -> AnyElement { - let (mark, description): (AnyElement, SharedString) = match os { - Some(os) => ( - Icon::default() - .path(os.icon_path()) - .small() - .into_any_element(), - os.label().into(), - ), - None => { - let initial: SharedString = label - .chars() - .next() - .map(|character| character.to_uppercase().to_string()) - .unwrap_or_default() - .into(); + let (background, ink, glyph, description) = match os { + Some(os) => { + let (background, ink) = match (os.brand_color(), os.brand_foreground()) { + (Some(background), Some(ink)) => (background.into(), ink.into()), + // A monochrome mark follows the theme, which is also how + // Apple's own guidance draws it. + _ => (cx.theme().foreground, cx.theme().background), + }; ( - h_flex() - // Matches `Icon::small`, so a row keeps its alignment - // whichever mark it ends up with. - .size_3p5() - .justify_center() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(initial.clone()) + background, + ink, + Icon::default() + .path(os.icon_path()) + .xsmall() .into_any_element(), - initial, + SharedString::from(os.label()), ) } + None => ( + cx.theme().muted, + cx.theme().muted_foreground, + div() + .text_xs() + .child( + label + .chars() + .next() + .map(|character| character.to_uppercase().to_string()) + .unwrap_or_default(), + ) + .into_any_element(), + SharedString::from("未探测到系统"), + ), }; - div() + let tooltip = description.clone(); + h_flex() .id(("session-os", id.0)) .test_support() .aria_label(description) + .tooltip(move |window, cx| Tooltip::new(tooltip.clone()).build(window, cx)) .flex_shrink_0() - .child(mark) + .size_5() + .justify_center() + .rounded_full_style(cx) + .border_1() + .border_color(cx.theme().border) + .bg(background) + .text_color(ink) + .child(glyph) + .into_any_element() +} + +/// A row that is not a session still has to reserve the badge's width, or the +/// labels of groups and sessions would not line up. +fn plain_mark(icon: Icon, cx: &App) -> AnyElement { + h_flex() + .flex_shrink_0() + .size_5() + .justify_center() + .text_color(cx.theme().muted_foreground) + .child(icon.small()) .into_any_element() } @@ -441,13 +474,14 @@ fn render_row( let node = SessionNode::parse(&item.id); let (mark, row_id): (AnyElement, ElementId) = match node { Some(SessionNode::Group(id)) => ( - Icon::new(if entry.is_expanded() { - IconName::FolderOpen - } else { - IconName::Folder - }) - .small() - .into_any_element(), + plain_mark( + Icon::new(if entry.is_expanded() { + IconName::FolderOpen + } else { + IconName::Folder + }), + cx, + ), ("group-row", id.0).into(), ), Some(SessionNode::Session(id)) => ( @@ -455,7 +489,7 @@ fn render_row( ("session-row", id.0).into(), ), None => ( - Icon::new(IconName::File).small().into_any_element(), + plain_mark(Icon::new(IconName::File), cx), item.id.clone().into(), ), }; diff --git a/tests/ui.rs b/tests/ui.rs index cfd6854..bfe6f53 100644 --- a/tests/ui.rs +++ b/tests/ui.rs @@ -2048,9 +2048,20 @@ async fn the_tree_falls_back_to_the_first_letter_until_a_host_is_probed(cx: &mut ); assert_eq!( window.find(("session-os", fresh.0)).label(), - Some("数"), - "没探测过就用名称第一个字" + Some("未探测到系统"), + "没探测过就退回名称首字的中性徽章" ); + // The badges carry brand colours of their own, so they have to hold up + // in both themes. + window.click("theme-toggle", cx); + }) + .unwrap(); + cx.run_until_parked(); + + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + assert!(window.find(("session-os", probed.0)).visible()); + assert!(window.find(("session-os", fresh.0)).visible()); }) .unwrap(); } @@ -2063,7 +2074,10 @@ async fn connecting_marks_the_session_with_the_host_operating_system(cx: &mut Te cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); - assert_eq!(window.find(("session-os", id.0)).label(), Some("P")); + assert_eq!( + window.find(("session-os", id.0)).label(), + Some("未探测到系统") + ); window .within("session-tree") .double_click(("session-row", id.0), cx);