Files
shell-rs/tests/ui/terminal.rs
T
XuzandClaude Opus 5.5 5ca6e53366 设置 › 终端新增「终端交互」:选中复制、右键行为
「选中复制」打开后,鼠标选中的文字在松开时进入系统剪贴板,单击没选中东西时剪贴板不变;
默认关闭。「右键行为」可选无动作、粘贴、显示菜单,默认显示菜单;右键从不交给要求鼠标的
程序。设置存在 settings.json 的 terminal_interaction,终端经全局量 TerminalInteraction
读取,改动立即作用于已打开的终端。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:24:04 +08:00

1260 lines
45 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Terminals: local terminals, keys and input, selection, find, clearing,
//! the font and the size, links, the mouse for programs and their copies.
use crate::support::*;
use shellrs::terminal::RightClick;
fn open_workspace_with_factory(
cx: &mut TestAppContext,
factory: Arc<FakeTerminalFactory>,
) -> (WindowHandle<Root>, Entity<Workspace>) {
init_app(cx);
// Dialogs still, as in every fixture: see `open_workspace_with_tester`.
cx.update(|cx| cx.set_reduce_motion(true));
let mut workspace = None;
let handle = cx.open_window(size(px(1280.), px(800.)), |window, cx| {
let store = cx.new(|_| HostStore::seed());
let remote = Arc::new(FixedRemoteTerminalTransportProvider::new(Arc::new(
FakeTerminalFactory::default(),
)));
let view = cx.new(|cx| {
Workspace::new_with_services(
store,
cx.new(|_| settings_store()),
remote,
factory.clone(),
Arc::new(FakeSftpProvider::default()),
Arc::new(FakeLocalDirectory::default()),
Arc::new(FakeConnectionTester::default()),
Arc::new(FakeForwardProvider::default()),
window,
cx,
)
});
workspace = Some(view.clone());
Root::new(view, window, cx)
});
(handle, workspace.expect("workspace created"))
}
#[gpui_kit::test]
async fn creates_independent_local_terminals_from_button_and_shortcut(cx: &mut TestAppContext) {
let factory = Arc::new(FakeTerminalFactory::default());
let (handle, workspace) = open_workspace_with_factory(cx, factory.clone());
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.click("new-local-terminal", cx);
})
.unwrap();
cx.run_until_parked();
cx.wait_for(handle.into(), Duration::from_secs(2), |window, _| {
window
.try_find("status-connection")
.map(|element| element.label() == Some("运行中 本地终端"))
.unwrap_or(false)
})
.await;
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert!(window.find(("local-terminal", 1_u64)).visible());
assert_eq!(window.find(("local-terminal", 1_u64)).focused(), Some(true));
#[cfg(target_os = "macos")]
window.press("cmd-t", cx);
#[cfg(not(target_os = "macos"))]
window.press("ctrl-t", cx);
})
.unwrap();
cx.run_until_parked();
cx.wait_for(handle.into(), Duration::from_secs(2), |window, _| {
window
.try_find("status-connection")
.map(|element| element.label() == Some("运行中 本地终端"))
.unwrap_or(false)
&& window.try_find(("local-terminal", 2_u64)).is_some()
})
.await;
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert!(window.find(("local-terminal", 2_u64)).visible());
assert_eq!(
window.find("status-connection").label(),
Some("运行中 本地终端")
);
window.click(("close-local-terminal", 2_u64), cx);
})
.unwrap();
cx.run_until_parked();
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert!(window.try_find(("local-terminal", 2_u64)).is_none());
assert!(window.find(("local-terminal", 1_u64)).visible());
})
.unwrap();
cx.update(|cx| {
let workspace = workspace.read(cx);
assert!(workspace.local_terminal(LocalTerminalId(1)).is_some());
assert!(workspace.local_terminal(LocalTerminalId(2)).is_none());
assert!(workspace.store().read(cx).active().is_none());
assert_eq!(factory.starts.load(Ordering::SeqCst), 2);
});
}
#[gpui_kit::test]
async fn tab_keys_are_sent_to_the_focused_terminal(cx: &mut TestAppContext) {
let factory = Arc::new(FakeTerminalFactory::default());
let (handle, _) = open_workspace_with_factory(cx, factory.clone());
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;
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert_eq!(window.find(("local-terminal", 1_u64)).focused(), Some(true));
window.press("tab", cx);
window.press("shift-tab", cx);
})
.unwrap();
cx.wait_for(handle.into(), Duration::from_secs(2), |_, _| {
factory.written_text() == "\t\x1b[Z"
})
.await;
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert_eq!(window.find(("local-terminal", 1_u64)).focused(), Some(true));
})
.unwrap();
}
#[gpui_kit::test]
async fn exited_local_terminal_keeps_its_tab_and_restarts_fresh(cx: &mut TestAppContext) {
let factory = Arc::new(FakeTerminalFactory::exit_first());
let (handle, workspace) = open_workspace_with_factory(cx, factory.clone());
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(("local-terminal-exit", 1_u64)).is_some()
})
.await;
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert!(window.find(("local-terminal", 1_u64)).visible());
assert!(window.find(("local-terminal-exit", 1_u64)).visible());
cx.write_to_clipboard(ClipboardItem::new_string("退出后不可粘贴".into()));
#[cfg(target_os = "macos")]
window.press("cmd-v", cx);
#[cfg(not(target_os = "macos"))]
window.press("ctrl-shift-v", cx);
assert!(factory.written_text().is_empty());
let bounds = window.find(("local-terminal", 1_u64)).bounds();
window.drag(
point(bounds.left() + px(1.), bounds.top() + px(8.)),
point(bounds.right() - px(1.), bounds.top() + px(8.)),
cx,
);
#[cfg(target_os = "macos")]
window.press("cmd-c", cx);
#[cfg(not(target_os = "macos"))]
window.press("ctrl-shift-c", cx);
assert_eq!(
cx.read_from_clipboard().and_then(|item| item.text()),
Some("run:1$ alpha.txt 会议纪要.md".into())
);
window.click(("restart-local-terminal", 1_u64), cx);
})
.unwrap();
cx.wait_for(handle.into(), Duration::from_secs(2), |window, _| {
window.try_find(("local-terminal-exit", 1_u64)).is_none()
&& window
.try_find("status-connection")
.map(|element| element.label() == Some("运行中 本地终端"))
.unwrap_or(false)
})
.await;
cx.update(|cx| {
let panel = workspace
.read(cx)
.local_terminal(LocalTerminalId(1))
.unwrap()
.clone();
assert_eq!(
panel.read(cx).status(cx).lifecycle(),
&TerminalLifecycle::Running
);
assert_eq!(factory.starts.load(Ordering::SeqCst), 2);
});
}
#[gpui_kit::test]
async fn local_terminal_start_failure_is_kept_with_a_chinese_error(cx: &mut TestAppContext) {
let factory = Arc::new(FakeTerminalFactory::fail_first());
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(("local-terminal-exit", 1_u64)).is_some()
})
.await;
cx.update(|cx| {
let panel = workspace
.read(cx)
.local_terminal(LocalTerminalId(1))
.unwrap()
.clone();
assert!(matches!(
panel.read(cx).status(cx).lifecycle(),
TerminalLifecycle::Failed(error) if error.contains("测试启动失败")
));
assert!(
panel
.read(cx)
.terminal()
.read(cx)
.screen_text(cx)
.contains("终端错误:测试启动失败")
);
});
}
#[gpui_kit::test]
async fn terminal_selection_copies_only_on_command_and_finishes_outside_view(
cx: &mut TestAppContext,
) {
let factory = Arc::new(FakeTerminalFactory::default());
let (handle, _) = 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;
cx.update(|cx| cx.write_to_clipboard(ClipboardItem::new_string("原剪贴板".into())));
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.right_click(("local-terminal", 1_u64), cx);
})
.unwrap();
cx.run_until_parked();
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
let mut popup = window.within("popup-menu");
assert_eq!(popup.find(0_usize).label(), Some("复制"));
popup.click(0_usize, cx);
assert_eq!(
cx.read_from_clipboard().and_then(|item| item.text()),
Some("原剪贴板".into())
);
window.click("host-search", cx);
})
.unwrap();
cx.run_until_parked();
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
let bounds = window.find(("local-terminal", 1_u64)).bounds();
let from = point(bounds.left() + px(1.), bounds.top() + px(8.));
let to = point(bounds.right() - px(1.), bounds.top() + px(8.));
window.drag(from, to, cx);
assert_eq!(
cx.read_from_clipboard().and_then(|item| item.text()),
Some("原剪贴板".into())
);
#[cfg(target_os = "macos")]
window.press("cmd-c", cx);
#[cfg(not(target_os = "macos"))]
window.press("ctrl-shift-c", cx);
assert_eq!(
cx.read_from_clipboard().and_then(|item| item.text()),
Some("run:1$ alpha.txt 会议纪要.md".into())
);
window.right_click(("local-terminal", 1_u64), cx);
})
.unwrap();
cx.run_until_parked();
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
let mut popup = window.within("popup-menu");
assert_eq!(popup.find(0_usize).label(), Some("复制"));
assert_eq!(popup.find(1_usize).label(), Some("粘贴"));
popup.click(0_usize, cx);
})
.unwrap();
cx.run_until_parked();
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
let bounds = window.find(("local-terminal", 1_u64)).bounds();
let from = point(bounds.left() + px(100.), bounds.top() + px(8.));
let outside = point(px(20.), bounds.top() + px(8.));
let to = point(bounds.right() - px(1.), bounds.top() + px(8.));
window.drag(from, outside, cx);
#[cfg(target_os = "macos")]
window.press("cmd-c", cx);
#[cfg(not(target_os = "macos"))]
window.press("ctrl-shift-c", cx);
let selection_after_release = cx
.read_from_clipboard()
.and_then(|item| item.text())
.expect("outside release leaves a valid selection");
cx.write_to_clipboard(ClipboardItem::new_string("哨兵".into()));
window.dispatch_event(
MouseMoveEvent {
position: to,
pressed_button: Some(MouseButton::Left),
modifiers: Default::default(),
}
.to_platform_input(),
cx,
);
window.render_frame(cx);
#[cfg(target_os = "macos")]
window.press("cmd-c", cx);
#[cfg(not(target_os = "macos"))]
window.press("ctrl-shift-c", cx);
assert_eq!(
cx.read_from_clipboard().and_then(|item| item.text()),
Some(selection_after_release)
);
window.click(("close-local-terminal", 1_u64), cx);
})
.unwrap();
cx.run_until_parked();
}
#[gpui_kit::test]
async fn copy_on_select_puts_what_the_mouse_selects_on_the_clipboard(cx: &mut TestAppContext) {
let factory = Arc::new(FakeTerminalFactory::default());
let (handle, workspace) = open_running_local_terminal(cx, factory).await;
let settings = cx.update(|cx| workspace.read(cx).settings().clone());
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.render_frame(cx);
local_screen(&workspace, cx).contains("会议纪要")
})
.await;
// 终端交互 is the second group of 终端: 选中复制, off, then 右键行为.
in_frame(cx, handle, |window, cx| window.click("open-settings", cx));
in_frame(cx, handle, |window, cx| {
window.within("settings").click("0-1", cx)
});
in_frame(cx, handle, |window, cx| {
let mut settings = window.within("settings");
let mut group = settings.within("group-1");
assert_eq!(group.within("item-0").find("check").checked(), Some(false));
assert_eq!(group.within("item-1").find("btn").label(), Some("显示菜单"));
group.within("item-0").click("check", cx);
});
cx.update(|cx| {
assert!(
settings
.read(cx)
.settings()
.terminal_interaction
.copy_on_select
)
});
in_frame(cx, handle, |window, cx| {
window.click(("local-terminal-tab", 1_u64), cx)
});
cx.update(|cx| cx.write_to_clipboard(ClipboardItem::new_string("原剪贴板".into())));
in_frame(cx, handle, |window, cx| {
let bounds = window.find(("local-terminal", 1_u64)).bounds();
let from = point(bounds.left() + px(1.), bounds.top() + px(8.));
let to = point(bounds.right() - px(1.), bounds.top() + px(8.));
// A click selects nothing, and the clipboard keeps what it had.
click_at(window, from, gpui_kit::Modifiers::none(), cx);
assert_eq!(
cx.read_from_clipboard().and_then(|item| item.text()),
Some("原剪贴板".into())
);
window.drag(from, to, cx);
assert_eq!(
cx.read_from_clipboard().and_then(|item| item.text()),
Some("run:1$ alpha.txt 会议纪要.md".into())
);
});
}
#[gpui_kit::test]
async fn right_click_pastes_or_does_nothing_when_set_to(cx: &mut TestAppContext) {
let factory = Arc::new(FakeTerminalFactory::default());
let (handle, workspace) = open_running_local_terminal(cx, factory.clone()).await;
let settings = cx.update(|cx| workspace.read(cx).settings().clone());
let right_click = |cx: &mut TestAppContext, action: RightClick, clipboard: &str| {
cx.update(|cx| {
settings.update(cx, |settings, cx| {
settings.update(
|settings| settings.terminal_interaction.right_click = action,
cx,
)
});
cx.write_to_clipboard(ClipboardItem::new_string(clipboard.into()));
});
in_frame(cx, handle, |window, cx| {
window.right_click(("local-terminal", 1_u64), cx)
});
in_frame(cx, handle, |window, _| {
assert!(window.try_find("popup-menu").is_none())
});
};
right_click(cx, RightClick::Paste, "粘贴一次");
take_written(cx, handle, &factory, "粘贴一次").await;
// Nothing is pasted: had it been, the next paste would follow it.
right_click(cx, RightClick::Nothing, "不该粘贴");
right_click(cx, RightClick::Paste, "再粘贴");
take_written(cx, handle, &factory, "再粘贴").await;
}
#[gpui_kit::test]
async fn cjk_input_is_sent_once_and_resize_commands_are_deduplicated(cx: &mut TestAppContext) {
let factory = Arc::new(FakeTerminalFactory::default());
let (handle, workspace) = open_workspace_with_factory(cx, factory.clone());
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;
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.input("输入一次", cx);
})
.unwrap();
cx.wait_for(handle.into(), Duration::from_secs(2), |_, _| {
factory.written_text() == "输入一次"
})
.await;
cx.update(|cx| {
let terminal = workspace
.read(cx)
.local_terminal(LocalTerminalId(1))
.expect("local terminal exists")
.read(cx)
.terminal()
.clone();
let screen = terminal.read(cx).screen_text(cx);
assert_eq!(screen.matches("输入一次").count(), 1);
});
let resizes = factory
.resizes
.lock()
.unwrap_or_else(|error| error.into_inner());
assert!(!resizes.is_empty());
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)
}
/// The row height the fake shell was last told about.
fn last_cell_height(factory: &FakeTerminalFactory) -> Option<u16> {
factory
.resizes
.lock()
.unwrap_or_else(|error| error.into_inner())
.last()
.map(TerminalSize::cell_height)
}
/// Menus are not driven here: the settings page's fields write the settings
/// store, so the test writes it the same way.
#[gpui_kit::test]
async fn the_terminal_font_setting_reaches_open_terminals_and_the_preview(cx: &mut TestAppContext) {
let factory = Arc::new(FakeTerminalFactory::default());
let (handle, workspace) = open_running_local_terminal(cx, factory.clone()).await;
let settings = cx.update(|cx| workspace.read(cx).settings().clone());
// The defaults keep the 20 px rows terminals had before the setting.
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.render_frame(cx);
last_cell_height(&factory) == Some(20)
})
.await;
cx.update(|cx| {
settings.update(cx, |settings, cx| {
settings.update(
|settings| {
settings.terminal_font.size = 16.;
settings.terminal_font.line_height = 1.5;
},
cx,
)
})
});
// The open terminal takes the new rows at once.
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.render_frame(cx);
last_cell_height(&factory) == Some(24)
})
.await;
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.click("open-settings", cx);
})
.unwrap();
cx.run_until_parked();
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
// 终端 is the second category.
window.within("settings").click("0-1", cx);
})
.unwrap();
cx.run_until_parked();
let default_family = cx.update(|cx| cx.theme().mono_font_family.to_string());
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
let field = |window: &mut gpui_kit::Window, item: usize| {
window
.within("settings")
.within("group-0")
.within(format!("item-{item}"))
.find("btn")
.label()
.map(str::to_string)
};
assert_eq!(field(window, 0), Some(format!("{default_family}(默认)")));
assert_eq!(field(window, 1).as_deref(), Some("16"));
assert_eq!(
window.find("terminal-font-preview").label(),
Some(format!("{default_family} 16 px,行高 1.5").as_str())
);
})
.unwrap();
// A family that is not installed, say from a file copied from another
// machine, gives way to the default instead of a wrong font.
cx.update(|cx| {
settings.update(cx, |settings, cx| {
settings.update(
|settings| settings.terminal_font.family = Some("没有这个字体".into()),
cx,
)
})
});
cx.run_until_parked();
cx.update(|cx| {
assert_eq!(cx.global::<TerminalFont>().family, None);
assert_eq!(
settings.read(cx).settings().terminal_font.family.as_deref(),
Some("没有这个字体")
);
});
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert_eq!(
window.find("terminal-font-preview").label(),
Some(format!("{default_family} 16 px,行高 1.5").as_str())
);
})
.unwrap();
}
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)
}
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;
}
/// The interface language changes under an open find bar: the placeholder
/// gpui-kit keeps is given again, and what is drawn is drawn in English.
#[gpui_kit::test]
async fn an_open_find_bar_follows_the_interface_language(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.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").label(), Some("查找"));
window.input("zzz", cx);
})
.unwrap();
wait_for_find_count(cx, handle, "无结果").await;
workspace.update(cx, |workspace, cx| {
workspace.settings().update(cx, |settings, cx| {
settings.update(
|settings| settings.language = InterfaceLanguage::English,
cx,
)
});
});
cx.run_until_parked();
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert_eq!(window.find("terminal-find").label(), Some("Find"));
assert_eq!(
window.find(("local-terminal", 1_u64)).label(),
Some("Local terminal 1")
);
})
.unwrap();
wait_for_find_count(cx, handle, "No results").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 the_status_bar_shows_the_size_of_the_terminal_in_front(cx: &mut TestAppContext) {
let (handle, workspace) = open_workspace(cx);
let terminal = RemoteTerminalId(FIRST_NEW_TERMINAL);
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.dispatch_action(Box::new(ConnectHost(HostId(WEB_01))), cx);
})
.unwrap();
cx.wait_for(handle.into(), Duration::from_secs(2), |_, cx| {
remote_lifecycle(&workspace, terminal, cx) == TerminalLifecycle::Running
})
.await;
let columns = |cx: &App| {
workspace
.read(cx)
.remote_terminal(terminal)
.expect("terminal exists")
.read(cx)
.status(cx)
.columns()
};
// Whether the status bar shows the terminal's screen as it is now.
let shows_its_size = |window: &mut gpui_kit::Window, cx: &mut App| {
let status = workspace
.read(cx)
.remote_terminal(terminal)
.expect("terminal exists")
.read(cx)
.status(cx);
let size = format!("{}×{}", status.columns(), status.rows());
window
.try_find("status-terminal-size")
.is_some_and(|element| element.label() == Some(size.as_str()))
};
// Laid out in the window, not the 80 columns it starts with.
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
columns(cx) > 80 && shows_its_size(window, cx)
})
.await;
let wide = cx.update(|cx| columns(cx));
// A smaller window, a smaller terminal, and the status bar follows.
cx.simulate_window_resize(handle.into(), size(px(900.), px(600.)));
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
columns(cx) < wide && shows_its_size(window, cx)
})
.await;
// An SFTP tab has no terminal, so no size either.
in_frame(cx, handle, |window, cx| {
window.dispatch_action(Box::new(OpenExplorer(HostId(WEB_01))), cx);
});
cx.wait_for(handle.into(), Duration::from_secs(2), |window, _| {
window
.try_find(("explorer", SFTP_TAB))
.is_some_and(|explorer| explorer.visible())
})
.await;
in_frame(cx, handle, |window, cx| {
assert!(window.find("status-connection").visible());
assert!(window.try_find("status-terminal-size").is_none());
window.click("new-local-terminal", cx);
});
// A local terminal shows its own.
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
let Some(local) = workspace.read(cx).local_terminal(LocalTerminalId(1)) else {
return false;
};
let status = local.read(cx).status(cx);
let size = format!("{}×{}", status.columns(), status.rows());
// Laid out, so no longer the 80×24 it starts with.
size != "80×24"
&& window
.try_find("status-terminal-size")
.is_some_and(|element| element.label() == Some(size.as_str()))
})
.await;
}
/// Where the cell `(column, row)` of the first local terminal is, by the cell
/// size the fake shell was told of.
fn cell_center(
window: &mut gpui_kit::Window,
factory: &FakeTerminalFactory,
column: usize,
row: usize,
) -> gpui_kit::Point<gpui_kit::Pixels> {
let size = *factory
.resizes
.lock()
.unwrap_or_else(|error| error.into_inner())
.last()
.expect("the terminal has been laid out");
let bounds = window.find(("local-terminal", 1_u64)).bounds();
point(
bounds.left() + px(size.cell_width() as f32 * (column as f32 + 0.5)),
bounds.top() + px(size.cell_height() as f32 * (row as f32 + 0.5)),
)
}
/// A local terminal whose shell has printed `text`, which ends in `marker`,
/// laid out.
async fn terminal_printing(
cx: &mut TestAppContext,
text: &'static str,
marker: &'static str,
) -> (
WindowHandle<Root>,
Entity<Workspace>,
Arc<FakeTerminalFactory>,
) {
let factory = Arc::new(FakeTerminalFactory::printing(text));
let (handle, workspace) = open_running_local_terminal(cx, factory.clone()).await;
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.render_frame(cx);
local_screen(&workspace, cx).contains(marker) && last_cell_height(&factory).is_some()
})
.await;
(handle, workspace, factory)
}
#[gpui_kit::test]
async fn links_open_with_the_command_key_and_show_where_they_lead(cx: &mut TestAppContext) {
// The prompt line is row 0; the link starts row 1.
let (handle, _, factory) =
terminal_printing(cx, "https://example.com/docs. 文档\r\n", "文档").await;
// A plain click selects, as it always has, and opens nothing.
in_frame(cx, handle, |window, cx| {
let link = cell_center(window, &factory, 2, 1);
click_at(window, link, gpui_kit::Modifiers::none(), cx);
});
assert_eq!(cx.opened_url(), None);
// Resting on it shows where it leads, without the full stop.
cx.background_executor
.advance_clock(Duration::from_millis(600));
cx.run_until_parked();
in_frame(cx, handle, |window, cx| {
assert_eq!(
window.find("terminal-link-tooltip").label(),
Some("https://example.com/docs")
);
let link = cell_center(window, &factory, 2, 1);
click_at(window, link, gpui_kit::Modifiers::secondary_key(), cx);
});
assert_eq!(cx.opened_url().as_deref(), Some("https://example.com/docs"));
}
/// Wait until the fake shell has been sent `expected`, which it records on
/// its own thread, and forget it.
async fn take_written(
cx: &mut TestAppContext,
handle: WindowHandle<Root>,
factory: &FakeTerminalFactory,
expected: &str,
) {
cx.wait_for(handle.into(), Duration::from_secs(2), |_, _| {
factory.written_text() == expected
})
.await;
factory
.writes
.lock()
.unwrap_or_else(|error| error.into_inner())
.clear();
}
#[gpui_kit::test]
async fn programs_that_ask_for_the_mouse_hear_clicks_and_the_wheel(cx: &mut TestAppContext) {
// 1000: clicks, 1006: SGR encoding.
let (handle, _, factory) =
terminal_printing(cx, "\x1b[?1000h\x1b[?1006hmouse on", "mouse on").await;
in_frame(cx, handle, |window, cx| {
let cell = cell_center(window, &factory, 2, 0);
click_at(window, cell, gpui_kit::Modifiers::none(), cx);
});
take_written(cx, handle, &factory, "\x1b[<0;3;1M\x1b[<0;3;1m").await;
// Shift keeps the mouse for selecting text: the program hears only the
// wheel after it.
in_frame(cx, handle, |window, cx| {
let cell = cell_center(window, &factory, 2, 0);
click_at(window, cell, gpui_kit::Modifiers::shift(), cx);
let position = cell_center(window, &factory, 4, 1);
window.dispatch_event(
gpui_kit::ScrollWheelEvent {
position,
delta: gpui_kit::ScrollDelta::Lines(point(0., 1.)),
modifiers: gpui_kit::Modifiers::default(),
touch_phase: gpui_kit::TouchPhase::Moved,
}
.to_platform_input(),
cx,
);
});
take_written(cx, handle, &factory, "\x1b[<64;5;2M").await;
}
#[gpui_kit::test]
async fn the_scrollbar_scrolls_back_through_the_history(cx: &mut TestAppContext) {
let text: String = (1..=200).map(|line| format!("line {line}\r\n")).collect();
let (handle, workspace, _) = terminal_printing(cx, String::leak(text + "end"), "end").await;
let screen = |cx: &mut TestAppContext| cx.read(|cx| local_screen(&workspace, cx));
assert!(!screen(cx).lines().any(|line| line == "line 1"));
// A click at the top of the scrollbar's track, in the margin beside the
// text, goes to the oldest line.
in_frame(cx, handle, |window, cx| {
let terminal = window.find(("local-terminal", 1_u64)).bounds();
let top = point(terminal.right() + px(2.), terminal.top() + px(2.));
click_at(window, top, gpui_kit::Modifiers::none(), cx);
});
let lines = screen(cx);
assert!(lines.lines().any(|line| line == "line 1"), "{lines}");
assert!(!lines.contains("end"), "{lines}");
}
#[gpui_kit::test]
async fn a_program_copies_to_the_local_clipboard(cx: &mut TestAppContext) {
// 「你好」 in base64.
let (handle, _, _) = terminal_printing(cx, "\x1b]52;c;5L2g5aW9\x07copied", "copied").await;
cx.wait_for(handle.into(), Duration::from_secs(2), |_, cx| {
cx.read_from_clipboard().and_then(|item| item.text()) == Some("你好".into())
})
.await;
}
/// Have local terminal `id` print `text`: the fake shell prints back what it
/// is sent, and a paste outside bracketed paste mode sends it as it is.
fn print_into(cx: &mut TestAppContext, workspace: &Entity<Workspace>, id: u64, text: &str) {
cx.update(|cx| {
cx.write_to_clipboard(ClipboardItem::new_string(text.to_string()));
let terminal = workspace
.read(cx)
.local_terminal(LocalTerminalId(id))
.expect("the terminal is open")
.read(cx)
.terminal()
.clone();
terminal.update(cx, |terminal, cx| terminal.paste_clipboard(cx));
});
}
/// The system notifications shown once there are `count` of them.
async fn system_notifications(
cx: &mut TestAppContext,
count: usize,
) -> Vec<gpui_kit::SystemNotification> {
for _ in 0..200 {
let shown = cx.shown_system_notifications();
if shown.len() >= count {
return shown;
}
cx.executor().timer(Duration::from_millis(10)).await;
}
panic!(
"expected {count} system notifications, got {:?}",
cx.shown_system_notifications()
);
}
fn in_window(cx: &mut TestAppContext, handle: WindowHandle<Root>) -> usize {
cx.update_window(handle.into(), |_, window, cx| {
window.notifications(cx).len()
})
.unwrap()
}
#[gpui_kit::test]
async fn terminals_notify_where_the_user_will_see_it(cx: &mut TestAppContext) {
let factory = Arc::new(FakeTerminalFactory::default());
let (handle, workspace) = open_running_local_terminal(cx, factory).await;
// Test windows start out not active.
in_frame(cx, handle, |window, _| window.activate_window());
assert!(cx.shown_system_notifications().is_empty());
// In sight, a program's notice shows in the window; the bell says nothing.
print_into(cx, &workspace, 1, "\x1b]777;notify;构建;完成\x07\x07");
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.notifications(cx).len() == 1
})
.await;
cx.background_executor
.advance_clock(Duration::from_millis(200));
cx.run_until_parked();
assert_eq!(in_window(cx, handle), 1);
assert!(cx.shown_system_notifications().is_empty());
// Its notice gone, the first terminal rings behind a second one.
cx.background_executor
.advance_clock(Duration::from_secs(10));
cx.run_until_parked();
assert_eq!(in_window(cx, handle), 0);
in_frame(cx, handle, |window, cx| {
window.click("new-local-terminal", cx)
});
cx.wait_for(handle.into(), Duration::from_secs(2), |_, cx| {
workspace
.read(cx)
.local_terminal(LocalTerminalId(2))
.is_some_and(|terminal| {
terminal.read(cx).status(cx).lifecycle() == &TerminalLifecycle::Running
})
})
.await;
print_into(cx, &workspace, 1, "y/N\x07");
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.notifications(cx).len() == 1
})
.await;
// With the window away, a notice goes to the system, under the tab's
// name, and clicking it shows that terminal.
cx.update(|cx| {
assert_eq!(
workspace.read(cx).active_tab(),
Some(shellrs::app::CenterTab::LocalTerminal(LocalTerminalId(2)))
);
});
gpui_kit::VisualTestContext::from_window(handle.into(), cx).deactivate_window();
print_into(cx, &workspace, 1, "\x1b]9;部署完成\x07");
let shown = system_notifications(cx, 1).await;
assert_eq!(shown[0].tag.as_ref(), "terminal:local:1");
assert_eq!(shown[0].title.as_ref(), "本地终端 1");
assert_eq!(shown[0].body.as_ref(), "部署完成");
cx.simulate_system_notification_response(gpui_kit::SystemNotificationResponse {
tag: shown[0].tag.clone(),
action_id: None,
});
cx.run_until_parked();
cx.update(|cx| {
assert_eq!(
workspace.read(cx).active_tab(),
Some(shellrs::app::CenterTab::LocalTerminal(LocalTerminalId(1)))
);
});
}
/// Have the settings hold `rules`, as the 关键字高亮 page writes them, with
/// highlighting on.
fn set_highlight_rules(
cx: &mut TestAppContext,
workspace: &Entity<Workspace>,
rules: Vec<shellrs::terminal::HighlightRule>,
) {
let settings = cx.update(|cx| workspace.read(cx).settings().clone());
settings.update(cx, |settings, cx| {
settings.update(
|settings| {
settings.terminal_highlight.enabled = true;
settings.terminal_highlight.rules = rules;
},
cx,
)
});
cx.run_until_parked();
}
#[gpui_kit::test]
async fn a_matching_line_notifies_from_a_terminal_out_of_sight(cx: &mut TestAppContext) {
use shellrs::terminal::HighlightRule;
let factory = Arc::new(FakeTerminalFactory::default());
let (handle, workspace) = open_running_local_terminal(cx, factory).await;
// WARN only colors; ERROR tells.
set_highlight_rules(
cx,
&workspace,
vec![
HighlightRule {
pattern: "WARN".into(),
..HighlightRule::default()
},
HighlightRule {
pattern: "ERROR".into(),
notify: true,
..HighlightRule::default()
},
],
);
in_frame(cx, handle, |window, _| window.activate_window());
// Behind a second terminal, a finished line that matches shows in the
// window; neither the WARN line nor the line still being written does.
in_frame(cx, handle, |window, cx| {
window.click("new-local-terminal", cx)
});
cx.wait_for(handle.into(), Duration::from_secs(2), |_, cx| {
workspace
.read(cx)
.local_terminal(LocalTerminalId(2))
.is_some_and(|terminal| {
terminal.read(cx).status(cx).lifecycle() == &TerminalLifecycle::Running
})
})
.await;
print_into(cx, &workspace, 1, "12:01 WARN low disk\n12:02 ERR");
cx.wait_for(handle.into(), Duration::from_secs(2), |_, cx| {
local_screen(&workspace, cx).contains("12:02 ERR")
})
.await;
print_into(cx, &workspace, 1, "OR boom\n");
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.notifications(cx).len() == 1
})
.await;
// With the window away, the system's notification names the tab and
// the rule, and has the line.
cx.background_executor
.advance_clock(Duration::from_secs(10));
cx.run_until_parked();
gpui_kit::VisualTestContext::from_window(handle.into(), cx).deactivate_window();
print_into(cx, &workspace, 1, "12:03 ERROR again\n");
let shown = system_notifications(cx, 1).await;
assert_eq!(shown[0].tag.as_ref(), "terminal:local:1");
assert_eq!(shown[0].title.as_ref(), "本地终端 1:ERROR");
assert_eq!(shown[0].body.as_ref(), "12:03 ERROR again");
// In sight, a matching line says nothing: had it, the next one would
// be held back as too soon after it.
cx.background_executor
.advance_clock(Duration::from_secs(10));
cx.simulate_system_notification_response(gpui_kit::SystemNotificationResponse {
tag: shown[0].tag.clone(),
action_id: None,
});
cx.run_until_parked();
in_frame(cx, handle, |window, _| window.activate_window());
cx.update(|cx| {
assert_eq!(
workspace.read(cx).active_tab(),
Some(shellrs::app::CenterTab::LocalTerminal(LocalTerminalId(1)))
);
});
// The program's notice after it comes once the line has been weighed.
print_into(cx, &workspace, 1, "12:04 ERROR seen\n\x1b]9;done\x07");
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.notifications(cx).len() == 1
})
.await;
gpui_kit::VisualTestContext::from_window(handle.into(), cx).deactivate_window();
print_into(cx, &workspace, 1, "12:05 ERROR later\n");
let shown = system_notifications(cx, 2).await;
assert_eq!(shown[1].body.as_ref(), "12:05 ERROR later");
}