diff --git a/AGENTS.md b/AGENTS.md index 4121f3d..c0fdabe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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/.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:` / `s:`)、`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`)、`database.rs`(`SessionDatabase`:rusqlite 连接、`PRAGMA user_version` 迁移、行与模型的映射)、`store.rs`(`SessionStore` 实体,**内存是唯一事实来源**,写穿到数据库)、`outline.rs`(纯函数:store → `TreeItem`,递归铺开嵌套分组;节点 id 形如 `g:` / `s:`;`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` 的克隆,通过它的方法修改(方法内部会 `notify`),消费方用 `cx.observe(&store, ..)`。状态栏得知当前会话的路径是 `BasePanel::set_active` → `store.set_active`;`on_removed` 负责复位状态并发出 `TerminalPanelEvent::Closed` / `ExplorerPanelEvent::Closed`,工作区据此清理注册表。 -- **Dock 面板**需实现 `EventEmitter + Focusable + Render + BasePanel + Panel`,一律用 `panel_handle(...)` 包装;`tab_name` 保持 `None`,由 `title` 返回富元素——标签页的图标和「×」关闭按钮就是这样来的(`ClosableTabTitle` 通过 `on_added_to` 记下的 `TabGroup` 关闭自身)。`panel_name()` 是持久化键,一经选定不可更改。 +- **Dock 面板**需实现 `EventEmitter + 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>`)。 - `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`:生产环境由 `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` 初始为已连接并已打开终端标签。 diff --git a/CLAUDE.md b/CLAUDE.md index dd0456d..75769c2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/.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:` / `s:`)、`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`)、`database.rs`(`SessionDatabase`:rusqlite 连接、`PRAGMA user_version` 迁移、行与模型的映射)、`store.rs`(`SessionStore` 实体,**内存是唯一事实来源**,写穿到数据库)、`outline.rs`(纯函数:store → `TreeItem`,递归铺开嵌套分组;节点 id 形如 `g:` / `s:`;`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` 的克隆,通过它的方法修改(方法内部会 `notify`),消费方用 `cx.observe(&store, ..)`。状态栏得知当前会话的路径是 `BasePanel::set_active` → `store.set_active`;`on_removed` 负责复位状态并发出 `TerminalPanelEvent::Closed` / `ExplorerPanelEvent::Closed`,工作区据此清理注册表。 - **Dock 面板**需实现 `EventEmitter + 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>`)。 - `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`:生产环境由 `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` 初始为已连接并已打开终端标签。 diff --git a/Cargo.lock b/Cargo.lock index 3c63495..dcb000c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 08c6da5..8608778 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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. diff --git a/src/app/actions.rs b/src/app/actions.rs index a9da66e..21e75bf 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -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 diff --git a/src/app/assets.rs b/src/app/assets.rs index 960b22a..2885e26 100644 --- a/src/app/assets.rs +++ b/src/app/assets.rs @@ -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 ] ); diff --git a/src/app/mod.rs b/src/app/mod.rs index 5008d16..c75edba 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -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 { 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), diff --git a/src/app/paths.rs b/src/app/paths.rs new file mode 100644 index 0000000..6bd047e --- /dev/null +++ b/src/app/paths.rs @@ -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 { + let dir = data_dir(); + std::fs::create_dir_all(&dir)?; + Ok(dir.join("shellr.db")) +} diff --git a/src/main.rs b/src/main.rs index 61087fd..9a9c430 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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) { + match load_store() { + Ok(store) => (store, None), + Err(error) => { + eprintln!("shellr: 无法打开本地数据库:{error}"); + ( + SessionStore::empty(), + Some(format!("无法打开本地数据库,本次运行的改动不会被保存:{error}").into()), + ) + } + } +} + +fn load_store() -> anyhow::Result { + let path = shellr::app::database_path()?; + Ok(SessionStore::load(SessionDatabase::open(&path)?)?) +} diff --git a/src/session/database.rs b/src/session/database.rs new file mode 100644 index 0000000..80cd9c9 --- /dev/null +++ b/src/session/database.rs @@ -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, + /// Sessions in insertion order, all of them disconnected. + pub sessions: Vec, + /// Sessions that have ever connected, most recently connected first. + pub recent: Vec, +} + +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::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::prepare(Connection::open_in_memory()?) + } + + fn prepare(mut connection: Connection) -> rusqlite::Result { + // 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 { + 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 = row.get(2)?; + Ok(SessionGroup::new( + GroupId(from_sql(id)), + GroupDraft::new(name, parent.map(|id| GroupId(from_sql(id)))), + )) + })? + .collect::>>()?; + + 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 = 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::>>()?; + + 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::>>()?; + + 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) -> SessionGroup { + SessionGroup::new(GroupId(id), GroupDraft::new(name, parent.map(GroupId))) + } + + fn session(id: u64, name: &str, group: Option) -> 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)]); + } +} diff --git a/src/session/group_dialog.rs b/src/session/group_dialog.rs new file mode 100644 index 0000000..41a0043 --- /dev/null +++ b/src/session/group_dialog.rs @@ -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, + editing: Option, + name: Entity, + parent: Entity>>, + /// Parallel to the parent select's rows; `None` is the top level. + parent_ids: Vec>, + error: Option, +} + +impl GroupForm { + pub fn new( + editing: Option, + default_parent: Option, + store: Entity, + window: &mut Window, + cx: &mut Context, + ) -> 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> = vec![None]; + let mut labels: Vec = 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) -> 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) -> 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, + default_parent: Option, + store: Entity, + 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 { + 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 标签会一并关闭。") + ); + } +} diff --git a/src/session/mod.rs b/src/session/mod.rs index 76f3826..127a409 100644 --- a/src/session/mod.rs +++ b/src/session/mod.rs @@ -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}; diff --git a/src/session/model.rs b/src/session/model.rs index fbe5781..3417c73 100644 --- a/src/session/model.rs +++ b/src/session/model.rs @@ -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, 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, } impl SessionGroup { - pub fn new(id: GroupId, name: impl Into) -> 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, } impl SessionDraft { @@ -134,7 +165,7 @@ impl SessionDraft { port: u16, user: impl Into, auth: AuthKind, - group: GroupId, + group: Option, ) -> 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, +} + +impl GroupDraft { + pub fn new(name: impl Into, parent: Option) -> Self { + Self { + name: name.into(), + parent, + } + } +} diff --git a/src/session/outline.rs b/src/session/outline.rs index a711620..da91985 100644 --- a/src/session/outline.rs +++ b/src/session/outline.rs @@ -39,6 +39,13 @@ impl SessionNode { SessionNode::Group(_) => None, } } + + pub fn group_id(self) -> Option { + 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, ) -> Vec { let filtering = !query.trim().is_empty(); - groups - .iter() - .filter_map(|group| { - let children: Vec = 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, + depth: usize, + groups: &[SessionGroup], + sessions: &[Session], + query: &str, + filtering: bool, + expanded: &HashSet, +) -> Vec { + // 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 = 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, + 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 { store.groups().iter().map(|g| g.id).collect() } + fn labels(items: &[TreeItem]) -> Vec { + 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"]); + } } diff --git a/src/session/session_dialog.rs b/src/session/session_dialog.rs index 42b1fcc..06c5d77 100644 --- a/src/session/session_dialog.rs +++ b/src/session/session_dialog.rs @@ -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; @@ -28,36 +31,36 @@ pub struct SessionForm { user: Entity, auth: Entity>>, group: Entity>>, - group_ids: Vec, + /// Parallel to the group select's rows; `None` is the root of the tree. + group_ids: Vec>, error: Option, } impl SessionForm { pub fn new( editing: Option, + preselect_group: Option, store: Entity, window: &mut Window, cx: &mut Context, ) -> 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 = groups.iter().map(|group| group.id).collect(); - let group_names: Vec = - 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> = vec![None]; + let mut group_names: Vec = 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, + preselect_group: Option, store: Entity, 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 { diff --git a/src/session/session_panel.rs b/src/session/session_panel.rs index 0a1e4e6..c9e93a9 100644 --- a/src/session/session_panel.rs +++ b/src/session/session_panel.rs @@ -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, query: String, expanded: HashSet, + /// 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, + known_sessions: HashSet, focus_handle: FocusHandle, _subscriptions: Vec, } impl SessionPanel { pub fn new(store: Entity, window: &mut Window, cx: &mut Context) -> Self { - let expanded: HashSet = 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 = 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) { + 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) -> Option { + 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) { 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.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.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) -> Option> { 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, 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), + ), } } diff --git a/src/session/store.rs b/src/session/store.rs index a90c138..f534e74 100644 --- a/src/session/store.rs +++ b/src/session/store.rs @@ -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, sessions: Vec, - next_id: u64, + next_session_id: u64, + next_group_id: u64, active: Option, /// Sessions in the order they last connected, most recent first. recent: Vec, + /// `None` for a memory-only store, as used by tests. + database: Option, } /// 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 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 { + 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) -> impl Iterator { + 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 { + 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 { + 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 { + 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) -> 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) -> 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) -> 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 ` 副本`, placed right after the original. pub fn duplicate(&mut self, id: SessionId, cx: &mut Context) -> Option { - 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 { 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) { - if self.set_state_unnotified(id, state) { + pub fn insert_group(&mut self, draft: GroupDraft, cx: &mut Context) -> 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) -> 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) -> Vec { + 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 { + 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) { + 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) { + 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) -> 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::>(), + [staging] + ); + // The root-level session and the untouched group's session survive. + assert_eq!( + store.sessions().iter().map(|s| s.id).collect::>(), + [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::>(), + [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)); + } } diff --git a/src/workspace/recent_sessions.rs b/src/workspace/recent_sessions.rs index 66b8e2b..5437583 100644 --- a/src/workspace/recent_sessions.rs +++ b/src/workspace/recent_sessions.rs @@ -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() diff --git a/src/workspace/workspace_view.rs b/src/workspace/workspace_view.rs index 727f7b4..6b2a6dd 100644 --- a/src/workspace/workspace_view.rs +++ b/src/workspace/workspace_view.rs @@ -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::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, window: &mut Window, cx: &mut Context) -> 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, local_terminal_factory: SharedTerminalTransportFactory, window: &mut Window, cx: &mut Context, ) -> 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) { - 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, ) { 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.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) { 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, + ) { + 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) { + 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, + ) { + 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, + ) { + 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, + ) { + 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) { + 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)) diff --git a/tests/ui.rs b/tests/ui.rs index 83d1a49..a65639a 100644 --- a/tests/ui.rs +++ b/tests/ui.rs @@ -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, Entity) { + 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, Entity) { 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::>(), + [session] + ); +}