终端右键菜单增加查找、清屏、SFTP、重新连接与断开连接

This commit is contained in:
Xuz committed 2026-09-24 23:13:14 +08:00
1 parent 48ac2b95aa
commit adbd1de688
13 files changed
+1407 -74

No files matched your search

+12 -2
View File
@@ -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<GroupId>`,`Session.os` 是 `Option<HostOs>`)、`database.rs`(`SessionDatabase`:rusqlite 连接、`PRAGMA user_version` 迁移、行与模型的映射)、`store.rs`(`SessionStore` 实体,**内存是唯一事实来源**,写穿到数据库)、`outline.rs`(纯函数:store → `TreeItem`,递归铺开嵌套分组;节点 id 形如 `g:<id>` / `s:<id>`;`group_options` 给表单提供按全路径标注的分组列表)、`session_panel.rs`(左侧 Dock 面板:搜索 + 树 + 右键菜单)、`session_dialog.rs`、`group_dialog.rs`(`GroupForm`、`open_group_dialog`、`confirm_delete_group`)。
- `terminal/` — 本地终端是真的:`transport.rs`(字节流传输的 trait)、`local_pty.rs`(`portable-pty` 实现,跑在专用线程上)、`engine.rs`(驱动 `alacritty_terminal`,解析线程 + 16ms UI 轮询批量刷新)、`terminal_view.rs`(网格渲染、选区、输入编码)、`local_terminal_panel.rs`(⌘T 开的本地标签页)、`model.rs`。远程会话的传输在 `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 校验写 `<data_dir>/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<SessionStore>` 的克隆,通过它的方法修改(方法内部会 `notify`),消费方用 `cx.observe(&store, ..)`。状态栏得知当前会话的路径是 `BasePanel::set_active` → `store.set_active`;`on_removed` 负责复位状态并发出 `TerminalPanelEvent::Closed` / `ExplorerPanelEvent::Closed`,工作区据此清理注册表。
- **Dock 面板**需实现 `EventEmitter<PanelEvent> + Focusable + Render + BasePanel + Panel`,一律用 `panel_handle(...)` 包装;`tab_name` 保持 `None`,由 `title` 返回富元素——标签页的图标和「×」关闭按钮就是这样来的(`ClosableTabTitle` 的「×」只派发面板的关闭动作,见下一条)。`panel_name()` 是持久化键,一经选定不可更改。
- **标签页全部可关。** `TabGroup::close_panel` 会拒绝关闭区域内最后一个面板,所以关闭不走 tab group:「×」派发 `CloseTerminal` / `CloseExplorer`,⌘W 派发 `CloseActiveTab`(关工作区记录的最近显示的中间标签,面板在 `set_active(true)` 时发出 `Activated` 事件告知),处理器一律用 `DockArea::remove_panel` 移除。中间区空了就显示最近连接列表:`SessionStore::recent_sessions()` 按「最近一次变为已连接」的顺序给出,`set_state` 变为 Connected 时把会话移到最前。
- **终端内容区的右键菜单与查找、清屏。**
- **菜单**:`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<SessionStore>`:生产环境由 `main.rs` 从数据库加载,测试用 `open_workspace_with_store` 注入 `SessionStore::seed()`(或一个临时文件上的真库)。**不要用右键菜单驱动测试**:菜单项的 ElementId 只是序号,而且 `PopupMenu` 实体会让测试以「leaked handles」失败;直接 `window.dispatch_action` 派发菜单会派发的那个动作。测试模块里要显式导入类型,`use gpui_kit::*` 会遮蔽 `#[test]`。被测试查询的元素带稳定的领域 id,自定义 `div` 还要加 `.test_support()`:`("session-row", id)`、`("session-os", id)` 与开始页的 `("recent-session-os", id)`(`aria_label` 是系统名,未探测时是「未探测到系统」)、`("group-row", id)`、`("terminal", id)`、`("explorer", id)`、`("local-pane"|"remote-pane", id)`、`("close-terminal", id)`、`("close-explorer", id)`、标签标题 `("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<SessionStore>`:生产环境由 `main.rs` 从数据库加载,测试用 `open_workspace_with_store` 注入 `SessionStore::seed()`(或一个临时文件上的真库)。**不要用右键菜单驱动测试**:菜单项的 ElementId 只是序号,而且 `PopupMenu` 实体会让测试以「leaked handles」失败;直接 `window.dispatch_action` 派发菜单会派发的那个动作。测试模块里要显式导入类型,`use gpui_kit::*` 会遮蔽 `#[test]`。被测试查询的元素带稳定的领域 id,自定义 `div` 还要加 `.test_support()`:`("session-row", id)`、`("session-os", id)` 与开始页的 `("recent-session-os", id)`(`aria_label` 是系统名,未探测时是「未探测到系统」)、`("group-row", id)`、`("terminal", id)`、`("explorer", id)`、`("local-pane"|"remote-pane", id)`、`("close-terminal", id)`、`("close-explorer", id)`、标签标题 `("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` 初始为已连接并已打开终端标签。
+7
View File
@@ -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,关闭终端不会中止上传。
+15
View File
@@ -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
+13 -2
View File
@@ -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
]
);
+26 -1
View File
@@ -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> {
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
}
+244 -2
View File
@@ -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<String>,
/// The connection's latest round trip, while one is running.
latency: Option<Latency>,
/// 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<Option<TerminalSearch>>,
generation: u64,
size: TerminalSize,
event_sender: mpsc::Sender<TerminalUiEvent>,
@@ -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<Self>) {
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<Self>) {
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<Self>) {
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<SearchPosition> {
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<Self>) {
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<String> {
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<u8> {
}
}
/// 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::<alacritty_terminal::vte::ansi::StdSyncHandler>::new().advance(term, bytes);
}
fn line_text(term: &AlacrittyTerm, line: i32) -> String {
term.grid()[Line(line)]
.into_iter()
.map(|cell| cell.c)
.collect::<String>()
.trim_end()
.to_owned()
}
fn position(search: &TerminalSearch) -> (Option<usize>, 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]
-10
View File
@@ -87,16 +87,6 @@ impl LocalTerminalPanel {
window.focus(&focus, cx);
}
pub fn copy(&mut self, cx: &mut Context<Self>) -> bool {
self.terminal
.update(cx, |terminal, cx| terminal.copy_selection(cx))
}
pub fn paste(&mut self, cx: &mut Context<Self>) -> bool {
self.terminal
.update(cx, |terminal, cx| terminal.paste_clipboard(cx))
}
fn display_title(&self, cx: &App) -> String {
self.terminal
.read(cx)
+6 -1
View File
@@ -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,
+428
View File
@@ -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<usize>,
total: usize,
}
impl SearchPosition {
pub fn new(current: Option<usize>, total: usize) -> Self {
Self { current, total }
}
/// The one-based index of the focused match, if one is focused.
pub fn current(&self) -> Option<usize> {
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<RegexSearch>,
focused: Option<Match>,
/// 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<SearchPosition> {
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<T: alacritty_terminal::event::EventListener>(
&mut self,
query: &str,
term: &mut Term<T>,
) {
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<T: alacritty_terminal::event::EventListener>(
&mut self,
direction: SearchDirection,
term: &mut Term<T>,
) {
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<T: alacritty_terminal::event::EventListener>(&mut self, term: &mut Term<T>) {
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<T>(&mut self, term: &Term<T>) {
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<T: alacritty_terminal::event::EventListener>(
&mut self,
term: &mut Term<T>,
current: Option<Indexed>,
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<T>(&mut self, term: &Term<T>) -> (Vec<Match>, Option<Match>) {
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<T>,
regex: &'a mut RegexSearch,
) -> impl Iterator<Item = Match> + '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<T>(term: &Term<T>, regex: &mut RegexSearch) -> Vec<Match> {
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<T>(term: &Term<T>) -> Point {
Point::new(Line(-(term.grid().display_offset() as i32)), Column(0))
}
fn viewport_bottom<T>(term: &Term<T>) -> 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<Indexed>,
last: Option<Indexed>,
/// The first match at or below the top of the viewport.
first_from_top: Option<Indexed>,
/// The last match at or above the bottom of the viewport.
last_to_bottom: Option<Indexed>,
/// The focused match's neighbours, when it was found.
focused_found: bool,
before_focused: Option<Indexed>,
after_focused: Option<Indexed>,
}
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<Indexed> {
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<Item = Match>,
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<usize>,
direction: SearchDirection,
) -> Option<usize> {
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]);
}
}
+35 -5
View File
@@ -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.
+283 -10
View File
@@ -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<dyn Fn(PopupMenu, &TerminalLifecycle) -> PopupMenu>;
/// The open find bar of one terminal.
struct FindBar {
input: Entity<InputState>,
/// The search waiting for typing to pause, if any.
pending: Option<Task<()>>,
_subscription: Subscription,
}
gpui_kit::actions!(shellr_terminal, [SendTab, SendBackTab]);
@@ -142,6 +172,8 @@ pub struct TerminalView {
scroll_accumulator: ScrollAccumulator,
context_menu: Option<(Entity<PopupMenu>, gpui_kit::Point<Pixels>)>,
context_menu_subscription: Option<Subscription>,
menu_items: Option<TerminalMenuItems>,
find: Option<FindBar>,
cursor_visible: bool,
focused: bool,
_subscriptions: Vec<Subscription>,
@@ -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>,
) {
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>) {
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<Self>) {
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<Self>) {
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<Self>) {
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<Self>) {
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<Self>) {
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>,
) {
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
},
+103 -37
View File
@@ -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<Entity<TerminalView>> {
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<Self>) {
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<Self>) {
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<Self>,
) {
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<Self>,
) {
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<Self>,
) {
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<Self>,
) {
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<Self>) {
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<Self>,
) {
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))
+235 -4
View File
@@ -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<FakeTerminalFactory>,
) -> (WindowHandle<Root>, Entity<Workspace>) {
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<Workspace>, 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<Workspace>,
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<Root>, 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);