系统图标改成品牌色徽章
图标从 14px 提到 20px,加上该系统品牌色的圆形底。 - 品牌色用各项目公布的官方值,写在 host_os! 表里而不是渲染处;设计 规范禁止界面写死颜色,但留了「颜色本身即数据」这一档,品牌色属于 这一类。 - logo 一律白色,只有 Tux 和 OpenBSD 这两个黄色品牌换成黑色,判据是 WCAG 相对亮度,阈值取得比正文对比度要求高,因为几乎所有品牌都用白 标画自己的 logo。 - AlmaLinux 和 macOS 没有单一官方色,改用 foreground / background 跟 随主题,这也是 Apple 官方的画法。 - 徽章沿用 Avatar 的画法:主题圆角加一圈 border 描边。描边是纯黑或 纯白品牌在明暗两个主题下都还看得见的原因。 - 分组行和其他行用 plain_mark 占同宽槽位,否则标签对不齐。 - 未探测到的会话退回 muted 底色加名称首字,aria_label 也从那个字改成 「未探测到系统」,屏幕阅读器读一个孤零零的字没有意义。 顺带在文档里记下:GPUI 的 svg() 是单色遮罩,要彩色只能走 img()。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018Ub6VpmWWAJB15MnYLT9G5
This commit is contained in:
1 parent
321a65d842
commit
d8e41646ec
5 files changed
+188
-70
No files matched your search
@@ -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<SessionStore>` 的克隆,通过它的方法修改(方法内部会 `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<HashSet<SessionId>>`)。
|
||||
- `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<SessionStore>`:生产环境由 `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<SessionStore>`:生产环境由 `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` 初始为已连接并已打开终端标签。
|
||||
@@ -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<SessionStore>` 的克隆,通过它的方法修改(方法内部会 `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<Cell<Option<SessionNode>>>`(容器在捕获阶段先清空它,空白处右键因此得到根菜单)。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<HashSet<SessionId>>`)。
|
||||
- `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<SessionStore>`:生产环境由 `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<SessionStore>`:生产环境由 `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` 初始为已连接并已打开终端标签。
|
||||
+94
-26
@@ -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<Rgba> {
|
||||
let hex: Option<u32> = 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<Rgba> {
|
||||
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<Self> {
|
||||
@@ -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();
|
||||
|
||||
@@ -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<HostOs>, 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(),
|
||||
),
|
||||
};
|
||||
|
||||
+17
-3
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user