用 SQLite 持久化会话与分组

会话和分组不再只活在进程里。`SessionStore` 带 `Context` 的 mutator 现在
「改内存 → 写库 → notify」,落到 `~/Library/Application Support/shellr/
shellr.db`(`SHELLR_DATA_DIR` 可覆盖目录)。内存仍是唯一事实来源:写库
失败只丢持久化、不丢用户的编辑,store 发 `PersistFailed`,工作区据此弹
错误通知,绝不静默吞掉。

分组从只读的种子数据变成真正的配置项:任意层级嵌套,可新建 / 重命名 /
改上级 / 删除,删除按外键 `ON DELETE CASCADE` 连同子分组和里面的会话一起
带走,确认框写明数量。会话因此可以不属于任何分组,渲染在树的根层级——
首次启动是空库,第一个会话必须能在还没有任何分组时建出来。

- rusqlite 0.40(bundled):同步写、WAL、外键、`PRAGMA user_version` 迁移
- `app/paths.rs` 解析数据目录;`dirs` 6 已在依赖树里,不增加编译量
- `Workspace::new` 改为外部注入 store:`main.rs` 从库加载,测试注入 seed
- `ConnectionState` 不入库;`last_connected_at` 入库,最近连接跨启动保留
- 新增 5 个 UI 测试,其中一个走真实写路径后用第二个连接读回同一个文件

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JcMDBhETbHS7rEucBMAVvv
This commit is contained in:
since2006andClaude Opus 5 committed 2026-09-11 22:32:10 +08:00
1 parent 3bceb8879d
commit 6bec390ecd
20 files changed
+2074 -150

No files matched your search

+18 -8
View File
@@ -2,7 +2,11 @@
## 项目是什么
shellr 是一个类似 Xshell / WinSCP 的 SSH 会话管理工具,基于 `gpui-kit` 0.6.1(GPUI + gpui-base + gpui-component)。第一版是**纯 mock UI**:不接网络,只有内存样例数据。界面文案用中文,标识符用英文。已确认的产品决定:WinSCP 式双栏文件浏览器是每个会话独立的「SFTP」Dock 标签页;暂不做传输队列和 Dock 布局持久化。
shellr 是一个类似 Xshell / WinSCP 的 SSH 会话管理工具,基于 `gpui-kit` 0.6.1(GPUI + gpui-base + gpui-component)。界面文案用中文,标识符用英文。
会话和分组是**真实持久化**的,存在一个 SQLite 文件里(`~/Library/Application Support/shellr/shellr.db`,`SHELLR_DATA_DIR` 可覆盖目录)。本地终端也是真的(`portable-pty` + `alacritty_terminal`)。**仍然是 mock 的**:SSH 远程连接(「连接」只是把会话标成已连接并开一个预制回复的标签页)和 SFTP 文件浏览器(内存里的种子目录树)。
已确认的产品决定:WinSCP 式双栏文件浏览器是每个会话独立的「SFTP」Dock 标签页;暂不做传输队列和 Dock 布局持久化;首次启动是空库,不预置任何分组或会话;分组支持任意层级嵌套,会话也可以不属于任何分组(渲染在树的根层级);删除分组会连同其子分组和里面的会话一起删,删除前确认并写明数量。
做 UI 之前先读 `gpui-kit` 与 `gpui-kit-design-guides` 两个技能(其中的 Coding Guides 和 Design Guides 在本仓库是规范,不是参考)。不要凭记忆臆造 gpui-kit API:以 `~/.cargo/registry/src/*/gpui-component-0.6.1/`、`gpui-base-0.6.1/` 的源码或 `https://gpui-kit.com/component/<name>.md` 为准。
@@ -17,6 +21,8 @@ cargo test --test ui # 只跑 UI 集成测试
cargo test --test ui new_session -- --nocapture # 按名称片段跑单个 UI 测试
cargo clippy --all-targets -- -D warnings # 必须无警告
cargo fmt --check
sqlite3 ~/Library/Application\ Support/shellr/shellr.db '.schema' # 查落盘结果
```
工具链:`rust-toolchain.toml` 固定 Rust 1.98.1,因为 `gpui-pre` 用到了 1.94 尚未稳定的 std API。`Cargo.lock` 把 `gpui-pre*` 系列固定在 0.3.2(gpui-kit 0.6.1 发布时配套的版本),不要随手 `cargo update` 它们,除非确认仍能编译。`cargo test` 需要 gpui-kit 的 `test-support` 特性,首次会再编译一遍(同样慢)。
@@ -27,23 +33,27 @@ cargo fmt --check
单个二进制 crate,带 `src/lib.rs`,这样 `tests/ui.rs` 能驱动生产环境的 `Workspace`。模块按能力划分,将来可拆成独立 crate;功能模块不得依赖 `workspace`(窗口壳),也不得触碰彼此的内部实现。
- `app/` — `actions.rs` 定义全部用户命令(`gpui_kit::actions!` 单元动作 + 携带 `SessionId` 的 `session_action!` 动作);`mod.rs` 绑定快捷键、初始化 gpui-kit 并 `set_locale("zh-CN")`;`assets.rs` 用 `icon_assets!` 把额外的 Lucide 图标并入默认图标包——额外图标用 `CatalogIcon::*`,默认包用 `IconName::*`。
- `session/` — `model.rs`(Session / Group / Draft)、`store.rs`(`SessionStore` 实体,**唯一事实来源**)、`outline.rs`(纯函数:store → `TreeItem`,节点 id 形如 `g:<id>` / `s:<id>`)、`session_panel.rs`(左侧 Dock 面板:搜索 + 树 + 右键菜单)、`session_dialog.rs`(`SessionForm`、`open_session_dialog`、`confirm_delete_session`)。
- `terminal/` — `mock_shell.rs`(纯函数,预制回复)与 `terminal_panel.rs`(中间区标签页)。
- `app/` — `actions.rs` 定义全部用户命令(`gpui_kit::actions!` 单元动作 + 携带 `SessionId` / `GroupId` 的 `session_action!` / `group_action!` 动作);`paths.rs` 解析数据目录(`SHELLR_DATA_DIR` → `dirs::data_dir()/shellr`)与数据库路径;`mod.rs` 绑定快捷键、初始化 gpui-kit 并 `set_locale("zh-CN")`;`assets.rs` 用 `icon_assets!` 把额外的 Lucide 图标并入默认图标包——额外图标用 `CatalogIcon::*`,默认包用 `IconName::*`。
- `session/` — `model.rs`(Session / SessionGroup / SessionDraft / GroupDraft;`Session.group` 与 `SessionGroup.parent` 都是 `Option<GroupId>`)、`database.rs`(`SessionDatabase`:rusqlite 连接、`PRAGMA user_version` 迁移、行与模型的映射)、`store.rs`(`SessionStore` 实体,**内存是唯一事实来源**,写穿到数据库)、`outline.rs`(纯函数:store → `TreeItem`,递归铺开嵌套分组;节点 id 形如 `g:<id>` / `s:<id>`;`group_options` 给表单提供按全路径标注的分组列表)、`session_panel.rs`(左侧 Dock 面板:搜索 + 树 + 右键菜单)、`session_dialog.rs`、`group_dialog.rs`(`GroupForm`、`open_group_dialog`、`confirm_delete_group`)。
- `terminal/` — 本地终端是真的:`transport.rs`(字节流传输的 trait)、`local_pty.rs`(`portable-pty` 实现,跑在专用线程上)、`engine.rs`(驱动 `alacritty_terminal`,解析线程 + 16ms UI 轮询批量刷新)、`terminal_view.rs`(网格渲染、选区、输入编码)、`local_terminal_panel.rs`(⌘T 开的本地标签页)、`model.rs`。远程会话仍是 mock:`mock_shell.rs`(纯函数,预制回复)、`mock_transport.rs` 与 `terminal_panel.rs`(中间区标签页)。
- `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`、`welcome_panel.rs`(中间区的空状态,`DockEvent::LayoutChanged` 时若中间区为空会自动补回)。
- `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`)。
关键流程与不变量:
- **一个命令一个处理器。** 按钮、菜单、右键菜单、快捷键都只派发 `app/actions.rs` 里的动作,由 `Workspace` 处理。新增命令加在那里,不要写临时闭包直接改状态。
- **持久化写穿,内存说了算。** `SessionStore` 带 `Context` 的 mutator 负责「改内存 → 写库 → `notify`」,`*_unnotified` 那一半是纯内存的(给单元测试和加载用)。写库同步发生在 UI 线程:要落盘的都是用户在对话框里确认的单行写入,不是流。写失败时内存里的改动照样生效,store 发 `SessionStoreEvent::PersistFailed`,工作区订阅后弹一个错误通知——**绝不静默吞掉**。`ConnectionState` 是运行时状态,不入库;`last_connected_at` 入库,开始页的「最近连接」因此能跨启动。删除分组靠数据库两个外键的 `ON DELETE CASCADE`,内存里的 `remove_group_unnotified` 必须给出一样的结果,并把被删会话的 id 返回给工作区去关标签页。
- **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`,工作区据此清理注册表。
- **Dock 面板**需实现 `EventEmitter<PanelEvent> + Focusable + Render + BasePanel + Panel`,一律用 `panel_handle(...)` 包装;`tab_name` 保持 `None`,由 `title` 返回富元素——标签页的图标和「×」关闭按钮就是这样来的(`ClosableTabTitle` 通过 `on_added_to` 记下的 `TabGroup` 关闭自身)。`panel_name()` 是持久化键,一经选定不可更改。
- **Dock 面板**需实现 `EventEmitter<PanelEvent> + Focusable + Render + BasePanel + Panel`,一律用 `panel_handle(...)` 包装;`tab_name` 保持 `None`,由 `title` 返回富元素——标签页的图标和「×」关闭按钮就是这样来的(`ClosableTabTitle` 的「×」只派发面板的关闭动作,见下一条)。`panel_name()` 是持久化键,一经选定不可更改。
- **标签页全部可关。** `TabGroup::close_panel` 会拒绝关闭区域内最后一个面板,所以关闭不走 tab group:「×」派发 `CloseTerminal` / `CloseExplorer`,⌘W 派发 `CloseActiveTab`(关工作区记录的最近显示的中间标签,面板在 `set_active(true)` 时发出 `Activated` 事件告知),处理器一律用 `DockArea::remove_panel` 移除。中间区空了就显示最近连接列表:`SessionStore::recent_sessions()` 按「最近一次变为已连接」的顺序给出,`set_state` 变为 Connected 时把会话移到最前。
- **焦点规则。** `window.dispatch_action` 只能到达焦点元素路径上的处理器,所以工作区内部必须有东西持有焦点(启动时聚焦会话面板)。永远不要聚焦工作区根节点的 handle:对话框层是它的子元素,祖先持有焦点会让对话框的焦点陷阱拿不到焦点,导致「取消 / 创建」无响应。标题栏和开始页的按钮改用工作区 handle 上的 `FocusHandle::dispatch_action` 派发。对话框打开后焦点停在对话框宿主上,直到用户点击某个字段;延迟或下一帧的聚焦请求不会生效。
- **树的语义。** 树行的左键按下会同时选中并切换展开(无法抑制);双击打开靠 `ListItem` 上的 `ClickEvent::click_count() == 2`。`TreeState::set_items` 会清空选择,`SessionPanel::rebuild_tree` 按 id 恢复选择,展开状态由它自己的 `expanded` 集合维护。
- **树的语义。** 树行的左键按下会同时选中并切换展开(无法抑制);双击打开靠 `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`)。
- **渲染回调不得读取实体。** 树行渲染闭包和 `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`、`find`、`within`)驱动。测试模块里要显式导入类型,`use gpui_kit::*` 会遮蔽 `#[test]`。被测试查询的元素带稳定的领域 id,自定义 `div` 还要加 `.test_support()`:`("session-row", id)`、`("terminal", id)`、`("explorer", id)`、`("local-pane"|"remote-pane", id)`、`("close-terminal", id)`、`session-search`、`new-session`、`theme-toggle`、`status-connection`、`commit`、`form-error`、`session-name` / `-host` / `-port` / `-user`、`remote-path`。动作派发和 `set_active` 都是延迟执行的:先退出 `update_window`,调用 `cx.run_until_parked()`,再 `render_frame` 后断言。种子会话 id 按插入顺序(`web-01`=1 … `dev-box`=6);`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)`、`("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`、`group-name`、`remote-path`。动作派发和 `set_active` 都是延迟执行的:先退出 `update_window`,调用 `cx.run_until_parked()`,再 `render_frame` 后断言。种子会话 id 按插入顺序(`web-01`=1 … `dev-box`=6),种子分组同理(`生产`=1、`测试`=2、`开发`=3);`web-01` 与 `staging-api` 初始为已连接并已打开终端标签。
+15 -6
View File
@@ -4,7 +4,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## 项目是什么
shellr 是一个类似 Xshell / WinSCP 的 SSH 会话管理工具,基于 `gpui-kit` 0.6.1(GPUI + gpui-base + gpui-component)。第一版是**纯 mock UI**:不接网络,只有内存样例数据。界面文案用中文,标识符用英文。已确认的产品决定:WinSCP 式双栏文件浏览器是每个会话独立的「SFTP」Dock 标签页;暂不做传输队列和 Dock 布局持久化。
shellr 是一个类似 Xshell / WinSCP 的 SSH 会话管理工具,基于 `gpui-kit` 0.6.1(GPUI + gpui-base + gpui-component)。界面文案用中文,标识符用英文。
会话和分组是**真实持久化**的,存在一个 SQLite 文件里(`~/Library/Application Support/shellr/shellr.db`,`SHELLR_DATA_DIR` 可覆盖目录)。本地终端也是真的(`portable-pty` + `alacritty_terminal`)。**仍然是 mock 的**:SSH 远程连接(「连接」只是把会话标成已连接并开一个预制回复的标签页)和 SFTP 文件浏览器(内存里的种子目录树)。
已确认的产品决定:WinSCP 式双栏文件浏览器是每个会话独立的「SFTP」Dock 标签页;暂不做传输队列和 Dock 布局持久化;首次启动是空库,不预置任何分组或会话;分组支持任意层级嵌套,会话也可以不属于任何分组(渲染在树的根层级);删除分组会连同其子分组和里面的会话一起删,删除前确认并写明数量。
做 UI 之前先读 `gpui-kit` 与 `gpui-kit-design-guides` 两个技能(其中的 Coding Guides 和 Design Guides 在本仓库是规范,不是参考)。不要凭记忆臆造 gpui-kit API:以 `~/.cargo/registry/src/*/gpui-component-0.6.1/`、`gpui-base-0.6.1/` 的源码或 `https://gpui-kit.com/component/<name>.md` 为准。
@@ -19,6 +23,8 @@ cargo test --test ui # 只跑 UI 集成测试
cargo test --test ui new_session -- --nocapture # 按名称片段跑单个 UI 测试
cargo clippy --all-targets -- -D warnings # 必须无警告
cargo fmt --check
sqlite3 ~/Library/Application\ Support/shellr/shellr.db '.schema' # 查落盘结果
```
工具链:`rust-toolchain.toml` 固定 Rust 1.98.1,因为 `gpui-pre` 用到了 1.94 尚未稳定的 std API。`Cargo.lock` 把 `gpui-pre*` 系列固定在 0.3.2(gpui-kit 0.6.1 发布时配套的版本),不要随手 `cargo update` 它们,除非确认仍能编译。`cargo test` 需要 gpui-kit 的 `test-support` 特性,首次会再编译一遍(同样慢)。
@@ -29,9 +35,9 @@ cargo fmt --check
单个二进制 crate,带 `src/lib.rs`,这样 `tests/ui.rs` 能驱动生产环境的 `Workspace`。模块按能力划分,将来可拆成独立 crate;功能模块不得依赖 `workspace`(窗口壳),也不得触碰彼此的内部实现。
- `app/` — `actions.rs` 定义全部用户命令(`gpui_kit::actions!` 单元动作 + 携带 `SessionId` 的 `session_action!` 动作);`mod.rs` 绑定快捷键、初始化 gpui-kit 并 `set_locale("zh-CN")`;`assets.rs` 用 `icon_assets!` 把额外的 Lucide 图标并入默认图标包——额外图标用 `CatalogIcon::*`,默认包用 `IconName::*`。
- `session/` — `model.rs`(Session / Group / Draft)、`store.rs`(`SessionStore` 实体,**唯一事实来源**)、`outline.rs`(纯函数:store → `TreeItem`,节点 id 形如 `g:<id>` / `s:<id>`)、`session_panel.rs`(左侧 Dock 面板:搜索 + 树 + 右键菜单)、`session_dialog.rs`(`SessionForm`、`open_session_dialog`、`confirm_delete_session`)。
- `terminal/` — `mock_shell.rs`(纯函数,预制回复)与 `terminal_panel.rs`(中间区标签页)。
- `app/` — `actions.rs` 定义全部用户命令(`gpui_kit::actions!` 单元动作 + 携带 `SessionId` / `GroupId` 的 `session_action!` / `group_action!` 动作);`paths.rs` 解析数据目录(`SHELLR_DATA_DIR` → `dirs::data_dir()/shellr`)与数据库路径;`mod.rs` 绑定快捷键、初始化 gpui-kit 并 `set_locale("zh-CN")`;`assets.rs` 用 `icon_assets!` 把额外的 Lucide 图标并入默认图标包——额外图标用 `CatalogIcon::*`,默认包用 `IconName::*`。
- `session/` — `model.rs`(Session / SessionGroup / SessionDraft / GroupDraft;`Session.group` 与 `SessionGroup.parent` 都是 `Option<GroupId>`)、`database.rs`(`SessionDatabase`:rusqlite 连接、`PRAGMA user_version` 迁移、行与模型的映射)、`store.rs`(`SessionStore` 实体,**内存是唯一事实来源**,写穿到数据库)、`outline.rs`(纯函数:store → `TreeItem`,递归铺开嵌套分组;节点 id 形如 `g:<id>` / `s:<id>`;`group_options` 给表单提供按全路径标注的分组列表)、`session_panel.rs`(左侧 Dock 面板:搜索 + 树 + 右键菜单)、`session_dialog.rs`、`group_dialog.rs`(`GroupForm`、`open_group_dialog`、`confirm_delete_group`)。
- `terminal/` — 本地终端是真的:`transport.rs`(字节流传输的 trait)、`local_pty.rs`(`portable-pty` 实现,跑在专用线程上)、`engine.rs`(驱动 `alacritty_terminal`,解析线程 + 16ms UI 轮询批量刷新)、`terminal_view.rs`(网格渲染、选区、输入编码)、`local_terminal_panel.rs`(⌘T 开的本地标签页)、`model.rs`。远程会话仍是 mock:`mock_shell.rs`(纯函数,预制回复)、`mock_transport.rs` 与 `terminal_panel.rs`(中间区标签页)。
- `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`)。
@@ -39,14 +45,17 @@ cargo fmt --check
关键流程与不变量:
- **一个命令一个处理器。** 按钮、菜单、右键菜单、快捷键都只派发 `app/actions.rs` 里的动作,由 `Workspace` 处理。新增命令加在那里,不要写临时闭包直接改状态。
- **持久化写穿,内存说了算。** `SessionStore` 带 `Context` 的 mutator 负责「改内存 → 写库 → `notify`」,`*_unnotified` 那一半是纯内存的(给单元测试和加载用)。写库同步发生在 UI 线程:要落盘的都是用户在对话框里确认的单行写入,不是流。写失败时内存里的改动照样生效,store 发 `SessionStoreEvent::PersistFailed`,工作区订阅后弹一个错误通知——**绝不静默吞掉**。`ConnectionState` 是运行时状态,不入库;`last_connected_at` 入库,开始页的「最近连接」因此能跨启动。删除分组靠数据库两个外键的 `ON DELETE CASCADE`,内存里的 `remove_group_unnotified` 必须给出一样的结果,并把被删会话的 id 返回给工作区去关标签页。
- **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`,工作区据此清理注册表。
- **Dock 面板**需实现 `EventEmitter<PanelEvent> + Focusable + Render + BasePanel + Panel`,一律用 `panel_handle(...)` 包装;`tab_name` 保持 `None`,由 `title` 返回富元素——标签页的图标和「×」关闭按钮就是这样来的(`ClosableTabTitle` 的「×」只派发面板的关闭动作,见下一条)。`panel_name()` 是持久化键,一经选定不可更改。
- **标签页全部可关。** `TabGroup::close_panel` 会拒绝关闭区域内最后一个面板,所以关闭不走 tab group:「×」派发 `CloseTerminal` / `CloseExplorer`,⌘W 派发 `CloseActiveTab`(关工作区记录的最近显示的中间标签,面板在 `set_active(true)` 时发出 `Activated` 事件告知),处理器一律用 `DockArea::remove_panel` 移除。中间区空了就显示最近连接列表:`SessionStore::recent_sessions()` 按「最近一次变为已连接」的顺序给出,`set_state` 变为 Connected 时把会话移到最前。
- **焦点规则。** `window.dispatch_action` 只能到达焦点元素路径上的处理器,所以工作区内部必须有东西持有焦点(启动时聚焦会话面板)。永远不要聚焦工作区根节点的 handle:对话框层是它的子元素,祖先持有焦点会让对话框的焦点陷阱拿不到焦点,导致「取消 / 创建」无响应。标题栏和开始页的按钮改用工作区 handle 上的 `FocusHandle::dispatch_action` 派发。对话框打开后焦点停在对话框宿主上,直到用户点击某个字段;延迟或下一帧的聚焦请求不会生效。
- **树的语义。** 树行的左键按下会同时选中并切换展开(无法抑制);双击打开靠 `ListItem` 上的 `ClickEvent::click_count() == 2`。`TreeState::set_items` 会清空选择,`SessionPanel::rebuild_tree` 按 id 恢复选择,展开状态由它自己的 `expanded` 集合维护。
- **树的语义。** 树行的左键按下会同时选中并切换展开(无法抑制);双击打开靠 `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`)。
- **渲染回调不得读取实体。** 树行渲染闭包和 `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`、`find`、`within`)驱动。测试模块里要显式导入类型,`use gpui_kit::*` 会遮蔽 `#[test]`。被测试查询的元素带稳定的领域 id,自定义 `div` 还要加 `.test_support()`:`("session-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`、`theme-toggle`、`status-connection`、`commit`、`form-error`、`session-name` / `-host` / `-port` / `-user`、`remote-path`。动作派发和 `set_active` 都是延迟执行的:先退出 `update_window`,调用 `cx.run_until_parked()`,再 `render_frame` 后断言。种子会话 id 按插入顺序(`web-01`=1 … `dev-box`=6);`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)`、`("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`、`group-name`、`remote-path`。动作派发和 `set_active` 都是延迟执行的:先退出 `update_window`,调用 `cx.run_until_parked()`,再 `render_frame` 后断言。种子会话 id 按插入顺序(`web-01`=1 … `dev-box`=6),种子分组同理(`生产`=1、`测试`=2、`开发`=3);`web-01` 与 `staging-api` 初始为已连接并已打开终端标签。
Generated
+81
View File
@@ -1769,6 +1769,18 @@ dependencies = [
"zune-inflate",
]
[[package]]
name = "fallible-iterator"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
[[package]]
name = "fallible-streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "fastrand"
version = "2.5.0"
@@ -3031,6 +3043,18 @@ name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash 0.2.0",
]
[[package]]
name = "hashlink"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb"
dependencies = [
"hashbrown 0.17.1",
]
[[package]]
name = "heapless"
@@ -3740,6 +3764,17 @@ dependencies = [
"libc",
]
[[package]]
name = "libsqlite3-sys"
version = "0.38.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "linebender_resource_handle"
version = "0.1.1"
@@ -5769,6 +5804,31 @@ dependencies = [
"memchr",
]
[[package]]
name = "rsqlite-vfs"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c"
dependencies = [
"hashbrown 0.16.1",
"thiserror 2.0.20",
]
[[package]]
name = "rusqlite"
version = "0.40.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3"
dependencies = [
"bitflags 2.13.2",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
"libsqlite3-sys",
"smallvec",
"sqlite-wasm-rs",
]
[[package]]
name = "rust-embed"
version = "8.12.0"
@@ -6338,9 +6398,12 @@ dependencies = [
"alacritty_terminal",
"anyhow",
"async-channel",
"dirs 6.0.0",
"gpui-kit",
"portable-pty",
"rusqlite",
"serde",
"tempfile",
"unicode-width",
]
@@ -6517,6 +6580,18 @@ dependencies = [
"bitflags 2.13.2",
]
[[package]]
name = "sqlite-wasm-rs"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75"
dependencies = [
"cc",
"js-sys",
"rsqlite-vfs",
"wasm-bindgen",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
@@ -7518,6 +7593,12 @@ dependencies = [
"sval_serde",
]
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version_check"
version = "0.9.5"
+3
View File
@@ -10,12 +10,15 @@ gpui-kit = "0.6.1"
alacritty_terminal = "0.26"
anyhow = "1"
async-channel = "2"
dirs = "6"
portable-pty = "0.9"
rusqlite = { version = "0.40", features = ["bundled"] }
serde = { version = "1", features = ["derive"] }
unicode-width = "0.2"
[dev-dependencies]
gpui-kit = { version = "0.6.1", features = ["test-support"] }
tempfile = "3"
# Optimize the rendering stack in debug builds (per gpui-kit's installation
# guide); shellr itself stays unoptimized and debuggable.
+33 -2
View File
@@ -4,13 +4,18 @@
use gpui_kit::*;
use serde::Deserialize;
use crate::{session::SessionId, terminal::LocalTerminalId};
use crate::{
session::{GroupId, SessionId},
terminal::LocalTerminalId,
};
gpui_kit::actions!(
shellr,
[
/// Open the new-session dialog.
/// Open the new-session dialog for a session at the root of the tree.
NewSession,
/// Open the new-group dialog for a top-level group.
NewGroup,
/// Open a new local login-shell terminal.
NewLocalTerminal,
/// Copy the active terminal selection.
@@ -47,6 +52,15 @@ macro_rules! session_action {
};
}
macro_rules! group_action {
($(#[$doc:meta])* $name:ident) => {
$(#[$doc])*
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
#[action(namespace = shellr, no_json)]
pub struct $name(pub GroupId);
};
}
macro_rules! local_terminal_action {
($(#[$doc:meta])* $name:ident) => {
$(#[$doc])*
@@ -93,6 +107,23 @@ session_action!(
DeleteSession
);
group_action!(
/// Open the new-session dialog with this group pre-selected.
NewSessionInGroup
);
group_action!(
/// Open the new-group dialog for a group nested inside this one.
NewChildGroup
);
group_action!(
/// Open the rename-group dialog.
RenameGroup
);
group_action!(
/// Ask for confirmation, then delete a group with everything inside it.
DeleteGroup
);
local_terminal_action!(
/// Close a local terminal tab and terminate its child process.
CloseLocalTerminal
+2 -1
View File
@@ -8,7 +8,8 @@ use gpui_kit::{AssetSource, Result, SharedString};
icon_assets!(
ExtraIcons,
[
Server, Terminal, Upload, Download, Trash, Pencil, RefreshCw, Plug, Unplug, FolderTree
Server, Terminal, Upload, Download, Trash, Pencil, RefreshCw, Plug, Unplug, FolderTree,
FolderPlus
]
);
+3
View File
@@ -2,9 +2,11 @@
mod actions;
mod assets;
mod paths;
pub use actions::*;
pub use assets::{AppAssets, CatalogIcon};
pub use paths::{data_dir, database_path};
use gpui_kit::component::dock::ToggleZoom;
use gpui_kit::*;
@@ -31,6 +33,7 @@ fn key_bindings() -> Vec<KeyBinding> {
let primary = |key: &str| format!("{PRIMARY}-{key}");
let mut bindings = vec![
KeyBinding::new(&primary("n"), NewSession, None),
KeyBinding::new(&primary("shift-n"), NewGroup, None),
KeyBinding::new(&primary("b"), ToggleSessionPanel, None),
KeyBinding::new(&primary("k"), FocusSearch, None),
KeyBinding::new(&primary("t"), NewLocalTerminal, None),
+26
View File
@@ -0,0 +1,26 @@
//! Where shellr keeps its data on disk.
use std::path::PathBuf;
/// Overrides the data directory. Set by tests and handy during development.
const DATA_DIR_ENV: &str = "SHELLR_DATA_DIR";
/// The directory holding shellr's own files, e.g.
/// `~/Library/Application Support/shellr` on macOS. Falls back to the
/// current directory on the platforms where `dirs` has nothing to offer.
pub fn data_dir() -> PathBuf {
if let Some(dir) = std::env::var_os(DATA_DIR_ENV) {
return PathBuf::from(dir);
}
dirs::data_dir()
.map(|dir| dir.join("shellr"))
.unwrap_or_else(|| PathBuf::from("."))
}
/// The SQLite file holding sessions and groups. The parent directory is
/// created if it does not exist yet.
pub fn database_path() -> std::io::Result<PathBuf> {
let dir = data_dir();
std::fs::create_dir_all(&dir)?;
Ok(dir.join("shellr.db"))
}
+31 -2
View File
@@ -1,6 +1,8 @@
use gpui_kit::component::Root;
use gpui_kit::component::{Root, WindowExt as _, notification::Notification};
use gpui_kit::*;
use shellr::session::{SessionDatabase, SessionStore};
fn main() {
gpui_kit::application()
.with_assets(shellr::app::AppAssets)
@@ -13,7 +15,13 @@ fn main() {
cx.open_window(options, |window, cx| {
window.activate_window();
window.set_window_title("shellr");
let workspace = cx.new(|cx| shellr::workspace::Workspace::new(window, cx));
let (store, problem) = open_store();
let store = cx.new(|_| store);
let workspace =
cx.new(|cx| shellr::workspace::Workspace::new(store, window, cx));
if let Some(problem) = problem {
window.push_notification(Notification::error(problem), cx);
}
cx.new(|cx| Root::new(workspace, window, cx))
})
.expect("failed to open window");
@@ -21,3 +29,24 @@ fn main() {
.detach();
});
}
/// Load the sessions saved on disk. If the database cannot be opened the
/// window still comes up, on a store that forgets everything when it closes;
/// the returned message says so.
fn open_store() -> (SessionStore, Option<SharedString>) {
match load_store() {
Ok(store) => (store, None),
Err(error) => {
eprintln!("shellr: 无法打开本地数据库:{error}");
(
SessionStore::empty(),
Some(format!("无法打开本地数据库,本次运行的改动不会被保存:{error}").into()),
)
}
}
}
fn load_store() -> anyhow::Result<SessionStore> {
let path = shellr::app::database_path()?;
Ok(SessionStore::load(SessionDatabase::open(&path)?)?)
}
+382
View File
@@ -0,0 +1,382 @@
//! The SQLite file behind the session store.
//!
//! Everything here is synchronous and runs on the UI thread: the writes are
//! single rows triggered by a dialog the user just confirmed, not a stream.
//! The store keeps memory as the source of truth and mirrors each change
//! here, so a failed write costs the user persistence, never the edit.
use std::path::Path;
use rusqlite::{Connection, params};
use super::{AuthKind, GroupDraft, GroupId, Session, SessionDraft, SessionGroup, SessionId};
/// SQLite's only integer type is `i64`, so the `u64` ids cross the boundary
/// as `i64`. They are counters that start at 1 and never come near the range
/// where the round trip would lose anything.
fn to_sql(id: u64) -> i64 {
id as i64
}
fn from_sql(id: i64) -> u64 {
id as u64
}
/// Bumped whenever `migrate` gains a step. Stored in `PRAGMA user_version`.
const SCHEMA_VERSION: i64 = 1;
const SCHEMA_V1: &str = "\
BEGIN;
CREATE TABLE groups (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
parent_id INTEGER REFERENCES groups(id) ON DELETE CASCADE
);
CREATE TABLE sessions (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
host TEXT NOT NULL,
port INTEGER NOT NULL,
username TEXT NOT NULL,
auth TEXT NOT NULL,
group_id INTEGER REFERENCES groups(id) ON DELETE CASCADE,
last_connected_at INTEGER
);
CREATE INDEX sessions_group_id ON sessions(group_id);
PRAGMA user_version = 1;
COMMIT;";
/// Everything one launch reads back from disk.
pub struct StoredData {
/// Groups in insertion order; `parent` gives the nesting.
pub groups: Vec<SessionGroup>,
/// Sessions in insertion order, all of them disconnected.
pub sessions: Vec<Session>,
/// Sessions that have ever connected, most recently connected first.
pub recent: Vec<SessionId>,
}
pub struct SessionDatabase {
connection: Connection,
}
impl SessionDatabase {
/// Open (creating it if needed) the database at `path` and migrate it.
pub fn open(path: &Path) -> rusqlite::Result<Self> {
Self::prepare(Connection::open(path)?)
}
/// An anonymous database that lives only as long as this value. Used by
/// tests, and as the fallback when the real file cannot be opened.
pub fn in_memory() -> rusqlite::Result<Self> {
Self::prepare(Connection::open_in_memory()?)
}
fn prepare(mut connection: Connection) -> rusqlite::Result<Self> {
// Cascading deletes are how removing a group removes its subtree, and
// SQLite only honours the foreign keys when asked, per connection.
connection.pragma_update(None, "foreign_keys", true)?;
connection.execute_batch("PRAGMA journal_mode = WAL;")?;
migrate(&mut connection)?;
Ok(Self { connection })
}
pub fn load(&self) -> rusqlite::Result<StoredData> {
let groups = self
.connection
.prepare("SELECT id, name, parent_id FROM groups ORDER BY id")?
.query_map([], |row| {
let id: i64 = row.get(0)?;
let name: String = row.get(1)?;
let parent: Option<i64> = row.get(2)?;
Ok(SessionGroup::new(
GroupId(from_sql(id)),
GroupDraft::new(name, parent.map(|id| GroupId(from_sql(id)))),
))
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
let sessions = self
.connection
.prepare(
"SELECT id, name, host, port, username, auth, group_id \
FROM sessions ORDER BY id",
)?
.query_map([], |row| {
let id: i64 = row.get(0)?;
let name: String = row.get(1)?;
let host: String = row.get(2)?;
let port: u16 = row.get(3)?;
let user: String = row.get(4)?;
let auth: String = row.get(5)?;
let group: Option<i64> = row.get(6)?;
Ok(Session::new(
SessionId(from_sql(id)),
SessionDraft::new(
name,
host,
port,
user,
AuthKind::from_stored(&auth),
group.map(|id| GroupId(from_sql(id))),
),
))
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
let recent = self
.connection
.prepare(
"SELECT id FROM sessions WHERE last_connected_at IS NOT NULL \
ORDER BY last_connected_at DESC, id DESC",
)?
.query_map([], |row| {
row.get::<_, i64>(0).map(|id| SessionId(from_sql(id)))
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(StoredData {
groups,
sessions,
recent,
})
}
pub fn insert_group(&self, group: &SessionGroup) -> rusqlite::Result<()> {
self.connection.execute(
"INSERT INTO groups (id, name, parent_id) VALUES (?1, ?2, ?3)",
params![
to_sql(group.id.0),
group.name.as_ref(),
group.parent.map(|parent| to_sql(parent.0)),
],
)?;
Ok(())
}
pub fn update_group(&self, group: &SessionGroup) -> rusqlite::Result<()> {
self.connection.execute(
"UPDATE groups SET name = ?2, parent_id = ?3 WHERE id = ?1",
params![
to_sql(group.id.0),
group.name.as_ref(),
group.parent.map(|parent| to_sql(parent.0)),
],
)?;
Ok(())
}
/// Deleting a group takes its subgroups and their sessions with it, via
/// the `ON DELETE CASCADE` on both foreign keys.
pub fn remove_group(&self, id: GroupId) -> rusqlite::Result<()> {
self.connection
.execute("DELETE FROM groups WHERE id = ?1", params![to_sql(id.0)])?;
Ok(())
}
pub fn insert_session(&self, session: &Session) -> rusqlite::Result<()> {
self.connection.execute(
"INSERT INTO sessions (id, name, host, port, username, auth, group_id) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![
to_sql(session.id.0),
session.name.as_ref(),
session.host.as_ref(),
session.port,
session.user.as_ref(),
session.auth.as_str(),
session.group.map(|group| to_sql(group.0)),
],
)?;
Ok(())
}
/// Rewrites the editable fields. `last_connected_at` is left alone: it is
/// written by `touch_connected` and is not part of the session form.
pub fn update_session(&self, session: &Session) -> rusqlite::Result<()> {
self.connection.execute(
"UPDATE sessions SET name = ?2, host = ?3, port = ?4, username = ?5, \
auth = ?6, group_id = ?7 WHERE id = ?1",
params![
to_sql(session.id.0),
session.name.as_ref(),
session.host.as_ref(),
session.port,
session.user.as_ref(),
session.auth.as_str(),
session.group.map(|group| to_sql(group.0)),
],
)?;
Ok(())
}
pub fn remove_session(&self, id: SessionId) -> rusqlite::Result<()> {
self.connection
.execute("DELETE FROM sessions WHERE id = ?1", params![to_sql(id.0)])?;
Ok(())
}
/// Record that a session just connected, which is what orders the start
/// page's recent list across launches.
pub fn touch_connected(&self, id: SessionId, at: u64) -> rusqlite::Result<()> {
self.connection.execute(
"UPDATE sessions SET last_connected_at = ?2 WHERE id = ?1",
params![to_sql(id.0), to_sql(at)],
)?;
Ok(())
}
}
fn migrate(connection: &mut Connection) -> rusqlite::Result<()> {
let version: i64 = connection.pragma_query_value(None, "user_version", |row| row.get(0))?;
if version >= SCHEMA_VERSION {
return Ok(());
}
if version < 1 {
connection.execute_batch(SCHEMA_V1)?;
}
Ok(())
}
/// Seconds since the Unix epoch, for `touch_connected`.
pub fn now_seconds() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|elapsed| elapsed.as_secs())
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
fn group(id: u64, name: &str, parent: Option<u64>) -> SessionGroup {
SessionGroup::new(GroupId(id), GroupDraft::new(name, parent.map(GroupId)))
}
fn session(id: u64, name: &str, group: Option<u64>) -> Session {
Session::new(
SessionId(id),
SessionDraft::new(
name,
"10.0.0.1",
22,
"root",
AuthKind::Password,
group.map(GroupId),
),
)
}
#[test]
fn a_fresh_database_is_empty_and_migrated() {
let db = SessionDatabase::in_memory().unwrap();
let version: i64 = db
.connection
.pragma_query_value(None, "user_version", |row| row.get(0))
.unwrap();
assert_eq!(version, SCHEMA_VERSION);
let data = db.load().unwrap();
assert!(data.groups.is_empty());
assert!(data.sessions.is_empty());
assert!(data.recent.is_empty());
}
#[test]
fn round_trips_nested_groups_and_sessions() {
let db = SessionDatabase::in_memory().unwrap();
db.insert_group(&group(1, "生产", None)).unwrap();
db.insert_group(&group(2, "数据库", Some(1))).unwrap();
db.insert_session(&session(1, "web-01", Some(1))).unwrap();
db.insert_session(&session(2, "db-01", Some(2))).unwrap();
// A session at the root of the tree, outside every group.
db.insert_session(&session(3, "jump", None)).unwrap();
let data = db.load().unwrap();
assert_eq!(data.groups.len(), 2);
assert_eq!(data.groups[1].parent, Some(GroupId(1)));
let names: Vec<_> = data.sessions.iter().map(|s| s.name.as_ref()).collect();
assert_eq!(names, ["web-01", "db-01", "jump"]);
assert_eq!(data.sessions[2].group, None);
assert_eq!(data.sessions[0].auth, AuthKind::Password);
// Connection state is runtime only: everything loads disconnected.
assert!(data.sessions.iter().all(|s| !s.state.is_connected()));
}
#[test]
fn updating_rewrites_the_editable_fields() {
let db = SessionDatabase::in_memory().unwrap();
db.insert_group(&group(1, "生产", None)).unwrap();
db.insert_group(&group(2, "测试", None)).unwrap();
db.insert_session(&session(1, "web-01", Some(1))).unwrap();
let mut moved = session(1, "web-01", Some(2));
moved.port = 2222;
moved.auth = AuthKind::Key;
db.update_session(&moved).unwrap();
db.update_group(&group(2, "预发", None)).unwrap();
let data = db.load().unwrap();
assert_eq!(data.sessions[0].port, 2222);
assert_eq!(data.sessions[0].auth, AuthKind::Key);
assert_eq!(data.sessions[0].group, Some(GroupId(2)));
assert_eq!(data.groups[1].name.as_ref(), "预发");
}
#[test]
fn removing_a_group_cascades_to_subgroups_and_their_sessions() {
let db = SessionDatabase::in_memory().unwrap();
db.insert_group(&group(1, "生产", None)).unwrap();
db.insert_group(&group(2, "数据库", Some(1))).unwrap();
db.insert_group(&group(3, "测试", None)).unwrap();
db.insert_session(&session(1, "web-01", Some(1))).unwrap();
db.insert_session(&session(2, "db-01", Some(2))).unwrap();
db.insert_session(&session(3, "qa", Some(3))).unwrap();
db.insert_session(&session(4, "jump", None)).unwrap();
db.remove_group(GroupId(1)).unwrap();
let data = db.load().unwrap();
let groups: Vec<_> = data.groups.iter().map(|g| g.name.as_ref()).collect();
assert_eq!(groups, ["测试"]);
let sessions: Vec<_> = data.sessions.iter().map(|s| s.name.as_ref()).collect();
assert_eq!(sessions, ["qa", "jump"]);
}
#[test]
fn recent_lists_the_most_recently_connected_first() {
let db = SessionDatabase::in_memory().unwrap();
for id in 1..=3 {
db.insert_session(&session(id, &format!("s{id}"), None))
.unwrap();
}
db.touch_connected(SessionId(1), 100).unwrap();
db.touch_connected(SessionId(3), 300).unwrap();
// Session 2 never connected and stays out of the list.
assert_eq!(
db.load().unwrap().recent,
[SessionId(3), SessionId(1)],
"newest connection first"
);
db.touch_connected(SessionId(1), 400).unwrap();
assert_eq!(db.load().unwrap().recent, [SessionId(1), SessionId(3)]);
}
#[test]
fn reopening_the_same_file_keeps_the_data() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("shellr.db");
{
let db = SessionDatabase::open(&path).unwrap();
db.insert_group(&group(1, "生产", None)).unwrap();
db.insert_session(&session(1, "web-01", Some(1))).unwrap();
db.touch_connected(SessionId(1), 42).unwrap();
}
let db = SessionDatabase::open(&path).unwrap();
let data = db.load().unwrap();
assert_eq!(data.groups.len(), 1);
assert_eq!(data.sessions[0].name.as_ref(), "web-01");
assert_eq!(data.recent, [SessionId(1)]);
}
}
+285
View File
@@ -0,0 +1,285 @@
use gpui_kit::component::{
ActiveTheme as _, IndexPath, Sizable as _, WindowExt as _,
button::{Button, ButtonVariant, ButtonVariants as _},
dialog::{DialogAction, DialogButtonProps, DialogClose, DialogFooter},
form::{Field, Form},
input::{Input, InputState},
select::{Select, SelectState},
v_flex,
};
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;
use super::{DeleteHandler, GroupDraft, GroupId, SessionStore, group_options};
/// The label of the row that stands for "no parent" / "no group".
pub const ROOT_LABEL: &str = "(顶层)";
/// The body of the new/rename group dialog. Owns the field states and
/// validates on commit; the store is only touched when validation passes.
pub struct GroupForm {
store: Entity<SessionStore>,
editing: Option<GroupId>,
name: Entity<InputState>,
parent: Entity<SelectState<Vec<SharedString>>>,
/// Parallel to the parent select's rows; `None` is the top level.
parent_ids: Vec<Option<GroupId>>,
error: Option<SharedString>,
}
impl GroupForm {
pub fn new(
editing: Option<GroupId>,
default_parent: Option<GroupId>,
store: Entity<SessionStore>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let (draft, options) = {
let read = store.read(cx);
let draft = editing
.and_then(|id| read.group(id))
.map(|group| group.draft())
.unwrap_or_else(|| GroupDraft::new("", default_parent));
// A group cannot become its own ancestor, so its subtree is not
// offered as a destination.
let mut excluded = editing
.map(|id| read.descendant_groups(id))
.unwrap_or_default();
excluded.extend(editing);
(draft, group_options(read.groups(), &excluded))
};
let mut parent_ids: Vec<Option<GroupId>> = vec![None];
let mut labels: Vec<SharedString> = vec![ROOT_LABEL.into()];
for (id, path) in options {
parent_ids.push(Some(id));
labels.push(path);
}
let name = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("例如 生产")
.default_value(draft.name.clone())
});
let parent_ix = parent_ids
.iter()
.position(|id| *id == draft.parent)
.unwrap_or(0);
let parent =
cx.new(|cx| SelectState::new(labels, Some(IndexPath::new(parent_ix)), window, cx));
Self {
store,
editing,
name,
parent,
parent_ids,
error: None,
}
}
/// Validate and write to the store. Returns whether the dialog may close.
pub fn commit(&mut self, _: &mut Window, cx: &mut Context<Self>) -> bool {
let name = self.name.read(cx).value().trim().to_string();
let parent = self
.parent
.read(cx)
.selected_index(cx)
.and_then(|ix| self.parent_ids.get(ix.row).copied())
.unwrap_or(None);
let editing = self.editing;
let duplicate = self.store.read(cx).groups().iter().any(|group| {
group.parent == parent && group.name == name.as_str() && Some(group.id) != editing
});
let error = if name.is_empty() {
Some("请输入分组名称")
} else if duplicate {
Some("同一层级下已有同名分组")
} else {
None
};
if let Some(error) = error {
self.error = Some(error.into());
cx.notify();
return false;
}
let draft = GroupDraft::new(name, parent);
let committed = self.store.update(cx, |store, cx| match editing {
Some(id) => store.update_group(id, draft, cx),
None => {
store.insert_group(draft, cx);
true
}
});
if !committed {
self.error = Some("无法把分组移动到它自己的下级".into());
cx.notify();
return false;
}
self.error = None;
true
}
}
impl Render for GroupForm {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.gap_3()
.w_full()
.child(
Form::new()
.child(
Field::new()
.label("名称")
.required(true)
.child(Input::new(&self.name).id("group-name").small()),
)
.child(
Field::new()
.label("上级分组")
.child(Select::new(&self.parent).small()),
),
)
.when_some(self.error.clone(), |form, error| {
form.child(
div()
.id("form-error")
.test_support()
.aria_label(error.clone())
.text_sm()
.text_color(cx.theme().danger)
.child(error),
)
})
}
}
/// Open the new-group (`editing == None`) or rename-group dialog.
pub fn open_group_dialog(
editing: Option<GroupId>,
default_parent: Option<GroupId>,
store: Entity<SessionStore>,
window: &mut Window,
cx: &mut App,
) {
let form = cx.new(|cx| GroupForm::new(editing, default_parent, store, window, cx));
let title: SharedString = if editing.is_some() {
"重命名分组"
} else {
"新建分组"
}
.into();
let commit_label: SharedString = if editing.is_some() {
"保存"
} else {
"创建"
}
.into();
window.open_dialog(cx, {
let form = form.clone();
move |dialog, _, _| {
dialog
.title(title.clone())
.child(form.clone())
.footer(
DialogFooter::new()
.child(DialogClose::new().trigger(|button| button.label("取消")))
.child(
DialogAction::new()
.child(Button::new("commit").primary().label(commit_label.clone())),
),
)
.on_ok({
let form = form.clone();
move |_, window, cx| form.update(cx, |form, cx| form.commit(window, cx))
})
}
});
}
/// Ask before deleting a group. Deleting one takes its subgroups and every
/// session inside them, so the counts go in the description.
pub fn confirm_delete_group(
name: &str,
sessions: usize,
subgroups: usize,
closes_tabs: bool,
on_delete: DeleteHandler,
window: &mut Window,
cx: &mut App,
) {
let title: SharedString = format!("删除“{name}”?").into();
let description = describe_contents(sessions, subgroups, closes_tabs);
window.open_alert_dialog(cx, move |alert, _, _| {
alert
.title(title.clone())
.when_some(description.clone(), |alert, description| {
alert.description(description)
})
.button_props(
DialogButtonProps::default()
.ok_text("删除")
.ok_variant(ButtonVariant::Danger)
.cancel_text("取消"),
)
.show_cancel(true)
.on_ok({
let on_delete = on_delete.clone();
move |_, window, cx| {
on_delete(window, cx);
true
}
})
});
}
/// What the delete dialog says about everything that goes with the group.
/// `None` for an empty group with nothing open.
fn describe_contents(sessions: usize, subgroups: usize, closes_tabs: bool) -> Option<SharedString> {
let contents = match (sessions, subgroups) {
(0, 0) => None,
(0, subgroups) => Some(format!("将同时删除其中的 {subgroups} 个子分组。")),
(sessions, 0) => Some(format!("将同时删除其中的 {sessions} 个会话。")),
(sessions, subgroups) => Some(format!(
"将同时删除其中的 {sessions} 个会话和 {subgroups} 个子分组。"
)),
};
let tabs = closes_tabs.then_some("已打开的终端和 SFTP 标签会一并关闭。");
match (contents, tabs) {
(None, None) => None,
(Some(contents), None) => Some(contents.into()),
(None, Some(tabs)) => Some(tabs.into()),
(Some(contents), Some(tabs)) => Some(format!("{contents}{tabs}").into()),
}
}
#[cfg(test)]
mod tests {
// Imported one by one: `use gpui_kit::*` at the top of this file would
// shadow `#[test]` with gpui-kit's own test macro.
use super::describe_contents;
#[test]
fn the_description_only_mentions_what_is_there() {
assert_eq!(describe_contents(0, 0, false), None);
assert_eq!(
describe_contents(3, 0, false).as_deref(),
Some("将同时删除其中的 3 个会话。")
);
assert_eq!(
describe_contents(0, 2, false).as_deref(),
Some("将同时删除其中的 2 个子分组。")
);
assert_eq!(
describe_contents(3, 2, true).as_deref(),
Some("将同时删除其中的 3 个会话和 2 个子分组。已打开的终端和 SFTP 标签会一并关闭。")
);
assert_eq!(
describe_contents(0, 0, true).as_deref(),
Some("已打开的终端和 SFTP 标签会一并关闭。")
);
}
}
+6 -2
View File
@@ -1,13 +1,17 @@
//! 会话管理: the session model, its store, and the session panel/dialog.
mod database;
mod group_dialog;
mod model;
mod outline;
mod session_dialog;
mod session_panel;
mod store;
pub use database::{SessionDatabase, StoredData};
pub use group_dialog::{GroupForm, confirm_delete_group, open_group_dialog};
pub use model::*;
pub use outline::{SessionNode, matches_query, session_tree_items};
pub use outline::{SessionNode, group_options, matches_query, session_tree_items};
pub use session_dialog::{DeleteHandler, SessionForm, confirm_delete_session, open_session_dialog};
pub use session_panel::SessionPanel;
pub use store::SessionStore;
pub use store::{SessionStore, SessionStoreEvent};
+56 -8
View File
@@ -27,9 +27,28 @@ impl AuthKind {
AuthKind::Key => "密钥",
}
}
/// The stored spelling. Kept separate from `label` so translating the UI
/// cannot rewrite what is already in the database.
pub fn as_str(self) -> &'static str {
match self {
AuthKind::Password => "password",
AuthKind::Key => "key",
}
}
/// Parse a stored spelling, falling back to the default for anything a
/// newer version might have written.
pub fn from_stored(value: &str) -> Self {
match value {
"password" => AuthKind::Password,
_ => AuthKind::Key,
}
}
}
/// Connection state of a session. Mock only: nothing is connected for real.
/// Connection state of a session. Runtime only: it is never persisted, so a
/// freshly loaded session always starts disconnected.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum ConnectionState {
#[default]
@@ -52,7 +71,8 @@ impl ConnectionState {
}
}
/// A saved SSH session.
/// A saved SSH session. `group` is `None` for a session that sits at the root
/// of the tree rather than inside a folder.
#[derive(Clone, Debug)]
#[non_exhaustive]
pub struct Session {
@@ -62,7 +82,7 @@ pub struct Session {
pub port: u16,
pub user: SharedString,
pub auth: AuthKind,
pub group: GroupId,
pub group: Option<GroupId>,
pub state: ConnectionState,
}
@@ -98,19 +118,30 @@ impl Session {
}
}
/// A folder in the session tree.
/// A folder in the session tree. Groups nest: `parent` is `None` for a
/// top-level folder.
#[derive(Clone, Debug)]
#[non_exhaustive]
pub struct SessionGroup {
pub id: GroupId,
pub name: SharedString,
pub parent: Option<GroupId>,
}
impl SessionGroup {
pub fn new(id: GroupId, name: impl Into<SharedString>) -> Self {
pub fn new(id: GroupId, draft: GroupDraft) -> Self {
Self {
id,
name: name.into(),
name: draft.name,
parent: draft.parent,
}
}
/// The editable fields, for pre-filling the group form.
pub fn draft(&self) -> GroupDraft {
GroupDraft {
name: self.name.clone(),
parent: self.parent,
}
}
}
@@ -124,7 +155,7 @@ pub struct SessionDraft {
pub port: u16,
pub user: SharedString,
pub auth: AuthKind,
pub group: GroupId,
pub group: Option<GroupId>,
}
impl SessionDraft {
@@ -134,7 +165,7 @@ impl SessionDraft {
port: u16,
user: impl Into<SharedString>,
auth: AuthKind,
group: GroupId,
group: Option<GroupId>,
) -> Self {
Self {
name: name.into(),
@@ -146,3 +177,20 @@ impl SessionDraft {
}
}
}
/// The values the group form commits.
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct GroupDraft {
pub name: SharedString,
pub parent: Option<GroupId>,
}
impl GroupDraft {
pub fn new(name: impl Into<SharedString>, parent: Option<GroupId>) -> Self {
Self {
name: name.into(),
parent,
}
}
}
+177 -21
View File
@@ -39,6 +39,13 @@ impl SessionNode {
SessionNode::Group(_) => None,
}
}
pub fn group_id(self) -> Option<GroupId> {
match self {
SessionNode::Group(id) => Some(id),
SessionNode::Session(_) => None,
}
}
}
/// Case-insensitive match on name, host or user.
@@ -52,8 +59,10 @@ pub fn matches_query(session: &Session, query: &str) -> bool {
.any(|field| field.to_lowercase().contains(&query))
}
/// Build the grouped tree. A non-empty query keeps only matching sessions,
/// drops groups without matches and expands the remaining groups; otherwise
/// Build the nested tree. Each level lists its subgroups first, then the
/// sessions that belong to it; sessions with no group sit at the root beside
/// the top-level groups. A non-empty query keeps only matching sessions, drops
/// the groups left without any, and expands what remains; otherwise
/// `expanded` decides which groups are open.
pub fn session_tree_items(
groups: &[SessionGroup],
@@ -62,35 +71,104 @@ pub fn session_tree_items(
expanded: &HashSet<GroupId>,
) -> Vec<TreeItem> {
let filtering = !query.trim().is_empty();
groups
.iter()
.filter_map(|group| {
let children: Vec<TreeItem> = sessions
.iter()
.filter(|s| s.group == group.id && matches_query(s, query))
.map(|s| TreeItem::new(SessionNode::Session(s.id).id(), s.name.clone()))
.collect();
if filtering && children.is_empty() {
return None;
}
Some(
TreeItem::new(SessionNode::Group(group.id).id(), group.name.clone())
.expanded(filtering || expanded.contains(&group.id))
.children(children),
)
})
.collect()
items_under(None, 0, groups, sessions, query, filtering, expanded)
}
fn items_under(
parent: Option<GroupId>,
depth: usize,
groups: &[SessionGroup],
sessions: &[Session],
query: &str,
filtering: bool,
expanded: &HashSet<GroupId>,
) -> Vec<TreeItem> {
// Nesting cannot run deeper than the number of groups unless the data has
// a cycle, which the store refuses to create. Stop rather than recurse
// forever if a hand-edited database ever produces one.
if depth > groups.len() {
return Vec::new();
}
let mut items: Vec<TreeItem> = Vec::new();
for group in groups.iter().filter(|group| group.parent == parent) {
let children = items_under(
Some(group.id),
depth + 1,
groups,
sessions,
query,
filtering,
expanded,
);
if filtering && children.is_empty() {
continue;
}
items.push(
TreeItem::new(SessionNode::Group(group.id).id(), group.name.clone())
.expanded(filtering || expanded.contains(&group.id))
.children(children),
);
}
items.extend(
sessions
.iter()
.filter(|s| s.group == parent && matches_query(s, query))
.map(|s| TreeItem::new(SessionNode::Session(s.id).id(), s.name.clone())),
);
items
}
/// Groups flattened depth first, each labelled by its full path (e.g.
/// `生产 / 数据库`), for the group pickers in the forms. An excluded group is
/// skipped along with everything below it, which is how the rename dialog
/// keeps a group from being moved into its own subtree.
pub fn group_options(
groups: &[SessionGroup],
excluded: &[GroupId],
) -> Vec<(GroupId, SharedString)> {
let mut options = Vec::new();
collect_group_options(groups, None, "", 0, excluded, &mut options);
options
}
fn collect_group_options(
groups: &[SessionGroup],
parent: Option<GroupId>,
prefix: &str,
depth: usize,
excluded: &[GroupId],
options: &mut Vec<(GroupId, SharedString)>,
) {
if depth > groups.len() {
return;
}
for group in groups.iter().filter(|group| group.parent == parent) {
if excluded.contains(&group.id) {
continue;
}
let path = if prefix.is_empty() {
group.name.to_string()
} else {
format!("{prefix} / {}", group.name)
};
options.push((group.id, path.as_str().into()));
collect_group_options(groups, Some(group.id), &path, depth + 1, excluded, options);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::session::SessionStore;
use crate::session::{AuthKind, GroupDraft, SessionDraft, SessionStore};
fn all_groups(store: &SessionStore) -> HashSet<GroupId> {
store.groups().iter().map(|g| g.id).collect()
}
fn labels(items: &[TreeItem]) -> Vec<String> {
items.iter().map(|item| item.label.to_string()).collect()
}
#[test]
fn node_id_round_trips() {
for node in [
@@ -149,4 +227,82 @@ mod tests {
assert_eq!(items[0].label.as_ref(), "开发");
assert!(items[0].is_expanded());
}
/// 生产 / 数据库 with one session each, plus a session at the root.
fn nested_store() -> SessionStore {
let mut store = SessionStore::empty();
let production = store.insert_group_unnotified(GroupDraft::new("生产", None));
let databases = store.insert_group_unnotified(GroupDraft::new("数据库", Some(production)));
for (name, group) in [
("web-01", Some(production)),
("db-01", Some(databases)),
("jump", None),
] {
store.insert_unnotified(SessionDraft::new(
name,
"10.0.0.1",
22,
"root",
AuthKind::Key,
group,
));
}
store
}
#[test]
fn subgroups_nest_and_ungrouped_sessions_sit_at_the_root() {
let store = nested_store();
let items = session_tree_items(store.groups(), store.sessions(), "", &all_groups(&store));
// A top-level group, then the session that belongs to no group.
assert_eq!(labels(&items), ["生产", "jump"]);
// Inside 生产: the subgroup first, then its own session.
assert_eq!(labels(&items[0].children), ["数据库", "web-01"]);
assert_eq!(labels(&items[0].children[0].children), ["db-01"]);
}
#[test]
fn a_match_deep_in_the_tree_keeps_its_whole_ancestor_chain() {
let store = nested_store();
let items = session_tree_items(store.groups(), store.sessions(), "db-01", &HashSet::new());
assert_eq!(labels(&items), ["生产"]);
assert!(items[0].is_expanded());
assert_eq!(labels(&items[0].children), ["数据库"]);
assert!(items[0].children[0].is_expanded());
assert_eq!(labels(&items[0].children[0].children), ["db-01"]);
}
#[test]
fn group_options_are_depth_first_and_skip_an_excluded_subtree() {
let mut store = SessionStore::empty();
let production = store.insert_group_unnotified(GroupDraft::new("生产", None));
let databases = store.insert_group_unnotified(GroupDraft::new("数据库", Some(production)));
store.insert_group_unnotified(GroupDraft::new("只读副本", Some(databases)));
store.insert_group_unnotified(GroupDraft::new("测试", None));
let paths: Vec<_> = group_options(store.groups(), &[])
.into_iter()
.map(|(_, path)| path.to_string())
.collect();
assert_eq!(
paths,
["生产", "生产 / 数据库", "生产 / 数据库 / 只读副本", "测试"]
);
// Renaming 数据库: neither it nor its children may become its parent.
let mut excluded = store.descendant_groups(databases);
excluded.push(databases);
let paths: Vec<_> = group_options(store.groups(), &excluded)
.into_iter()
.map(|(_, path)| path.to_string())
.collect();
assert_eq!(paths, ["生产", "测试"]);
}
#[test]
fn a_query_matching_only_a_root_session_drops_every_group() {
let store = nested_store();
let items = session_tree_items(store.groups(), store.sessions(), "jump", &HashSet::new());
assert_eq!(labels(&items), ["jump"]);
}
}
+25 -20
View File
@@ -12,7 +12,10 @@ use gpui_kit::component::{
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;
use super::{AuthKind, GroupId, Session, SessionDraft, SessionId, SessionStore};
use super::{AuthKind, GroupId, Session, SessionDraft, SessionId, SessionStore, group_options};
/// The label of the row that puts a session at the root of the tree.
pub const NO_GROUP_LABEL: &str = "(无分组)";
/// What runs when the user confirms deleting a session.
pub type DeleteHandler = Rc<dyn Fn(&mut Window, &mut App)>;
@@ -28,36 +31,36 @@ pub struct SessionForm {
user: Entity<InputState>,
auth: Entity<SelectState<Vec<&'static str>>>,
group: Entity<SelectState<Vec<SharedString>>>,
group_ids: Vec<GroupId>,
/// Parallel to the group select's rows; `None` is the root of the tree.
group_ids: Vec<Option<GroupId>>,
error: Option<SharedString>,
}
impl SessionForm {
pub fn new(
editing: Option<SessionId>,
preselect_group: Option<GroupId>,
store: Entity<SessionStore>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let (draft, groups) = {
let store = store.read(cx);
let (draft, options) = {
let read = store.read(cx);
(
editing.and_then(|id| store.session(id)).map(Session::draft),
store.groups().to_vec(),
editing.and_then(|id| read.session(id)).map(Session::draft),
group_options(read.groups(), &[]),
)
};
let group_ids: Vec<GroupId> = groups.iter().map(|group| group.id).collect();
let group_names: Vec<SharedString> =
groups.iter().map(|group| group.name.clone()).collect();
// A session with no group sits at the root of the tree, which is where
// every session starts when the database is still empty.
let mut group_ids: Vec<Option<GroupId>> = vec![None];
let mut group_names: Vec<SharedString> = vec![NO_GROUP_LABEL.into()];
for (id, path) in options {
group_ids.push(Some(id));
group_names.push(path);
}
let draft = draft.unwrap_or_else(|| {
SessionDraft::new(
"",
"",
22,
"root",
AuthKind::Key,
group_ids.first().copied().unwrap_or(GroupId(0)),
)
SessionDraft::new("", "", 22, "root", AuthKind::Key, preselect_group)
});
let name = cx.new(|cx| {
@@ -149,8 +152,7 @@ impl SessionForm {
.read(cx)
.selected_index(cx)
.and_then(|ix| self.group_ids.get(ix.row).copied())
.or_else(|| self.group_ids.first().copied())
.unwrap_or(GroupId(0));
.unwrap_or(None);
let draft = SessionDraft::new(
name,
host,
@@ -233,13 +235,16 @@ impl Render for SessionForm {
}
/// Open the new-session (`editing == None`) or edit-session dialog.
/// `preselect_group` fills in the group field of a new session, so creating
/// one from a group's context menu lands it in that group.
pub fn open_session_dialog(
editing: Option<SessionId>,
preselect_group: Option<GroupId>,
store: Entity<SessionStore>,
window: &mut Window,
cx: &mut App,
) {
let form = cx.new(|cx| SessionForm::new(editing, store, window, cx));
let form = cx.new(|cx| SessionForm::new(editing, preselect_group, store, window, cx));
let title: SharedString = if editing.is_some() {
"编辑会话"
} else {
+125 -10
View File
@@ -15,8 +15,9 @@ use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;
use crate::app::{
CatalogIcon, ConnectSelected, ConnectSession, DeleteSession, DisconnectSession,
DuplicateSession, EditSession, NewSession, OpenExplorer, SESSION_PANEL_CONTEXT,
CatalogIcon, ConnectSelected, ConnectSession, DeleteGroup, DeleteSession, DisconnectSession,
DuplicateSession, EditSession, NewChildGroup, NewGroup, NewSession, NewSessionInGroup,
OpenExplorer, RenameGroup, SESSION_PANEL_CONTEXT,
};
use super::{GroupId, SessionId, SessionNode, SessionStore, matches_query, session_tree_items};
@@ -31,16 +32,27 @@ pub struct SessionPanel {
search: Entity<InputState>,
query: String,
expanded: HashSet<GroupId>,
/// What the store held the last time the tree was rebuilt. Comparing
/// against these is how a group or session created in a dialog gets
/// revealed without the dialog having to report back to the panel.
known_groups: HashSet<GroupId>,
known_sessions: HashSet<SessionId>,
focus_handle: FocusHandle,
_subscriptions: Vec<Subscription>,
}
impl SessionPanel {
pub fn new(store: Entity<SessionStore>, window: &mut Window, cx: &mut Context<Self>) -> Self {
let expanded: HashSet<GroupId> = store.read(cx).groups().iter().map(|g| g.id).collect();
let items = {
let store = store.read(cx);
session_tree_items(store.groups(), store.sessions(), "", &expanded)
let (expanded, known_groups, known_sessions, items) = {
let read = store.read(cx);
let expanded: HashSet<GroupId> = read.groups().iter().map(|g| g.id).collect();
let items = session_tree_items(read.groups(), read.sessions(), "", &expanded);
(
expanded.clone(),
expanded,
read.sessions().iter().map(|s| s.id).collect(),
items,
)
};
let tree_state = cx.new(|cx| TreeState::new(cx).items(items));
let search = cx.new(|cx| {
@@ -50,7 +62,7 @@ impl SessionPanel {
});
let subscriptions = vec![
cx.observe(&store, |this, _, cx| this.rebuild_tree(cx)),
cx.observe(&store, |this, _, cx| this.on_store_changed(cx)),
cx.subscribe_in(
&search,
window,
@@ -83,11 +95,70 @@ impl SessionPanel {
search,
query: String::new(),
expanded,
known_groups,
known_sessions,
focus_handle: cx.focus_handle(),
_subscriptions: subscriptions,
}
}
/// Rebuild the tree, then put the cursor on whatever the store just
/// gained so a freshly created group or session is visible and selected.
fn on_store_changed(&mut self, cx: &mut Context<Self>) {
let created = self.take_created_node(cx);
self.rebuild_tree(cx);
match created {
Some(SessionNode::Group(id)) => self.select_group(id, cx),
Some(SessionNode::Session(id)) => self.select_session(id, cx),
None => {}
}
}
/// The node the store gained since the last rebuild, if any, with the
/// folders above it opened so it can be scrolled to.
fn take_created_node(&mut self, cx: &mut Context<Self>) -> Option<SessionNode> {
let (created, ancestors, groups, sessions) = {
let store = self.store.read(cx);
let created = store
.groups()
.iter()
.rev()
.find(|group| !self.known_groups.contains(&group.id))
.map(|group| SessionNode::Group(group.id))
.or_else(|| {
store
.sessions()
.iter()
.rev()
.find(|session| !self.known_sessions.contains(&session.id))
.map(|session| SessionNode::Session(session.id))
});
let ancestors = match created {
Some(SessionNode::Group(id)) => store.ancestor_groups(id),
Some(SessionNode::Session(id)) => store
.session(id)
.and_then(|session| session.group)
.map(|group| {
let mut chain = store.ancestor_groups(group);
chain.push(group);
chain
})
.unwrap_or_default(),
None => Vec::new(),
};
(
created,
ancestors,
store.groups().iter().map(|g| g.id).collect(),
store.sessions().iter().map(|s| s.id).collect(),
)
};
self.known_groups = groups;
self.known_sessions = sessions;
self.expanded.extend(ancestors);
created
}
/// Rebuild the tree from the store, keeping the selection by id.
fn rebuild_tree(&mut self, cx: &mut Context<Self>) {
let items = {
@@ -123,6 +194,17 @@ impl SessionPanel {
});
}
/// Select (and reveal) a group row, for example one just created.
pub fn select_group(&mut self, id: GroupId, cx: &mut Context<Self>) {
self.rebuild_tree(cx);
let row_id = SessionNode::Group(id).id();
self.tree_state.update(cx, |state, cx| {
state.reveal_item(&row_id, ScrollStrategy::Center, cx);
let ix = state.index_of(&row_id);
state.set_selected_index(ix, cx);
});
}
pub fn focus_search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.search.update(cx, |input, cx| input.focus(window, cx));
}
@@ -201,6 +283,10 @@ impl Panel for SessionPanel {
fn toolbar_buttons(&mut self, _: &mut Window, _: &mut Context<Self>) -> Option<Vec<Button>> {
Some(vec![
Button::new("new-group")
.icon(Icon::new(CatalogIcon::FolderPlus))
.tooltip("新建分组…")
.on_click(|_, window, cx| window.dispatch_action(Box::new(NewGroup), cx)),
Button::new("new-session-panel")
.icon(IconName::Plus)
.tooltip("新建会话…")
@@ -267,13 +353,13 @@ fn render_row(entry: &TreeEntry, connected: &HashSet<SessionId>, cx: &mut App) -
let item = entry.item();
let node = SessionNode::parse(&item.id);
let (icon, row_id): (Icon, ElementId) = match node {
Some(SessionNode::Group(_)) => (
Some(SessionNode::Group(id)) => (
Icon::new(if entry.is_expanded() {
IconName::FolderOpen
} else {
IconName::Folder
}),
item.id.clone().into(),
("group-row", id.0).into(),
),
Some(SessionNode::Session(id)) => {
(Icon::new(CatalogIcon::Server), ("session-row", id.0).into())
@@ -356,6 +442,35 @@ fn build_context_menu(
Icon::new(CatalogIcon::Trash),
Box::new(DeleteSession(id)),
),
_ => menu.menu_with_icon("新建会话…", Icon::new(IconName::Plus), Box::new(NewSession)),
Some(SessionNode::Group(id)) => menu
.menu_with_icon(
"新建会话…",
Icon::new(IconName::Plus),
Box::new(NewSessionInGroup(id)),
)
.menu_with_icon(
"新建子分组…",
Icon::new(CatalogIcon::FolderPlus),
Box::new(NewChildGroup(id)),
)
.separator()
.menu_with_icon(
"重命名分组…",
Icon::new(CatalogIcon::Pencil),
Box::new(RenameGroup(id)),
)
.separator()
.menu_with_icon(
"删除分组",
Icon::new(CatalogIcon::Trash),
Box::new(DeleteGroup(id)),
),
None => menu
.menu_with_icon("新建会话…", Icon::new(IconName::Plus), Box::new(NewSession))
.menu_with_icon(
"新建分组…",
Icon::new(CatalogIcon::FolderPlus),
Box::new(NewGroup),
),
}
}
+391 -51
View File
@@ -1,57 +1,112 @@
use gpui_kit::Context;
use gpui_kit::{Context, EventEmitter, SharedString};
use super::{AuthKind, ConnectionState, GroupId, Session, SessionDraft, SessionGroup, SessionId};
use super::database::now_seconds;
use super::{
AuthKind, ConnectionState, GroupDraft, GroupId, Session, SessionDatabase, SessionDraft,
SessionGroup, SessionId, StoredData,
};
/// The single source of truth for sessions and groups. Created once by the
/// workspace and shared with every panel and dialog; consumers observe it.
///
/// Mutators that take a `Context` notify observers; the `*_unnotified`
/// variants exist for pure tests and for callers that batch several changes
/// before one notification.
/// Mutators that take a `Context` notify observers and mirror the change into
/// the database; the `*_unnotified` variants are pure in-memory and exist for
/// tests, for loading, and for callers that batch several changes before one
/// notification.
///
/// Memory is authoritative. A failed write costs persistence, never the edit:
/// the change stands and a `SessionStoreEvent::PersistFailed` goes out so the
/// workspace can tell the user.
pub struct SessionStore {
groups: Vec<SessionGroup>,
sessions: Vec<Session>,
next_id: u64,
next_session_id: u64,
next_group_id: u64,
active: Option<SessionId>,
/// Sessions in the order they last connected, most recent first.
recent: Vec<SessionId>,
/// `None` for a memory-only store, as used by tests.
database: Option<SessionDatabase>,
}
/// How many sessions the start page lists as recently connected.
const MAX_RECENT: usize = 10;
/// What the store tells the workspace about, beyond plain change notification.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SessionStoreEvent {
/// A change was applied in memory but could not be written to disk.
PersistFailed(SharedString),
}
impl EventEmitter<SessionStoreEvent> for SessionStore {}
impl SessionStore {
/// An empty memory-only store.
pub fn empty() -> Self {
Self {
groups: Vec::new(),
sessions: Vec::new(),
next_id: 1,
next_session_id: 1,
next_group_id: 1,
active: None,
recent: Vec::new(),
database: None,
}
}
/// The mock data every launch starts with.
/// Read everything back from `database` and keep writing to it. Ids carry
/// over from disk, so the allocators resume past the largest stored id.
pub fn load(database: SessionDatabase) -> rusqlite::Result<Self> {
let StoredData {
groups,
sessions,
mut recent,
} = database.load()?;
recent.truncate(MAX_RECENT);
Ok(Self {
next_group_id: groups.iter().map(|group| group.id.0).max().unwrap_or(0) + 1,
next_session_id: sessions.iter().map(|s| s.id.0).max().unwrap_or(0) + 1,
groups,
sessions,
active: None,
recent,
database: Some(database),
})
}
/// Sample data for tests. Production starts from an empty database, so
/// nothing outside `tests/` builds a store this way; the UI tests depend
/// on the exact shape (ids 1..=6 in the order listed here).
pub fn seed() -> Self {
let production = GroupId(1);
let staging = GroupId(2);
let development = GroupId(3);
let mut store = Self::empty();
store.groups = vec![
SessionGroup::new(production, "生产"),
SessionGroup::new(staging, "测试"),
SessionGroup::new(development, "开发"),
];
let production = store.insert_group_unnotified(GroupDraft::new("生产", None));
let staging = store.insert_group_unnotified(GroupDraft::new("测试", None));
let development = store.insert_group_unnotified(GroupDraft::new("开发", None));
let drafts = [
SessionDraft::new("web-01", "10.0.1.12", 22, "root", AuthKind::Key, production),
SessionDraft::new("web-02", "10.0.1.13", 22, "root", AuthKind::Key, production),
SessionDraft::new(
"web-01",
"10.0.1.12",
22,
"root",
AuthKind::Key,
Some(production),
),
SessionDraft::new(
"web-02",
"10.0.1.13",
22,
"root",
AuthKind::Key,
Some(production),
),
SessionDraft::new(
"db-01",
"10.0.2.5",
22,
"postgres",
AuthKind::Password,
production,
Some(production),
),
SessionDraft::new(
"staging-api",
@@ -59,7 +114,7 @@ impl SessionStore {
2222,
"deploy",
AuthKind::Key,
staging,
Some(staging),
),
SessionDraft::new(
"qa-runner",
@@ -67,7 +122,7 @@ impl SessionStore {
22,
"ci",
AuthKind::Password,
staging,
Some(staging),
),
SessionDraft::new(
"dev-box",
@@ -75,7 +130,7 @@ impl SessionStore {
22,
"xuz",
AuthKind::Key,
development,
Some(development),
),
];
for draft in drafts {
@@ -105,6 +160,70 @@ impl SessionStore {
self.groups.iter().find(|g| g.id == id)
}
/// The groups directly under `parent`, in insertion order. `None` asks
/// for the top-level groups.
pub fn child_groups(&self, parent: Option<GroupId>) -> impl Iterator<Item = &SessionGroup> {
self.groups.iter().filter(move |g| g.parent == parent)
}
/// Every group below `id`, at any depth. Shared by the cascading delete
/// and by the check that keeps a group from being moved into itself.
pub fn descendant_groups(&self, id: GroupId) -> Vec<GroupId> {
let mut found = Vec::new();
let mut frontier = vec![id];
while let Some(current) = frontier.pop() {
for child in self.groups.iter().filter(|g| g.parent == Some(current)) {
found.push(child.id);
frontier.push(child.id);
}
}
found
}
/// Every session inside `id` or any group below it. What deleting the
/// group takes with it.
pub fn sessions_under(&self, id: GroupId) -> Vec<SessionId> {
let mut doomed = self.descendant_groups(id);
doomed.push(id);
self.sessions
.iter()
.filter(|session| session.group.is_some_and(|group| doomed.contains(&group)))
.map(|session| session.id)
.collect()
}
/// The groups above `id`, nearest parent first. Used to open the folders
/// over a row that has to be revealed.
pub fn ancestor_groups(&self, id: GroupId) -> Vec<GroupId> {
let mut chain = Vec::new();
let mut current = self.group(id).and_then(|group| group.parent);
while let Some(parent) = current {
if chain.len() > self.groups.len() {
break;
}
chain.push(parent);
current = self.group(parent).and_then(|group| group.parent);
}
chain
}
/// The group's full path, e.g. `生产 / 数据库`, as the forms list it.
pub fn group_path(&self, id: GroupId) -> String {
let mut parts: Vec<&str> = Vec::new();
let mut current = Some(id);
while let Some(group) = current.and_then(|id| self.group(id)) {
parts.push(group.name.as_ref());
// A cycle is rejected on the way in; refuse to hang if one ever
// reaches here anyway.
if parts.len() > self.groups.len() {
break;
}
current = group.parent;
}
parts.reverse();
parts.join(" / ")
}
/// The session whose tab is currently displayed, if any.
pub fn active(&self) -> Option<&Session> {
self.active.and_then(|id| self.session(id))
@@ -118,13 +237,17 @@ impl SessionStore {
pub fn insert(&mut self, draft: SessionDraft, cx: &mut Context<Self>) -> SessionId {
let id = self.insert_unnotified(draft);
if let (Some(database), Some(session)) = (self.database.as_ref(), self.session(id)) {
let result = database.insert_session(session);
self.report(result, "新建会话", cx);
}
cx.notify();
id
}
pub fn insert_unnotified(&mut self, draft: SessionDraft) -> SessionId {
let id = SessionId(self.next_id);
self.next_id += 1;
let id = SessionId(self.next_session_id);
self.next_session_id += 1;
self.sessions.push(Session::new(id, draft));
id
}
@@ -133,6 +256,10 @@ impl SessionStore {
pub fn update(&mut self, id: SessionId, draft: SessionDraft, cx: &mut Context<Self>) -> bool {
let updated = self.update_unnotified(id, draft);
if updated {
if let (Some(database), Some(session)) = (self.database.as_ref(), self.session(id)) {
let result = database.update_session(session);
self.report(result, "保存会话", cx);
}
cx.notify();
}
updated
@@ -151,6 +278,10 @@ impl SessionStore {
pub fn remove(&mut self, id: SessionId, cx: &mut Context<Self>) -> bool {
let removed = self.remove_unnotified(id);
if removed {
if let Some(database) = self.database.as_ref() {
let result = database.remove_session(id);
self.report(result, "删除会话", cx);
}
cx.notify();
}
removed
@@ -168,27 +299,116 @@ impl SessionStore {
/// Copy a session as `<name> 副本`, placed right after the original.
pub fn duplicate(&mut self, id: SessionId, cx: &mut Context<Self>) -> Option<SessionId> {
let copy = self.duplicate_unnotified(id);
if copy.is_some() {
cx.notify();
let copy = self.duplicate_unnotified(id)?;
if let (Some(database), Some(session)) = (self.database.as_ref(), self.session(copy)) {
let result = database.insert_session(session);
self.report(result, "复制会话", cx);
}
copy
cx.notify();
Some(copy)
}
pub fn duplicate_unnotified(&mut self, id: SessionId) -> Option<SessionId> {
let ix = self.sessions.iter().position(|s| s.id == id)?;
let mut draft = self.sessions[ix].draft();
draft.name = format!("{} 副本", draft.name).into();
let copy_id = SessionId(self.next_id);
self.next_id += 1;
let copy_id = SessionId(self.next_session_id);
self.next_session_id += 1;
self.sessions.insert(ix + 1, Session::new(copy_id, draft));
Some(copy_id)
}
pub fn set_state(&mut self, id: SessionId, state: ConnectionState, cx: &mut Context<Self>) {
if self.set_state_unnotified(id, state) {
pub fn insert_group(&mut self, draft: GroupDraft, cx: &mut Context<Self>) -> GroupId {
let id = self.insert_group_unnotified(draft);
if let (Some(database), Some(group)) = (self.database.as_ref(), self.group(id)) {
let result = database.insert_group(group);
self.report(result, "新建分组", cx);
}
cx.notify();
id
}
pub fn insert_group_unnotified(&mut self, draft: GroupDraft) -> GroupId {
let id = GroupId(self.next_group_id);
self.next_group_id += 1;
self.groups.push(SessionGroup::new(id, draft));
id
}
/// Rename a group, and optionally move it under a different parent.
pub fn update_group(&mut self, id: GroupId, draft: GroupDraft, cx: &mut Context<Self>) -> bool {
let updated = self.update_group_unnotified(id, draft);
if updated {
if let (Some(database), Some(group)) = (self.database.as_ref(), self.group(id)) {
let result = database.update_group(group);
self.report(result, "保存分组", cx);
}
cx.notify();
}
updated
}
/// Returns whether the group changed. Moving a group into its own subtree
/// would cut that subtree off from the root, so it is refused here as well
/// as being left out of the form's parent list.
pub fn update_group_unnotified(&mut self, id: GroupId, draft: GroupDraft) -> bool {
if self.group(id).is_none() {
return false;
}
if let Some(parent) = draft.parent
&& (parent == id || self.descendant_groups(id).contains(&parent))
{
return false;
}
let Some(group) = self.groups.iter_mut().find(|g| g.id == id) else {
return false;
};
*group = SessionGroup::new(id, draft);
true
}
/// Delete a group with its subgroups and every session inside them.
/// Returns the sessions that went with it, so the workspace can close
/// their tabs.
pub fn remove_group(&mut self, id: GroupId, cx: &mut Context<Self>) -> Vec<SessionId> {
if self.group(id).is_none() {
return Vec::new();
}
let removed = self.remove_group_unnotified(id);
// One delete mirrors the whole subtree: both foreign keys cascade.
if let Some(database) = self.database.as_ref() {
let result = database.remove_group(id);
self.report(result, "删除分组", cx);
}
cx.notify();
removed
}
pub fn remove_group_unnotified(&mut self, id: GroupId) -> Vec<SessionId> {
if self.group(id).is_none() {
return Vec::new();
}
let removed = self.sessions_under(id);
let mut doomed = self.descendant_groups(id);
doomed.push(id);
self.groups.retain(|group| !doomed.contains(&group.id));
for session in &removed {
self.remove_unnotified(*session);
}
removed
}
pub fn set_state(&mut self, id: SessionId, state: ConnectionState, cx: &mut Context<Self>) {
if !self.set_state_unnotified(id, state) {
return;
}
if state.is_connected()
&& let Some(database) = self.database.as_ref()
{
let result = database.touch_connected(id, now_seconds());
self.report(result, "记录最近连接", cx);
}
cx.notify();
}
/// Returns whether the state changed. Becoming connected also moves the
@@ -215,12 +435,25 @@ impl SessionStore {
cx.notify();
}
}
/// Turn a failed write into an event. The in-memory change stands.
fn report(&self, result: rusqlite::Result<()>, action: &str, cx: &mut Context<Self>) {
if let Err(error) = result {
cx.emit(SessionStoreEvent::PersistFailed(
format!("{action}未能保存到本地数据库:{error}").into(),
));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn draft(name: &str, group: Option<GroupId>) -> SessionDraft {
SessionDraft::new(name, "10.0.0.1", 22, "root", AuthKind::Key, group)
}
#[test]
fn seed_has_three_groups_and_two_connected_sessions() {
let store = SessionStore::seed();
@@ -235,25 +468,25 @@ mod tests {
assert_eq!(connected, ["web-01", "staging-api"]);
}
#[test]
fn groups_and_sessions_have_independent_id_sequences() {
let mut store = SessionStore::seed();
let group_ids: Vec<_> = store.groups().iter().map(|g| g.id).collect();
assert_eq!(group_ids, [GroupId(1), GroupId(2), GroupId(3)]);
assert_eq!(store.sessions()[0].id, SessionId(1));
assert_eq!(store.sessions()[5].id, SessionId(6));
assert_eq!(
store.insert_group_unnotified(GroupDraft::new("预发", None)),
GroupId(4)
);
assert_eq!(store.insert_unnotified(draft("new", None)), SessionId(7));
}
#[test]
fn insert_assigns_increasing_ids_and_keeps_order() {
let mut store = SessionStore::empty();
let a = store.insert_unnotified(SessionDraft::new(
"a",
"h",
22,
"u",
AuthKind::Key,
GroupId(1),
));
let b = store.insert_unnotified(SessionDraft::new(
"b",
"h",
22,
"u",
AuthKind::Key,
GroupId(1),
));
let a = store.insert_unnotified(draft("a", Some(GroupId(1))));
let b = store.insert_unnotified(draft("b", Some(GroupId(1))));
assert!(a < b);
assert_eq!(store.sessions()[1].id, b);
}
@@ -325,13 +558,120 @@ mod tests {
fn recent_keeps_only_the_newest_entries() {
let mut store = SessionStore::empty();
for ix in 0..(MAX_RECENT + 2) {
let draft =
SessionDraft::new(format!("s{ix}"), "h", 22, "u", AuthKind::Key, GroupId(1));
let id = store.insert_unnotified(draft);
let id = store.insert_unnotified(draft(&format!("s{ix}"), None));
store.set_state_unnotified(id, ConnectionState::Connected);
}
assert_eq!(store.recent_sessions().count(), MAX_RECENT);
let newest = store.recent_sessions().next().unwrap();
assert_eq!(newest.name.as_ref(), "s11");
}
#[test]
fn nested_groups_report_children_descendants_and_path() {
let mut store = SessionStore::empty();
let production = store.insert_group_unnotified(GroupDraft::new("生产", None));
let databases = store.insert_group_unnotified(GroupDraft::new("数据库", Some(production)));
let replicas = store.insert_group_unnotified(GroupDraft::new("只读副本", Some(databases)));
let staging = store.insert_group_unnotified(GroupDraft::new("测试", None));
let top: Vec<_> = store.child_groups(None).map(|g| g.id).collect();
assert_eq!(top, [production, staging]);
let under_production: Vec<_> = store.child_groups(Some(production)).map(|g| g.id).collect();
assert_eq!(under_production, [databases]);
let mut descendants = store.descendant_groups(production);
descendants.sort();
assert_eq!(descendants, [databases, replicas]);
assert!(store.descendant_groups(replicas).is_empty());
assert_eq!(store.group_path(replicas), "生产 / 数据库 / 只读副本");
assert_eq!(store.group_path(staging), "测试");
}
#[test]
fn removing_a_group_takes_its_subtree_and_its_sessions() {
let mut store = SessionStore::empty();
let production = store.insert_group_unnotified(GroupDraft::new("生产", None));
let databases = store.insert_group_unnotified(GroupDraft::new("数据库", Some(production)));
let staging = store.insert_group_unnotified(GroupDraft::new("测试", None));
let web = store.insert_unnotified(draft("web-01", Some(production)));
let db = store.insert_unnotified(draft("db-01", Some(databases)));
let qa = store.insert_unnotified(draft("qa", Some(staging)));
let jump = store.insert_unnotified(draft("jump", None));
store.set_state_unnotified(db, ConnectionState::Connected);
store.active = Some(db);
let mut removed = store.remove_group_unnotified(production);
removed.sort();
assert_eq!(removed, [web, db]);
assert_eq!(
store.groups().iter().map(|g| g.id).collect::<Vec<_>>(),
[staging]
);
// The root-level session and the untouched group's session survive.
assert_eq!(
store.sessions().iter().map(|s| s.id).collect::<Vec<_>>(),
[qa, jump]
);
assert_eq!(store.recent_sessions().count(), 0);
assert!(store.active().is_none());
// Removing it a second time finds nothing left to do.
assert!(store.remove_group_unnotified(production).is_empty());
}
#[test]
fn a_group_cannot_be_moved_into_its_own_subtree() {
let mut store = SessionStore::empty();
let production = store.insert_group_unnotified(GroupDraft::new("生产", None));
let databases = store.insert_group_unnotified(GroupDraft::new("数据库", Some(production)));
let replicas = store.insert_group_unnotified(GroupDraft::new("只读副本", Some(databases)));
assert!(
!store.update_group_unnotified(production, GroupDraft::new("生产", Some(replicas)))
);
assert!(
!store.update_group_unnotified(production, GroupDraft::new("生产", Some(production)))
);
assert!(store.group(production).unwrap().parent.is_none());
// Moving in the other direction, and plain renaming, are fine.
assert!(store.update_group_unnotified(replicas, GroupDraft::new("副本", Some(production))));
assert_eq!(store.group_path(replicas), "生产 / 副本");
assert!(!store.update_group_unnotified(GroupId(99), GroupDraft::new("x", None)));
}
#[test]
fn load_resumes_the_id_sequences_and_the_recent_order() {
let database = SessionDatabase::in_memory().unwrap();
database
.insert_group(&SessionGroup::new(
GroupId(4),
GroupDraft::new("生产", None),
))
.unwrap();
database
.insert_session(&Session::new(
SessionId(7),
draft("web-01", Some(GroupId(4))),
))
.unwrap();
database
.insert_session(&Session::new(SessionId(9), draft("db-01", None)))
.unwrap();
database.touch_connected(SessionId(9), 100).unwrap();
let mut store = SessionStore::load(database).unwrap();
assert_eq!(store.groups().len(), 1);
assert_eq!(
store.recent_sessions().map(|s| s.id).collect::<Vec<_>>(),
[SessionId(9)]
);
// Nothing is connected on load, however recently it last connected.
assert!(store.sessions().iter().all(|s| !s.state.is_connected()));
assert_eq!(
store.insert_group_unnotified(GroupDraft::new("测试", None)),
GroupId(5)
);
assert_eq!(store.insert_unnotified(draft("new", None)), SessionId(10));
}
}
+5 -1
View File
@@ -52,7 +52,11 @@ impl RecentSessions {
id: session.id,
name: session.name.clone(),
address: session.address(),
group: store.group(session.group).map(|group| group.name.clone()),
// The full path, so two 数据库 groups under different
// parents do not read the same here.
group: session
.group
.map(|id| SharedString::from(store.group_path(id))),
connected: session.state.is_connected(),
})
.collect()
+117 -14
View File
@@ -1,21 +1,23 @@
use std::{collections::HashMap, rc::Rc, sync::Arc, time::Duration};
use gpui_kit::component::{
ActiveTheme as _, Root, Theme, ThemeMode, TitleBar,
ActiveTheme as _, Root, Theme, ThemeMode, TitleBar, WindowExt as _,
dock::{DockArea, DockEvent, DockLayout, DockPlacement, PanelId, TabGroup, panel_handle},
notification::Notification,
};
use gpui_kit::*;
use crate::app::{
CloseActiveTab, CloseExplorer, CloseLocalTerminal, CloseTerminal, ConnectSession, CopyTerminal,
DeleteSession, DisconnectSession, DuplicateSession, EditSession, FocusSearch, NewLocalTerminal,
NewSession, OpenExplorer, PasteTerminal, ReconnectSession, RestartLocalTerminal,
ToggleSessionPanel, ToggleTheme, ZoomIn, ZoomOut, ZoomReset,
DeleteGroup, DeleteSession, DisconnectSession, DuplicateSession, EditSession, FocusSearch,
NewChildGroup, NewGroup, NewLocalTerminal, NewSession, NewSessionInGroup, OpenExplorer,
PasteTerminal, ReconnectSession, RenameGroup, RestartLocalTerminal, ToggleSessionPanel,
ToggleTheme, ZoomIn, ZoomOut, ZoomReset,
};
use crate::explorer::{ExplorerPanel, ExplorerPanelEvent};
use crate::session::{
ConnectionState, SessionId, SessionPanel, SessionStore, confirm_delete_session,
open_session_dialog,
ConnectionState, GroupId, SessionId, SessionPanel, SessionStore, SessionStoreEvent,
confirm_delete_group, confirm_delete_session, open_group_dialog, open_session_dialog,
};
use crate::terminal::{
LocalPtyTransportFactory, LocalTerminalId, LocalTerminalPanel, LocalTerminalPanelEvent,
@@ -81,19 +83,21 @@ pub struct Workspace {
}
impl Workspace {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
Self::new_with_local_terminal_factory(Arc::new(LocalPtyTransportFactory), window, cx)
/// `store` is built by `main` from the database on disk, and by the UI
/// tests from `SessionStore::seed`.
pub fn new(store: Entity<SessionStore>, window: &mut Window, cx: &mut Context<Self>) -> Self {
Self::new_with_local_terminal_factory(store, Arc::new(LocalPtyTransportFactory), window, cx)
}
/// Alternate constructor used by UI tests to avoid launching a real
/// login shell while exercising workspace behavior.
pub fn new_with_local_terminal_factory(
store: Entity<SessionStore>,
local_terminal_factory: SharedTerminalTransportFactory,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let focus_handle = cx.focus_handle();
let store = cx.new(|_| SessionStore::seed());
let recent = cx.new(|cx| RecentSessions::new(store.clone(), focus_handle.clone(), cx));
let (dock_area, skin) = WorkspaceDockSkin::dock_area(
DOCK_ID,
@@ -112,6 +116,14 @@ impl Workspace {
let mut subscriptions = vec![
cx.observe(&store, |_, _, cx| cx.notify()),
cx.subscribe_in(
&store,
window,
|_, _, event: &SessionStoreEvent, window, cx| {
let SessionStoreEvent::PersistFailed(message) = event;
window.push_notification(Notification::error(message.clone()), cx);
},
),
cx.subscribe_in(
&dock_area,
window,
@@ -449,7 +461,7 @@ impl Workspace {
}
fn on_new_session(&mut self, _: &NewSession, window: &mut Window, cx: &mut Context<Self>) {
open_session_dialog(None, self.store.clone(), window, cx);
open_session_dialog(None, None, self.store.clone(), window, cx);
}
fn on_edit_session(
@@ -459,7 +471,7 @@ impl Workspace {
cx: &mut Context<Self>,
) {
if self.store.read(cx).session(action.0).is_some() {
open_session_dialog(Some(action.0), self.store.clone(), window, cx);
open_session_dialog(Some(action.0), None, self.store.clone(), window, cx);
}
}
@@ -504,6 +516,16 @@ impl Workspace {
}
fn remove_session(&mut self, id: SessionId, window: &mut Window, cx: &mut Context<Self>) {
self.close_session_tabs(id, window, cx);
self.store.update(cx, |store, cx| {
store.remove(id, cx);
});
}
/// Close whatever a session has open in the center, leaving the store
/// alone. Deleting a session and deleting the group around it both need
/// this, the latter for every session in the subtree.
fn close_session_tabs(&mut self, id: SessionId, window: &mut Window, cx: &mut Context<Self>) {
if let Some(terminal) = self.terminals.remove(&id) {
self.dock_area
.update(cx, |area, cx| area.remove_panel(terminal, window, cx));
@@ -512,9 +534,85 @@ impl Workspace {
self.dock_area
.update(cx, |area, cx| area.remove_panel(explorer, window, cx));
}
self.store.update(cx, |store, cx| {
store.remove(id, cx);
});
}
fn on_new_session_in_group(
&mut self,
action: &NewSessionInGroup,
window: &mut Window,
cx: &mut Context<Self>,
) {
open_session_dialog(None, Some(action.0), self.store.clone(), window, cx);
}
fn on_new_group(&mut self, _: &NewGroup, window: &mut Window, cx: &mut Context<Self>) {
open_group_dialog(None, None, self.store.clone(), window, cx);
}
fn on_new_child_group(
&mut self,
action: &NewChildGroup,
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.store.read(cx).group(action.0).is_some() {
open_group_dialog(None, Some(action.0), self.store.clone(), window, cx);
}
}
fn on_rename_group(
&mut self,
action: &RenameGroup,
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.store.read(cx).group(action.0).is_some() {
open_group_dialog(Some(action.0), None, self.store.clone(), window, cx);
}
}
fn on_delete_group(
&mut self,
action: &DeleteGroup,
window: &mut Window,
cx: &mut Context<Self>,
) {
let id = action.0;
let store = self.store.read(cx);
let Some(group) = store.group(id) else {
return;
};
let name = group.name.to_string();
let subgroups = store.descendant_groups(id).len();
let doomed = store.sessions_under(id);
let closes_tabs = doomed
.iter()
.any(|id| self.terminals.contains_key(id) || self.explorers.contains_key(id));
let workspace = cx.entity().downgrade();
confirm_delete_group(
&name,
doomed.len(),
subgroups,
closes_tabs,
Rc::new(move |window, cx| {
workspace
.update(cx, |this, cx| this.remove_group(id, window, cx))
.ok();
}),
window,
cx,
);
}
/// The store cascades the delete; the workspace only has to close the
/// tabs of the sessions that went with the group.
fn remove_group(&mut self, id: GroupId, window: &mut Window, cx: &mut Context<Self>) {
let removed = self
.store
.update(cx, |store, cx| store.remove_group(id, cx));
for session in removed {
self.close_session_tabs(session, window, cx);
}
}
fn on_toggle_session_panel(
@@ -667,6 +765,11 @@ impl Render for Workspace {
.on_action(cx.listener(Self::on_edit_session))
.on_action(cx.listener(Self::on_duplicate_session))
.on_action(cx.listener(Self::on_delete_session))
.on_action(cx.listener(Self::on_new_session_in_group))
.on_action(cx.listener(Self::on_new_group))
.on_action(cx.listener(Self::on_new_child_group))
.on_action(cx.listener(Self::on_rename_group))
.on_action(cx.listener(Self::on_delete_group))
.on_action(cx.listener(Self::on_connect_session))
.on_action(cx.listener(Self::on_disconnect_session))
.on_action(cx.listener(Self::on_reconnect_session))
+293 -4
View File
@@ -12,7 +12,8 @@ use gpui_kit::{
MouseMoveEvent, TestAppContext, WindowHandle, point, px, size,
};
use shellr::session::SessionId;
use shellr::app::{DeleteGroup, NewSessionInGroup, RenameGroup};
use shellr::session::{GroupId, SessionDatabase, SessionId, SessionStore};
use shellr::terminal::{
LocalTerminalId, TerminalLifecycle, TerminalSize, TerminalTransport, TerminalTransportCommand,
TerminalTransportEvent, TerminalTransportFactory,
@@ -23,12 +24,25 @@ use shellr::workspace::Workspace;
const WEB_01: u64 = 1;
const DB_01: u64 = 3;
const STAGING_API: u64 = 4;
/// Seeded group ids, in insertion order: 生产, 测试, 开发.
const PRODUCTION: u64 = 1;
const DEVELOPMENT: u64 = 3;
fn open_workspace(cx: &mut TestAppContext) -> (WindowHandle<Root>, Entity<Workspace>) {
open_workspace_with_store(cx, SessionStore::seed())
}
/// Production loads the store from the database; the tests hand one in
/// directly so they get the fixed shape `SessionStore::seed` describes.
fn open_workspace_with_store(
cx: &mut TestAppContext,
store: SessionStore,
) -> (WindowHandle<Root>, Entity<Workspace>) {
cx.update(shellr::init);
let mut workspace = None;
let handle = cx.open_window(size(px(1280.), px(800.)), |window, cx| {
let view = cx.new(|cx| Workspace::new(window, cx));
let store = cx.new(|_| store);
let view = cx.new(|cx| Workspace::new(store, window, cx));
workspace = Some(view.clone());
Root::new(view, window, cx)
});
@@ -152,8 +166,10 @@ fn open_workspace_with_factory(
cx.update(shellr::init);
let mut workspace = None;
let handle = cx.open_window(size(px(1280.), px(800.)), |window, cx| {
let view =
cx.new(|cx| Workspace::new_with_local_terminal_factory(factory.clone(), window, cx));
let store = cx.new(|_| SessionStore::seed());
let view = cx.new(|cx| {
Workspace::new_with_local_terminal_factory(store, factory.clone(), window, cx)
});
workspace = Some(view.clone());
Root::new(view, window, cx)
});
@@ -881,3 +897,276 @@ async fn cjk_input_is_sent_once_and_resize_commands_are_deduplicated(cx: &mut Te
assert!(!resizes.is_empty());
assert!(resizes.windows(2).all(|sizes| sizes[0] != sizes[1]));
}
#[gpui_kit::test]
async fn new_group_from_the_toolbar_appears_in_the_tree(cx: &mut TestAppContext) {
let (handle, workspace) = open_workspace(cx);
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.click("new-group", cx);
})
.unwrap();
cx.run_until_parked();
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert!(window.find("commit").visible());
// An unnamed group is rejected and the dialog stays open.
window.click("group-name", cx);
window.click("commit", cx);
})
.unwrap();
cx.run_until_parked();
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert!(window.find("form-error").visible());
window.click("group-name", cx);
window.input("预发", cx);
window.click("commit", cx);
})
.unwrap();
cx.wait_for(handle.into(), Duration::from_secs(2), |window, _| {
window.try_find("commit").is_none()
})
.await;
let created = cx.update(|cx| {
let store = workspace.read(cx).store().read(cx);
let created = store.groups().last().expect("group inserted");
assert_eq!(created.name.as_ref(), "预发");
assert_eq!(created.parent, None);
created.id
});
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
let tree = window.within("session-tree");
assert!(tree.find(("group-row", created.0)).visible());
})
.unwrap();
}
/// The action a group row's 新建会话… menu entry dispatches.
#[gpui_kit::test]
async fn a_new_session_in_a_group_starts_out_in_that_group(cx: &mut TestAppContext) {
let (handle, workspace) = open_workspace(cx);
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.dispatch_action(Box::new(NewSessionInGroup(GroupId(DEVELOPMENT))), cx);
})
.unwrap();
cx.run_until_parked();
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.click("session-name", cx);
window.input("dev-02", cx);
window.click("session-host", cx);
window.input("192.168.1.21", cx);
window.click("commit", cx);
})
.unwrap();
cx.wait_for(handle.into(), Duration::from_secs(2), |window, _| {
window.try_find("commit").is_none()
})
.await;
cx.update(|cx| {
let store = workspace.read(cx).store().read(cx);
let created = store
.sessions()
.iter()
.find(|session| session.name == "dev-02")
.expect("dev-02 inserted");
// The form opened with 开发 pre-selected and nothing changed it.
assert_eq!(created.group, Some(GroupId(DEVELOPMENT)));
});
}
#[gpui_kit::test]
async fn renaming_a_group_keeps_the_sessions_under_it(cx: &mut TestAppContext) {
let (handle, workspace) = open_workspace(cx);
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.dispatch_action(Box::new(RenameGroup(GroupId(PRODUCTION))), cx);
})
.unwrap();
cx.run_until_parked();
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert!(window.find("commit").visible());
window.click("group-name", cx);
window.press("cmd-a", cx);
window.input("生产环境", cx);
window.click("commit", cx);
})
.unwrap();
cx.wait_for(handle.into(), Duration::from_secs(2), |window, _| {
window.try_find("commit").is_none()
})
.await;
cx.update(|cx| {
let store = workspace.read(cx).store().read(cx);
let group = store.group(GroupId(PRODUCTION)).expect("group kept");
assert_eq!(group.name.as_ref(), "生产环境");
assert_eq!(store.group_path(GroupId(PRODUCTION)), "生产环境");
// The three sessions still belong to it.
assert_eq!(
store
.sessions()
.iter()
.filter(|s| s.group == Some(GroupId(PRODUCTION)))
.count(),
3
);
});
}
#[gpui_kit::test]
async fn deleting_a_group_removes_its_sessions_and_closes_their_tabs(cx: &mut TestAppContext) {
let (handle, workspace) = open_workspace(cx);
// db-01 joins web-01, which starts connected, in having an open tab.
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window
.within("session-tree")
.double_click(("session-row", DB_01), cx);
})
.unwrap();
cx.run_until_parked();
cx.update(|cx| {
let workspace = workspace.read(cx);
assert!(workspace.terminal(SessionId(WEB_01)).is_some());
assert!(workspace.terminal(SessionId(DB_01)).is_some());
});
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.dispatch_action(Box::new(DeleteGroup(GroupId(PRODUCTION))), cx);
})
.unwrap();
cx.run_until_parked();
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.click("ok", cx);
})
.unwrap();
cx.run_until_parked();
cx.update(|cx| {
let workspace = workspace.read(cx);
assert!(workspace.terminal(SessionId(WEB_01)).is_none());
assert!(workspace.terminal(SessionId(DB_01)).is_none());
// staging-api is in another group and keeps its tab.
assert!(workspace.terminal(SessionId(STAGING_API)).is_some());
let store = workspace.store().read(cx);
assert!(store.group(GroupId(PRODUCTION)).is_none());
assert_eq!(store.groups().len(), 2);
let names: Vec<_> = store
.sessions()
.iter()
.map(|session| session.name.as_ref())
.collect();
assert_eq!(names, ["staging-api", "qa-runner", "dev-box"]);
});
}
#[gpui_kit::test]
async fn groups_and_sessions_are_read_back_from_the_database(cx: &mut TestAppContext) {
let directory = tempfile::tempdir().expect("temp dir");
let path = directory.path().join("shellr.db");
let store = SessionStore::load(SessionDatabase::open(&path).expect("database opened"))
.expect("store loaded");
// A first launch starts with nothing at all.
assert_eq!(store.groups().len(), 0);
assert_eq!(store.sessions().len(), 0);
let (handle, workspace) = open_workspace_with_store(cx, store);
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.click("new-group", cx);
})
.unwrap();
cx.run_until_parked();
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.click("group-name", cx);
window.input("生产", cx);
window.click("commit", cx);
})
.unwrap();
cx.wait_for(handle.into(), Duration::from_secs(2), |window, _| {
window.try_find("commit").is_none()
})
.await;
let group = cx.update(|cx| workspace.read(cx).store().read(cx).groups()[0].id);
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
// A closed dialog leaves nothing focused, and an action only reaches
// handlers on the focused element's path.
window.click("session-search", cx);
window.dispatch_action(Box::new(NewSessionInGroup(group)), cx);
})
.unwrap();
cx.run_until_parked();
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.click("session-name", cx);
window.input("web-01", cx);
window.click("session-host", cx);
window.input("10.0.1.12", cx);
window.click("commit", cx);
})
.unwrap();
cx.wait_for(handle.into(), Duration::from_secs(2), |window, _| {
window.try_find("commit").is_none()
})
.await;
let session = cx.update(|cx| workspace.read(cx).store().read(cx).sessions()[0].id);
// Connecting is what puts a session on the start page's recent list.
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window
.within("session-tree")
.double_click(("session-row", session.0), cx);
})
.unwrap();
cx.run_until_parked();
// Everything above went through the real write path; read it back with a
// second connection to the same file.
let reloaded = SessionStore::load(SessionDatabase::open(&path).expect("database reopened"))
.expect("store reloaded");
assert_eq!(reloaded.groups().len(), 1);
assert_eq!(reloaded.groups()[0].name.as_ref(), "生产");
assert_eq!(reloaded.sessions().len(), 1);
let saved = &reloaded.sessions()[0];
assert_eq!(saved.name.as_ref(), "web-01");
assert_eq!(saved.host.as_ref(), "10.0.1.12");
assert_eq!(saved.port, 22);
assert_eq!(saved.group, Some(group));
// Runtime state is not persisted, but the last connection time is.
assert!(!saved.state.is_connected());
assert_eq!(
reloaded.recent_sessions().map(|s| s.id).collect::<Vec<_>>(),
[session]
);
}