diff --git a/CLAUDE.md b/CLAUDE.md index a24d824..0c853e0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,7 +39,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - `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::*`;`OS_ICONS` 另外嵌入 `assets/icons/os/*.svg`(各操作系统的真实 logo,取自 Simple Icons,CC0;Windows 那个是自己画的四格,Simple Icons 已下架),用 `Icon::default().path(os.icon_path())` 渲染。 - `session/` — `model.rs`(Session / SessionGroup / SessionDraft / GroupDraft / `HostOs`;`Session.group` 与 `SessionGroup.parent` 都是 `Option`,`Session.os` 是 `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`。远程会话的传输在 `ssh/`,中间区的标签页是 `terminal_panel.rs`。共享 prompt 协议位于 `connection.rs`,`transport.rs` 通过 `TerminalPrompt` / `TerminalPromptKind` / `TerminalSecret` 重导出保持兼容。 +- `terminal/` — 本地终端是真的:`transport.rs`(字节流传输的 trait)、`local_pty.rs`(`portable-pty` 实现,跑在专用线程上)、`engine.rs`(驱动 `alacritty_terminal`,解析线程 + 16ms UI 轮询批量刷新)、`terminal_view.rs`(网格渲染、选区、输入编码、内容区右键菜单、查找条)、`search.rs`(查找)、`local_terminal_panel.rs`(⌘T 开的本地标签页)、`model.rs`。远程会话的传输在 `ssh/`,中间区的标签页是 `terminal_panel.rs`。共享 prompt 协议位于 `connection.rs`,`transport.rs` 通过 `TerminalPrompt` / `TerminalPromptKind` / `TerminalSecret` 重导出保持兼容。 - `ssh/` — `probe.rs`(探测远端系统的命令与纯解析函数,外加驱动两步探测的 `HostOsProbe` 状态机)、`transport.rs`:真实的 russh 客户端。known_hosts 校验写 `/known_hosts`(**不碰** `~/.ssh/known_hosts`),认证链是 agent → publickey → password → keyboard-interactive。密码和私钥口令先查 `secrets`,查不到或被服务器拒绝才走 prompt 弹框;被拒绝时**不删**钥匙串条目,只在弹框说明里讲清原因。shell 起来后主循环每 5 秒用 `Handle::send_ping()`(`keepalive@openssh.com`,服务器回成功或失败都算回复)量一次往返,5 秒没回就记为超时,结果以 `TerminalTransportEvent::Latency` 送出;引擎把最新一次存成状态(和 `lifecycle` 一样,重启、退出、失败时清空),`TerminalPanel::title_suffix` 在标签栏右侧显示:< 100 ms 用 `success`,100–200 ms 用 `warning`,> 200 ms 或超时用 `danger`,分档在 `Latency::level`。 - **会话对话框的「测试连接」是真登录。** 会话模块不依赖 `ssh`:`connection.rs` 定义 `LoginTest`(表单当前值,秘密用 `Zeroizing`,`Debug` 打码)和 `ConnectionTester` 接口,工作区用和终端 / SFTP 共享的 `SshConnector` 造 `ssh::SshConnectionTester` 注入进去(`Workspace::new_with_services` 的最后一个服务参数,UI 测试注入 `FakeConnectionTester`)。测试用的秘密来源 `FormSecrets` 只读:本端点的登录密码、表单所选私钥的口令只认表单里的值(钥匙串里的旧密码不参与),其余条目回退钥匙串,从不写入。`SshPrompts::non_interactive()` 让缺密码 / 口令 / 键盘交互直接以带类型的 `MissingCredential` 失败而不弹框,失败原因由 `tester.rs` 的 `describe_test_failure` 按类型映射成中文。陌生主机密钥仍然要问:工作线程通过 channel 把问题交给表单,表单每 50 ms 轮询,在会话对话框上方弹「首次连接此主机」,信任就写入 known_hosts;密钥已变化一律判失败。结果只以通知呈现:「连接成功」,或标题「连接失败」加原因。 - `secrets/` — 系统钥匙串(`keyring` 4:macOS Keychain / Windows 凭据管理器 / Secret Service)。`SecretRef` 决定归属:密码按 `user@host:port`(改名、复制会话都不丢,同一台机器的多个会话共用一条),私钥口令按文件路径(同一把钥匙只问一次)。`SecretStore` 的三个方法全是阻塞的,**只能**在后台执行器或传输层工作线程上调用。`InMemorySecretStore` 给测试,`NoSecretStore` 给没有钥匙串的机器(界面据此禁用密码字段)。 @@ -61,6 +61,16 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - **状态经由 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 时把会话移到最前。 +- **终端内容区的右键菜单与查找、清屏。** + - **菜单**:`TerminalView` 自己给出复制、粘贴、查找、清屏。面板通过 `set_menu_items` 在下面追加一段:远程终端追加打开 SFTP、重新连接、断开连接(`DisconnectTerminal` 只断这一个标签);本地终端不追加。追加段的构造器在菜单打开时运行,拿到的是事先读好的 `TerminalLifecycle`,因为这时 `TerminalView` 正在更新,读它会重入。 + - **动作**:`FindInTerminal` / `FindNextInTerminal` / `FindPreviousInTerminal` / `DismissTerminalFind` / `ClearTerminal` 和复制、粘贴一样,由工作区通过 `active_terminal` 转给当前标签的 `TerminalView`。 + - **快捷键**:⌘K 在 `Terminal` 上下文里是清屏,比全局的 `FocusSearch` 更深,所以优先生效。 + - **查找**是字面量加智能大小写(`RegexSearch` 本身就是智能大小写)。 + - 渲染时只扫视口,起止扩到所在逻辑行的首尾,每帧几十行。 + - 全量扫描只在查询变化(去抖 80 ms)和上下切换时各做一次,而且是流式计数,不收集结果:一个字母可能匹配上百万次。release 构建里一万行乘 200 列约 15–55 ms。 + - 焦点匹配按回滚历史的增长量跟着输出上移,再用 `regex_search_right` 核对,对不上就丢掉。计数不随新输出刷新。 + - **查找条**必须是终端 `div` 的兄弟元素,不能是子元素:否则输入框里敲的键会冒泡到终端的 `on_key_down`,被发给 shell。它还要 `.occlude()`,否则点它会被下面终端的鼠标处理器抢走焦点。 + - **清屏**只在本地模拟器里做:把光标所在的逻辑行(含折行)挪到第 0 行,清空回滚历史,远端不知情。`ALT_SCREEN` 下什么也不做,菜单项置灰。 - **标签右键菜单挂在 `ClosableTabTitle` 上。** dock 的 `Tab` 不归我们渲染,所以菜单只覆盖标题元素,`Tab` 自己左右的内边距右键无反应。gpui 的 `on_click` 只认左键,右键不会切换标签,菜单作用于被点的那个标签,不一定是当前显示的。菜单尾部的关闭一族由 `shared::close_tab_items` 统一给出:「关闭」派发各类型自己的关闭动作,左侧 / 右侧 / 其他 / 全部派发 `CloseTabs { tab: CenterTab, scope: CloseScope }`;工作区按 `TabGroup::panels()` 的顺序用 `CloseScope::targets` 选出标签,再逐个走 `close_center_tab`(正在上传的 SFTP 照样先确认)。范围是这个标签所在的 TabGroup,不跨分栏。菜单构造器在打开时才运行(不在 render 中),所以可以读 `TabGroup` 算置灰状态。标签栏「…」菜单(`Panel::dropdown_menu`)和右键菜单由同一个构造函数给出(远程终端是 `TabMenu`,SFTP 和本地终端是各自文件里的 `tab_menu`),两处不许分叉。三种中间区面板的 `BasePanel::closable` 都返回 `false`:否则 Dock 会往「…」里再加一个自己的「关闭」,走 `TabGroup::close_panel`,既重复又绕过上传确认;我们自己的关闭走 `DockArea::remove_panel`,不受这个开关影响。远程终端标签的自定义名 `TerminalPanel::custom_title` 只活在内存里,和 Dock 布局一样不持久化;为空或等于会话名时回到跟随会话名。 - **焦点规则。** `window.dispatch_action` 只能到达焦点元素路径上的处理器,所以工作区内部必须有东西持有焦点(启动时聚焦会话面板)。永远不要聚焦工作区根节点的 handle:对话框层是它的子元素,祖先持有焦点会让对话框的焦点陷阱拿不到焦点,导致「取消 / 创建」无响应。标题栏和开始页的按钮改用工作区 handle 上的 `FocusHandle::dispatch_action` 派发。对话框打开后焦点停在对话框宿主上,直到用户点击某个字段;延迟或下一帧的聚焦请求不会生效。中间区一次只渲染激活的那个标签页,所以每个面板都必须在 `set_active(true)` 里把焦点收下:失活的标签页要是还攥着焦点,它的 handle 就不在这一帧的派发树里,「×」、⌘W 和所有快捷键会一起静默失效(见 `both_tabs_of_one_session_stay_closable`)。 - **树的语义。** 树行的左键按下会同时选中并切换展开(无法抑制);双击打开靠 `ListItem` 上的 `ClickEvent::click_count() == 2`。`TreeState::set_items` 会清空选择,`SessionPanel::rebuild_tree` 按 id 恢复选择,展开状态由它自己的 `expanded` 集合维护。对话框新建出来的节点靠 `SessionPanel` 比对 `known_groups` / `known_sessions` 快照来发现,然后展开祖先链并选中——对话框不需要回调面板。 @@ -73,4 +83,4 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 ## 测试约定 -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)`、`("session-os", id)` 与开始页的 `("recent-session-os", id)`(`aria_label` 是系统名,未探测时是「未探测到系统」)、`("group-row", id)`、`("terminal", id)`、`("explorer", id)`、`("local-pane"|"remote-pane", id)`、`("close-terminal", id)`、`("close-explorer", id)`、标签标题 `("terminal-tab", id)` / `("explorer-tab", session_id)` / `("local-terminal-tab", id)`(`aria_label` 是标签文字)、`("terminal-tab-os", id)`、`test-connection`、`("terminal-latency", id)`(`aria_label` 是「32 ms」或「超时」)、`tab-name`(重命名标签对话框)、`recent-sessions`、`("recent-session", id)`、`recent-new-session`、`session-search`、`new-session`、`new-group`、`theme-toggle`、`status-connection`、`commit`、`ok`(确认对话框)、`form-error`、`session-name` / `-host` / `-port` / `-user` / `-auth` / `-key-path` / `-password` / `-passphrase`、`group-name`、`remote-path`。被遮蔽的输入框在测试里读不到值(`ElementSnapshot::value()` 返回 `None`),所以密码相关的断言一律查注入的 `InMemorySecretStore`,不查界面。动作派发和 `set_active` 都是延迟执行的:先退出 `update_window`,调用 `cx.run_until_parked()`,再 `render_frame` 后断言。种子会话 id 按插入顺序(`web-01`=1 … `dev-box`=6),种子分组同理(`生产`=1、`测试`=2、`开发`=3);`web-01` 与 `staging-api` 初始为已连接并已打开终端标签。 +UI 测试(`tests/ui.rs`)在无头窗口里渲染真实的 `Workspace`,通过 `gpui_kit::test::TestWindowExt`(`click`、`double_click`、`input`、`press`、`find`、`within`)驱动。`Workspace::new` 要求外部传入 `Entity`:生产环境由 `main.rs` 从数据库加载,测试用 `open_workspace_with_store` 注入 `SessionStore::seed()`(或一个临时文件上的真库)。**不要用右键菜单驱动测试**:菜单项的 ElementId 只是序号,而且 `PopupMenu` 实体会让测试以「leaked handles」失败;直接 `window.dispatch_action` 派发菜单会派发的那个动作。测试模块里要显式导入类型,`use gpui_kit::*` 会遮蔽 `#[test]`。被测试查询的元素带稳定的领域 id,自定义 `div` 还要加 `.test_support()`:`("session-row", id)`、`("session-os", id)` 与开始页的 `("recent-session-os", id)`(`aria_label` 是系统名,未探测时是「未探测到系统」)、`("group-row", id)`、`("terminal", id)`、`("explorer", id)`、`("local-pane"|"remote-pane", id)`、`("close-terminal", id)`、`("close-explorer", id)`、标签标题 `("terminal-tab", id)` / `("explorer-tab", session_id)` / `("local-terminal-tab", id)`(`aria_label` 是标签文字)、`("terminal-tab-os", id)`、`test-connection`、`("terminal-latency", id)`(`aria_label` 是「32 ms」或「超时」)、`terminal-find`(查找输入框)、`terminal-find-count`(`aria_label` 是「3/12」「无结果」或「N 项」,查询为空或搜索还在去抖时不存在)、`terminal-find-previous` / `-next` / `-close`、`tab-name`(重命名标签对话框)、`recent-sessions`、`("recent-session", id)`、`recent-new-session`、`session-search`、`new-session`、`new-group`、`theme-toggle`、`status-connection`、`commit`、`ok`(确认对话框)、`form-error`、`session-name` / `-host` / `-port` / `-user` / `-auth` / `-key-path` / `-password` / `-passphrase`、`group-name`、`remote-path`。被遮蔽的输入框在测试里读不到值(`ElementSnapshot::value()` 返回 `None`),所以密码相关的断言一律查注入的 `InMemorySecretStore`,不查界面。动作派发和 `set_active` 都是延迟执行的:先退出 `update_window`,调用 `cx.run_until_parked()`,再 `render_frame` 后断言。种子会话 id 按插入顺序(`web-01`=1 … `dev-box`=6),种子分组同理(`生产`=1、`测试`=2、`开发`=3);`web-01` 与 `staging-api` 初始为已连接并已打开终端标签。 diff --git a/README.md b/README.md index 2a0bb5a..992d40c 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,13 @@ 远程终端标签以主机系统徽章为图标。右键标签可重命名(仅本次运行有效,留空恢复为会话名称,用于区分同一台主机的多个连接)、在新标签页中连接、打开 SFTP、复制 IP 地址 / 主机名、重新连接、编辑会话,以及关闭当前、左侧、右侧、其他或全部标签。SFTP 标签的菜单是「编辑会话…」加关闭命令,本地终端标签是「重新启动」加关闭命令。标签栏右侧「…」按钮的菜单与当前标签的右键菜单相同。远程终端运行时,标签栏右侧显示这条 SSH 连接的往返延迟,每 5 秒更新一次:低于 100 ms 为绿色,100–200 ms 为黄色,高于 200 ms 或超时为红色。批量关闭时,正在上传的 SFTP 标签仍会先确认。 +## 终端 + +右键终端内容区可复制、粘贴、查找、清屏;远程终端另有打开 SFTP、重新连接和断开连接。断开连接只断开这一个标签,标签保留,可随时重新连接,同一会话的其他标签和 SFTP 不受影响。 + +- **查找**(⌘F,其他平台 Ctrl+Shift+F):在终端右上角打开查找条,按字面量搜索屏幕和回滚历史。查询全为小写时不区分大小写,含大写字母时区分。所有匹配高亮显示,当前项颜色更深,查找条显示「当前项/总数」。回车或 ⌘G(F3)跳到下一项(更新的输出),Shift+回车或 ⌘⇧G(Shift+F3)跳到上一项(更早的输出),到头后循环。Esc 关闭。打开时如有选中文字,会作为查询词。 +- **清屏**(⌘K,其他平台 Ctrl+Shift+K;焦点不在终端时 ⌘K 仍是聚焦会话搜索):清掉屏幕和回滚历史,只留下光标所在的提示符行,包括已输入但未回车的内容。清屏只在本地进行,不向服务器发送任何内容。vim、top 等全屏程序运行时不可用。 + ## SFTP 上传 打开会话的「SFTP」标签即可浏览本地与远程目录。SFTP 使用独立 SSH 连接,仅请求 SFTP 子系统,不要求服务器提供 Shell,关闭终端不会中止上传。 diff --git a/src/app/actions.rs b/src/app/actions.rs index bd93495..7c32210 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -22,6 +22,17 @@ gpui_kit::actions!( CopyTerminal, /// Paste the clipboard into the active terminal. PasteTerminal, + /// Open the active terminal's find bar, or select its query again. + FindInTerminal, + /// Focus the next match down (newer output) in the active terminal. + FindNextInTerminal, + /// Focus the next match up (older output) in the active terminal. + FindPreviousInTerminal, + /// Close the active terminal's find bar and its highlights. + DismissTerminalFind, + /// Clear the active terminal's screen and scrollback, keeping the + /// prompt line. + ClearTerminal, /// Show or hide the left session dock. ToggleSessionPanel, /// Switch between the light and dark theme. @@ -159,6 +170,10 @@ remote_terminal_action!( /// Reconnect one remote terminal using the session's latest settings. ReconnectTerminal ); +remote_terminal_action!( + /// Disconnect one remote terminal, keeping its tab to reconnect from. + DisconnectTerminal +); remote_terminal_action!( /// Open the dialog that gives one remote terminal tab its own title. RenameTerminal diff --git a/src/app/assets.rs b/src/app/assets.rs index dd43f4e..c79bff5 100644 --- a/src/app/assets.rs +++ b/src/app/assets.rs @@ -8,8 +8,19 @@ use gpui_kit::{AssetSource, Result, SharedString}; icon_assets!( ExtraIcons, [ - Server, Terminal, Upload, Download, Trash, Pencil, RefreshCw, Plug, Unplug, FolderTree, - FolderPlus + Server, + Terminal, + Upload, + Download, + Trash, + Pencil, + RefreshCw, + Plug, + Unplug, + FolderTree, + FolderPlus, + Eraser, + ClipboardPaste ] ); diff --git a/src/app/mod.rs b/src/app/mod.rs index f3dbd52..8067219 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -11,7 +11,7 @@ pub use paths::{data_dir, database_path, known_hosts_path}; use gpui_kit::component::{Theme, dock::ToggleZoom}; use gpui_kit::*; -use crate::terminal::{TERMINAL_KEY_CONTEXT, terminal_key_bindings}; +use crate::terminal::{TERMINAL_FIND_KEY_CONTEXT, TERMINAL_KEY_CONTEXT, terminal_key_bindings}; /// Key context of the session panel, for bindings that only apply there. pub const SESSION_PANEL_CONTEXT: &str = "SessionPanel"; @@ -79,7 +79,32 @@ fn key_bindings() -> Vec { KeyBinding::new("ctrl-shift-c", CopyTerminal, Some(TERMINAL_KEY_CONTEXT)), #[cfg(not(target_os = "macos"))] KeyBinding::new("ctrl-shift-v", PasteTerminal, Some(TERMINAL_KEY_CONTEXT)), + // In a terminal ⌘K clears, as in other macOS terminals; everywhere + // else it still focuses the session search. Inside a terminal, Ctrl + // with a letter belongs to the shell, so other platforms add Shift, as + // they do for copy and paste. + #[cfg(target_os = "macos")] + KeyBinding::new("cmd-k", ClearTerminal, Some(TERMINAL_KEY_CONTEXT)), + #[cfg(not(target_os = "macos"))] + KeyBinding::new("ctrl-shift-k", ClearTerminal, Some(TERMINAL_KEY_CONTEXT)), + KeyBinding::new( + "escape", + DismissTerminalFind, + Some(TERMINAL_FIND_KEY_CONTEXT), + ), ]; + // The find keys work from the terminal and from inside the find bar. + #[cfg(target_os = "macos")] + let find_keys = ["cmd-f", "cmd-g", "cmd-shift-g"]; + #[cfg(not(target_os = "macos"))] + let find_keys = ["ctrl-shift-f", "f3", "shift-f3"]; + for context in [TERMINAL_KEY_CONTEXT, TERMINAL_FIND_KEY_CONTEXT] { + bindings.extend([ + KeyBinding::new(find_keys[0], FindInTerminal, Some(context)), + KeyBinding::new(find_keys[1], FindNextInTerminal, Some(context)), + KeyBinding::new(find_keys[2], FindPreviousInTerminal, Some(context)), + ]); + } bindings.extend(terminal_key_bindings()); bindings } diff --git a/src/terminal/engine.rs b/src/terminal/engine.rs index 029c41e..c4d7c08 100644 --- a/src/terminal/engine.rs +++ b/src/terminal/engine.rs @@ -1,3 +1,4 @@ +use std::cell::RefCell; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, mpsc}; use std::thread; @@ -6,7 +7,7 @@ use std::time::Duration; use alacritty_terminal::Term; use alacritty_terminal::event::{Event, EventListener, WindowSize}; use alacritty_terminal::grid::{Dimensions, Scroll}; -use alacritty_terminal::index::{Point, Side}; +use alacritty_terminal::index::{Line, Point, Side}; use alacritty_terminal::selection::{Selection, SelectionType}; use alacritty_terminal::sync::FairMutex; use alacritty_terminal::term::cell::Flags; @@ -16,6 +17,7 @@ use alacritty_terminal::vte::ansi::{Color, CursorShape, CursorStyle, Processor, use crate::session::HostOs; +use super::search::{MatchMarker, SearchDirection, SearchMark, SearchPosition, TerminalSearch}; use super::{ Latency, SharedTerminalTransportFactory, TerminalLifecycle, TerminalPrompt, TerminalPromptReply, TerminalSize, TerminalStatus, TerminalTransportCommand, @@ -31,6 +33,7 @@ pub struct TerminalCell { pub background: Color, pub flags: Flags, pub selected: bool, + pub search: SearchMark, } #[derive(Clone, Debug)] @@ -71,6 +74,9 @@ pub struct TerminalEngine { title: Option, /// The connection's latest round trip, while one is running. latency: Option, + /// The open find, if any. Rendering reads it through `&self` and still + /// has to advance the search engine's cache, hence the `RefCell`. + search: RefCell>, generation: u64, size: TerminalSize, event_sender: mpsc::Sender, @@ -93,6 +99,7 @@ impl TerminalEngine { lifecycle: TerminalLifecycle::Starting, title: None, latency: None, + search: RefCell::new(None), generation, size, event_sender, @@ -175,6 +182,8 @@ impl TerminalEngine { self.lifecycle = TerminalLifecycle::Starting; self.title = None; self.latency = None; + // The new emulator starts empty; matches in the old one mean nothing. + self.search.replace(None); self.runtime = TerminalRuntime::start( self.generation, self.size, @@ -307,6 +316,65 @@ impl TerminalEngine { cx.notify(); } + /// Open the find, or search the open one for `query`, focusing the newest + /// match at or above the bottom of the viewport. + pub fn set_search_query(&mut self, query: &str, cx: &mut gpui_kit::Context) { + let mut term = self.runtime.term.lock(); + self.search + .borrow_mut() + .get_or_insert_with(TerminalSearch::new) + .set_query(query, &mut term); + drop(term); + cx.notify(); + } + + /// Focus the next match up (older output) or down (newer output). + pub fn step_search(&mut self, direction: SearchDirection, cx: &mut gpui_kit::Context) { + let mut term = self.runtime.term.lock(); + if let Some(search) = self.search.borrow_mut().as_mut() { + search.step(direction, &mut term); + } + drop(term); + cx.notify(); + } + + pub fn clear_search(&mut self, cx: &mut gpui_kit::Context) { + if self.search.replace(None).is_some() { + cx.notify(); + } + } + + /// Where the focused match is, while the find has a query. + pub fn search_position(&self) -> Option { + self.search + .borrow() + .as_ref() + .and_then(|search| search.position()) + } + + /// Whether `clear_keeping_prompt` has anything to do: a full-screen program + /// owns its screen and has no scrollback to clear. + pub fn accepts_clear(&self) -> bool { + !self.mode().contains(TermMode::ALT_SCREEN) + } + + /// Clear the screen and the scrollback, keeping only the line the cursor + /// is on (with everything wrapped into it), at the top. This happens in + /// the emulator alone; the shell is not told, and carries on writing from + /// the cursor, which stays beside its prompt. + pub fn clear_keeping_prompt(&mut self, cx: &mut gpui_kit::Context) { + let mut term = self.runtime.term.lock(); + if !clear_keeping_cursor_line(&mut term) { + return; + } + if let Some(search) = self.search.borrow_mut().as_mut() { + search.refresh(&mut term); + } + drop(term); + cx.emit(TerminalEngineEvent::Changed); + cx.notify(); + } + pub fn selection_text(&self) -> Option { self.runtime.term.lock().selection_to_string() } @@ -364,6 +432,13 @@ impl TerminalEngine { let cursor_point = content.cursor.point; let cursor_shape = content.cursor.shape; let columns = term.grid().columns(); + let (matches, focused) = self + .search + .borrow_mut() + .as_mut() + .map(|search| search.visible(&term)) + .unwrap_or_default(); + let mut marker = MatchMarker::new(&matches, focused.as_ref()); let mut cells: Vec<_> = content .display_iter .map(|indexed| { @@ -379,6 +454,7 @@ impl TerminalEngine { selected: selection.is_some_and(|range| { range.contains_cell(&indexed, cursor_point, cursor_shape) }), + search: marker.mark(indexed.point), } }) .collect(); @@ -400,7 +476,8 @@ impl TerminalEngine { } } -/// Keep the leading cell and spacer of a full-width glyph visually atomic. +/// Keep the leading cell and spacer of a full-width glyph visually atomic, +/// for the selection and for find highlights alike. /// Alacritty stores CJK glyphs in one `WIDE_CHAR` cell followed by a /// `WIDE_CHAR_SPACER`; painting only one half produces a one-column highlight /// that cannot line up with the two-column glyph. @@ -417,6 +494,9 @@ fn normalize_wide_character_selection(cells: &mut [TerminalCell], columns: usize let selected = row[column].selected || row[column + 1].selected; row[column].selected = selected; row[column + 1].selected = selected; + let search = row[column].search.max(row[column + 1].search); + row[column].search = search; + row[column + 1].search = search; } } } @@ -736,6 +816,29 @@ fn encode_paste(text: &str, mode: TermMode) -> Vec { } } +/// See `TerminalEngine::clear_keeping_prompt`. Returns whether anything was +/// cleared. +fn clear_keeping_cursor_line(term: &mut AlacrittyTerm) -> bool { + if term.mode().contains(TermMode::ALT_SCREEN) { + return false; + } + term.scroll_display(Scroll::Bottom); + term.selection = None; + let cursor = term.grid().cursor.point; + // The prompt may have wrapped onto the cursor's row; keep it whole. A part + // already pushed into the scrollback goes with the scrollback. + let start = term.line_search_left(cursor).line.max(Line(0)); + let lines = start.0 as usize; + if lines > 0 { + let screen = Line(0)..Line(term.screen_lines() as i32); + let grid = term.grid_mut(); + grid.scroll_up(&screen, lines); + grid.cursor.point.line -= lines; + } + term.grid_mut().clear_history(); + true +} + fn prepare_term_for_user_input(term: &mut AlacrittyTerm) { term.scroll_display(Scroll::Bottom); term.selection = None; @@ -769,6 +872,137 @@ mod tests { Processor::::new().advance(term, bytes); } + fn line_text(term: &AlacrittyTerm, line: i32) -> String { + term.grid()[Line(line)] + .into_iter() + .map(|cell| cell.c) + .collect::() + .trim_end() + .to_owned() + } + + fn position(search: &TerminalSearch) -> (Option, usize) { + let position = search.position().unwrap(); + (position.current(), position.total()) + } + + #[test] + fn find_reaches_into_the_scrollback_and_wraps() { + let mut term = test_term(10, 3); + feed(&mut term, b"err 1\r\nok\r\nerr 2\r\nok\r\nok\r\nerr 3"); + assert!(term.history_size() > 0); + let mut search = TerminalSearch::new(); + + search.set_query("err", &mut term); + assert_eq!(position(&search), (Some(3), 3)); + + search.step(SearchDirection::Up, &mut term); + assert_eq!(position(&search), (Some(2), 3)); + search.step(SearchDirection::Up, &mut term); + assert_eq!(position(&search), (Some(1), 3)); + // The oldest match is in the scrollback; the view scrolls up to it. + assert!(term.grid().display_offset() > 0); + let (_, focused) = search.visible(&term); + assert_eq!(line_text(&term, focused.unwrap().start().line.0), "err 1"); + + search.step(SearchDirection::Up, &mut term); + assert_eq!(position(&search), (Some(3), 3)); + search.step(SearchDirection::Down, &mut term); + assert_eq!(position(&search), (Some(1), 3)); + } + + #[test] + fn find_starts_at_the_first_match_on_screen() { + let mut term = test_term(10, 3); + feed(&mut term, b"err 1\r\nok\r\nok\r\nerr 2\r\nerr 3"); + let mut search = TerminalSearch::new(); + search.set_query("err", &mut term); + assert_eq!(position(&search), (Some(2), 3)); + assert_eq!(term.grid().display_offset(), 0); + } + + #[test] + fn find_is_literal_and_smart_case() { + let mut term = test_term(20, 2); + feed(&mut term, b"Error: err"); + let mut search = TerminalSearch::new(); + search.set_query("err", &mut term); + assert_eq!(position(&search).1, 2); + search.set_query("Err", &mut term); + assert_eq!(position(&search).1, 1); + search.set_query("e.r", &mut term); + assert_eq!(position(&search).1, 0); + assert_eq!(search.position().unwrap().label(), "无结果"); + search.set_query("", &mut term); + assert!(search.position().is_none()); + } + + #[test] + fn focused_match_follows_output_into_the_scrollback() { + let mut term = test_term(10, 3); + feed(&mut term, b"one\r\nmark\r\n"); + let mut search = TerminalSearch::new(); + search.set_query("mark", &mut term); + feed(&mut term, b"two\r\nthree\r\nfour\r\n"); + + let (_, focused) = search.visible(&term); + let focused = focused.expect("the match is still in the buffer"); + assert!(focused.start().line.0 < 0); + assert_eq!(line_text(&term, focused.start().line.0), "mark"); + assert_eq!(position(&search), (Some(1), 1)); + } + + #[test] + fn find_highlights_both_cells_of_a_wide_match() { + let mut term = test_term(12, 2); + feed(&mut term, "a 日志".as_bytes()); + let mut search = TerminalSearch::new(); + search.set_query("日志", &mut term); + let (matches, focused) = search.visible(&term); + let mut marker = MatchMarker::new(&matches, focused.as_ref()); + let marks: Vec<_> = (0..6) + .map(|column| marker.mark(Point::new(Line(0), Column(column)))) + .collect(); + assert_eq!(marks[1], SearchMark::None); + assert_eq!(marks[2], SearchMark::Focused); + assert_eq!(marks[4], SearchMark::Focused); + } + + #[test] + fn clearing_keeps_only_the_prompt_line() { + let mut term = test_term(30, 4); + feed(&mut term, b"a\r\nb\r\nc\r\nd\r\ne\r\nroot@localhost:~# ls"); + assert!(term.history_size() > 0); + + assert!(clear_keeping_cursor_line(&mut term)); + + assert_eq!(term.history_size(), 0); + assert_eq!(line_text(&term, 0), "root@localhost:~# ls"); + assert!((1..4).all(|line| line_text(&term, line).is_empty())); + assert_eq!(term.grid().cursor.point, Point::new(Line(0), Column(20))); + // The shell keeps writing beside its prompt. + feed(&mut term, b"\r\nfile"); + assert_eq!(line_text(&term, 1), "file"); + } + + #[test] + fn clearing_keeps_a_wrapped_prompt_whole() { + let mut term = test_term(10, 4); + feed(&mut term, b"x\r\ny\r\n0123456789abc"); + assert!(clear_keeping_cursor_line(&mut term)); + assert_eq!(line_text(&term, 0), "0123456789"); + assert_eq!(line_text(&term, 1), "abc"); + assert_eq!(term.grid().cursor.point, Point::new(Line(1), Column(3))); + } + + #[test] + fn clearing_leaves_a_full_screen_program_alone() { + let mut term = test_term(10, 3); + feed(&mut term, b"\x1b[?1049hvim"); + assert!(!clear_keeping_cursor_line(&mut term)); + assert_eq!(line_text(&term, 0), "vim"); + } + #[test] fn sanitizes_terminal_titles() { assert_eq!(sanitize_title("hello\nworld"), Some("helloworld".into())); @@ -909,6 +1143,11 @@ mod tests { background: Color::Named(NamedColor::Background), flags, selected, + search: if selected { + SearchMark::Match + } else { + SearchMark::None + }, }; let mut cells = vec![ cell(Flags::WIDE_CHAR, true), @@ -927,6 +1166,9 @@ mod tests { assert!(cells[3].selected); assert!(cells[4].selected); assert!(!cells[5].selected); + assert_eq!(cells[1].search, SearchMark::Match); + assert_eq!(cells[3].search, SearchMark::Match); + assert_eq!(cells[2].search, SearchMark::None); } #[test] diff --git a/src/terminal/local_terminal_panel.rs b/src/terminal/local_terminal_panel.rs index 75b497e..0a9c29e 100644 --- a/src/terminal/local_terminal_panel.rs +++ b/src/terminal/local_terminal_panel.rs @@ -87,16 +87,6 @@ impl LocalTerminalPanel { window.focus(&focus, cx); } - pub fn copy(&mut self, cx: &mut Context) -> bool { - self.terminal - .update(cx, |terminal, cx| terminal.copy_selection(cx)) - } - - pub fn paste(&mut self, cx: &mut Context) -> bool { - self.terminal - .update(cx, |terminal, cx| terminal.paste_clipboard(cx)) - } - fn display_title(&self, cx: &App) -> String { self.terminal .read(cx) diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index 29a9ea5..2a0a537 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -5,6 +5,7 @@ mod local_pty; mod local_terminal_panel; mod model; mod rename_tab_dialog; +mod search; mod terminal_panel; mod terminal_view; mod transport; @@ -17,9 +18,13 @@ pub use model::{ TerminalStatus, }; pub use rename_tab_dialog::open_rename_tab_dialog; +pub use search::{SearchDirection, SearchMark, SearchPosition}; pub use terminal_panel::{TerminalPanel, TerminalPanelEvent}; pub(crate) use terminal_view::terminal_key_bindings; -pub use terminal_view::{TERMINAL_KEY_CONTEXT, TerminalView, TerminalViewEvent}; +pub use terminal_view::{ + TERMINAL_FIND_KEY_CONTEXT, TERMINAL_KEY_CONTEXT, TerminalMenuItems, TerminalView, + TerminalViewEvent, +}; pub(crate) use transport::send_event; pub use transport::{ AuthenticationPrompt, FixedRemoteTerminalTransportProvider, HostKeyChangedPrompt, diff --git a/src/terminal/search.rs b/src/terminal/search.rs new file mode 100644 index 0000000..6796dfa --- /dev/null +++ b/src/terminal/search.rs @@ -0,0 +1,428 @@ +//! Find in a terminal: a literal, smart-case search over the scrollback and +//! the screen. +//! +//! Only the visible rows are searched while rendering, so highlighting costs +//! a few dozen rows a frame however long the scrollback is. The whole buffer +//! is scanned only when the query changes or the user steps to another match, +//! which is also when the 「3/12」 position is refreshed. + +use alacritty_terminal::grid::Dimensions as _; +use alacritty_terminal::index::{Column, Direction, Line, Point}; +use alacritty_terminal::term::Term; +use alacritty_terminal::term::search::{Match, RegexIter, RegexSearch}; +use gpui_kit::SharedString; + +/// How far a highlighted match may reach above the first visible row along a +/// wrapped line. Longer wrapped lines are cut there, as in Alacritty. +const MAX_WRAPPED_LINES: i32 = 100; + +/// Which way to step from the focused match: `Up` towards older output, +/// `Down` towards newer output. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SearchDirection { + Up, + Down, +} + +/// How a cell takes part in the open search, weakest first. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] +pub enum SearchMark { + #[default] + None, + Match, + Focused, +} + +/// Where the focused match sits among all matches, as of the last full scan. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SearchPosition { + current: Option, + total: usize, +} + +impl SearchPosition { + pub fn new(current: Option, total: usize) -> Self { + Self { current, total } + } + + /// The one-based index of the focused match, if one is focused. + pub fn current(&self) -> Option { + self.current.map(|index| index + 1) + } + + pub fn total(&self) -> usize { + self.total + } + + /// 「3/12」, 「无结果」, or the bare count once new output has moved the + /// focused match away. + pub fn label(&self) -> SharedString { + match (self.total, self.current()) { + (0, _) => "无结果".into(), + (total, Some(current)) => format!("{current}/{total}").into(), + (total, None) => format!("{total} 项").into(), + } + } +} + +/// The open search of one terminal. +pub struct TerminalSearch { + query: String, + /// `None` for an empty query, which matches nothing. + regex: Option, + focused: Option, + /// The scrollback length when `focused` was located. Every line pushed + /// into the scrollback since then moves the match up one line. + history_size: usize, + position: SearchPosition, +} + +impl TerminalSearch { + pub fn new() -> Self { + Self { + query: String::new(), + regex: None, + focused: None, + history_size: 0, + position: SearchPosition::new(None, 0), + } + } + + /// `None` while the query is empty. + pub fn position(&self) -> Option { + self.regex.as_ref().map(|_| self.position) + } + + /// Search for `query` and focus the first match on screen, so the view + /// stays put when it can. With none on screen, focus the newest match + /// above the viewport, scrolling to it. + pub fn set_query( + &mut self, + query: &str, + term: &mut Term, + ) { + self.query = query.to_string(); + self.regex = (!query.is_empty()) + .then(|| RegexSearch::new(&escape_literal(query)).ok()) + .flatten(); + self.focused = None; + let Some(regex) = self.regex.as_mut() else { + self.position = SearchPosition::new(None, 0); + return; + }; + let (top, bottom) = (viewport_top(term), viewport_bottom(term)); + let survey = survey(all_matches(term, regex), None, top, bottom); + let total = survey.count; + let on_screen = survey + .first_from_top + .filter(|(_, found)| *found.start() <= bottom); + let current = on_screen.or(survey.last_to_bottom).or(survey.first); + self.focus(term, current, total); + } + + /// Move the focus one match up or down, wrapping at either end. Without a + /// focused match, start from the edge of the viewport. + pub fn step( + &mut self, + direction: SearchDirection, + term: &mut Term, + ) { + self.sync(term); + let Some(regex) = self.regex.as_mut() else { + return; + }; + let survey = survey( + all_matches(term, regex), + self.focused.as_ref(), + viewport_top(term), + viewport_bottom(term), + ); + let total = survey.count; + let current = survey.step(direction); + self.focus(term, current, total); + } + + /// Search again for the current query, after the buffer was rewritten. + pub fn refresh(&mut self, term: &mut Term) { + let query = std::mem::take(&mut self.query); + self.set_query(&query, term); + } + + /// Follow the focused match as output pushes lines into the scrollback, + /// and drop it once the text there no longer matches. + fn sync(&mut self, term: &Term) { + let history_size = term.history_size(); + let (Some(focused), Some(regex)) = (self.focused.take(), self.regex.as_mut()) else { + self.history_size = history_size; + return; + }; + let shift = history_size as i32 - self.history_size as i32; + self.history_size = history_size; + let moved = shifted(&focused, shift); + let still_there = moved.start().line >= term.topmost_line() + && term.regex_search_right(regex, *moved.start(), *moved.end()) == Some(moved.clone()); + if still_there { + self.focused = Some(moved); + } else { + self.position = SearchPosition::new(None, self.position.total); + } + } + + fn focus( + &mut self, + term: &mut Term, + current: Option, + total: usize, + ) { + self.history_size = term.history_size(); + let (index, focused) = current.unzip(); + self.focused = focused; + self.position = SearchPosition::new(index, total); + if let Some(focused) = &self.focused { + term.scroll_to_point(*focused.start()); + } + } + + /// The matches that touch the viewport, in order, and the focused one. + pub fn visible(&mut self, term: &Term) -> (Vec, Option) { + self.sync(term); + let Some(regex) = self.regex.as_mut() else { + return (Vec::new(), None); + }; + (visible_matches(term, regex), self.focused.clone()) + } +} + +impl Default for TerminalSearch { + fn default() -> Self { + Self::new() + } +} + +/// Tells, cell by cell in reading order, which cells of the display belong to +/// a match. +pub struct MatchMarker<'a> { + matches: &'a [Match], + focused: Option<&'a Match>, + next: usize, +} + +impl<'a> MatchMarker<'a> { + /// `matches` must be in order, as the search returns them. + pub fn new(matches: &'a [Match], focused: Option<&'a Match>) -> Self { + Self { + matches, + focused, + next: 0, + } + } + + /// The mark of the cell at `point`. Points must come in reading order. + pub fn mark(&mut self, point: Point) -> SearchMark { + while self.next < self.matches.len() && *self.matches[self.next].end() < point { + self.next += 1; + } + if self.focused.is_some_and(|focused| focused.contains(&point)) { + SearchMark::Focused + } else if self + .matches + .get(self.next) + .is_some_and(|found| found.contains(&point)) + { + SearchMark::Match + } else { + SearchMark::None + } + } +} + +/// Escape `query` so the search engine matches it literally. +pub fn escape_literal(query: &str) -> String { + let mut escaped = String::with_capacity(query.len()); + for character in query.chars() { + if r"\.+*?()|[]{}^$#&-~".contains(character) { + escaped.push('\\'); + } + escaped.push(character); + } + escaped +} + +/// Every match in the scrollback and on the screen, oldest first. A lazy +/// iterator: a one-letter query can match millions of times, far too many to +/// collect. +fn all_matches<'a, T>( + term: &'a Term, + regex: &'a mut RegexSearch, +) -> impl Iterator + 'a { + let start = Point::new(term.topmost_line(), Column(0)); + let end = Point::new(term.bottommost_line(), term.last_column()); + RegexIter::new(start, end, Direction::Right, term, regex) +} + +/// The matches that touch the viewport. The search reaches back along a line +/// wrapped across the top edge, so a match that starts above it still shows. +pub fn visible_matches(term: &Term, regex: &mut RegexSearch) -> Vec { + let top = viewport_top(term); + let start = term.line_search_left(top); + let start = Point::new(start.line.max(top.line - MAX_WRAPPED_LINES), Column(0)); + let end = term.line_search_right(viewport_bottom(term)); + RegexIter::new(start, end, Direction::Right, term, regex).collect() +} + +fn viewport_top(term: &Term) -> Point { + Point::new(Line(-(term.grid().display_offset() as i32)), Column(0)) +} + +fn viewport_bottom(term: &Term) -> Point { + let top = viewport_top(term); + Point::new( + top.line + (term.screen_lines() as i32 - 1), + term.last_column(), + ) +} + +fn shifted(found: &Match, lines: i32) -> Match { + let mut start = *found.start(); + let mut end = *found.end(); + start.line -= lines; + end.line -= lines; + start..=end +} + +/// A match with its zero-based index among all matches. +type Indexed = (usize, Match); + +/// What one pass over the matches learns: enough to count them and to pick +/// the match to focus, without keeping them. +#[derive(Default)] +struct Survey { + count: usize, + first: Option, + last: Option, + /// The first match at or below the top of the viewport. + first_from_top: Option, + /// The last match at or above the bottom of the viewport. + last_to_bottom: Option, + /// The focused match's neighbours, when it was found. + focused_found: bool, + before_focused: Option, + after_focused: Option, +} + +impl Survey { + /// The match one step away. From a focused match, move next to it and + /// wrap at either end; without one, take the first match from the top of + /// the viewport going down, or the last from its bottom going up. + fn step(self, direction: SearchDirection) -> Option { + match (self.focused_found, direction) { + (true, SearchDirection::Down) => self.after_focused.or(self.first), + (true, SearchDirection::Up) => self.before_focused.or(self.last), + (false, SearchDirection::Down) => self.first_from_top.or(self.first), + (false, SearchDirection::Up) => self.last_to_bottom.or(self.last), + } + } +} + +fn survey( + matches: impl Iterator, + focused: Option<&Match>, + top: Point, + bottom: Point, +) -> Survey { + let mut survey = Survey::default(); + for (index, found) in matches.enumerate() { + survey.count += 1; + let indexed = || Some((index, found.clone())); + if survey.first.is_none() { + survey.first = indexed(); + } + if survey.first_from_top.is_none() && *found.start() >= top { + survey.first_from_top = indexed(); + } + if *found.start() <= bottom { + survey.last_to_bottom = indexed(); + } + if Some(&found) == focused { + survey.focused_found = true; + } else if survey.focused_found { + if survey.after_focused.is_none() { + survey.after_focused = indexed(); + } + } else if focused.is_some() { + survey.before_focused = indexed(); + } + survey.last = Some((index, found)); + } + survey +} + +#[cfg(test)] +mod tests { + use super::*; + + fn point(line: i32, column: usize) -> Point { + Point::new(Line(line), Column(column)) + } + + fn found(line: i32, start: usize, end: usize) -> Match { + point(line, start)..=point(line, end) + } + + #[test] + fn literal_queries_escape_regex_syntax() { + assert_eq!(escape_literal("a.b"), r"a\.b"); + assert_eq!(escape_literal("[x]*"), r"\[x\]\*"); + assert_eq!(escape_literal("日志"), "日志"); + } + + #[test] + fn position_labels_read_as_the_bar_shows_them() { + assert_eq!(SearchPosition::new(Some(2), 12).label(), "3/12"); + assert_eq!(SearchPosition::new(None, 0).label(), "无结果"); + assert_eq!(SearchPosition::new(None, 4).label(), "4 项"); + } + + fn step( + matches: &[Match], + focused: Option, + direction: SearchDirection, + ) -> Option { + let (top, bottom) = (point(0, 0), point(4, 9)); + let focused = focused.map(|index| &matches[index]); + survey(matches.iter().cloned(), focused, top, bottom) + .step(direction) + .map(|(index, _)| index) + } + + #[test] + fn stepping_wraps_at_both_ends() { + let matches = [found(-3, 0, 1), found(0, 0, 1), found(2, 0, 1)]; + assert_eq!(step(&matches, Some(0), SearchDirection::Down), Some(1)); + assert_eq!(step(&matches, Some(2), SearchDirection::Down), Some(0)); + assert_eq!(step(&matches, Some(0), SearchDirection::Up), Some(2)); + assert_eq!(step(&matches, Some(1), SearchDirection::Up), Some(0)); + } + + #[test] + fn stepping_without_a_focus_starts_at_the_viewport() { + let matches = [found(-3, 0, 1), found(0, 0, 1), found(6, 0, 1)]; + assert_eq!(step(&matches, None, SearchDirection::Down), Some(1)); + assert_eq!(step(&matches, None, SearchDirection::Up), Some(1)); + assert_eq!(step(&[], None, SearchDirection::Up), None); + let above = [found(-3, 0, 1)]; + assert_eq!(step(&above, None, SearchDirection::Down), Some(0)); + } + + #[test] + fn marks_follow_display_order_and_prefer_the_focus() { + let matches = [found(0, 1, 2), found(0, 4, 4), found(1, 0, 0)]; + let mut marker = MatchMarker::new(&matches, Some(&matches[1])); + let marks: Vec<_> = (0..2) + .flat_map(|line| (0..5).map(move |column| point(line, column))) + .map(|point| marker.mark(point)) + .collect(); + use SearchMark::{Focused as F, Match as M, None as N}; + assert_eq!(marks, [N, M, M, N, F, M, N, N, N, N]); + } +} diff --git a/src/terminal/terminal_panel.rs b/src/terminal/terminal_panel.rs index 863d0c8..c7d68b2 100644 --- a/src/terminal/terminal_panel.rs +++ b/src/terminal/terminal_panel.rs @@ -1,3 +1,5 @@ +use std::rc::Rc; + use gpui_kit::component::{ ActiveTheme as _, Icon, IconName, Sizable as _, button::Button, @@ -7,15 +9,15 @@ use gpui_kit::component::{ use gpui_kit::*; use crate::app::{ - CatalogIcon, CenterTab, CloseTerminal, ConnectSession, CopySessionHost, EditSession, - OpenExplorer, ReconnectTerminal, RenameTerminal, + CatalogIcon, CenterTab, CloseTerminal, ConnectSession, CopySessionHost, DisconnectTerminal, + EditSession, OpenExplorer, ReconnectTerminal, RenameTerminal, }; use crate::session::{HostOs, SessionId, SessionStore}; use crate::shared::{ClosableTabTitle, HostMark, close_tab_items}; use super::{ LatencyLevel, RemoteTerminalId, SharedRemoteTerminalTransportProvider, TerminalLifecycle, - TerminalPrompt, TerminalPromptReply, TerminalView, TerminalViewEvent, + TerminalMenuItems, TerminalPrompt, TerminalPromptReply, TerminalView, TerminalViewEvent, }; #[derive(Clone, Debug, PartialEq, Eq)] @@ -57,13 +59,15 @@ impl TerminalPanel { .cloned() .expect("terminal sessions must exist in the store"); let terminal = cx.new(|cx| { - TerminalView::new( + let mut terminal = TerminalView::new( ("terminal", id.0), format!("{} 的终端", session.name), remote_provider.factory_for(&session), window, cx, - ) + ); + terminal.set_menu_items(connection_menu_items(id, session_id)); + terminal }); let subscriptions = vec![ cx.observe(&store, |_, _, cx| cx.notify()), @@ -316,6 +320,32 @@ impl Panel for TerminalPanel { } } +/// The connection commands at the bottom of a remote terminal's context menu. +fn connection_menu_items(id: RemoteTerminalId, session_id: SessionId) -> TerminalMenuItems { + Rc::new(move |menu, lifecycle| { + let connected = matches!( + lifecycle, + TerminalLifecycle::Starting | TerminalLifecycle::Running + ); + menu.menu_with_icon( + "打开 SFTP", + Icon::new(CatalogIcon::FolderTree), + Box::new(OpenExplorer(session_id)), + ) + .menu_with_icon( + "重新连接", + Icon::new(CatalogIcon::RefreshCw), + Box::new(ReconnectTerminal(id)), + ) + .menu_with_icon_and_disabled( + "断开连接", + Icon::new(CatalogIcon::Unplug), + Box::new(DisconnectTerminal(id)), + !connected, + ) + }) +} + /// The commands of one remote terminal tab, as a snapshot taken while the tab /// renders. The tab's context menu and the tab bar's 「…」 menu both build /// from it, so the two always list the same commands. diff --git a/src/terminal/terminal_view.rs b/src/terminal/terminal_view.rs index 8bafe2b..ac10fba 100644 --- a/src/terminal/terminal_view.rs +++ b/src/terminal/terminal_view.rs @@ -1,4 +1,5 @@ use std::ops::Range; +use std::rc::Rc; use std::time::Duration; use alacritty_terminal::index::{Column, Line, Point as TerminalPoint, Side}; @@ -7,21 +8,50 @@ use alacritty_terminal::term::TermMode; use alacritty_terminal::term::cell::Flags; use alacritty_terminal::term::color::COUNT; use alacritty_terminal::vte::ansi::{Color, CursorShape, NamedColor, Rgb}; -use gpui_kit::component::{ActiveTheme as _, menu::PopupMenu}; +use gpui_kit::component::{ + ActiveTheme as _, Disableable as _, Icon, IconName, Sizable as _, + button::{Button, ButtonVariants as _}, + h_flex, + input::{Input, InputEvent, InputState}, + menu::PopupMenu, +}; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::*; use unicode_width::UnicodeWidthChar as _; -use crate::app::{CopyTerminal, PasteTerminal}; +use crate::app::{ + CatalogIcon, ClearTerminal, CopyTerminal, DismissTerminalFind, FindInTerminal, + FindNextInTerminal, FindPreviousInTerminal, PasteTerminal, +}; use crate::session::HostOs; +use super::search::SearchMark; use super::{ - Latency, SharedTerminalTransportFactory, TerminalEngine, TerminalEngineEvent, + Latency, SearchDirection, SharedTerminalTransportFactory, TerminalEngine, TerminalEngineEvent, TerminalLifecycle, TerminalPrompt, TerminalPromptReply, TerminalSize, TerminalSnapshot, TerminalStatus, }; pub const TERMINAL_KEY_CONTEXT: &str = "Terminal"; +/// Key context of a terminal's find bar. +pub const TERMINAL_FIND_KEY_CONTEXT: &str = "TerminalFind"; + +/// How long typing in the find bar pauses before the scrollback is searched. +/// A full search of a long scrollback takes a frame or more, so it waits for +/// the query to settle rather than running on every keystroke. +const FIND_DEBOUNCE: Duration = Duration::from_millis(80); + +/// The commands a terminal's owner adds to the bottom of its context menu, +/// built from the terminal's lifecycle when the menu opens. +pub type TerminalMenuItems = Rc PopupMenu>; + +/// The open find bar of one terminal. +struct FindBar { + input: Entity, + /// The search waiting for typing to pause, if any. + pending: Option>, + _subscription: Subscription, +} gpui_kit::actions!(shellr_terminal, [SendTab, SendBackTab]); @@ -142,6 +172,8 @@ pub struct TerminalView { scroll_accumulator: ScrollAccumulator, context_menu: Option<(Entity, gpui_kit::Point)>, context_menu_subscription: Option, + menu_items: Option, + find: Option, cursor_visible: bool, focused: bool, _subscriptions: Vec, @@ -225,6 +257,8 @@ impl TerminalView { scroll_accumulator: ScrollAccumulator::default(), context_menu: None, context_menu_subscription: None, + menu_items: None, + find: None, cursor_visible: true, focused: false, _subscriptions: subscriptions, @@ -248,6 +282,7 @@ impl TerminalView { self.scroll_accumulator.reset(); self.context_menu = None; self.context_menu_subscription = None; + self.find = None; self.cursor_visible = true; self.engine.update(cx, |engine, cx| engine.restart(cx)); } @@ -258,6 +293,7 @@ impl TerminalView { cx: &mut Context, ) { self.reset_interaction(); + self.find = None; self.engine .update(cx, |engine, cx| engine.restart_with_factory(factory, cx)); } @@ -285,6 +321,7 @@ impl TerminalView { self.scroll_accumulator.reset(); self.context_menu = None; self.context_menu_subscription = None; + self.find = None; self.engine.update(cx, |engine, cx| engine.shutdown(cx)); } @@ -350,6 +387,115 @@ impl TerminalView { true } + /// Add the owner's commands below the terminal's own in the context menu. + pub fn set_menu_items(&mut self, items: TerminalMenuItems) { + self.menu_items = Some(items); + } + + /// Clear the screen and the scrollback, keeping the prompt line. + pub fn clear(&mut self, cx: &mut Context) { + self.selection_gesture.finish(); + self.scroll_accumulator.reset(); + self.engine + .update(cx, |engine, cx| engine.clear_keeping_prompt(cx)); + } + + /// Open the find bar and put the cursor in it. An open bar selects its + /// query again. A selection, when there is one, becomes the query. + pub fn open_find(&mut self, window: &mut Window, cx: &mut Context) { + let selection = self + .engine + .read(cx) + .selection_text() + .filter(|text| !text.is_empty() && !text.contains('\n')); + let input = match &self.find { + Some(find) => find.input.clone(), + None => { + let input = cx.new(|cx| InputState::new(window, cx).placeholder("查找")); + let subscription = + cx.subscribe_in(&input, window, |this, _, event: &InputEvent, window, cx| { + match event { + InputEvent::Change => this.schedule_find(window, cx), + InputEvent::PressEnter { shift, .. } => { + let direction = if *shift { + SearchDirection::Up + } else { + SearchDirection::Down + }; + this.step_find(direction, cx); + } + _ => {} + } + }); + self.find = Some(FindBar { + input: input.clone(), + pending: None, + _subscription: subscription, + }); + input + } + }; + if let Some(query) = selection { + input.update(cx, |input, cx| input.set_value(query, window, cx)); + self.run_find(cx); + } + input.update(cx, |input, cx| { + input.focus(window, cx); + input.select_all(window, cx); + }); + cx.notify(); + } + + /// Focus the next match up or down. A query still being typed is searched + /// first, and that search picks the match to focus. + pub fn step_find(&mut self, direction: SearchDirection, cx: &mut Context) { + let Some(find) = &mut self.find else { + return; + }; + if find.pending.take().is_some() { + self.run_find(cx); + return; + } + self.engine + .update(cx, |engine, cx| engine.step_search(direction, cx)); + } + + /// Close the find bar, drop its highlights and hand the keyboard back to + /// the terminal. + pub fn dismiss_find(&mut self, window: &mut Window, cx: &mut Context) { + if self.find.take().is_none() { + return; + } + self.engine.update(cx, |engine, cx| engine.clear_search(cx)); + self.focus(window, cx); + cx.notify(); + } + + fn schedule_find(&mut self, _: &mut Window, cx: &mut Context) { + let Some(find) = &mut self.find else { + return; + }; + find.pending = Some(cx.spawn(async move |this, cx| { + cx.background_executor().timer(FIND_DEBOUNCE).await; + _ = this.update(cx, |this, cx| { + if let Some(find) = &mut this.find { + find.pending = None; + } + this.run_find(cx); + }); + })); + } + + fn run_find(&mut self, cx: &mut Context) { + let Some(find) = &mut self.find else { + return; + }; + find.pending = None; + let query = find.input.read(cx).value(); + self.engine + .update(cx, |engine, cx| engine.set_search_query(&query, cx)); + } + fn snapshot(&self, cx: &App) -> TerminalSnapshot { self.engine.read(cx).snapshot() } @@ -437,17 +583,48 @@ impl TerminalView { cx: &mut Context, ) { self.focus(window, cx); - let has_selection = self.engine.read(cx).has_selection(); - let can_paste = self.engine.read(cx).lifecycle().accepts_input() + let engine = self.engine.read(cx); + let has_selection = engine.has_selection(); + let lifecycle = engine.lifecycle().clone(); + let can_clear = engine.accepts_clear(); + let can_paste = lifecycle.accepts_input() && cx .read_from_clipboard() .and_then(|item| item.text()) .is_some(); let action_context = self.focus_handle.clone(); + let owner_items = self.menu_items.clone(); let menu = PopupMenu::build(window, cx, move |menu, _, _| { - menu.action_context(action_context) - .menu_with_disabled("复制", Box::new(CopyTerminal), !has_selection) - .menu_with_disabled("粘贴", Box::new(PasteTerminal), !can_paste) + let menu = menu + .action_context(action_context) + .menu_with_icon_and_disabled( + "复制", + Icon::new(IconName::Copy), + Box::new(CopyTerminal), + !has_selection, + ) + .menu_with_icon_and_disabled( + "粘贴", + Icon::new(CatalogIcon::ClipboardPaste), + Box::new(PasteTerminal), + !can_paste, + ) + .separator() + .menu_with_icon( + "查找…", + Icon::new(IconName::Search), + Box::new(FindInTerminal), + ) + .menu_with_icon_and_disabled( + "清屏", + Icon::new(CatalogIcon::Eraser), + Box::new(ClearTerminal), + !can_clear, + ); + match &owner_items { + Some(items) => items(menu.separator(), &lifecycle), + None => menu, + } }); let terminal = cx.weak_entity(); let subscription = window.subscribe(&menu, cx, move |_, _: &DismissEvent, _, cx| { @@ -622,7 +799,97 @@ impl Render for TerminalView { ) }); - div().size_full().p_2().child(terminal) + // The find bar is the terminal's sibling, not its child: keys typed + // into it must not bubble to the terminal's key handler, which would + // send them to the shell (and Tab would hit `SendTab`). + div() + .relative() + .size_full() + .p_2() + .child(terminal) + .when_some(self.find.as_ref(), |container, find| { + container.child(self.render_find_bar(find, cx)) + }) + } +} + +impl TerminalView { + fn render_find_bar(&self, find: &FindBar, cx: &App) -> impl IntoElement { + let has_query = !find.input.read(cx).value().is_empty(); + let position = self + .engine + .read(cx) + .search_position() + .filter(|_| has_query && find.pending.is_none()); + let navigate = position.is_some_and(|position| position.total() > 0); + h_flex() + .key_context(TERMINAL_FIND_KEY_CONTEXT) + // Keep the terminal underneath from taking clicks on the bar, + // which would move focus and start a selection there. + .occlude() + .absolute() + .top_2() + .right_4() + .w_80() + .gap_1() + .p_1() + .bg(cx.theme().popover) + .border_1() + .border_color(cx.theme().border) + .rounded(cx.theme().radius) + .shadow_md() + .child( + Input::new(&find.input) + .id("terminal-find") + .small() + .appearance(false) + .prefix(Icon::new(IconName::Search).small()), + ) + .when_some(position, |bar, position| { + let label = position.label(); + bar.child( + div() + .id("terminal-find-count") + .test_support() + .aria_label(label.clone()) + .flex_none() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(label), + ) + }) + .child( + Button::new("terminal-find-previous") + .ghost() + .xsmall() + .icon(IconName::ChevronUp) + .tooltip("上一项(⇧↩)") + .disabled(!navigate) + .on_click(|_, window, cx| { + window.dispatch_action(Box::new(FindPreviousInTerminal), cx) + }), + ) + .child( + Button::new("terminal-find-next") + .ghost() + .xsmall() + .icon(IconName::ChevronDown) + .tooltip("下一项(↩)") + .disabled(!navigate) + .on_click(|_, window, cx| { + window.dispatch_action(Box::new(FindNextInTerminal), cx) + }), + ) + .child( + Button::new("terminal-find-close") + .ghost() + .xsmall() + .icon(IconName::Close) + .tooltip("关闭(Esc)") + .on_click(|_, window, cx| { + window.dispatch_action(Box::new(DismissTerminalFind), cx) + }), + ) } } @@ -757,7 +1024,11 @@ impl Element for TerminalElement { let cell_background = if cell.selected { cx.theme().selection } else { - background + match cell.search { + SearchMark::Focused => cx.theme().warning, + SearchMark::Match => cx.theme().warning.opacity(0.35), + SearchMark::None => background, + } }; if column == 0 { background_color = Some(cell_background); @@ -782,6 +1053,8 @@ impl Element for TerminalElement { font, color: if cell.selected { palette.foreground + } else if cell.search == SearchMark::Focused { + cx.theme().warning_foreground } else { foreground }, diff --git a/src/workspace/workspace_view.rs b/src/workspace/workspace_view.rs index 59b8085..cfdeeb2 100644 --- a/src/workspace/workspace_view.rs +++ b/src/workspace/workspace_view.rs @@ -19,14 +19,15 @@ use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::*; use crate::app::{ - CenterTab, CloseActiveTab, CloseExplorer, CloseLocalTerminal, CloseTabs, CloseTerminal, - CollapseAllGroups, ConnectGroup, ConnectSession, CopySessionHost, CopyTerminal, DeleteGroup, - DeleteSession, DisconnectSession, DuplicateSession, EditSession, ExpandAllGroups, - ExplorerAction, ExplorerCommand, FocusSearch, MoveSessionNode, NewChildGroup, NewGroup, - NewLocalTerminal, NewSession, NewSessionInGroup, OpenExplorer, PasteTerminal, - ReconnectTerminal, RenameGroup, RenameTerminal, RestartLocalTerminal, SelectAllUploadFiles, - ToggleSessionPanel, ToggleTheme, ToggleUploadSelection, UploadSelectedFiles, ZoomIn, ZoomOut, - ZoomReset, + CenterTab, ClearTerminal, CloseActiveTab, CloseExplorer, CloseLocalTerminal, CloseTabs, + CloseTerminal, CollapseAllGroups, ConnectGroup, ConnectSession, CopySessionHost, CopyTerminal, + DeleteGroup, DeleteSession, DisconnectSession, DisconnectTerminal, DismissTerminalFind, + DuplicateSession, EditSession, ExpandAllGroups, ExplorerAction, ExplorerCommand, + FindInTerminal, FindNextInTerminal, FindPreviousInTerminal, FocusSearch, MoveSessionNode, + NewChildGroup, NewGroup, NewLocalTerminal, NewSession, NewSessionInGroup, OpenExplorer, + PasteTerminal, ReconnectTerminal, RenameGroup, RenameTerminal, RestartLocalTerminal, + SelectAllUploadFiles, ToggleSessionPanel, ToggleTheme, ToggleUploadSelection, + UploadSelectedFiles, ZoomIn, ZoomOut, ZoomReset, }; use crate::connection::SharedConnectionTester; use crate::explorer::{ExplorerPanel, ExplorerPanelEvent, confirm_close_upload}; @@ -40,9 +41,10 @@ use crate::sftp::{ }; use crate::terminal::{ LocalPtyTransportFactory, LocalTerminalId, LocalTerminalPanel, LocalTerminalPanelEvent, - RemoteTerminalId, SharedRemoteTerminalTransportProvider, SharedTerminalTransportFactory, - TerminalLifecycle, TerminalPanel, TerminalPanelEvent, TerminalPrompt, TerminalPromptField, - TerminalPromptKind, TerminalPromptReply, TerminalSecret, open_rename_tab_dialog, + RemoteTerminalId, SearchDirection, SharedRemoteTerminalTransportProvider, + SharedTerminalTransportFactory, TerminalLifecycle, TerminalPanel, TerminalPanelEvent, + TerminalPrompt, TerminalPromptField, TerminalPromptKind, TerminalPromptReply, TerminalSecret, + TerminalView, open_rename_tab_dialog, }; use super::{ @@ -1085,37 +1087,95 @@ impl Workspace { } } + /// The terminal of the center tab shown most recently, remote or local. + fn active_terminal(&self, cx: &App) -> Option> { + match self.active_tab? { + CenterTab::Terminal(id) => Some(self.terminals.get(&id)?.read(cx).terminal().clone()), + CenterTab::LocalTerminal(id) => { + Some(self.local_terminals.get(&id)?.read(cx).terminal().clone()) + } + CenterTab::Explorer(_) => None, + } + } + fn on_copy_terminal(&mut self, _: &CopyTerminal, _: &mut Window, cx: &mut Context) { - match self.active_tab { - Some(CenterTab::Terminal(id)) => { - if let Some(panel) = self.terminals.get(&id) { - let terminal = panel.read(cx).terminal().clone(); - terminal.update(cx, |terminal, cx| terminal.copy_selection(cx)); - } - } - Some(CenterTab::LocalTerminal(id)) => { - if let Some(panel) = self.local_terminals.get(&id) { - panel.update(cx, |panel, cx| panel.copy(cx)); - } - } - Some(CenterTab::Explorer(_)) | None => {} + if let Some(terminal) = self.active_terminal(cx) { + terminal.update(cx, |terminal, cx| terminal.copy_selection(cx)); } } fn on_paste_terminal(&mut self, _: &PasteTerminal, _: &mut Window, cx: &mut Context) { - match self.active_tab { - Some(CenterTab::Terminal(id)) => { - if let Some(panel) = self.terminals.get(&id) { - let terminal = panel.read(cx).terminal().clone(); - terminal.update(cx, |terminal, cx| terminal.paste_clipboard(cx)); - } - } - Some(CenterTab::LocalTerminal(id)) => { - if let Some(panel) = self.local_terminals.get(&id) { - panel.update(cx, |panel, cx| panel.paste(cx)); - } - } - Some(CenterTab::Explorer(_)) | None => {} + if let Some(terminal) = self.active_terminal(cx) { + terminal.update(cx, |terminal, cx| terminal.paste_clipboard(cx)); + } + } + + fn on_find_in_terminal( + &mut self, + _: &FindInTerminal, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(terminal) = self.active_terminal(cx) { + terminal.update(cx, |terminal, cx| terminal.open_find(window, cx)); + } + } + + fn on_find_next_in_terminal( + &mut self, + _: &FindNextInTerminal, + _: &mut Window, + cx: &mut Context, + ) { + if let Some(terminal) = self.active_terminal(cx) { + terminal.update(cx, |terminal, cx| { + terminal.step_find(SearchDirection::Down, cx) + }); + } + } + + fn on_find_previous_in_terminal( + &mut self, + _: &FindPreviousInTerminal, + _: &mut Window, + cx: &mut Context, + ) { + if let Some(terminal) = self.active_terminal(cx) { + terminal.update(cx, |terminal, cx| { + terminal.step_find(SearchDirection::Up, cx) + }); + } + } + + fn on_dismiss_terminal_find( + &mut self, + _: &DismissTerminalFind, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(terminal) = self.active_terminal(cx) { + terminal.update(cx, |terminal, cx| terminal.dismiss_find(window, cx)); + } + } + + fn on_clear_terminal(&mut self, _: &ClearTerminal, _: &mut Window, cx: &mut Context) { + if let Some(terminal) = self.active_terminal(cx) { + terminal.update(cx, |terminal, cx| terminal.clear(cx)); + } + } + + fn on_disconnect_terminal( + &mut self, + action: &DisconnectTerminal, + window: &mut Window, + cx: &mut Context, + ) { + let id = action.0; + self.cancel_prompts_for_terminal(id, window, cx); + if let Some(terminal) = self.terminals.get(&id).cloned() { + let session_id = terminal.read(cx).session_id(); + terminal.update(cx, |terminal, cx| terminal.disconnect(cx)); + self.refresh_session_connection_state(session_id, cx); } } @@ -1700,6 +1760,7 @@ impl Render for Workspace { .on_action(cx.listener(Self::on_connect_group)) .on_action(cx.listener(Self::on_disconnect_session)) .on_action(cx.listener(Self::on_reconnect_terminal)) + .on_action(cx.listener(Self::on_disconnect_terminal)) .on_action(cx.listener(Self::on_open_explorer)) .on_action(cx.listener(Self::on_explorer_action)) .on_action(cx.listener(Self::on_upload_selected)) @@ -1715,6 +1776,11 @@ impl Render for Workspace { .on_action(cx.listener(Self::on_restart_local_terminal)) .on_action(cx.listener(Self::on_copy_terminal)) .on_action(cx.listener(Self::on_paste_terminal)) + .on_action(cx.listener(Self::on_find_in_terminal)) + .on_action(cx.listener(Self::on_find_next_in_terminal)) + .on_action(cx.listener(Self::on_find_previous_in_terminal)) + .on_action(cx.listener(Self::on_dismiss_terminal_find)) + .on_action(cx.listener(Self::on_clear_terminal)) .on_action(cx.listener(Self::on_toggle_session_panel)) .on_action(cx.listener(Self::on_toggle_theme)) .on_action(cx.listener(Self::on_focus_search)) diff --git a/tests/ui.rs b/tests/ui.rs index e12c995..fdffa67 100644 --- a/tests/ui.rs +++ b/tests/ui.rs @@ -8,14 +8,15 @@ use std::time::Duration; use gpui_kit::component::{ActiveTheme as _, Root, WindowExt as _}; use gpui_kit::test::{TestAppContextExt, TestWindowExt}; use gpui_kit::{ - AppContext as _, ClipboardItem, ElementId, Entity, InputEvent as _, MouseButton, + App, AppContext as _, ClipboardItem, ElementId, Entity, InputEvent as _, MouseButton, MouseMoveEvent, TestAppContext, WindowHandle, point, px, size, }; use shellr::app::{ - CenterTab, CloseScope, CloseTabs, CollapseAllGroups, ConnectGroup, ConnectSession, - CopySessionHost, DeleteGroup, DeleteSession, EditSession, ExpandAllGroups, NewLocalTerminal, - NewSessionInGroup, OpenExplorer, RenameGroup, RenameTerminal, + CenterTab, ClearTerminal, CloseScope, CloseTabs, CollapseAllGroups, ConnectGroup, + ConnectSession, CopySessionHost, DeleteGroup, DeleteSession, DisconnectTerminal, EditSession, + ExpandAllGroups, FindInTerminal, FindNextInTerminal, FindPreviousInTerminal, NewLocalTerminal, + NewSessionInGroup, OpenExplorer, ReconnectTerminal, RenameGroup, RenameTerminal, }; use shellr::connection::{ConnectionPromptKind, ConnectionTester, LoginTest, TrustCallback}; use shellr::secrets::{InMemorySecretStore, SecretRef, SecretStore as _}; @@ -2228,6 +2229,236 @@ async fn cjk_input_is_sent_once_and_resize_commands_are_deduplicated(cx: &mut Te assert!(resizes.windows(2).all(|sizes| sizes[0] != sizes[1])); } +/// Open a local terminal on `factory` and wait for the fake shell's banner. +async fn open_running_local_terminal( + cx: &mut TestAppContext, + factory: Arc, +) -> (WindowHandle, Entity) { + let (handle, workspace) = open_workspace_with_factory(cx, factory); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.click("new-local-terminal", cx); + }) + .unwrap(); + cx.wait_for(handle.into(), Duration::from_secs(2), |window, _| { + window + .try_find("status-connection") + .is_some_and(|element| element.label() == Some("运行中 本地终端")) + }) + .await; + (handle, workspace) +} + +fn local_screen(workspace: &Entity, cx: &App) -> String { + workspace + .read(cx) + .local_terminal(LocalTerminalId(1)) + .expect("local terminal exists") + .read(cx) + .terminal() + .read(cx) + .screen_text(cx) +} + +fn remote_lifecycle( + workspace: &Entity, + id: RemoteTerminalId, + cx: &App, +) -> TerminalLifecycle { + workspace + .read(cx) + .remote_terminal(id) + .expect("terminal exists") + .read(cx) + .lifecycle(cx) +} + +async fn wait_for_find_count(cx: &mut TestAppContext, handle: WindowHandle, label: &str) { + cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { + window.render_frame(cx); + window + .try_find("terminal-find-count") + .is_some_and(|element| element.label() == Some(label)) + }) + .await; +} + +#[gpui_kit::test] +async fn find_highlights_matches_and_steps_between_them(cx: &mut TestAppContext) { + let factory = Arc::new(FakeTerminalFactory::default()); + let (handle, workspace) = open_running_local_terminal(cx, factory.clone()).await; + // The banner holds one 「alpha」; the fake shell echoes two more. + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.input("alpha alpha", cx); + }) + .unwrap(); + cx.wait_for(handle.into(), Duration::from_secs(2), |_, cx| { + local_screen(&workspace, cx).matches("alpha").count() == 3 + }) + .await; + + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.dispatch_action(Box::new(FindInTerminal), cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + assert_eq!(window.find("terminal-find").focused(), Some(true)); + // Nothing is counted before there is a query. + assert!(window.try_find("terminal-find-count").is_none()); + // The bar floats over the terminal; clicking it must not hand the + // keyboard back to the terminal underneath. + window.click("terminal-find", cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + assert_eq!(window.find("terminal-find").focused(), Some(true)); + window.input("alpha", cx); + }) + .unwrap(); + // The first match on screen is focused. + wait_for_find_count(cx, handle, "1/3").await; + // What is typed into the find bar never reaches the shell. + assert_eq!(factory.written_text(), "alpha alpha"); + + cx.update_window(handle.into(), |_, window, cx| { + window.dispatch_action(Box::new(FindNextInTerminal), cx); + }) + .unwrap(); + wait_for_find_count(cx, handle, "2/3").await; + cx.update_window(handle.into(), |_, window, cx| { + window.dispatch_action(Box::new(FindPreviousInTerminal), cx); + window.dispatch_action(Box::new(FindPreviousInTerminal), cx); + }) + .unwrap(); + // Stepping up from the first match wraps to the last. + wait_for_find_count(cx, handle, "3/3").await; + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.press("enter", cx); + }) + .unwrap(); + wait_for_find_count(cx, handle, "1/3").await; + + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.press("backspace", cx); + window.input("z", cx); + }) + .unwrap(); + wait_for_find_count(cx, handle, "无结果").await; + + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.press("escape", cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + assert!(window.try_find("terminal-find").is_none()); + assert_eq!(window.find(("local-terminal", 1_u64)).focused(), Some(true)); + window.input("!", cx); + }) + .unwrap(); + cx.wait_for(handle.into(), Duration::from_secs(2), |_, _| { + factory.written_text() == "alpha alpha!" + }) + .await; +} + +#[gpui_kit::test] +async fn clearing_a_terminal_keeps_only_the_prompt_line(cx: &mut TestAppContext) { + let factory = Arc::new(FakeTerminalFactory::default()); + let (handle, workspace) = open_running_local_terminal(cx, factory.clone()).await; + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.input("root@localhost:~# ", cx); + }) + .unwrap(); + cx.wait_for(handle.into(), Duration::from_secs(2), |_, cx| { + local_screen(&workspace, cx).contains("root@localhost:~#") + }) + .await; + assert!( + cx.update(|cx| local_screen(&workspace, cx)) + .contains("alpha.txt") + ); + + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.dispatch_action(Box::new(ClearTerminal), cx); + }) + .unwrap(); + cx.run_until_parked(); + + assert_eq!( + cx.update(|cx| local_screen(&workspace, cx)).trim_end(), + "root@localhost:~#" + ); + // Clearing happens on this side; the shell is not sent anything. + assert_eq!(factory.written_text(), "root@localhost:~# "); +} + +#[gpui_kit::test] +async fn disconnecting_one_tab_leaves_the_other_tabs_of_its_session(cx: &mut TestAppContext) { + let (handle, workspace) = open_workspace(cx); + let second = RemoteTerminalId(FIRST_NEW_TERMINAL); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.dispatch_action(Box::new(ConnectSession(SessionId(WEB_01))), cx); + }) + .unwrap(); + cx.wait_for(handle.into(), Duration::from_secs(2), |_, cx| { + remote_lifecycle(&workspace, second, cx) == TerminalLifecycle::Running + }) + .await; + + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.dispatch_action(Box::new(DisconnectTerminal(second)), cx); + }) + .unwrap(); + cx.run_until_parked(); + + cx.update(|cx| { + assert!(!remote_lifecycle(&workspace, second, cx).accepts_input()); + assert_eq!( + remote_lifecycle(&workspace, RemoteTerminalId(INITIAL_WEB_TERMINAL), cx), + TerminalLifecycle::Running + ); + let workspace = workspace.read(cx); + let store = workspace.store().read(cx); + assert_eq!( + store.session(SessionId(WEB_01)).unwrap().state, + ConnectionState::Connected + ); + let screen = workspace + .remote_terminal(second) + .unwrap() + .read(cx) + .terminal() + .read(cx) + .screen_text(cx); + assert!(screen.contains("已断开连接")); + }); + + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.dispatch_action(Box::new(ReconnectTerminal(second)), cx); + }) + .unwrap(); + cx.wait_for(handle.into(), Duration::from_secs(2), |_, cx| { + remote_lifecycle(&workspace, second, cx) == TerminalLifecycle::Running + }) + .await; +} + #[gpui_kit::test] async fn new_group_from_the_toolbar_appears_in_the_tree(cx: &mut TestAppContext) { let (handle, workspace) = open_workspace(cx);