终端右键菜单增加查找、清屏、SFTP、重新连接与断开连接
This commit is contained in:
1 parent
48ac2b95aa
commit
adbd1de688
13 files changed
+1407
-74
No files matched your search
@@ -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` 初始为已连接并已打开终端标签。
|
||||
@@ -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,关闭终端不会中止上传。
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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]
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user