1358 lines
47 KiB
Rust
1358 lines
47 KiB
Rust
//! Connecting a host: login questions, its connection state, the tabs it
|
|
//! opens, and the system and latency found on the way.
|
|
|
|
use crate::support::*;
|
|
|
|
#[derive(Clone, Copy)]
|
|
enum PromptBehavior {
|
|
Authentication,
|
|
UnknownHost,
|
|
HostKeyChanged,
|
|
}
|
|
|
|
struct PromptTerminalFactory {
|
|
behavior: PromptBehavior,
|
|
accepted: Arc<AtomicUsize>,
|
|
canceled: Arc<AtomicUsize>,
|
|
}
|
|
|
|
impl PromptTerminalFactory {
|
|
fn new(behavior: PromptBehavior) -> Self {
|
|
Self {
|
|
behavior,
|
|
accepted: Arc::new(AtomicUsize::new(0)),
|
|
canceled: Arc::new(AtomicUsize::new(0)),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl TerminalTransportFactory for PromptTerminalFactory {
|
|
fn create(&self) -> Box<dyn TerminalTransport> {
|
|
Box::new(PromptTerminalTransport {
|
|
behavior: self.behavior,
|
|
accepted: self.accepted.clone(),
|
|
canceled: self.canceled.clone(),
|
|
})
|
|
}
|
|
}
|
|
|
|
struct PromptTerminalTransport {
|
|
behavior: PromptBehavior,
|
|
accepted: Arc<AtomicUsize>,
|
|
canceled: Arc<AtomicUsize>,
|
|
}
|
|
|
|
impl TerminalTransport for PromptTerminalTransport {
|
|
fn run(
|
|
self: Box<Self>,
|
|
_: TerminalSize,
|
|
commands: mpsc::Receiver<TerminalTransportCommand>,
|
|
events: async_channel::Sender<TerminalTransportEvent>,
|
|
) -> anyhow::Result<()> {
|
|
let request_id = 9001;
|
|
let kind = match self.behavior {
|
|
PromptBehavior::Authentication => ConnectionPromptKind::authentication(
|
|
"SSH 登录",
|
|
"请输入密码",
|
|
vec![ConnectionPromptField::new("密码", false)],
|
|
),
|
|
PromptBehavior::UnknownHost => ConnectionPromptKind::unknown_host(
|
|
"example.test",
|
|
22,
|
|
"ssh-ed25519",
|
|
"SHA256:test-fingerprint",
|
|
),
|
|
PromptBehavior::HostKeyChanged => ConnectionPromptKind::host_key_changed(
|
|
"example.test",
|
|
22,
|
|
"ssh-ed25519",
|
|
vec!["SHA256:old-fingerprint".into()],
|
|
"SHA256:new-fingerprint",
|
|
),
|
|
};
|
|
events.send_blocking(TerminalTransportEvent::Prompt(ConnectionPrompt::new(
|
|
request_id, kind,
|
|
)))?;
|
|
loop {
|
|
match commands.recv()? {
|
|
TerminalTransportCommand::PromptReply {
|
|
request_id: reply_id,
|
|
reply,
|
|
} if reply_id == request_id => match reply {
|
|
ConnectionPromptReply::TrustAndSave
|
|
if matches!(
|
|
self.behavior,
|
|
PromptBehavior::UnknownHost | PromptBehavior::HostKeyChanged
|
|
) =>
|
|
{
|
|
self.accepted.fetch_add(1, Ordering::SeqCst);
|
|
break;
|
|
}
|
|
ConnectionPromptReply::Answers(answers)
|
|
if matches!(self.behavior, PromptBehavior::Authentication)
|
|
&& answers.len() == 1 =>
|
|
{
|
|
self.accepted.fetch_add(1, Ordering::SeqCst);
|
|
break;
|
|
}
|
|
ConnectionPromptReply::Cancel => {
|
|
self.canceled.fetch_add(1, Ordering::SeqCst);
|
|
events
|
|
.send_blocking(TerminalTransportEvent::Failed("认证已取消".into()))?;
|
|
return Ok(());
|
|
}
|
|
_ => {}
|
|
},
|
|
TerminalTransportCommand::Shutdown => return Ok(()),
|
|
_ => {}
|
|
}
|
|
}
|
|
events.send_blocking(TerminalTransportEvent::Started)?;
|
|
while let Ok(command) = commands.recv() {
|
|
if matches!(command, TerminalTransportCommand::Shutdown) {
|
|
break;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[gpui_kit::test]
|
|
async fn authentication_prompt_is_masked_and_drives_connected_state(cx: &mut TestAppContext) {
|
|
let (store, id) = one_host_store(AuthKind::Password);
|
|
let factory = Arc::new(PromptTerminalFactory::new(PromptBehavior::Authentication));
|
|
let (handle, workspace) = open_workspace_with_remote_factory(cx, store, factory.clone());
|
|
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
window.render_frame(cx);
|
|
window
|
|
.within("host-tree")
|
|
.double_click(("host-row", id.0), cx);
|
|
})
|
|
.unwrap();
|
|
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
|
|
window.render_frame(cx);
|
|
window.try_find("ssh-auth-submit").is_some()
|
|
})
|
|
.await;
|
|
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
window.render_frame(cx);
|
|
window.click("status-connection", cx);
|
|
})
|
|
.unwrap();
|
|
cx.run_until_parked();
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
window.render_frame(cx);
|
|
assert!(
|
|
window.try_find("ssh-auth-submit").is_some(),
|
|
"点击对话框外部不应取消认证"
|
|
);
|
|
})
|
|
.unwrap();
|
|
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
window.render_frame(cx);
|
|
assert_eq!(
|
|
window
|
|
.within(("terminal-tab", 1u64))
|
|
.find("connection-status")
|
|
.label(),
|
|
Some("连接中")
|
|
);
|
|
let password = window.find(("ssh-auth-answer", 0usize));
|
|
assert_eq!(
|
|
password.value(),
|
|
None,
|
|
"masked input must not expose its value"
|
|
);
|
|
window.click(("ssh-auth-answer", 0usize), cx);
|
|
window.input("test-password", cx);
|
|
window.click("ssh-auth-submit", cx);
|
|
})
|
|
.unwrap();
|
|
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
|
|
window.render_frame(cx);
|
|
window.find("status-connection").label() == Some("已连接 prompt-host")
|
|
&& window
|
|
.within(("terminal-tab", 1u64))
|
|
.try_find("connection-status")
|
|
.is_none()
|
|
})
|
|
.await;
|
|
|
|
assert_eq!(factory.accepted.load(Ordering::SeqCst), 1);
|
|
assert!(workspace.read_with(cx, |workspace, cx| {
|
|
workspace
|
|
.store()
|
|
.read(cx)
|
|
.host(id)
|
|
.unwrap()
|
|
.state
|
|
.is_connected()
|
|
}));
|
|
}
|
|
|
|
#[gpui_kit::test]
|
|
async fn a_changed_host_key_is_updated_from_its_question(cx: &mut TestAppContext) {
|
|
let (store, id) = one_host_store(AuthKind::Password);
|
|
let factory = Arc::new(PromptTerminalFactory::new(PromptBehavior::HostKeyChanged));
|
|
let (handle, _) = open_workspace_with_remote_factory(cx, store, factory.clone());
|
|
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
window.render_frame(cx);
|
|
window
|
|
.within("host-tree")
|
|
.double_click(("host-row", id.0), cx);
|
|
})
|
|
.unwrap();
|
|
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
|
|
window.render_frame(cx);
|
|
window.try_find("ok").is_some()
|
|
})
|
|
.await;
|
|
// Two ways out: keep the saved key, or update it and go on.
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
window.render_frame(cx);
|
|
assert_eq!(window.find("ok").label(), Some("更新并连接"));
|
|
assert!(window.try_find("cancel").is_some());
|
|
window.click("ok", cx);
|
|
})
|
|
.unwrap();
|
|
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
|
|
window.render_frame(cx);
|
|
window.find("status-connection").label() == Some("已连接 prompt-host")
|
|
})
|
|
.await;
|
|
assert_eq!(factory.accepted.load(Ordering::SeqCst), 1);
|
|
assert_eq!(factory.canceled.load(Ordering::SeqCst), 0);
|
|
}
|
|
|
|
#[gpui_kit::test]
|
|
async fn canceling_unknown_host_prompt_keeps_host_disconnected(cx: &mut TestAppContext) {
|
|
let (store, id) = one_host_store(AuthKind::Password);
|
|
let factory = Arc::new(PromptTerminalFactory::new(PromptBehavior::UnknownHost));
|
|
let (handle, workspace) = open_workspace_with_remote_factory(cx, store, factory.clone());
|
|
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
window.render_frame(cx);
|
|
window
|
|
.within("host-tree")
|
|
.double_click(("host-row", id.0), cx);
|
|
})
|
|
.unwrap();
|
|
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
|
|
window.render_frame(cx);
|
|
window.try_find("cancel").is_some()
|
|
})
|
|
.await;
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
window.render_frame(cx);
|
|
window.click("status-connection", cx);
|
|
})
|
|
.unwrap();
|
|
cx.run_until_parked();
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
window.render_frame(cx);
|
|
assert!(
|
|
window.try_find("cancel").is_some(),
|
|
"点击对话框外部不应取消主机指纹确认"
|
|
);
|
|
})
|
|
.unwrap();
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
window.render_frame(cx);
|
|
window.click("cancel", cx);
|
|
})
|
|
.unwrap();
|
|
// The window says so at once; the transport hears of the cancel on its
|
|
// own thread, a moment later.
|
|
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
|
|
window.render_frame(cx);
|
|
window.find("status-connection").label() == Some("未连接 prompt-host")
|
|
&& window
|
|
.within(("terminal-tab", 1u64))
|
|
.find("connection-status")
|
|
.label()
|
|
== Some("未连接")
|
|
&& factory.canceled.load(Ordering::SeqCst) == 1
|
|
})
|
|
.await;
|
|
|
|
assert_eq!(factory.accepted.load(Ordering::SeqCst), 0);
|
|
assert_eq!(factory.canceled.load(Ordering::SeqCst), 1);
|
|
assert!(!workspace.read_with(cx, |workspace, cx| {
|
|
workspace
|
|
.store()
|
|
.read(cx)
|
|
.host(id)
|
|
.unwrap()
|
|
.state
|
|
.is_connected()
|
|
}));
|
|
|
|
// The failed terminal tab remains visible, but connecting the host
|
|
// again must start a fresh transport instead of only activating that tab.
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
window.render_frame(cx);
|
|
window
|
|
.within("host-tree")
|
|
.double_click(("host-row", id.0), cx);
|
|
})
|
|
.unwrap();
|
|
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
|
|
window.render_frame(cx);
|
|
window.try_find("cancel").is_some()
|
|
})
|
|
.await;
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
window.render_frame(cx);
|
|
window.click("cancel", cx);
|
|
})
|
|
.unwrap();
|
|
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
|
|
window.render_frame(cx);
|
|
factory.canceled.load(Ordering::SeqCst) == 2
|
|
})
|
|
.await;
|
|
|
|
// The context-menu item dispatches this same command, so it must also
|
|
// restart the exited transport instead of merely activating the tab.
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
window.render_frame(cx);
|
|
window.dispatch_action(Box::new(ConnectHost(id)), cx);
|
|
})
|
|
.unwrap();
|
|
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
|
|
window.render_frame(cx);
|
|
window.try_find("cancel").is_some()
|
|
})
|
|
.await;
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
window.render_frame(cx);
|
|
window.click("cancel", cx);
|
|
})
|
|
.unwrap();
|
|
}
|
|
|
|
#[gpui_kit::test]
|
|
async fn opening_sftp_updates_connection_state_without_terminal(cx: &mut TestAppContext) {
|
|
let (store, id) = one_host_store(AuthKind::Password);
|
|
let (handle, workspace) = open_workspace_with_store(cx, store);
|
|
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
window.render_frame(cx);
|
|
window.dispatch_action(Box::new(OpenExplorer(id)), cx);
|
|
})
|
|
.unwrap();
|
|
cx.wait_for(handle.into(), Duration::from_secs(2), |_, cx| {
|
|
workspace.read(cx).store().read(cx).host(id).unwrap().state == ConnectionState::Connected
|
|
})
|
|
.await;
|
|
|
|
cx.update(|cx| {
|
|
let workspace = workspace.read(cx);
|
|
assert_eq!(workspace.explorers_of(id, cx).len(), 1);
|
|
assert_eq!(
|
|
workspace.store().read(cx).host(id).unwrap().state,
|
|
shellrs::host::ConnectionState::Connected
|
|
);
|
|
});
|
|
}
|
|
|
|
#[gpui_kit::test]
|
|
async fn double_click_on_host_opens_terminal_and_updates_status(cx: &mut TestAppContext) {
|
|
let (handle, workspace) = open_workspace(cx);
|
|
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
window.render_frame(cx);
|
|
// Hosts seeded as connected already have terminal tabs.
|
|
assert!(window.find(("terminal", INITIAL_WEB_TERMINAL)).visible());
|
|
assert!(window.try_find(("terminal", FIRST_NEW_TERMINAL)).is_none());
|
|
|
|
window
|
|
.within("host-tree")
|
|
.double_click(("host-row", DB_01), cx);
|
|
})
|
|
.unwrap();
|
|
cx.run_until_parked();
|
|
|
|
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
|
|
window.render_frame(cx);
|
|
window.find("status-connection").label() == Some("已连接 db-01")
|
|
})
|
|
.await;
|
|
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
window.render_frame(cx);
|
|
assert!(window.find(("terminal", FIRST_NEW_TERMINAL)).visible());
|
|
assert_eq!(
|
|
window.find("status-connection").label(),
|
|
Some("已连接 db-01")
|
|
);
|
|
})
|
|
.unwrap();
|
|
|
|
cx.update(|cx| {
|
|
let workspace = workspace.read(cx);
|
|
assert!(workspace.terminal(HostId(DB_01), cx).is_some());
|
|
let store = workspace.store().read(cx);
|
|
assert!(store.host(HostId(DB_01)).unwrap().state.is_connected());
|
|
});
|
|
}
|
|
|
|
#[gpui_kit::test]
|
|
async fn connected_host_opens_an_independent_terminal_each_time(cx: &mut TestAppContext) {
|
|
let (store, id) = one_host_store(AuthKind::Password);
|
|
let factory = Arc::new(FakeTerminalFactory::default());
|
|
let (handle, workspace) = open_workspace_with_remote_factory(cx, store, factory.clone());
|
|
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
window.render_frame(cx);
|
|
window
|
|
.within("host-tree")
|
|
.double_click(("host-row", id.0), cx);
|
|
})
|
|
.unwrap();
|
|
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
|
|
window.render_frame(cx);
|
|
factory.starts() == 1
|
|
})
|
|
.await;
|
|
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
window.render_frame(cx);
|
|
window
|
|
.within("host-tree")
|
|
.double_click(("host-row", id.0), cx);
|
|
})
|
|
.unwrap();
|
|
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
|
|
window.render_frame(cx);
|
|
factory.starts() == 2
|
|
})
|
|
.await;
|
|
|
|
// The right-click menu's “连接” item dispatches this same action.
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
window.render_frame(cx);
|
|
window.dispatch_action(Box::new(ConnectHost(id)), cx);
|
|
})
|
|
.unwrap();
|
|
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
|
|
window.render_frame(cx);
|
|
factory.starts() == 3
|
|
&& workspace
|
|
.read(cx)
|
|
.store()
|
|
.read(cx)
|
|
.host(id)
|
|
.unwrap()
|
|
.state
|
|
.is_connected()
|
|
})
|
|
.await;
|
|
|
|
cx.update(|cx| {
|
|
let workspace = workspace.read(cx);
|
|
assert_eq!(workspace.terminal_count(id, cx), 3);
|
|
assert!(workspace.remote_terminal(RemoteTerminalId(1)).is_some());
|
|
assert!(workspace.remote_terminal(RemoteTerminalId(2)).is_some());
|
|
assert!(workspace.remote_terminal(RemoteTerminalId(3)).is_some());
|
|
});
|
|
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
window.render_frame(cx);
|
|
window.click(("close-terminal", 3_u64), cx);
|
|
})
|
|
.unwrap();
|
|
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
|
|
window.render_frame(cx);
|
|
let workspace = workspace.read(cx);
|
|
workspace.terminal_count(id, cx) == 2
|
|
&& workspace
|
|
.store()
|
|
.read(cx)
|
|
.host(id)
|
|
.unwrap()
|
|
.state
|
|
.is_connected()
|
|
})
|
|
.await;
|
|
cx.update(|cx| {
|
|
let workspace = workspace.read(cx);
|
|
assert_eq!(workspace.terminal_count(id, cx), 2);
|
|
assert!(
|
|
workspace
|
|
.store()
|
|
.read(cx)
|
|
.host(id)
|
|
.unwrap()
|
|
.state
|
|
.is_connected()
|
|
);
|
|
});
|
|
}
|
|
|
|
#[gpui_kit::test]
|
|
async fn disconnecting_one_tab_leaves_the_other_tabs_of_its_host(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(ConnectHost(HostId(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);
|
|
// Connected tabs carry no dot.
|
|
for id in [INITIAL_WEB_TERMINAL, second.0] {
|
|
assert!(
|
|
window
|
|
.within(("terminal-tab", id))
|
|
.try_find("connection-status")
|
|
.is_none()
|
|
);
|
|
}
|
|
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.host(HostId(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);
|
|
assert!(
|
|
window
|
|
.within(("terminal-tab", INITIAL_WEB_TERMINAL))
|
|
.try_find("connection-status")
|
|
.is_none()
|
|
);
|
|
let tab = window.within(("terminal-tab", second.0));
|
|
assert_eq!(tab.find("connection-status").label(), Some("未连接"));
|
|
let mark = tab.find(("terminal-tab-os", second.0)).bounds();
|
|
let dot = tab.find("connection-status").bounds();
|
|
let title = window.find(("terminal-tab", second.0)).bounds();
|
|
let close = window.find(("close-terminal", second.0)).bounds();
|
|
assert_eq!(mark.center().y, close.center().y);
|
|
let centers = dot.center() - mark.center();
|
|
let distance = (f32::from(centers.x).powi(2) + f32::from(centers.y).powi(2)).sqrt();
|
|
assert!(distance > f32::from(mark.size.width + dot.size.width) / 2.);
|
|
assert!(dot.right() < close.left());
|
|
assert_eq!(mark.center().y, title.center().y);
|
|
window.click(("terminal", second.0), cx);
|
|
window.press("enter", cx);
|
|
})
|
|
.unwrap();
|
|
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
|
|
window.render_frame(cx);
|
|
remote_lifecycle(&workspace, second, cx) == TerminalLifecycle::Running
|
|
&& window
|
|
.within(("terminal-tab", second.0))
|
|
.try_find("connection-status")
|
|
.is_none()
|
|
})
|
|
.await;
|
|
}
|
|
|
|
#[gpui_kit::test]
|
|
async fn failed_connection_tab_reports_disconnected_then_recovers(cx: &mut TestAppContext) {
|
|
let (store, host_id) = one_host_store(AuthKind::Password);
|
|
let factory = Arc::new(FakeTerminalFactory::fail_first());
|
|
let (handle, workspace) = open_workspace_with_remote_factory(cx, store, factory);
|
|
let terminal_id = RemoteTerminalId(1);
|
|
in_frame(cx, handle, |window, cx| {
|
|
window.dispatch_action(Box::new(ConnectHost(host_id)), cx);
|
|
});
|
|
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
|
|
window.render_frame(cx);
|
|
matches!(
|
|
remote_lifecycle(&workspace, terminal_id, cx),
|
|
TerminalLifecycle::Failed(_)
|
|
) && window
|
|
.within(("terminal-tab", terminal_id.0))
|
|
.find("connection-status")
|
|
.label()
|
|
== Some("未连接")
|
|
})
|
|
.await;
|
|
in_frame(cx, handle, |window, cx| {
|
|
window.dispatch_action(Box::new(ReconnectTerminal(terminal_id)), cx);
|
|
});
|
|
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
|
|
window.render_frame(cx);
|
|
remote_lifecycle(&workspace, terminal_id, cx) == TerminalLifecycle::Running
|
|
&& window
|
|
.within(("terminal-tab", terminal_id.0))
|
|
.try_find("connection-status")
|
|
.is_none()
|
|
})
|
|
.await;
|
|
}
|
|
|
|
#[gpui_kit::test]
|
|
async fn connecting_marks_the_host_with_the_host_operating_system(cx: &mut TestAppContext) {
|
|
let (store, id) = one_host_store(AuthKind::Password);
|
|
let factory = Arc::new(FakeTerminalFactory::reports_os(HostOs::Fedora));
|
|
let (handle, workspace) = open_workspace_with_remote_factory(cx, store, factory);
|
|
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
window.render_frame(cx);
|
|
assert_eq!(window.find(("host-os", id.0)).label(), Some("未探测到系统"));
|
|
window
|
|
.within("host-tree")
|
|
.double_click(("host-row", id.0), cx);
|
|
})
|
|
.unwrap();
|
|
// The engine batches transport events on a 16ms timer, so the mark
|
|
// changes a frame or two after the tab opens.
|
|
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
|
|
window.render_frame(cx);
|
|
window.find(("host-os", id.0)).label() == Some("Fedora")
|
|
})
|
|
.await;
|
|
|
|
cx.update(|cx| {
|
|
let store = workspace.read(cx).store().read(cx);
|
|
assert_eq!(store.host(id).unwrap().os, Some(HostOs::Fedora));
|
|
});
|
|
}
|
|
|
|
#[gpui_kit::test]
|
|
async fn the_tab_bar_shows_the_connection_latency_while_it_runs(cx: &mut TestAppContext) {
|
|
let (store, id) = one_host_store(AuthKind::Password);
|
|
let factory = Arc::new(FakeTerminalFactory::reports_latency(Latency::Measured(
|
|
Duration::from_millis(32),
|
|
)));
|
|
let (handle, _) = open_workspace_with_remote_factory(cx, store, factory);
|
|
// The first terminal of a store with nothing connected.
|
|
let terminal = 1_u64;
|
|
let latency = ("terminal-latency", terminal);
|
|
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
window.render_frame(cx);
|
|
window
|
|
.within("host-tree")
|
|
.double_click(("host-row", id.0), cx);
|
|
})
|
|
.unwrap();
|
|
// Transport events reach the engine on its 16ms batching timer.
|
|
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
|
|
window.render_frame(cx);
|
|
window
|
|
.try_find(latency)
|
|
.is_some_and(|element| element.label() == Some("32 ms"))
|
|
})
|
|
.await;
|
|
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
window.render_frame(cx);
|
|
window.click(("close-terminal", terminal), cx);
|
|
})
|
|
.unwrap();
|
|
cx.run_until_parked();
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
window.render_frame(cx);
|
|
assert!(window.try_find(latency).is_none());
|
|
})
|
|
.unwrap();
|
|
}
|
|
|
|
/// The first terminal and the first host of an empty store, which a link
|
|
/// opens.
|
|
const LINK_TERMINAL: u64 = 1;
|
|
const LINK_HOST: HostId = HostId(1);
|
|
|
|
fn open_link(
|
|
cx: &mut TestAppContext,
|
|
handle: WindowHandle<Root>,
|
|
workspace: &Entity<Workspace>,
|
|
url: &str,
|
|
tab: Option<&str>,
|
|
) {
|
|
let link = shellrs::cli::OpenLink {
|
|
url: url.into(),
|
|
tab: tab.map(str::to_string),
|
|
};
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
workspace.update(cx, |workspace, cx| workspace.open_link(link, window, cx));
|
|
})
|
|
.unwrap();
|
|
cx.run_until_parked();
|
|
}
|
|
|
|
#[gpui_kit::test]
|
|
async fn a_link_opens_a_terminal_to_a_host_that_goes_with_its_last_tab(cx: &mut TestAppContext) {
|
|
use shellrs::app::{CloseExplorer, CloseTerminal};
|
|
use shellrs::host::BookmarkSide;
|
|
|
|
let data = tempfile::tempdir().unwrap();
|
|
let path = data.path().join("shellrs.db");
|
|
let keychain = Arc::new(InMemorySecretStore::default());
|
|
let store = HostStore::load(HostDatabase::open(&path).unwrap())
|
|
.unwrap()
|
|
.with_secrets(keychain.clone());
|
|
let remote = Arc::new(RecordingRemoteProvider::default());
|
|
let (handle, workspace) = open_workspace_with_credentials(
|
|
cx,
|
|
store,
|
|
remote.clone(),
|
|
Arc::new(FakeConnectionTester::default()),
|
|
);
|
|
|
|
open_link(
|
|
cx,
|
|
handle,
|
|
&workspace,
|
|
"ssh://token:p%40ss@10.0.0.9:2222",
|
|
Some("db-prod"),
|
|
);
|
|
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
|
|
window.render_frame(cx);
|
|
window.find("status-connection").label() == Some("已连接 db-prod")
|
|
})
|
|
.await;
|
|
|
|
let login = remote.logins().pop().expect("connected");
|
|
assert_eq!(
|
|
(login.host.as_str(), login.port, login.user.as_str()),
|
|
("10.0.0.9", 2222, "token")
|
|
);
|
|
assert_eq!(login.method, LoginMethod::Password);
|
|
assert_eq!(login.route, LoginRoute::Direct);
|
|
assert_eq!(login.password, SecretRef::temporary(LINK_HOST.0));
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
window.render_frame(cx);
|
|
assert_eq!(
|
|
window.find(("terminal-tab", LINK_TERMINAL)).label(),
|
|
Some("db-prod")
|
|
);
|
|
// Not saved, and across the window: the hosts made way.
|
|
assert!(window.try_find(("host-row", LINK_HOST.0)).is_none());
|
|
assert_eq!(window.find("show-hosts").checked(), Some(false));
|
|
assert!(window.try_find("host-search").is_none());
|
|
// The bastion host gets the terminal alone: of the tools, only the
|
|
// snippets, which type into it.
|
|
assert!(window.try_find("tool-snippets").is_some());
|
|
for tool in ["tool-history", "tool-docker", "tool-monitor"] {
|
|
assert!(window.try_find(tool).is_none(), "{tool}");
|
|
}
|
|
})
|
|
.unwrap();
|
|
assert!(login.shell_only);
|
|
let store = workspace.read_with(cx, |workspace, _| workspace.store().clone());
|
|
store.read_with(cx, |store, _| {
|
|
assert!(store.is_temporary(LINK_HOST));
|
|
assert!(store.hosts().is_empty());
|
|
assert!(store.recent_hosts().next().is_none());
|
|
// The link's password is in memory, not in the keychain.
|
|
assert_eq!(
|
|
store
|
|
.secrets()
|
|
.get(&SecretRef::temporary(LINK_HOST.0))
|
|
.unwrap()
|
|
.as_deref()
|
|
.map(String::as_str),
|
|
Some("p@ss")
|
|
);
|
|
});
|
|
assert!(keychain.is_empty());
|
|
|
|
// There is nothing to edit.
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
window.dispatch_action(Box::new(EditHost(LINK_HOST)), cx);
|
|
})
|
|
.unwrap();
|
|
cx.run_until_parked();
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
window.render_frame(cx);
|
|
assert!(window.try_find("host-name").is_none());
|
|
})
|
|
.unwrap();
|
|
|
|
// Its SFTP tab works, bookmarks and all, without writing to disk.
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
window.dispatch_action(Box::new(OpenExplorer(LINK_HOST)), cx);
|
|
})
|
|
.unwrap();
|
|
cx.run_until_parked();
|
|
store.update(cx, |store, cx| {
|
|
assert!(store.add_bookmark(LINK_HOST, BookmarkSide::Remote, "/var/log", cx));
|
|
});
|
|
cx.run_until_parked();
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
window.render_frame(cx);
|
|
assert!(window.notifications(cx).is_empty());
|
|
})
|
|
.unwrap();
|
|
|
|
// The host stays while a tab of it does.
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
window.dispatch_action(Box::new(CloseTerminal(RemoteTerminalId(LINK_TERMINAL))), cx);
|
|
})
|
|
.unwrap();
|
|
cx.run_until_parked();
|
|
assert!(store.read_with(cx, |store, _| store.host(LINK_HOST).is_some()));
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
window.dispatch_action(Box::new(CloseExplorer(ExplorerId(SFTP_TAB))), cx);
|
|
})
|
|
.unwrap();
|
|
cx.run_until_parked();
|
|
store.read_with(cx, |store, _| {
|
|
assert!(store.host(LINK_HOST).is_none());
|
|
assert!(
|
|
store
|
|
.secrets()
|
|
.get(&SecretRef::temporary(LINK_HOST.0))
|
|
.unwrap()
|
|
.is_none()
|
|
);
|
|
});
|
|
|
|
let saved = HostStore::load(HostDatabase::open(&path).unwrap()).unwrap();
|
|
assert!(saved.hosts().is_empty());
|
|
assert!(saved.recent_hosts().next().is_none());
|
|
assert!(saved.bookmarks(LINK_HOST, BookmarkSide::Remote).is_empty());
|
|
}
|
|
|
|
#[gpui_kit::test]
|
|
async fn an_sftp_link_opens_an_sftp_tab_across_the_window(cx: &mut TestAppContext) {
|
|
use shellrs::app::CloseExplorer;
|
|
|
|
let remote = Arc::new(RecordingRemoteProvider::default());
|
|
let (handle, workspace) = open_workspace_with_credentials(
|
|
cx,
|
|
HostStore::empty(),
|
|
remote.clone(),
|
|
Arc::new(FakeConnectionTester::default()),
|
|
);
|
|
// How JumpServer opens WinSCP: the link alone.
|
|
open_link(
|
|
cx,
|
|
handle,
|
|
&workspace,
|
|
"sftp://token:p%40ss@10.0.0.9:2222",
|
|
None,
|
|
);
|
|
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
|
|
window.render_frame(cx);
|
|
window.find("status-connection").label() == Some("已连接 10.0.0.9")
|
|
})
|
|
.await;
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
window.render_frame(cx);
|
|
assert_eq!(
|
|
window.find(("explorer-tab", SFTP_TAB)).label(),
|
|
Some("10.0.0.9 · SFTP")
|
|
);
|
|
// An SFTP tab alone, across the window.
|
|
assert!(window.try_find(("terminal", LINK_TERMINAL)).is_none());
|
|
assert_eq!(window.find("show-hosts").checked(), Some(false));
|
|
assert!(window.try_find(("host-row", LINK_HOST.0)).is_none());
|
|
})
|
|
.unwrap();
|
|
assert!(remote.logins().is_empty());
|
|
let store = workspace.read_with(cx, |workspace, _| workspace.store().clone());
|
|
store.read_with(cx, |store, _| {
|
|
assert!(store.is_external(LINK_HOST));
|
|
let login = store.login(LINK_HOST).unwrap();
|
|
assert_eq!(
|
|
(login.host.as_str(), login.port, login.user.as_str()),
|
|
("10.0.0.9", 2222, "token")
|
|
);
|
|
assert_eq!(login.password, SecretRef::temporary(LINK_HOST.0));
|
|
});
|
|
|
|
// It goes with its tab.
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
window.dispatch_action(Box::new(CloseExplorer(ExplorerId(SFTP_TAB))), cx);
|
|
})
|
|
.unwrap();
|
|
cx.run_until_parked();
|
|
store.read_with(cx, |store, _| assert!(store.host(LINK_HOST).is_none()));
|
|
}
|
|
|
|
/// The title bar's 临时连接: a host's login without its group, route or
|
|
/// notes, connected to with every feature and saved nowhere.
|
|
#[gpui_kit::test]
|
|
async fn a_temporary_connection_has_every_feature_and_saves_nothing(cx: &mut TestAppContext) {
|
|
use shellrs::app::CloseTerminal;
|
|
|
|
let data = tempfile::tempdir().unwrap();
|
|
let path = data.path().join("shellrs.db");
|
|
let keychain = Arc::new(InMemorySecretStore::default());
|
|
let store = HostStore::load(HostDatabase::open(&path).unwrap())
|
|
.unwrap()
|
|
.with_secrets(keychain.clone());
|
|
let remote = Arc::new(RecordingRemoteProvider::default());
|
|
let tester = Arc::new(FakeConnectionTester::default());
|
|
let (handle, workspace) =
|
|
open_workspace_with_credentials(cx, store, remote.clone(), tester.clone());
|
|
|
|
in_frame(cx, handle, |window, cx| {
|
|
window.click("temporary-connection", cx)
|
|
});
|
|
in_frame(cx, handle, |window, cx| {
|
|
// Only what a login needs.
|
|
assert!(window.try_find("host-route").is_none());
|
|
assert!(window.try_find("host-notes").is_none());
|
|
assert!(window.try_find("test-connection").is_some());
|
|
assert_eq!(
|
|
window.find("temporary-note").label(),
|
|
Some("仅当前使用的临时会话,不会保存到主机列表。")
|
|
);
|
|
// The name may be left out.
|
|
window.click("host-address", cx);
|
|
window.input("10.0.0.9", cx);
|
|
window.click("host-port", cx);
|
|
window.press("secondary-a", cx);
|
|
window.input("2222", cx);
|
|
window.click("host-user", cx);
|
|
window.press("secondary-a", cx);
|
|
window.input("deploy", cx);
|
|
window.click("host-password", cx);
|
|
window.input("p@ss", cx);
|
|
window.click("test-connection", cx);
|
|
});
|
|
// 测试连接 tries it as it would connect: directly.
|
|
cx.wait_for(handle.into(), Duration::from_secs(3), |window, cx| {
|
|
window.render_frame(cx);
|
|
window.notifications(cx).len() == 1
|
|
})
|
|
.await;
|
|
assert_eq!(tester.routes(), [(LoginRoute::Direct, None)]);
|
|
in_frame(cx, handle, |window, cx| window.click("commit", cx));
|
|
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
|
|
window.render_frame(cx);
|
|
window.find("status-connection").label() == Some("已连接 10.0.0.9")
|
|
})
|
|
.await;
|
|
|
|
let login = remote.logins().pop().expect("connected");
|
|
assert_eq!(
|
|
(login.host.as_str(), login.port, login.user.as_str()),
|
|
("10.0.0.9", 2222, "deploy")
|
|
);
|
|
assert_eq!(login.method, LoginMethod::Password);
|
|
assert_eq!(login.route, LoginRoute::Direct);
|
|
assert_eq!(login.password, SecretRef::temporary(LINK_HOST.0));
|
|
// Every channel it likes, unlike a bastion host's link.
|
|
assert!(!login.shell_only);
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
window.render_frame(cx);
|
|
assert_eq!(
|
|
window.find(("terminal-tab", LINK_TERMINAL)).label(),
|
|
Some("10.0.0.9")
|
|
);
|
|
assert!(window.try_find(("host-row", LINK_HOST.0)).is_none());
|
|
// The hosts stay where they were.
|
|
assert_eq!(window.find("show-hosts").checked(), Some(true));
|
|
for tool in [
|
|
"tool-snippets",
|
|
"tool-history",
|
|
"tool-docker",
|
|
"tool-monitor",
|
|
] {
|
|
assert!(window.try_find(tool).is_some(), "{tool}");
|
|
}
|
|
})
|
|
.unwrap();
|
|
let store = workspace.read_with(cx, |workspace, _| workspace.store().clone());
|
|
store.read_with(cx, |store, _| {
|
|
assert!(store.is_temporary(LINK_HOST));
|
|
assert!(!store.is_external(LINK_HOST));
|
|
assert!(store.hosts().is_empty());
|
|
assert!(store.recent_hosts().next().is_none());
|
|
assert_eq!(
|
|
store
|
|
.secrets()
|
|
.get(&SecretRef::temporary(LINK_HOST.0))
|
|
.unwrap()
|
|
.as_deref()
|
|
.map(String::as_str),
|
|
Some("p@ss")
|
|
);
|
|
});
|
|
assert!(keychain.is_empty());
|
|
|
|
// It goes with its tab, password and all.
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
window.dispatch_action(Box::new(CloseTerminal(RemoteTerminalId(LINK_TERMINAL))), cx);
|
|
})
|
|
.unwrap();
|
|
cx.run_until_parked();
|
|
store.read_with(cx, |store, _| {
|
|
assert!(store.host(LINK_HOST).is_none());
|
|
assert!(
|
|
store
|
|
.secrets()
|
|
.get(&SecretRef::temporary(LINK_HOST.0))
|
|
.unwrap()
|
|
.is_none()
|
|
);
|
|
});
|
|
let saved = HostStore::load(HostDatabase::open(&path).unwrap()).unwrap();
|
|
assert!(saved.hosts().is_empty());
|
|
}
|
|
|
|
#[gpui_kit::test]
|
|
async fn a_link_that_cannot_be_read_says_why_and_opens_nothing(cx: &mut TestAppContext) {
|
|
let (handle, workspace) = open_workspace_with_credentials(
|
|
cx,
|
|
HostStore::empty(),
|
|
Arc::new(RecordingRemoteProvider::default()),
|
|
Arc::new(FakeConnectionTester::default()),
|
|
);
|
|
open_link(cx, handle, &workspace, "ssh://10.0.0.9:2222", None);
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
window.render_frame(cx);
|
|
assert_eq!(window.notifications(cx).len(), 1);
|
|
assert!(window.try_find(("terminal", LINK_TERMINAL)).is_none());
|
|
// Nothing opened, so the hosts stay.
|
|
assert_eq!(window.find("show-hosts").checked(), Some(true));
|
|
})
|
|
.unwrap();
|
|
workspace.read_with(cx, |workspace, cx| {
|
|
assert!(workspace.store().read(cx).host(LINK_HOST).is_none());
|
|
});
|
|
}
|
|
|
|
/// Initial errors and later exits both retain their tab and retry through
|
|
/// that tab's regular reconnect action, without leaking Enter to the shell.
|
|
async fn enter_reconnects_stopped_remote(
|
|
cx: &mut TestAppContext,
|
|
factory: Arc<FakeTerminalFactory>,
|
|
) {
|
|
let (store, host) = one_host_store(AuthKind::Password);
|
|
let (handle, workspace) = open_workspace_with_remote_factory(cx, store, factory.clone());
|
|
let terminal = RemoteTerminalId(1);
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
window.render_frame(cx);
|
|
window
|
|
.within("host-tree")
|
|
.double_click(("host-row", host.0), cx);
|
|
})
|
|
.unwrap();
|
|
cx.wait_for(handle.into(), Duration::from_secs(2), |_, cx| {
|
|
matches!(
|
|
remote_lifecycle(&workspace, terminal, cx),
|
|
TerminalLifecycle::Failed(_) | TerminalLifecycle::Exited { .. }
|
|
)
|
|
})
|
|
.await;
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
window.render_frame(cx);
|
|
let screen = workspace
|
|
.read(cx)
|
|
.remote_terminal(terminal)
|
|
.unwrap()
|
|
.read(cx)
|
|
.terminal()
|
|
.read(cx)
|
|
.screen_text(cx);
|
|
assert!(screen.contains("按 Enter 重新连接"), "{screen}");
|
|
window.click(("terminal", terminal.0), cx);
|
|
for key in ["shift-enter", "ctrl-enter", "alt-enter"] {
|
|
window.press(key, cx);
|
|
}
|
|
cx.write_to_clipboard(ClipboardItem::new_string("\n".into()));
|
|
window.dispatch_action(Box::new(shellrs::app::PasteTerminal), cx);
|
|
window.dispatch_action(Box::new(FindInTerminal), cx);
|
|
})
|
|
.unwrap();
|
|
cx.run_until_parked();
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
window.render_frame(cx);
|
|
window.click("terminal-find", cx);
|
|
window.press("enter", cx);
|
|
})
|
|
.unwrap();
|
|
cx.run_until_parked();
|
|
assert_eq!(
|
|
factory.starts(),
|
|
1,
|
|
"modifiers, paste and find must not reconnect"
|
|
);
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
window.render_frame(cx);
|
|
window.click(("terminal", terminal.0), cx);
|
|
window.press("enter", cx);
|
|
window.press("enter", cx);
|
|
})
|
|
.unwrap();
|
|
cx.wait_for(handle.into(), Duration::from_secs(2), |_, cx| {
|
|
remote_lifecycle(&workspace, terminal, cx) == TerminalLifecycle::Running
|
|
})
|
|
.await;
|
|
assert_eq!(factory.starts(), 2);
|
|
assert_eq!(
|
|
factory.written_text(),
|
|
"",
|
|
"reconnect Enter must not reach the new shell"
|
|
);
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
window.render_frame(cx);
|
|
assert_eq!(window.find(("terminal", terminal.0)).focused(), Some(true));
|
|
window.press("enter", cx);
|
|
})
|
|
.unwrap();
|
|
cx.wait_for(handle.into(), Duration::from_secs(2), |_, _| {
|
|
factory.written_text() == "\r"
|
|
})
|
|
.await;
|
|
assert_eq!(factory.starts(), 2, "connected Enter is ordinary input");
|
|
}
|
|
|
|
#[gpui_kit::test]
|
|
async fn enter_reconnects_failed_remote(cx: &mut TestAppContext) {
|
|
enter_reconnects_stopped_remote(cx, Arc::new(FakeTerminalFactory::fail_first())).await;
|
|
}
|
|
|
|
#[gpui_kit::test]
|
|
async fn enter_reconnects_exited_remote(cx: &mut TestAppContext) {
|
|
enter_reconnects_stopped_remote(cx, Arc::new(FakeTerminalFactory::exit_first())).await;
|
|
}
|
|
|
|
#[gpui_kit::test]
|
|
async fn enter_retries_canceled_authentication_in_the_same_tab(cx: &mut TestAppContext) {
|
|
let (store, host) = one_host_store(AuthKind::Password);
|
|
let factory = Arc::new(PromptTerminalFactory::new(PromptBehavior::Authentication));
|
|
let (handle, workspace) = open_workspace_with_remote_factory(cx, store, factory.clone());
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
window.render_frame(cx);
|
|
window
|
|
.within("host-tree")
|
|
.double_click(("host-row", host.0), cx);
|
|
})
|
|
.unwrap();
|
|
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
|
|
window.render_frame(cx);
|
|
window.try_find("ssh-auth-submit").is_some()
|
|
})
|
|
.await;
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
window.render_frame(cx);
|
|
window.press("escape", cx);
|
|
})
|
|
.unwrap();
|
|
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
|
|
window.render_frame(cx);
|
|
matches!(
|
|
remote_lifecycle(&workspace, RemoteTerminalId(1), cx),
|
|
TerminalLifecycle::Exited { .. }
|
|
) && window.try_find("ssh-auth-submit").is_none()
|
|
})
|
|
.await;
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
window.render_frame(cx);
|
|
window.click(("terminal", 1_u64), cx);
|
|
window.press("enter", cx);
|
|
})
|
|
.unwrap();
|
|
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
|
|
window.render_frame(cx);
|
|
window.try_find("ssh-auth-submit").is_some()
|
|
})
|
|
.await;
|
|
cx.update(|cx| {
|
|
assert_eq!(
|
|
remote_lifecycle(&workspace, RemoteTerminalId(1), cx),
|
|
TerminalLifecycle::Starting
|
|
);
|
|
assert!(
|
|
workspace
|
|
.read(cx)
|
|
.remote_terminal(RemoteTerminalId(2))
|
|
.is_none()
|
|
);
|
|
});
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
window.render_frame(cx);
|
|
window.press("escape", cx);
|
|
})
|
|
.unwrap();
|
|
cx.run_until_parked();
|
|
}
|
|
|
|
/// Exercise the production title in the same TabBar used by the dock, with
|
|
/// and without its badge. Geometry must be invariant under state/selection.
|
|
struct TabStatusLayout {
|
|
state: ConnectionState,
|
|
selected: usize,
|
|
rem: f32,
|
|
}
|
|
|
|
impl gpui_kit::Render for TabStatusLayout {
|
|
fn render(
|
|
&mut self,
|
|
window: &mut gpui_kit::Window,
|
|
_: &mut gpui_kit::Context<Self>,
|
|
) -> impl gpui_kit::IntoElement {
|
|
use gpui_kit::component::{
|
|
Sizable as _,
|
|
tab::{Tab, TabBar},
|
|
};
|
|
use gpui_kit::{InteractiveElement as _, ParentElement as _};
|
|
use shellrs::shared::{ClosableTabTitle, HostMark};
|
|
window.set_rem_size(px(self.rem));
|
|
let title = |id| {
|
|
ClosableTabTitle::new(
|
|
("layout-title", id),
|
|
HostMark::new(("layout-mark", id), "host", Some(HostOs::Ubuntu)).small(),
|
|
"host",
|
|
)
|
|
.closable(("layout-close", id), Box::new(shellrs::app::CloseActiveTab))
|
|
};
|
|
TabBar::new("layout-tabs")
|
|
.selected_index(self.selected)
|
|
.child(
|
|
Tab::new()
|
|
.debug_selector(|| "plain-tab".into())
|
|
.child(title(0u64)),
|
|
)
|
|
.child(
|
|
Tab::new()
|
|
.debug_selector(|| "status-tab".into())
|
|
.child(title(1u64).connection_state(self.state)),
|
|
)
|
|
}
|
|
}
|
|
|
|
#[gpui_kit::test]
|
|
fn connection_badge_preserves_icon_alignment_and_fits_the_tab(cx: &mut TestAppContext) {
|
|
init_app(cx);
|
|
let (view, cx) = cx.add_window_view(|_, _| TabStatusLayout {
|
|
state: ConnectionState::Connected,
|
|
selected: 0,
|
|
rem: 16.,
|
|
});
|
|
// The headless platform fixes device scaling at 2x; vary rem sizing too.
|
|
for rem in [12., 14., 16., 18.] {
|
|
for selected in [0, 1] {
|
|
for state in [
|
|
ConnectionState::Connected,
|
|
ConnectionState::Connecting,
|
|
ConnectionState::Disconnected,
|
|
] {
|
|
view.update(cx, |view, cx| {
|
|
view.rem = rem;
|
|
view.selected = selected;
|
|
view.state = state;
|
|
cx.notify();
|
|
});
|
|
cx.run_until_parked();
|
|
cx.update(|window, cx| window.render_frame(cx));
|
|
let clipped_tab = cx.debug_bounds("status-tab").unwrap();
|
|
cx.update(|window, _| {
|
|
let plain = window.find(("layout-title", 0u64)).bounds();
|
|
let title = window.find(("layout-title", 1u64)).bounds();
|
|
let plain_mark = window.find(("layout-mark", 0u64)).bounds();
|
|
let mark = window.find(("layout-mark", 1u64)).bounds();
|
|
let close = window.find(("layout-close", 1u64)).bounds();
|
|
assert_eq!(title.size, plain.size, "badge must not affect title layout");
|
|
assert_eq!(mark.size, plain_mark.size, "badge must not shrink the icon");
|
|
assert_eq!(
|
|
mark.center() - title.origin,
|
|
plain_mark.center() - plain.origin
|
|
);
|
|
assert_eq!(mark.center().y, close.center().y);
|
|
assert_eq!(mark.center().y, title.center().y);
|
|
// A connection that is up is the normal state: no dot.
|
|
let dot = window
|
|
.within(("layout-title", 1u64))
|
|
.try_find("connection-status");
|
|
assert_eq!(dot.is_none(), state == ConnectionState::Connected);
|
|
let Some(dot) = dot else {
|
|
return;
|
|
};
|
|
let dot = dot.bounds();
|
|
let centers = dot.center() - mark.center();
|
|
let distance =
|
|
(f32::from(centers.x).powi(2) + f32::from(centers.y).powi(2)).sqrt();
|
|
assert!(
|
|
distance > f32::from(mark.size.width + dot.size.width) / 2.,
|
|
"circles must not overlap"
|
|
);
|
|
assert!(
|
|
dot.bottom() <= clipped_tab.bottom() - px(1.),
|
|
"badge must fit the dock tab"
|
|
);
|
|
assert!(
|
|
dot.right() < mark.right() + px(rem / 4.),
|
|
"badge must stop before the label"
|
|
);
|
|
assert!(dot.right() < close.left());
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[gpui_kit::test]
|
|
async fn reconnect_toolbar_works_without_window_focus(cx: &mut TestAppContext) {
|
|
let (store, host) = one_host_store(AuthKind::Password);
|
|
let factory = Arc::new(FakeTerminalFactory::default());
|
|
let (handle, workspace) = open_workspace_with_remote_factory(cx, store, factory.clone());
|
|
in_frame(cx, handle, |window, cx| {
|
|
window
|
|
.within("host-tree")
|
|
.double_click(("host-row", host.0), cx);
|
|
});
|
|
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
|
|
window.render_frame(cx);
|
|
workspace
|
|
.read(cx)
|
|
.remote_terminal(RemoteTerminalId(1))
|
|
.unwrap()
|
|
.read(cx)
|
|
.lifecycle(cx)
|
|
== TerminalLifecycle::Running
|
|
})
|
|
.await;
|
|
let starts = factory.starts();
|
|
in_frame(cx, handle, |window, cx| window.blur(cx));
|
|
in_frame(cx, handle, |window, cx| {
|
|
assert!(window.focused(cx).is_none());
|
|
window.click(("reconnect", 1u64), cx);
|
|
});
|
|
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
|
|
window.render_frame(cx);
|
|
factory.starts() > starts
|
|
&& workspace
|
|
.read(cx)
|
|
.remote_terminal(RemoteTerminalId(1))
|
|
.unwrap()
|
|
.read(cx)
|
|
.lifecycle(cx)
|
|
== TerminalLifecycle::Running
|
|
})
|
|
.await;
|
|
assert_eq!(factory.starts(), starts + 1);
|
|
}
|