主机徽章去掉描边,开始页也用它

- 去掉徽章的边框。加描边本来是防纯黑或纯白的品牌在同色主题下糊掉,
  但那两个(Apple、AlmaLinux)用的是 foreground / background,跟着
  主题翻转,天然高对比,描边是多余的。
- 徽章抽成 shared::HostMark,会话树和「最近连接」开始页共用一份。
  尺寸走 Sizable:树里 small(20px),开始页默认 medium(32px),
  照着那行两行文字的高度定的。尺寸和字号都用 rem 助手,跟着缩放走。
- 开始页原来那个通用服务器图标换掉了;空状态里的大图标保留,那是
  插图不是主机标记。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Ub6VpmWWAJB15MnYLT9G5
This commit is contained in:
XuzandClaude Opus 5 committed 2026-09-12 17:13:33 +08:00
1 parent d8e41646ec
commit 698b9ed925
7 files changed
+171 -73

No files matched your search

+3 -3
View File
@@ -42,13 +42,13 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
- `secrets/` — 系统钥匙串(`keyring` 4:macOS Keychain / Windows 凭据管理器 / Secret Service)。`SecretRef` 决定归属:密码按 `user@host:port`(改名、复制会话都不丢,同一台机器的多个会话共用一条),私钥口令按文件路径(同一把钥匙只问一次)。`SecretStore` 的三个方法全是阻塞的,**只能**在后台执行器或传输层工作线程上调用。`InMemorySecretStore` 给测试,`NoSecretStore` 给没有钥匙串的机器(界面据此禁用密码字段)。
- `explorer/` — `model.rs`(纯内存 `DirTree` / `Location` 导航)、`mock_fs.rs`(种子目录树)、`file_listing.rs`(`TableDelegate`)、`file_pane.rs`(单栏:地址行 + `DataTable` + 底部汇总)、`explorer_panel.rs`(`h_resizable` 里的两个栏)。
- `workspace/` — `workspace_view.rs` 持有 `SessionStore`、`DockArea`、按会话登记的面板注册表,以及**全部动作处理器**;`title_bar.rs`、`status_bar.rs`、`recent_sessions.rs`(中间区没有标签页时显示的「最近连接」开始页,**不是** Dock 面板)、`dock_skin.rs`(`WorkspaceDockSkin`:包一层 `DockSkin`,中间区为空时用 `deferred` 把开始页画在空的中间区之上;工作区在 `DockEvent::LayoutChanged` 时同步「中间区是否为空」并在刚变空时把焦点移到开始页)。
- `shared/` — 多个功能共用的展示片段(`ClosableTabTitle`)。
- `shared/` — 多个功能共用的展示片段(`ClosableTabTitle`、`HostMark`)。
关键流程与不变量:
- **一个命令一个处理器。** 按钮、菜单、右键菜单、快捷键都只派发 `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`)。树行前面的标记是一枚 20px 的圆形徽章(`session_mark`):探到了就用该系统的品牌色打底、白色(浅色品牌用黑色)画 logo,没探到就退回 `muted` 底色加会话名第一个字。徽章沿用 `Avatar` 的画法——主题圆角加一圈 `border` 描边——所以纯黑或纯白的品牌在明暗两个主题下都还看得见;`AlmaLinux` 和 `macOS` 没有单一品牌色,改用 `foreground` / `background`,这也是 Apple 官方的画法。品牌色写在 `host_os!` 表里而不是渲染处,那是设计规范允许的「颜色本身即数据」那一档。分组行和其他行用 `plain_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`)。标记是 `shared::HostMark`,会话树和开始页共用:探到了就用该系统的品牌色打底、白色(浅色品牌用黑色)画 logo,没探到就退回 `muted` 底色加会话名第一个字。徽章沿用 `Avatar` 的圆形画法,尺寸走 `Sizable`——树里用 `small`(20px),开始页用默认的 `medium`(32px)。`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`,工作区据此清理注册表。
@@ -63,4 +63,4 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
## 测试约定
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)` 与开始页的 `("recent-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` 初始为已连接并已打开终端标签。
+3 -3
View File
@@ -44,13 +44,13 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
- `secrets/` — 系统钥匙串(`keyring` 4:macOS Keychain / Windows 凭据管理器 / Secret Service)。`SecretRef` 决定归属:密码按 `user@host:port`(改名、复制会话都不丢,同一台机器的多个会话共用一条),私钥口令按文件路径(同一把钥匙只问一次)。`SecretStore` 的三个方法全是阻塞的,**只能**在后台执行器或传输层工作线程上调用。`InMemorySecretStore` 给测试,`NoSecretStore` 给没有钥匙串的机器(界面据此禁用密码字段)。
- `explorer/` — `model.rs`(纯内存 `DirTree` / `Location` 导航)、`mock_fs.rs`(种子目录树)、`file_listing.rs`(`TableDelegate`)、`file_pane.rs`(单栏:地址行 + `DataTable` + 底部汇总)、`explorer_panel.rs`(`h_resizable` 里的两个栏)。
- `workspace/` — `workspace_view.rs` 持有 `SessionStore`、`DockArea`、按会话登记的面板注册表,以及**全部动作处理器**;`title_bar.rs`、`status_bar.rs`、`recent_sessions.rs`(中间区没有标签页时显示的「最近连接」开始页,**不是** Dock 面板)、`dock_skin.rs`(`WorkspaceDockSkin`:包一层 `DockSkin`,中间区为空时用 `deferred` 把开始页画在空的中间区之上;工作区在 `DockEvent::LayoutChanged` 时同步「中间区是否为空」并在刚变空时把焦点移到开始页)。
- `shared/` — 多个功能共用的展示片段(`ClosableTabTitle`)。
- `shared/` — 多个功能共用的展示片段(`ClosableTabTitle`、`HostMark`)。
关键流程与不变量:
- **一个命令一个处理器。** 按钮、菜单、右键菜单、快捷键都只派发 `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`)。树行前面的标记是一枚 20px 的圆形徽章(`session_mark`):探到了就用该系统的品牌色打底、白色(浅色品牌用黑色)画 logo,没探到就退回 `muted` 底色加会话名第一个字。徽章沿用 `Avatar` 的画法——主题圆角加一圈 `border` 描边——所以纯黑或纯白的品牌在明暗两个主题下都还看得见;`AlmaLinux` 和 `macOS` 没有单一品牌色,改用 `foreground` / `background`,这也是 Apple 官方的画法。品牌色写在 `host_os!` 表里而不是渲染处,那是设计规范允许的「颜色本身即数据」那一档。分组行和其他行用 `plain_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`)。标记是 `shared::HostMark`,会话树和开始页共用:探到了就用该系统的品牌色打底、白色(浅色品牌用黑色)画 logo,没探到就退回 `muted` 底色加会话名第一个字。徽章沿用 `Avatar` 的圆形画法,尺寸走 `Sizable`——树里用 `small`(20px),开始页用默认的 `medium`(32px)。`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`,工作区据此清理注册表。
@@ -66,4 +66,4 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
## 测试约定
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)` 与开始页的 `("recent-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` 初始为已连接并已打开终端标签。
+10 -64
View File
@@ -5,14 +5,13 @@ use std::{
};
use gpui_kit::component::{
ActiveTheme as _, Icon, IconName, Sizable as _, ThemeStyled as _,
ActiveTheme as _, Icon, IconName, Sizable 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,
};
@@ -25,6 +24,8 @@ use crate::app::{
OpenExplorer, RenameGroup, SESSION_PANEL_CONTEXT,
};
use crate::shared::HostMark;
use super::{
GroupId, HostOs, SessionId, SessionNode, SessionStore, matches_query, session_tree_items,
};
@@ -390,67 +391,6 @@ impl Render for SessionPanel {
}
}
/// 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 (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),
};
(
background,
ink,
Icon::default()
.path(os.icon_path())
.xsmall()
.into_any_element(),
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("未探测到系统"),
),
};
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()
.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 {
@@ -485,7 +425,13 @@ fn render_row(
("group-row", id.0).into(),
),
Some(SessionNode::Session(id)) => (
session_mark(id, &item.label, host_os.get(&id).copied(), cx),
HostMark::new(
("session-os", id.0),
item.label.clone(),
host_os.get(&id).copied(),
)
.small()
.into_any_element(),
("session-row", id.0).into(),
),
None => (
+112
View File
@@ -0,0 +1,112 @@
use gpui_kit::component::{
ActiveTheme as _, Icon, Sizable, Size, ThemeStyled as _, h_flex, tooltip::Tooltip,
};
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;
use crate::session::HostOs;
/// The mark that stands for a session: the operating system found on its host,
/// drawn on a badge in that project's own colour, or the first character of
/// the session name until a probe succeeds.
///
/// Shared so the session tree and the start page show one identity for the
/// same host. The badge borrows `Avatar`'s treatment, a circle at the theme's
/// radius, so identity marks look alike wherever the product shows one.
#[derive(IntoElement)]
pub struct HostMark {
id: ElementId,
name: SharedString,
os: Option<HostOs>,
size: Size,
}
impl HostMark {
pub fn new(
id: impl Into<ElementId>,
name: impl Into<SharedString>,
os: Option<HostOs>,
) -> Self {
Self {
id: id.into(),
name: name.into(),
os,
size: Size::Medium,
}
}
}
impl Sizable for HostMark {
fn with_size(mut self, size: impl Into<Size>) -> Self {
self.size = size.into();
self
}
}
impl RenderOnce for HostMark {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let compact = matches!(self.size, Size::XSmall | Size::Small);
let (background, ink, glyph, description) = match self.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),
};
let glyph = Icon::default().path(os.icon_path());
(
background,
ink,
if compact {
glyph.size_3()
} else {
glyph.size_5()
}
.into_any_element(),
SharedString::from(os.label()),
)
}
None => (
cx.theme().muted,
cx.theme().muted_foreground,
div()
.map(|text| {
if compact {
text.text_xs()
} else {
text.text_base()
}
})
.child(
self.name
.chars()
.next()
.map(|character| character.to_uppercase().to_string())
.unwrap_or_default(),
)
.into_any_element(),
SharedString::from("未探测到系统"),
),
};
let tooltip = description.clone();
h_flex()
.id(self.id)
.test_support()
.aria_label(description)
.tooltip(move |window, cx| Tooltip::new(tooltip.clone()).build(window, cx))
.flex_shrink_0()
.map(|badge| {
if compact {
badge.size_5()
} else {
badge.size_8()
}
})
.justify_center()
.rounded_full_style(cx)
.bg(background)
.text_color(ink)
.child(glyph)
}
}
+2
View File
@@ -1,5 +1,7 @@
//! Small presentation pieces shared by more than one feature.
mod closable_tab;
mod host_mark;
pub use closable_tab::ClosableTabTitle;
pub use host_mark::HostMark;
+9 -2
View File
@@ -6,7 +6,8 @@ use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;
use crate::app::{CatalogIcon, ConnectSession, NewSession};
use crate::session::{SessionId, SessionStore};
use crate::session::{HostOs, SessionId, SessionStore};
use crate::shared::HostMark;
/// The center's empty state: shown in place of the tabs while none is open.
///
@@ -30,6 +31,7 @@ struct RecentRow {
address: String,
group: Option<SharedString>,
connected: bool,
os: Option<HostOs>,
}
impl RecentSessions {
@@ -58,6 +60,7 @@ impl RecentSessions {
.group
.map(|id| SharedString::from(store.group_path(id))),
connected: session.state.is_connected(),
os: session.os,
})
.collect()
}
@@ -116,7 +119,11 @@ impl RecentSessions {
.child(
h_flex()
.gap_3()
.child(Icon::new(CatalogIcon::Server).text_color(muted))
.child(HostMark::new(
("recent-session-os", id.0),
row.name.clone(),
row.os,
))
.child(
v_flex()
.min_w_0()
+32 -1
View File
@@ -18,7 +18,8 @@ use shellr::app::{
};
use shellr::secrets::{InMemorySecretStore, SecretRef, SecretStore as _};
use shellr::session::{
AuthKind, GroupId, HostOs, SessionDatabase, SessionDraft, SessionId, SessionStore,
AuthKind, ConnectionState, GroupId, HostOs, SessionDatabase, SessionDraft, SessionId,
SessionStore,
};
use shellr::terminal::{
FixedRemoteTerminalTransportProvider, LocalTerminalId, RemoteTerminalId, TerminalLifecycle,
@@ -2096,3 +2097,33 @@ async fn connecting_marks_the_session_with_the_host_operating_system(cx: &mut Te
assert_eq!(store.session(id).unwrap().os, Some(HostOs::Fedora));
});
}
#[gpui_kit::test]
async fn the_start_page_marks_recent_hosts_with_their_operating_system(cx: &mut TestAppContext) {
let mut store = SessionStore::empty();
let id = store.insert_unnotified(SessionDraft::new(
"web-01",
"10.0.1.12",
22,
"root",
AuthKind::Auto,
None,
));
// Connecting once puts it on the start page; disconnecting keeps it there
// and leaves the centre empty, which is when that page shows.
store.set_state_unnotified(id, ConnectionState::Connected);
store.set_state_unnotified(id, ConnectionState::Disconnected);
store.set_host_os_unnotified(id, Some(HostOs::Ubuntu));
let (handle, _) = open_workspace_with_store(cx, store);
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert!(window.find("recent-sessions").visible());
assert_eq!(
window.find(("recent-session-os", id.0)).label(),
Some("Ubuntu"),
"开始页和会话树用同一个标记"
);
})
.unwrap();
}