用 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:
1 parent
3bceb8879d
commit
6bec390ecd
20 files changed
+2074
-150
No files matched your search
@@ -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` 初始为已连接并已打开终端标签。
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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)?)?)
|
||||
}
|
||||
@@ -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)]);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"]);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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]
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user