主机徽章去掉描边,开始页也用它
- 去掉徽章的边框。加描边本来是防纯黑或纯白的品牌在同色主题下糊掉, 但那两个(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:
1 parent
d8e41646ec
commit
698b9ed925
7 files changed
+171
-73
No files matched your search
@@ -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` 初始为已连接并已打开终端标签。
|
||||
@@ -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` 初始为已连接并已打开终端标签。
|
||||
@@ -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 => (
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user