Merge branch 'sqlite-session-persistence' of https://github.com/since2006/shellr into sqlite-session-persistence
This commit is contained in:
commit
e5f8790dba
23 files changed
+4354
-513
No files matched your search
Generated
+1063
-21
File diff suppressed because it is too large.
Load diff
@@ -12,9 +12,12 @@ anyhow = "1"
|
||||
async-channel = "2"
|
||||
dirs = "6"
|
||||
portable-pty = "0.9"
|
||||
russh = "0.63"
|
||||
rusqlite = { version = "0.40", features = ["bundled"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
tokio = { version = "1", features = ["io-util", "net", "rt", "sync", "time"] }
|
||||
unicode-width = "0.2"
|
||||
zeroize = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
gpui-kit = { version = "0.6.1", features = ["test-support"] }
|
||||
|
||||
+20
-10
@@ -6,7 +6,7 @@ use serde::Deserialize;
|
||||
|
||||
use crate::{
|
||||
session::{GroupId, SessionId},
|
||||
terminal::LocalTerminalId,
|
||||
terminal::{LocalTerminalId, RemoteTerminalId},
|
||||
};
|
||||
|
||||
gpui_kit::actions!(
|
||||
@@ -70,26 +70,27 @@ macro_rules! local_terminal_action {
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! remote_terminal_action {
|
||||
($(#[$doc:meta])* $name:ident) => {
|
||||
$(#[$doc])*
|
||||
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[action(namespace = shellr, no_json)]
|
||||
pub struct $name(pub RemoteTerminalId);
|
||||
};
|
||||
}
|
||||
|
||||
session_action!(
|
||||
/// Open (or activate) the terminal tab of a session.
|
||||
/// Open a new terminal connection for a session.
|
||||
ConnectSession
|
||||
);
|
||||
session_action!(
|
||||
/// Mark a session disconnected.
|
||||
DisconnectSession
|
||||
);
|
||||
session_action!(
|
||||
/// Reconnect a session (mock: a short "connecting" state).
|
||||
ReconnectSession
|
||||
);
|
||||
session_action!(
|
||||
/// Open (or activate) the SFTP explorer tab of a session.
|
||||
OpenExplorer
|
||||
);
|
||||
session_action!(
|
||||
/// Close the terminal tab of a session.
|
||||
CloseTerminal
|
||||
);
|
||||
session_action!(
|
||||
/// Close the SFTP explorer tab of a session.
|
||||
CloseExplorer
|
||||
@@ -128,6 +129,15 @@ local_terminal_action!(
|
||||
/// Close a local terminal tab and terminate its child process.
|
||||
CloseLocalTerminal
|
||||
);
|
||||
|
||||
remote_terminal_action!(
|
||||
/// Close one remote terminal connection and its tab.
|
||||
CloseTerminal
|
||||
);
|
||||
remote_terminal_action!(
|
||||
/// Reconnect one remote terminal using the session's latest settings.
|
||||
ReconnectTerminal
|
||||
);
|
||||
local_terminal_action!(
|
||||
/// Restart a local terminal with a fresh emulator and PTY.
|
||||
RestartLocalTerminal
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ mod paths;
|
||||
|
||||
pub use actions::*;
|
||||
pub use assets::{AppAssets, CatalogIcon};
|
||||
pub use paths::{data_dir, database_path};
|
||||
pub use paths::{data_dir, database_path, known_hosts_path};
|
||||
|
||||
use gpui_kit::component::dock::ToggleZoom;
|
||||
use gpui_kit::*;
|
||||
|
||||
@@ -24,3 +24,11 @@ pub fn database_path() -> std::io::Result<PathBuf> {
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
Ok(dir.join("shellr.db"))
|
||||
}
|
||||
|
||||
/// shellr's private host-key trust store. It deliberately does not read or
|
||||
/// modify OpenSSH's `~/.ssh/known_hosts`.
|
||||
pub fn known_hosts_path() -> std::io::Result<PathBuf> {
|
||||
let dir = data_dir();
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
Ok(dir.join("known_hosts"))
|
||||
}
|
||||
+2
-1
@@ -1,4 +1,4 @@
|
||||
//! shellr: an SSH session manager with a real local terminal and mock remote sessions.
|
||||
//! shellr: an SSH session manager with real SSH and local terminals.
|
||||
//!
|
||||
//! Modules are organized by capability so they can become crates later:
|
||||
//! `session` (会话管理), `terminal` (终端), `explorer` (SFTP 文件浏览),
|
||||
@@ -8,6 +8,7 @@ pub mod app;
|
||||
pub mod explorer;
|
||||
pub mod session;
|
||||
pub mod shared;
|
||||
pub mod ssh;
|
||||
pub mod terminal;
|
||||
pub mod workspace;
|
||||
|
||||
|
||||
+63
-6
@@ -23,7 +23,7 @@ fn from_sql(id: i64) -> u64 {
|
||||
}
|
||||
|
||||
/// Bumped whenever `migrate` gains a step. Stored in `PRAGMA user_version`.
|
||||
const SCHEMA_VERSION: i64 = 1;
|
||||
const SCHEMA_VERSION: i64 = 2;
|
||||
|
||||
const SCHEMA_V1: &str = "\
|
||||
BEGIN;
|
||||
@@ -46,6 +46,13 @@ CREATE INDEX sessions_group_id ON sessions(group_id);
|
||||
PRAGMA user_version = 1;
|
||||
COMMIT;";
|
||||
|
||||
const SCHEMA_V2: &str = "\
|
||||
BEGIN;
|
||||
ALTER TABLE sessions ADD COLUMN key_path TEXT;
|
||||
UPDATE sessions SET auth = 'auto' WHERE auth = 'key';
|
||||
PRAGMA user_version = 2;
|
||||
COMMIT;";
|
||||
|
||||
/// Everything one launch reads back from disk.
|
||||
pub struct StoredData {
|
||||
/// Groups in insertion order; `parent` gives the nesting.
|
||||
@@ -99,7 +106,7 @@ impl SessionDatabase {
|
||||
let sessions = self
|
||||
.connection
|
||||
.prepare(
|
||||
"SELECT id, name, host, port, username, auth, group_id \
|
||||
"SELECT id, name, host, port, username, auth, group_id, key_path \
|
||||
FROM sessions ORDER BY id",
|
||||
)?
|
||||
.query_map([], |row| {
|
||||
@@ -110,6 +117,7 @@ impl SessionDatabase {
|
||||
let user: String = row.get(4)?;
|
||||
let auth: String = row.get(5)?;
|
||||
let group: Option<i64> = row.get(6)?;
|
||||
let key_path: Option<String> = row.get(7)?;
|
||||
Ok(Session::new(
|
||||
SessionId(from_sql(id)),
|
||||
SessionDraft::new(
|
||||
@@ -119,7 +127,8 @@ impl SessionDatabase {
|
||||
user,
|
||||
AuthKind::from_stored(&auth),
|
||||
group.map(|id| GroupId(from_sql(id))),
|
||||
),
|
||||
)
|
||||
.with_optional_key_path(key_path),
|
||||
))
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
@@ -176,8 +185,8 @@ impl SessionDatabase {
|
||||
|
||||
pub fn insert_session(&self, session: &Session) -> rusqlite::Result<()> {
|
||||
self.connection.execute(
|
||||
"INSERT INTO sessions (id, name, host, port, username, auth, group_id) \
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
"INSERT INTO sessions (id, name, host, port, username, auth, group_id, key_path) \
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||||
params![
|
||||
to_sql(session.id.0),
|
||||
session.name.as_ref(),
|
||||
@@ -186,6 +195,7 @@ impl SessionDatabase {
|
||||
session.user.as_ref(),
|
||||
session.auth.as_str(),
|
||||
session.group.map(|group| to_sql(group.0)),
|
||||
session.key_path.as_deref(),
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
@@ -196,7 +206,7 @@ impl SessionDatabase {
|
||||
pub fn update_session(&self, session: &Session) -> rusqlite::Result<()> {
|
||||
self.connection.execute(
|
||||
"UPDATE sessions SET name = ?2, host = ?3, port = ?4, username = ?5, \
|
||||
auth = ?6, group_id = ?7 WHERE id = ?1",
|
||||
auth = ?6, group_id = ?7, key_path = ?8 WHERE id = ?1",
|
||||
params![
|
||||
to_sql(session.id.0),
|
||||
session.name.as_ref(),
|
||||
@@ -205,6 +215,7 @@ impl SessionDatabase {
|
||||
session.user.as_ref(),
|
||||
session.auth.as_str(),
|
||||
session.group.map(|group| to_sql(group.0)),
|
||||
session.key_path.as_deref(),
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
@@ -235,6 +246,9 @@ fn migrate(connection: &mut Connection) -> rusqlite::Result<()> {
|
||||
if version < 1 {
|
||||
connection.execute_batch(SCHEMA_V1)?;
|
||||
}
|
||||
if version < 2 {
|
||||
connection.execute_batch(SCHEMA_V2)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -313,12 +327,14 @@ mod tests {
|
||||
let mut moved = session(1, "web-01", Some(2));
|
||||
moved.port = 2222;
|
||||
moved.auth = AuthKind::Key;
|
||||
moved.key_path = Some("/tmp/test-key".into());
|
||||
db.update_session(&moved).unwrap();
|
||||
db.update_group(&group(2, "预发", None)).unwrap();
|
||||
|
||||
let data = db.load().unwrap();
|
||||
assert_eq!(data.sessions[0].port, 2222);
|
||||
assert_eq!(data.sessions[0].auth, AuthKind::Key);
|
||||
assert_eq!(data.sessions[0].key_path.as_deref(), Some("/tmp/test-key"));
|
||||
assert_eq!(data.sessions[0].group, Some(GroupId(2)));
|
||||
assert_eq!(data.groups[1].name.as_ref(), "预发");
|
||||
}
|
||||
@@ -379,4 +395,45 @@ mod tests {
|
||||
assert_eq!(data.sessions[0].name.as_ref(), "web-01");
|
||||
assert_eq!(data.recent, [SessionId(1)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrates_v1_key_auth_to_auto_and_adds_key_path() {
|
||||
let connection = Connection::open_in_memory().unwrap();
|
||||
connection.execute_batch(SCHEMA_V1).unwrap();
|
||||
connection
|
||||
.execute(
|
||||
"INSERT INTO sessions (id, name, host, port, username, auth) \
|
||||
VALUES (1, '旧会话', 'example.test', 22, 'root', 'key')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let db = SessionDatabase::prepare(connection).unwrap();
|
||||
let version: i64 = db
|
||||
.connection
|
||||
.pragma_query_value(None, "user_version", |row| row.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(version, 2);
|
||||
let data = db.load().unwrap();
|
||||
assert_eq!(data.sessions[0].auth, AuthKind::Auto);
|
||||
assert_eq!(data.sessions[0].key_path, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_never_contains_secret_columns() {
|
||||
let db = SessionDatabase::in_memory().unwrap();
|
||||
let mut statement = db
|
||||
.connection
|
||||
.prepare("PRAGMA table_info(sessions)")
|
||||
.unwrap();
|
||||
let columns = statement
|
||||
.query_map([], |row| row.get::<_, String>(1))
|
||||
.unwrap()
|
||||
.collect::<rusqlite::Result<Vec<_>>>()
|
||||
.unwrap();
|
||||
assert!(columns.contains(&"key_path".to_string()));
|
||||
assert!(!columns.iter().any(|column| {
|
||||
column.contains("password") || column.contains("passphrase") || column.contains("otp")
|
||||
}));
|
||||
}
|
||||
}
|
||||
+42
-4
@@ -12,19 +12,21 @@ pub struct GroupId(pub u64);
|
||||
/// How a session authenticates.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum AuthKind {
|
||||
Password,
|
||||
#[default]
|
||||
Auto,
|
||||
Password,
|
||||
Key,
|
||||
}
|
||||
|
||||
impl AuthKind {
|
||||
/// Every kind, in the order the session form lists them.
|
||||
pub const ALL: [AuthKind; 2] = [AuthKind::Password, AuthKind::Key];
|
||||
pub const ALL: [AuthKind; 3] = [AuthKind::Auto, AuthKind::Password, AuthKind::Key];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
AuthKind::Auto => "自动",
|
||||
AuthKind::Password => "密码",
|
||||
AuthKind::Key => "密钥",
|
||||
AuthKind::Key => "私钥文件",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +34,7 @@ impl AuthKind {
|
||||
/// cannot rewrite what is already in the database.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
AuthKind::Auto => "auto",
|
||||
AuthKind::Password => "password",
|
||||
AuthKind::Key => "key",
|
||||
}
|
||||
@@ -41,8 +44,10 @@ impl AuthKind {
|
||||
/// newer version might have written.
|
||||
pub fn from_stored(value: &str) -> Self {
|
||||
match value {
|
||||
"auto" => AuthKind::Auto,
|
||||
"password" => AuthKind::Password,
|
||||
_ => AuthKind::Key,
|
||||
"key" => AuthKind::Key,
|
||||
_ => AuthKind::Auto,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -82,6 +87,7 @@ pub struct Session {
|
||||
pub port: u16,
|
||||
pub user: SharedString,
|
||||
pub auth: AuthKind,
|
||||
pub key_path: Option<SharedString>,
|
||||
pub group: Option<GroupId>,
|
||||
pub state: ConnectionState,
|
||||
}
|
||||
@@ -95,6 +101,7 @@ impl Session {
|
||||
port: draft.port,
|
||||
user: draft.user,
|
||||
auth: draft.auth,
|
||||
key_path: draft.key_path,
|
||||
group: draft.group,
|
||||
state: ConnectionState::Disconnected,
|
||||
}
|
||||
@@ -113,6 +120,7 @@ impl Session {
|
||||
port: self.port,
|
||||
user: self.user.clone(),
|
||||
auth: self.auth,
|
||||
key_path: self.key_path.clone(),
|
||||
group: self.group,
|
||||
}
|
||||
}
|
||||
@@ -155,6 +163,7 @@ pub struct SessionDraft {
|
||||
pub port: u16,
|
||||
pub user: SharedString,
|
||||
pub auth: AuthKind,
|
||||
pub key_path: Option<SharedString>,
|
||||
pub group: Option<GroupId>,
|
||||
}
|
||||
|
||||
@@ -173,9 +182,38 @@ impl SessionDraft {
|
||||
port,
|
||||
user: user.into(),
|
||||
auth,
|
||||
key_path: None,
|
||||
group,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the private key used by [`AuthKind::Key`]. Keeping this as a
|
||||
/// builder preserves the existing six-argument constructor for callers.
|
||||
pub fn with_key_path(mut self, path: impl Into<SharedString>) -> Self {
|
||||
let path = path.into();
|
||||
self.key_path = (!path.trim().is_empty()).then_some(path);
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn with_optional_key_path(mut self, path: Option<String>) -> Self {
|
||||
self.key_path = path
|
||||
.filter(|path| !path.trim().is_empty())
|
||||
.map(SharedString::from);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn auto_is_the_default_and_key_path_is_opt_in() {
|
||||
assert_eq!(AuthKind::default(), AuthKind::Auto);
|
||||
let draft = SessionDraft::new("server", "host", 22, "me", AuthKind::Key, None)
|
||||
.with_key_path("/tmp/id_ed25519");
|
||||
assert_eq!(draft.key_path.as_deref(), Some("/tmp/id_ed25519"));
|
||||
}
|
||||
}
|
||||
|
||||
/// The values the group form commits.
|
||||
|
||||
@@ -5,6 +5,7 @@ use gpui_kit::component::{
|
||||
button::{Button, ButtonVariant, ButtonVariants as _},
|
||||
dialog::{DialogAction, DialogButtonProps, DialogClose, DialogFooter},
|
||||
form::{Field, Form},
|
||||
h_flex,
|
||||
input::{Input, InputState},
|
||||
select::{Select, SelectState},
|
||||
v_flex,
|
||||
@@ -30,10 +31,13 @@ pub struct SessionForm {
|
||||
port: Entity<InputState>,
|
||||
user: Entity<InputState>,
|
||||
auth: Entity<SelectState<Vec<&'static str>>>,
|
||||
key_path: Entity<InputState>,
|
||||
group: Entity<SelectState<Vec<SharedString>>>,
|
||||
/// Parallel to the group select's rows; `None` is the root of the tree.
|
||||
group_ids: Vec<Option<GroupId>>,
|
||||
error: Option<SharedString>,
|
||||
editing_connected: bool,
|
||||
_auth_subscription: Subscription,
|
||||
}
|
||||
|
||||
impl SessionForm {
|
||||
@@ -44,11 +48,14 @@ impl SessionForm {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let (draft, options) = {
|
||||
let (draft, options, editing_connected) = {
|
||||
let read = store.read(cx);
|
||||
(
|
||||
editing.and_then(|id| read.session(id)).map(Session::draft),
|
||||
group_options(read.groups(), &[]),
|
||||
editing
|
||||
.and_then(|id| read.session(id))
|
||||
.is_some_and(|session| session.state != super::ConnectionState::Disconnected),
|
||||
)
|
||||
};
|
||||
// A session with no group sits at the root of the tree, which is where
|
||||
@@ -60,7 +67,7 @@ impl SessionForm {
|
||||
group_names.push(path);
|
||||
}
|
||||
let draft = draft.unwrap_or_else(|| {
|
||||
SessionDraft::new("", "", 22, "root", AuthKind::Key, preselect_group)
|
||||
SessionDraft::new("", "", 22, "root", AuthKind::Auto, preselect_group)
|
||||
});
|
||||
|
||||
let name = cx.new(|cx| {
|
||||
@@ -98,6 +105,12 @@ impl SessionForm {
|
||||
cx,
|
||||
)
|
||||
});
|
||||
let key_path = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder("选择 OpenSSH 私钥文件")
|
||||
.default_value(draft.key_path.clone().unwrap_or_default())
|
||||
});
|
||||
let auth_subscription = cx.observe(&auth, |_, _, cx| cx.notify());
|
||||
let group_ix = group_ids
|
||||
.iter()
|
||||
.position(|group| *group == draft.group)
|
||||
@@ -113,18 +126,53 @@ impl SessionForm {
|
||||
port,
|
||||
user,
|
||||
auth,
|
||||
key_path,
|
||||
group,
|
||||
group_ids,
|
||||
error: None,
|
||||
editing_connected,
|
||||
_auth_subscription: auth_subscription,
|
||||
}
|
||||
}
|
||||
|
||||
fn choose_key(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let receiver = cx.prompt_for_paths(PathPromptOptions {
|
||||
files: true,
|
||||
directories: false,
|
||||
multiple: false,
|
||||
prompt: Some("选择 SSH 私钥".into()),
|
||||
});
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let Ok(Ok(Some(paths))) = receiver.await else {
|
||||
return;
|
||||
};
|
||||
let Some(path) = paths.into_iter().next() else {
|
||||
return;
|
||||
};
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.key_path.update(cx, |input, cx| {
|
||||
input.set_value(path.to_string_lossy().into_owned(), window, cx)
|
||||
});
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Validate and write to the store. Returns whether the dialog may close.
|
||||
pub fn commit(&mut self, _: &mut Window, cx: &mut Context<Self>) -> bool {
|
||||
let name = self.name.read(cx).value().trim().to_string();
|
||||
let host = self.host.read(cx).value().trim().to_string();
|
||||
let user = self.user.read(cx).value().trim().to_string();
|
||||
let port = self.port.read(cx).value().trim().parse::<u16>();
|
||||
let auth = self
|
||||
.auth
|
||||
.read(cx)
|
||||
.selected_index(cx)
|
||||
.and_then(|ix| AuthKind::ALL.get(ix.row).copied())
|
||||
.unwrap_or_default();
|
||||
let key_path = self.key_path.read(cx).value().trim().to_string();
|
||||
|
||||
let error = if name.is_empty() {
|
||||
Some("请输入名称")
|
||||
@@ -132,6 +180,8 @@ impl SessionForm {
|
||||
Some("请输入主机")
|
||||
} else if !matches!(port, Ok(1..=u16::MAX)) {
|
||||
Some("端口必须是 1 到 65535 之间的数字")
|
||||
} else if auth == AuthKind::Key && key_path.is_empty() {
|
||||
Some("私钥认证需要选择私钥文件")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -141,19 +191,13 @@ impl SessionForm {
|
||||
return false;
|
||||
}
|
||||
|
||||
let auth = self
|
||||
.auth
|
||||
.read(cx)
|
||||
.selected_index(cx)
|
||||
.and_then(|ix| AuthKind::ALL.get(ix.row).copied())
|
||||
.unwrap_or_default();
|
||||
let group = self
|
||||
.group
|
||||
.read(cx)
|
||||
.selected_index(cx)
|
||||
.and_then(|ix| self.group_ids.get(ix.row).copied())
|
||||
.unwrap_or(None);
|
||||
let draft = SessionDraft::new(
|
||||
let mut draft = SessionDraft::new(
|
||||
name,
|
||||
host,
|
||||
port.unwrap_or(22),
|
||||
@@ -165,6 +209,9 @@ impl SessionForm {
|
||||
auth,
|
||||
group,
|
||||
);
|
||||
if auth == AuthKind::Key {
|
||||
draft = draft.with_key_path(key_path);
|
||||
}
|
||||
|
||||
let editing = self.editing;
|
||||
self.store.update(cx, |store, cx| match editing {
|
||||
@@ -182,6 +229,12 @@ impl SessionForm {
|
||||
|
||||
impl Render for SessionForm {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let auth = self
|
||||
.auth
|
||||
.read(cx)
|
||||
.selected_index(cx)
|
||||
.and_then(|ix| AuthKind::ALL.get(ix.row).copied())
|
||||
.unwrap_or_default();
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.w_full()
|
||||
@@ -212,14 +265,43 @@ impl Render for SessionForm {
|
||||
.child(
|
||||
Field::new()
|
||||
.label("认证方式")
|
||||
.child(Select::new(&self.auth).small()),
|
||||
.child(Select::new(&self.auth).id("session-auth").small()),
|
||||
)
|
||||
.when(auth == AuthKind::Key, |form| {
|
||||
form.child(
|
||||
Field::new().label("私钥文件").required(true).child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.w_full()
|
||||
.child(
|
||||
Input::new(&self.key_path)
|
||||
.id("session-key-path")
|
||||
.small()
|
||||
.flex_1(),
|
||||
)
|
||||
.child(
|
||||
Button::new("choose-key")
|
||||
.label("选择…")
|
||||
.small()
|
||||
.on_click(cx.listener(Self::choose_key)),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
Field::new()
|
||||
.label("分组")
|
||||
.child(Select::new(&self.group).small()),
|
||||
),
|
||||
)
|
||||
.when(self.editing_connected, |form| {
|
||||
form.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child("保存后连接设置将立即生效并重连。"),
|
||||
)
|
||||
})
|
||||
.when_some(self.error.clone(), |form, error| {
|
||||
form.child(
|
||||
div()
|
||||
|
||||
+26
-5
@@ -37,6 +37,9 @@ const MAX_RECENT: usize = 10;
|
||||
pub enum SessionStoreEvent {
|
||||
/// A change was applied in memory but could not be written to disk.
|
||||
PersistFailed(SharedString),
|
||||
/// A live terminal must reconnect because its SSH endpoint or
|
||||
/// authentication configuration changed.
|
||||
ConnectionSettingsChanged(SessionId),
|
||||
}
|
||||
|
||||
impl EventEmitter<SessionStoreEvent> for SessionStore {}
|
||||
@@ -89,7 +92,7 @@ impl SessionStore {
|
||||
"10.0.1.12",
|
||||
22,
|
||||
"root",
|
||||
AuthKind::Key,
|
||||
AuthKind::Auto,
|
||||
Some(production),
|
||||
),
|
||||
SessionDraft::new(
|
||||
@@ -97,7 +100,7 @@ impl SessionStore {
|
||||
"10.0.1.13",
|
||||
22,
|
||||
"root",
|
||||
AuthKind::Key,
|
||||
AuthKind::Auto,
|
||||
Some(production),
|
||||
),
|
||||
SessionDraft::new(
|
||||
@@ -113,7 +116,7 @@ impl SessionStore {
|
||||
"10.0.9.20",
|
||||
2222,
|
||||
"deploy",
|
||||
AuthKind::Key,
|
||||
AuthKind::Auto,
|
||||
Some(staging),
|
||||
),
|
||||
SessionDraft::new(
|
||||
@@ -129,7 +132,7 @@ impl SessionStore {
|
||||
"192.168.1.20",
|
||||
22,
|
||||
"xuz",
|
||||
AuthKind::Key,
|
||||
AuthKind::Auto,
|
||||
Some(development),
|
||||
),
|
||||
];
|
||||
@@ -254,12 +257,22 @@ impl SessionStore {
|
||||
|
||||
/// Replace the editable fields of a session; connection state is kept.
|
||||
pub fn update(&mut self, id: SessionId, draft: SessionDraft, cx: &mut Context<Self>) -> bool {
|
||||
let connection_changed = self.session(id).is_some_and(|session| {
|
||||
session.host != draft.host
|
||||
|| session.port != draft.port
|
||||
|| session.user != draft.user
|
||||
|| session.auth != draft.auth
|
||||
|| session.key_path != draft.key_path
|
||||
});
|
||||
let updated = self.update_unnotified(id, draft);
|
||||
if updated {
|
||||
if let (Some(database), Some(session)) = (self.database.as_ref(), self.session(id)) {
|
||||
let result = database.update_session(session);
|
||||
self.report(result, "保存会话", cx);
|
||||
}
|
||||
if connection_changed {
|
||||
cx.emit(SessionStoreEvent::ConnectionSettingsChanged(id));
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
updated
|
||||
@@ -451,7 +464,7 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
fn draft(name: &str, group: Option<GroupId>) -> SessionDraft {
|
||||
SessionDraft::new(name, "10.0.0.1", 22, "root", AuthKind::Key, group)
|
||||
SessionDraft::new(name, "10.0.0.1", 22, "root", AuthKind::Auto, group)
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -495,9 +508,17 @@ mod tests {
|
||||
fn duplicate_places_a_copy_after_the_original() {
|
||||
let mut store = SessionStore::seed();
|
||||
let web01 = store.sessions()[0].id;
|
||||
let mut draft = store.session(web01).unwrap().draft();
|
||||
draft.auth = AuthKind::Key;
|
||||
draft.key_path = Some("/tmp/id_ed25519".into());
|
||||
assert!(store.update_unnotified(web01, draft));
|
||||
let copy = store.duplicate_unnotified(web01).unwrap();
|
||||
assert_eq!(store.sessions()[1].id, copy);
|
||||
assert_eq!(store.sessions()[1].name.as_ref(), "web-01 副本");
|
||||
assert_eq!(
|
||||
store.sessions()[1].key_path.as_deref(),
|
||||
Some("/tmp/id_ed25519")
|
||||
);
|
||||
assert_eq!(store.sessions()[1].state, ConnectionState::Disconnected);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
//! Real SSH transport. SFTP intentionally remains a separate mock capability.
|
||||
|
||||
mod transport;
|
||||
|
||||
pub use transport::SshTerminalTransportProvider;
|
||||
File diff suppressed because it is too large.
Load diff
+38
-6
@@ -15,8 +15,8 @@ use alacritty_terminal::term::{Config, TermMode};
|
||||
use alacritty_terminal::vte::ansi::{Color, CursorShape, CursorStyle, Processor, Rgb};
|
||||
|
||||
use super::{
|
||||
SharedTerminalTransportFactory, TerminalLifecycle, TerminalSize, TerminalStatus,
|
||||
TerminalTransportCommand, TerminalTransportEvent,
|
||||
SharedTerminalTransportFactory, TerminalLifecycle, TerminalPrompt, TerminalPromptReply,
|
||||
TerminalSize, TerminalStatus, TerminalTransportCommand, TerminalTransportEvent,
|
||||
};
|
||||
|
||||
pub type AlacrittyTerm = Term<TerminalEventProxy>;
|
||||
@@ -110,7 +110,9 @@ impl TerminalEngine {
|
||||
&& this
|
||||
.update(cx, |this, cx| {
|
||||
for event in batch {
|
||||
this.handle_event(event);
|
||||
if let Some(prompt) = this.handle_event(event) {
|
||||
cx.emit(TerminalEngineEvent::PromptRequested(prompt));
|
||||
}
|
||||
}
|
||||
cx.emit(TerminalEngineEvent::Changed);
|
||||
cx.notify();
|
||||
@@ -123,9 +125,9 @@ impl TerminalEngine {
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_event(&mut self, event: TerminalUiEvent) {
|
||||
fn handle_event(&mut self, event: TerminalUiEvent) -> Option<TerminalPrompt> {
|
||||
if event.generation != self.generation {
|
||||
return;
|
||||
return None;
|
||||
}
|
||||
|
||||
match event.kind {
|
||||
@@ -141,12 +143,14 @@ impl TerminalEngine {
|
||||
TerminalUiEventKind::Failed(error) => {
|
||||
self.lifecycle = TerminalLifecycle::Failed(error);
|
||||
}
|
||||
TerminalUiEventKind::Prompt(prompt) => return Some(prompt),
|
||||
TerminalUiEventKind::ColorRequest(index, formatter) => {
|
||||
let color = self.runtime.term.lock().colors()[index]
|
||||
.unwrap_or_else(|| default_query_color(index));
|
||||
self.runtime.write(formatter(color).into_bytes());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn restart(&mut self, cx: &mut gpui_kit::Context<Self>) {
|
||||
@@ -164,6 +168,19 @@ impl TerminalEngine {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn restart_with_factory(
|
||||
&mut self,
|
||||
factory: SharedTerminalTransportFactory,
|
||||
cx: &mut gpui_kit::Context<Self>,
|
||||
) {
|
||||
self.factory = factory;
|
||||
self.restart(cx);
|
||||
}
|
||||
|
||||
pub fn reply_to_prompt(&self, request_id: u64, reply: TerminalPromptReply) {
|
||||
self.runtime.reply_to_prompt(request_id, reply);
|
||||
}
|
||||
|
||||
pub fn shutdown(&mut self, cx: &mut gpui_kit::Context<Self>) {
|
||||
self.generation = self.generation.saturating_add(1);
|
||||
self.lifecycle = TerminalLifecycle::Closing;
|
||||
@@ -392,9 +409,10 @@ impl Drop for TerminalEngine {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum TerminalEngineEvent {
|
||||
Changed,
|
||||
PromptRequested(TerminalPrompt),
|
||||
}
|
||||
|
||||
struct TerminalRuntime {
|
||||
@@ -441,6 +459,13 @@ impl TerminalRuntime {
|
||||
processor.advance(&mut *parser_term.lock(), &bytes);
|
||||
parser_proxy.wakeup();
|
||||
}
|
||||
TerminalTransportEvent::Prompt(prompt) => {
|
||||
send_ui(
|
||||
&parser_ui_events,
|
||||
generation,
|
||||
TerminalUiEventKind::Prompt(prompt),
|
||||
);
|
||||
}
|
||||
TerminalTransportEvent::Exited { code, signal } => {
|
||||
let description = signal
|
||||
.as_deref()
|
||||
@@ -507,6 +532,12 @@ impl TerminalRuntime {
|
||||
fn shutdown(&self) {
|
||||
let _ = self.commands.send(TerminalTransportCommand::Shutdown);
|
||||
}
|
||||
|
||||
fn reply_to_prompt(&self, request_id: u64, reply: TerminalPromptReply) {
|
||||
let _ = self
|
||||
.commands
|
||||
.send(TerminalTransportCommand::PromptReply { request_id, reply });
|
||||
}
|
||||
}
|
||||
|
||||
fn terminal_config() -> Config {
|
||||
@@ -611,6 +642,7 @@ enum TerminalUiEventKind {
|
||||
ResetTitle,
|
||||
Exited { code: u32, signal: Option<String> },
|
||||
Failed(String),
|
||||
Prompt(TerminalPrompt),
|
||||
ColorRequest(usize, Arc<dyn Fn(Rgb) -> String + Send + Sync>),
|
||||
}
|
||||
|
||||
|
||||
@@ -167,7 +167,11 @@ mod tests {
|
||||
code = Some(exit_code);
|
||||
break;
|
||||
}
|
||||
Ok(TerminalTransportEvent::Started | TerminalTransportEvent::Failed(_)) => {}
|
||||
Ok(
|
||||
TerminalTransportEvent::Started
|
||||
| TerminalTransportEvent::Failed(_)
|
||||
| TerminalTransportEvent::Prompt(_),
|
||||
) => {}
|
||||
Err(async_channel::TryRecvError::Empty) => thread::sleep(Duration::from_millis(5)),
|
||||
Err(async_channel::TryRecvError::Closed) => break,
|
||||
}
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
//! A tiny canned shell so the terminal mock feels alive.
|
||||
|
||||
use gpui_kit::SharedString;
|
||||
|
||||
use crate::session::Session;
|
||||
|
||||
/// What a command asks the terminal to do besides printing.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ShellEffect {
|
||||
/// Clear the scrollback.
|
||||
Clear,
|
||||
/// End the session.
|
||||
Exit,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct ShellReply {
|
||||
pub lines: Vec<SharedString>,
|
||||
pub effect: Option<ShellEffect>,
|
||||
}
|
||||
|
||||
impl ShellReply {
|
||||
fn lines<I, S>(lines: I) -> Self
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: Into<SharedString>,
|
||||
{
|
||||
Self {
|
||||
lines: lines.into_iter().map(Into::into).collect(),
|
||||
effect: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `root@web-01:~$ `
|
||||
pub fn prompt(session: &Session) -> String {
|
||||
let symbol = if session.user.as_ref() == "root" {
|
||||
"#"
|
||||
} else {
|
||||
"$"
|
||||
};
|
||||
format!("{}@{}:~{symbol} ", session.user, session.name)
|
||||
}
|
||||
|
||||
/// The lines shown when a terminal opens.
|
||||
pub fn banner(session: &Session) -> Vec<SharedString> {
|
||||
vec![
|
||||
format!("Connecting to {}...", session.address()).into(),
|
||||
"Welcome to Ubuntu 24.04 LTS (GNU/Linux 6.8.0-45-generic x86_64)".into(),
|
||||
"".into(),
|
||||
" * Documentation: https://help.ubuntu.com".into(),
|
||||
" * Support: https://ubuntu.com/pro".into(),
|
||||
"".into(),
|
||||
format!(
|
||||
"Last login: Wed Sep 9 18:42:07 2026 from 10.0.0.2 ({})",
|
||||
session.host
|
||||
)
|
||||
.into(),
|
||||
]
|
||||
}
|
||||
|
||||
/// The canned reply to a command line.
|
||||
pub fn reply(session: &Session, command: &str) -> ShellReply {
|
||||
let command = command.trim();
|
||||
let (program, rest) = command.split_once(' ').unwrap_or((command, ""));
|
||||
match program {
|
||||
"" => ShellReply::default(),
|
||||
"clear" => ShellReply {
|
||||
lines: Vec::new(),
|
||||
effect: Some(ShellEffect::Clear),
|
||||
},
|
||||
"exit" | "logout" => ShellReply {
|
||||
lines: vec!["logout".into()],
|
||||
effect: Some(ShellEffect::Exit),
|
||||
},
|
||||
"ls" => ShellReply::lines(["backups deploy.sh"]),
|
||||
"pwd" => ShellReply::lines([format!("/home/{}", session.user)]),
|
||||
"whoami" => ShellReply::lines([session.user.to_string()]),
|
||||
"hostname" => ShellReply::lines([session.name.to_string()]),
|
||||
"uname" => ShellReply::lines([if rest.contains('a') {
|
||||
format!(
|
||||
"Linux {} 6.8.0-45-generic #45-Ubuntu SMP x86_64 GNU/Linux",
|
||||
session.name
|
||||
)
|
||||
} else {
|
||||
"Linux".to_string()
|
||||
}]),
|
||||
"uptime" => ShellReply::lines([
|
||||
" 20:41:13 up 37 days, 4:12, 1 user, load average: 0.08, 0.11, 0.09",
|
||||
]),
|
||||
"date" => ShellReply::lines(["Thu Sep 10 20:41:13 CST 2026"]),
|
||||
"echo" => ShellReply::lines([rest.to_string()]),
|
||||
"help" => ShellReply::lines([
|
||||
"mock shell — 可用命令: ls pwd whoami hostname uname [-a] uptime date echo clear exit",
|
||||
]),
|
||||
_ => ShellReply::lines([format!("bash: {program}: command not found")]),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::session::SessionStore;
|
||||
|
||||
fn session() -> Session {
|
||||
SessionStore::seed().sessions()[0].clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_command_reports_not_found() {
|
||||
let reply = reply(&session(), "frobnicate --now");
|
||||
assert_eq!(reply.lines, ["bash: frobnicate: command not found"]);
|
||||
assert_eq!(reply.effect, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_and_exit_carry_effects() {
|
||||
assert_eq!(reply(&session(), "clear").effect, Some(ShellEffect::Clear));
|
||||
assert_eq!(reply(&session(), "exit").effect, Some(ShellEffect::Exit));
|
||||
assert_eq!(reply(&session(), " ").lines.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_uses_hash_for_root() {
|
||||
let session = session();
|
||||
assert_eq!(prompt(&session), "root@web-01:~# ");
|
||||
}
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
use std::sync::mpsc;
|
||||
|
||||
use anyhow::Result;
|
||||
use async_channel::Sender;
|
||||
|
||||
use crate::session::Session;
|
||||
|
||||
use super::{
|
||||
ShellEffect, TerminalSize, TerminalTransport, TerminalTransportCommand, TerminalTransportEvent,
|
||||
TerminalTransportFactory, banner, prompt, reply, send_event,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct MockSshTransportFactory {
|
||||
session: Session,
|
||||
}
|
||||
|
||||
impl MockSshTransportFactory {
|
||||
pub fn new(session: &Session) -> Self {
|
||||
Self {
|
||||
session: session.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TerminalTransportFactory for MockSshTransportFactory {
|
||||
fn create(&self) -> Box<dyn TerminalTransport> {
|
||||
Box::new(MockSshTransport {
|
||||
session: self.session.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct MockSshTransport {
|
||||
session: Session,
|
||||
}
|
||||
|
||||
impl TerminalTransport for MockSshTransport {
|
||||
fn run(
|
||||
self: Box<Self>,
|
||||
_: TerminalSize,
|
||||
commands: mpsc::Receiver<TerminalTransportCommand>,
|
||||
events: Sender<TerminalTransportEvent>,
|
||||
) -> Result<()> {
|
||||
send_event(&events, TerminalTransportEvent::Started);
|
||||
|
||||
let mut opening = banner(&self.session)
|
||||
.into_iter()
|
||||
.map(|line| line.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\r\n");
|
||||
opening.push_str("\r\n");
|
||||
opening.push_str(&prompt(&self.session));
|
||||
send_event(
|
||||
&events,
|
||||
TerminalTransportEvent::Output(opening.into_bytes()),
|
||||
);
|
||||
|
||||
let mut input = Vec::new();
|
||||
let mut escape_state = 0_u8;
|
||||
while let Ok(command) = commands.recv() {
|
||||
match command {
|
||||
TerminalTransportCommand::Write(bytes) => {
|
||||
for byte in bytes {
|
||||
if escape_state != 0 {
|
||||
escape_state = match (escape_state, byte) {
|
||||
(1, b'[' | b'O') => 2,
|
||||
(2, 0x40..=0x7e) => 0,
|
||||
(2, _) => 2,
|
||||
_ => 0,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
match byte {
|
||||
b'\r' | b'\n' => {
|
||||
send_event(
|
||||
&events,
|
||||
TerminalTransportEvent::Output(b"\r\n".to_vec()),
|
||||
);
|
||||
let command = String::from_utf8_lossy(&input).into_owned();
|
||||
input.clear();
|
||||
let response = reply(&self.session, &command);
|
||||
if matches!(response.effect, Some(ShellEffect::Clear)) {
|
||||
send_event(
|
||||
&events,
|
||||
TerminalTransportEvent::Output(b"\x1b[2J\x1b[H".to_vec()),
|
||||
);
|
||||
} else {
|
||||
for line in response.lines {
|
||||
let mut output = line.to_string();
|
||||
output.push_str("\r\n");
|
||||
send_event(
|
||||
&events,
|
||||
TerminalTransportEvent::Output(output.into_bytes()),
|
||||
);
|
||||
}
|
||||
}
|
||||
if matches!(response.effect, Some(ShellEffect::Exit)) {
|
||||
send_event(
|
||||
&events,
|
||||
TerminalTransportEvent::Exited {
|
||||
code: 0,
|
||||
signal: None,
|
||||
},
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
send_event(
|
||||
&events,
|
||||
TerminalTransportEvent::Output(
|
||||
prompt(&self.session).into_bytes(),
|
||||
),
|
||||
);
|
||||
}
|
||||
0x03 => {
|
||||
input.clear();
|
||||
let output = format!("^C\r\n{}", prompt(&self.session));
|
||||
send_event(
|
||||
&events,
|
||||
TerminalTransportEvent::Output(output.into_bytes()),
|
||||
);
|
||||
}
|
||||
0x7f | 0x08 => {
|
||||
if input.pop().is_some() {
|
||||
while input
|
||||
.last()
|
||||
.is_some_and(|byte| byte & 0b1100_0000 == 0b1000_0000)
|
||||
{
|
||||
input.pop();
|
||||
}
|
||||
send_event(
|
||||
&events,
|
||||
TerminalTransportEvent::Output(b"\x08 \x08".to_vec()),
|
||||
);
|
||||
}
|
||||
}
|
||||
// The mock has no command-line navigation; consume
|
||||
// complete CSI/SS3 sequences without echoing them.
|
||||
0x1b => escape_state = 1,
|
||||
byte => {
|
||||
input.push(byte);
|
||||
send_event(&events, TerminalTransportEvent::Output(vec![byte]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
TerminalTransportCommand::Resize(_) => {}
|
||||
TerminalTransportCommand::Shutdown => break,
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
+8
-7
@@ -3,8 +3,6 @@
|
||||
mod engine;
|
||||
mod local_pty;
|
||||
mod local_terminal_panel;
|
||||
mod mock_shell;
|
||||
mod mock_transport;
|
||||
mod model;
|
||||
mod terminal_panel;
|
||||
mod terminal_view;
|
||||
@@ -13,14 +11,17 @@ mod transport;
|
||||
pub use engine::{TerminalCell, TerminalEngine, TerminalEngineEvent, TerminalSnapshot};
|
||||
pub use local_pty::LocalPtyTransportFactory;
|
||||
pub use local_terminal_panel::{LocalTerminalPanel, LocalTerminalPanelEvent};
|
||||
pub use mock_shell::*;
|
||||
pub use mock_transport::MockSshTransportFactory;
|
||||
pub use model::{LocalTerminalId, TerminalLifecycle, TerminalSize, TerminalStatus};
|
||||
pub use model::{
|
||||
LocalTerminalId, RemoteTerminalId, TerminalLifecycle, TerminalSize, TerminalStatus,
|
||||
};
|
||||
pub use terminal_panel::{TerminalPanel, TerminalPanelEvent};
|
||||
pub(crate) use terminal_view::terminal_key_bindings;
|
||||
pub use terminal_view::{TERMINAL_KEY_CONTEXT, TerminalView, TerminalViewEvent};
|
||||
pub(crate) use transport::send_event;
|
||||
pub use transport::{
|
||||
SharedTerminalTransportFactory, TerminalTransport, TerminalTransportCommand,
|
||||
TerminalTransportEvent, TerminalTransportFactory,
|
||||
AuthenticationPrompt, FixedRemoteTerminalTransportProvider, HostKeyChangedPrompt,
|
||||
RemoteTerminalTransportProvider, SharedRemoteTerminalTransportProvider,
|
||||
SharedTerminalTransportFactory, TerminalPrompt, TerminalPromptField, TerminalPromptKind,
|
||||
TerminalPromptReply, TerminalSecret, TerminalTransport, TerminalTransportCommand,
|
||||
TerminalTransportEvent, TerminalTransportFactory, UnknownHostPrompt,
|
||||
};
|
||||
@@ -7,6 +7,10 @@ use serde::Deserialize;
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Deserialize)]
|
||||
pub struct LocalTerminalId(pub u64);
|
||||
|
||||
/// Stable identity for one remote-terminal connection and its Dock tab.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Deserialize)]
|
||||
pub struct RemoteTerminalId(pub u64);
|
||||
|
||||
/// The dimensions shared by the emulator and a terminal transport.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct TerminalSize {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use gpui_kit::component::{
|
||||
Icon,
|
||||
button::Button,
|
||||
@@ -10,34 +8,42 @@ use gpui_kit::*;
|
||||
|
||||
use crate::app::{
|
||||
CatalogIcon, CloseTerminal, CopyTerminal, DuplicateSession, EditSession, OpenExplorer,
|
||||
PasteTerminal, ReconnectSession,
|
||||
PasteTerminal, ReconnectTerminal,
|
||||
};
|
||||
use crate::session::{ConnectionState, SessionId, SessionStore};
|
||||
use crate::session::{SessionId, SessionStore};
|
||||
use crate::shared::ClosableTabTitle;
|
||||
|
||||
use super::{MockSshTransportFactory, TerminalLifecycle, TerminalView};
|
||||
use super::{
|
||||
RemoteTerminalId, SharedRemoteTerminalTransportProvider, TerminalLifecycle, TerminalPrompt,
|
||||
TerminalPromptReply, TerminalView, TerminalViewEvent,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum TerminalPanelEvent {
|
||||
Activated(SessionId),
|
||||
Closed(SessionId),
|
||||
StatusChanged(SessionId),
|
||||
Activated(RemoteTerminalId, SessionId),
|
||||
Closed(RemoteTerminalId, SessionId),
|
||||
StatusChanged(RemoteTerminalId, SessionId),
|
||||
PromptRequested(RemoteTerminalId, SessionId, TerminalPrompt),
|
||||
}
|
||||
|
||||
/// A remote-session Dock panel backed by the shared terminal engine. The
|
||||
/// transport remains a canned SSH mock until the network feature arrives.
|
||||
/// transport is created from the latest saved session on every connection.
|
||||
pub struct TerminalPanel {
|
||||
id: RemoteTerminalId,
|
||||
session_id: SessionId,
|
||||
store: Entity<SessionStore>,
|
||||
terminal: Entity<TerminalView>,
|
||||
remote_provider: SharedRemoteTerminalTransportProvider,
|
||||
tab_group: Option<WeakEntity<TabGroup>>,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
impl TerminalPanel {
|
||||
pub fn new(
|
||||
id: RemoteTerminalId,
|
||||
session_id: SessionId,
|
||||
store: Entity<SessionStore>,
|
||||
remote_provider: SharedRemoteTerminalTransportProvider,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
@@ -48,40 +54,45 @@ impl TerminalPanel {
|
||||
.expect("terminal sessions must exist in the store");
|
||||
let terminal = cx.new(|cx| {
|
||||
TerminalView::new(
|
||||
("terminal", session_id.0),
|
||||
("terminal", id.0),
|
||||
format!("{} 的终端", session.name),
|
||||
Arc::new(MockSshTransportFactory::new(&session)),
|
||||
remote_provider.factory_for(&session),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
let subscriptions = vec![
|
||||
cx.observe(&store, |_, _, cx| cx.notify()),
|
||||
cx.observe(&terminal, |this, terminal, cx| {
|
||||
let lifecycle = terminal.read(cx).lifecycle(cx);
|
||||
if matches!(
|
||||
lifecycle,
|
||||
TerminalLifecycle::Exited { .. } | TerminalLifecycle::Failed(_)
|
||||
) {
|
||||
let id = this.session_id;
|
||||
this.store.update(cx, |store, cx| {
|
||||
store.set_state(id, ConnectionState::Disconnected, cx)
|
||||
});
|
||||
}
|
||||
cx.emit(TerminalPanelEvent::StatusChanged(this.session_id));
|
||||
cx.observe(&terminal, |this, _, cx| {
|
||||
cx.emit(TerminalPanelEvent::StatusChanged(this.id, this.session_id));
|
||||
cx.notify();
|
||||
}),
|
||||
cx.subscribe(&terminal, |this, _, event: &TerminalViewEvent, cx| {
|
||||
if let TerminalViewEvent::PromptRequested(prompt) = event {
|
||||
cx.emit(TerminalPanelEvent::PromptRequested(
|
||||
this.id,
|
||||
this.session_id,
|
||||
prompt.clone(),
|
||||
));
|
||||
}
|
||||
}),
|
||||
];
|
||||
|
||||
Self {
|
||||
id,
|
||||
session_id,
|
||||
store,
|
||||
terminal,
|
||||
remote_provider,
|
||||
tab_group: None,
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn id(&self) -> RemoteTerminalId {
|
||||
self.id
|
||||
}
|
||||
|
||||
pub fn session_id(&self) -> SessionId {
|
||||
self.session_id
|
||||
}
|
||||
@@ -94,6 +105,10 @@ impl TerminalPanel {
|
||||
&self.terminal
|
||||
}
|
||||
|
||||
pub fn lifecycle(&self, cx: &App) -> TerminalLifecycle {
|
||||
self.terminal.read(cx).lifecycle(cx)
|
||||
}
|
||||
|
||||
pub fn append_line(&mut self, line: impl AsRef<str>, cx: &mut Context<Self>) {
|
||||
self.terminal.update(cx, |terminal, cx| {
|
||||
terminal.append_system_message(line.as_ref(), cx)
|
||||
@@ -101,8 +116,13 @@ impl TerminalPanel {
|
||||
}
|
||||
|
||||
pub fn reconnect(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.terminal
|
||||
.update(cx, |terminal, cx| terminal.restart(cx));
|
||||
let Some(session) = self.store.read(cx).session(self.session_id).cloned() else {
|
||||
return;
|
||||
};
|
||||
let factory = self.remote_provider.factory_for(&session);
|
||||
self.terminal.update(cx, |terminal, cx| {
|
||||
terminal.restart_with_factory(factory, cx)
|
||||
});
|
||||
let focus = self.terminal.read(cx).focus_handle();
|
||||
window.focus(&focus, cx);
|
||||
}
|
||||
@@ -111,6 +131,17 @@ impl TerminalPanel {
|
||||
self.terminal
|
||||
.update(cx, |terminal, cx| terminal.stop("已断开连接", cx));
|
||||
}
|
||||
|
||||
pub fn cancel_connection(&mut self, cx: &mut Context<Self>) {
|
||||
self.terminal
|
||||
.update(cx, |terminal, cx| terminal.stop("连接已取消", cx));
|
||||
}
|
||||
|
||||
pub fn reply_to_prompt(&self, request_id: u64, reply: TerminalPromptReply, cx: &App) {
|
||||
self.terminal
|
||||
.read(cx)
|
||||
.reply_to_prompt(request_id, reply, cx);
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for TerminalPanel {}
|
||||
@@ -136,7 +167,7 @@ impl BasePanel for TerminalPanel {
|
||||
.update(cx, |store, cx| store.set_active(Some(id), cx));
|
||||
let focus = self.terminal.read(cx).focus_handle();
|
||||
window.focus(&focus, cx);
|
||||
cx.emit(TerminalPanelEvent::Activated(id));
|
||||
cx.emit(TerminalPanelEvent::Activated(self.id, id));
|
||||
}
|
||||
|
||||
fn on_added_to(&mut self, group: WeakEntity<TabGroup>, _: &mut Window, _: &mut Context<Self>) {
|
||||
@@ -148,13 +179,7 @@ impl BasePanel for TerminalPanel {
|
||||
self.terminal
|
||||
.update(cx, |terminal, cx| terminal.shutdown(cx));
|
||||
let id = self.session_id;
|
||||
self.store.update(cx, |store, cx| {
|
||||
store.set_state(id, ConnectionState::Disconnected, cx);
|
||||
if store.active().map(|session| session.id) == Some(id) {
|
||||
store.set_active(None, cx);
|
||||
}
|
||||
});
|
||||
cx.emit(TerminalPanelEvent::Closed(id));
|
||||
cx.emit(TerminalPanelEvent::Closed(self.id, id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,26 +192,27 @@ impl Panel for TerminalPanel {
|
||||
.map(|session| session.name.clone())
|
||||
.unwrap_or_else(|| "终端".into());
|
||||
ClosableTabTitle::new(CatalogIcon::Terminal, name).closable(
|
||||
("close-terminal", self.session_id.0),
|
||||
Box::new(CloseTerminal(self.session_id)),
|
||||
("close-terminal", self.id.0),
|
||||
Box::new(CloseTerminal(self.id)),
|
||||
)
|
||||
}
|
||||
|
||||
fn toolbar_buttons(&mut self, _: &mut Window, _: &mut Context<Self>) -> Option<Vec<Button>> {
|
||||
let id = self.session_id;
|
||||
let session_id = self.session_id;
|
||||
let terminal_id = self.id;
|
||||
Some(vec![
|
||||
Button::new("sftp")
|
||||
Button::new(("sftp", terminal_id.0))
|
||||
.icon(Icon::new(CatalogIcon::FolderTree))
|
||||
.label("SFTP")
|
||||
.tooltip("打开 SFTP 文件浏览")
|
||||
.on_click(move |_, window, cx| {
|
||||
window.dispatch_action(Box::new(OpenExplorer(id)), cx)
|
||||
window.dispatch_action(Box::new(OpenExplorer(session_id)), cx)
|
||||
}),
|
||||
Button::new("reconnect")
|
||||
Button::new(("reconnect", terminal_id.0))
|
||||
.icon(Icon::new(CatalogIcon::RefreshCw))
|
||||
.tooltip("重连")
|
||||
.on_click(move |_, window, cx| {
|
||||
window.dispatch_action(Box::new(ReconnectSession(id)), cx)
|
||||
window.dispatch_action(Box::new(ReconnectTerminal(terminal_id)), cx)
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -15,8 +15,8 @@ use unicode_width::UnicodeWidthChar as _;
|
||||
use crate::app::{CopyTerminal, PasteTerminal};
|
||||
|
||||
use super::{
|
||||
SharedTerminalTransportFactory, TerminalEngine, TerminalLifecycle, TerminalSize,
|
||||
TerminalSnapshot, TerminalStatus,
|
||||
SharedTerminalTransportFactory, TerminalEngine, TerminalEngineEvent, TerminalLifecycle,
|
||||
TerminalPrompt, TerminalPromptReply, TerminalSize, TerminalSnapshot, TerminalStatus,
|
||||
};
|
||||
|
||||
pub const TERMINAL_KEY_CONTEXT: &str = "Terminal";
|
||||
@@ -30,9 +30,10 @@ pub(crate) fn terminal_key_bindings() -> [KeyBinding; 2] {
|
||||
]
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum TerminalViewEvent {
|
||||
Changed,
|
||||
PromptRequested(TerminalPrompt),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
@@ -159,6 +160,11 @@ impl TerminalView {
|
||||
cx.emit(TerminalViewEvent::Changed);
|
||||
cx.notify();
|
||||
}),
|
||||
cx.subscribe(&engine, |_, _, event: &TerminalEngineEvent, cx| {
|
||||
if let TerminalEngineEvent::PromptRequested(prompt) = event {
|
||||
cx.emit(TerminalViewEvent::PromptRequested(prompt.clone()));
|
||||
}
|
||||
}),
|
||||
cx.on_focus(&focus_handle, window, |this, _, cx| {
|
||||
this.focused = true;
|
||||
this.cursor_visible = true;
|
||||
@@ -236,6 +242,31 @@ impl TerminalView {
|
||||
self.engine.update(cx, |engine, cx| engine.restart(cx));
|
||||
}
|
||||
|
||||
pub fn restart_with_factory(
|
||||
&mut self,
|
||||
factory: SharedTerminalTransportFactory,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.reset_interaction();
|
||||
self.engine
|
||||
.update(cx, |engine, cx| engine.restart_with_factory(factory, cx));
|
||||
}
|
||||
|
||||
pub fn reply_to_prompt(&self, request_id: u64, reply: TerminalPromptReply, cx: &App) {
|
||||
self.engine.read(cx).reply_to_prompt(request_id, reply);
|
||||
}
|
||||
|
||||
fn reset_interaction(&mut self) {
|
||||
self.marked_text.clear();
|
||||
self.marked_selection = 0..0;
|
||||
self.ime_cursor_bounds = None;
|
||||
self.selection_gesture.finish();
|
||||
self.scroll_accumulator.reset();
|
||||
self.context_menu = None;
|
||||
self.context_menu_subscription = None;
|
||||
self.cursor_visible = true;
|
||||
}
|
||||
|
||||
pub fn shutdown(&mut self, cx: &mut Context<Self>) {
|
||||
self.marked_text.clear();
|
||||
self.marked_selection = 0..0;
|
||||
|
||||
+261
-1
@@ -2,22 +2,255 @@ use std::sync::{Arc, mpsc};
|
||||
|
||||
use anyhow::Result;
|
||||
use async_channel::Sender;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use super::TerminalSize;
|
||||
use crate::session::Session;
|
||||
|
||||
/// One field requested by an SSH keyboard-interactive challenge.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct TerminalPromptField {
|
||||
label: String,
|
||||
echo: bool,
|
||||
}
|
||||
|
||||
impl TerminalPromptField {
|
||||
pub fn new(label: impl Into<String>, echo: bool) -> Self {
|
||||
Self {
|
||||
label: label.into(),
|
||||
echo,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(&self) -> &str {
|
||||
&self.label
|
||||
}
|
||||
pub fn echo(&self) -> bool {
|
||||
self.echo
|
||||
}
|
||||
}
|
||||
|
||||
/// A transport question that must be answered by the UI before SSH setup can
|
||||
/// continue. The id is scoped to a single transport generation.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct TerminalPrompt {
|
||||
request_id: u64,
|
||||
kind: TerminalPromptKind,
|
||||
}
|
||||
|
||||
impl TerminalPrompt {
|
||||
pub fn new(request_id: u64, kind: TerminalPromptKind) -> Self {
|
||||
Self { request_id, kind }
|
||||
}
|
||||
|
||||
pub fn request_id(&self) -> u64 {
|
||||
self.request_id
|
||||
}
|
||||
pub fn kind(&self) -> &TerminalPromptKind {
|
||||
&self.kind
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum TerminalPromptKind {
|
||||
UnknownHost(UnknownHostPrompt),
|
||||
HostKeyChanged(HostKeyChangedPrompt),
|
||||
Authentication(AuthenticationPrompt),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct UnknownHostPrompt {
|
||||
host: String,
|
||||
port: u16,
|
||||
algorithm: String,
|
||||
fingerprint: String,
|
||||
}
|
||||
|
||||
impl UnknownHostPrompt {
|
||||
pub fn host(&self) -> &str {
|
||||
&self.host
|
||||
}
|
||||
pub fn port(&self) -> u16 {
|
||||
self.port
|
||||
}
|
||||
pub fn algorithm(&self) -> &str {
|
||||
&self.algorithm
|
||||
}
|
||||
pub fn fingerprint(&self) -> &str {
|
||||
&self.fingerprint
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct HostKeyChangedPrompt {
|
||||
host: String,
|
||||
port: u16,
|
||||
algorithm: String,
|
||||
old_fingerprints: Vec<String>,
|
||||
fingerprint: String,
|
||||
known_hosts_path: std::path::PathBuf,
|
||||
}
|
||||
|
||||
impl HostKeyChangedPrompt {
|
||||
pub fn host(&self) -> &str {
|
||||
&self.host
|
||||
}
|
||||
pub fn port(&self) -> u16 {
|
||||
self.port
|
||||
}
|
||||
pub fn algorithm(&self) -> &str {
|
||||
&self.algorithm
|
||||
}
|
||||
pub fn old_fingerprints(&self) -> &[String] {
|
||||
&self.old_fingerprints
|
||||
}
|
||||
pub fn fingerprint(&self) -> &str {
|
||||
&self.fingerprint
|
||||
}
|
||||
pub fn known_hosts_path(&self) -> &std::path::Path {
|
||||
&self.known_hosts_path
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct AuthenticationPrompt {
|
||||
title: String,
|
||||
instructions: String,
|
||||
fields: Vec<TerminalPromptField>,
|
||||
}
|
||||
|
||||
impl AuthenticationPrompt {
|
||||
pub fn title(&self) -> &str {
|
||||
&self.title
|
||||
}
|
||||
pub fn instructions(&self) -> &str {
|
||||
&self.instructions
|
||||
}
|
||||
pub fn fields(&self) -> &[TerminalPromptField] {
|
||||
&self.fields
|
||||
}
|
||||
}
|
||||
|
||||
impl TerminalPromptKind {
|
||||
pub fn unknown_host(
|
||||
host: impl Into<String>,
|
||||
port: u16,
|
||||
algorithm: impl Into<String>,
|
||||
fingerprint: impl Into<String>,
|
||||
) -> Self {
|
||||
Self::UnknownHost(UnknownHostPrompt {
|
||||
host: host.into(),
|
||||
port,
|
||||
algorithm: algorithm.into(),
|
||||
fingerprint: fingerprint.into(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn host_key_changed(
|
||||
host: impl Into<String>,
|
||||
port: u16,
|
||||
algorithm: impl Into<String>,
|
||||
old_fingerprints: Vec<String>,
|
||||
fingerprint: impl Into<String>,
|
||||
known_hosts_path: impl Into<std::path::PathBuf>,
|
||||
) -> Self {
|
||||
Self::HostKeyChanged(HostKeyChangedPrompt {
|
||||
host: host.into(),
|
||||
port,
|
||||
algorithm: algorithm.into(),
|
||||
old_fingerprints,
|
||||
fingerprint: fingerprint.into(),
|
||||
known_hosts_path: known_hosts_path.into(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn authentication(
|
||||
title: impl Into<String>,
|
||||
instructions: impl Into<String>,
|
||||
fields: Vec<TerminalPromptField>,
|
||||
) -> Self {
|
||||
Self::Authentication(AuthenticationPrompt {
|
||||
title: title.into(),
|
||||
instructions: instructions.into(),
|
||||
fields,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A secret that is erased when dropped and never prints its contents.
|
||||
pub struct TerminalSecret(Zeroizing<String>);
|
||||
|
||||
impl TerminalSecret {
|
||||
pub fn new(value: impl Into<String>) -> Self {
|
||||
Self(Zeroizing::new(value.into()))
|
||||
}
|
||||
pub(crate) fn expose(&self) -> &str {
|
||||
self.0.as_str()
|
||||
}
|
||||
pub(crate) fn into_inner(mut self) -> String {
|
||||
std::mem::take(&mut *self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for TerminalSecret {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter.write_str("TerminalSecret([已隐藏])")
|
||||
}
|
||||
}
|
||||
|
||||
/// UI response to a transport prompt. Deliberately not `Clone`: answers may
|
||||
/// contain passwords, private-key passphrases or one-time codes.
|
||||
pub enum TerminalPromptReply {
|
||||
TrustAndSave,
|
||||
Answers(Vec<TerminalSecret>),
|
||||
Cancel,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for TerminalPromptReply {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::TrustAndSave => formatter.write_str("TrustAndSave"),
|
||||
Self::Answers(answers) => formatter
|
||||
.debug_tuple("Answers")
|
||||
.field(&format_args!("{} 个已隐藏答案", answers.len()))
|
||||
.finish(),
|
||||
Self::Cancel => formatter.write_str("Cancel"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Commands accepted by every terminal byte-stream transport.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum TerminalTransportCommand {
|
||||
Write(Vec<u8>),
|
||||
Resize(TerminalSize),
|
||||
PromptReply {
|
||||
request_id: u64,
|
||||
reply: TerminalPromptReply,
|
||||
},
|
||||
Shutdown,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for TerminalTransportCommand {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Write(bytes) => formatter.debug_tuple("Write").field(&bytes.len()).finish(),
|
||||
Self::Resize(size) => formatter.debug_tuple("Resize").field(size).finish(),
|
||||
Self::PromptReply { request_id, reply } => formatter
|
||||
.debug_struct("PromptReply")
|
||||
.field("request_id", request_id)
|
||||
.field("reply", reply)
|
||||
.finish(),
|
||||
Self::Shutdown => formatter.write_str("Shutdown"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Events produced by a terminal byte-stream transport.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum TerminalTransportEvent {
|
||||
Started,
|
||||
Output(Vec<u8>),
|
||||
Prompt(TerminalPrompt),
|
||||
Exited { code: u32, signal: Option<String> },
|
||||
Failed(String),
|
||||
}
|
||||
@@ -38,6 +271,33 @@ pub trait TerminalTransportFactory: Send + Sync + 'static {
|
||||
fn create(&self) -> Box<dyn TerminalTransport>;
|
||||
}
|
||||
|
||||
/// Creates a fresh remote factory from the latest saved session. Keeping this
|
||||
/// boundary at the workspace makes reconnects pick up edits immediately and
|
||||
/// lets UI tests inject a deterministic fake.
|
||||
pub trait RemoteTerminalTransportProvider: Send + Sync + 'static {
|
||||
fn factory_for(&self, session: &Session) -> SharedTerminalTransportFactory;
|
||||
}
|
||||
|
||||
pub type SharedRemoteTerminalTransportProvider = Arc<dyn RemoteTerminalTransportProvider>;
|
||||
|
||||
/// Provider useful for tests and embedders that want every session to use the
|
||||
/// same factory.
|
||||
pub struct FixedRemoteTerminalTransportProvider {
|
||||
factory: SharedTerminalTransportFactory,
|
||||
}
|
||||
|
||||
impl FixedRemoteTerminalTransportProvider {
|
||||
pub fn new(factory: SharedTerminalTransportFactory) -> Self {
|
||||
Self { factory }
|
||||
}
|
||||
}
|
||||
|
||||
impl RemoteTerminalTransportProvider for FixedRemoteTerminalTransportProvider {
|
||||
fn factory_for(&self, _: &Session) -> SharedTerminalTransportFactory {
|
||||
self.factory.clone()
|
||||
}
|
||||
}
|
||||
|
||||
pub type SharedTerminalTransportFactory = Arc<dyn TerminalTransportFactory>;
|
||||
|
||||
pub(crate) fn send_event(
|
||||
|
||||
+587
-78
@@ -1,17 +1,28 @@
|
||||
use std::{collections::HashMap, rc::Rc, sync::Arc, time::Duration};
|
||||
use std::{
|
||||
collections::{HashMap, VecDeque},
|
||||
rc::Rc,
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use gpui_kit::component::{
|
||||
ActiveTheme as _, Root, Theme, ThemeMode, TitleBar, WindowExt as _,
|
||||
ActiveTheme as _, Root, Sizable as _, Theme, ThemeMode, TitleBar, WindowExt as _,
|
||||
button::{Button, ButtonVariant, ButtonVariants as _},
|
||||
dialog::{DialogAction, DialogButtonProps, DialogClose, DialogFooter},
|
||||
dock::{DockArea, DockEvent, DockLayout, DockPlacement, PanelId, TabGroup, panel_handle},
|
||||
form::{Field, Form},
|
||||
input::{Input, InputContentType, InputState},
|
||||
notification::Notification,
|
||||
v_flex,
|
||||
};
|
||||
use gpui_kit::prelude::FluentBuilder as _;
|
||||
use gpui_kit::*;
|
||||
|
||||
use crate::app::{
|
||||
CloseActiveTab, CloseExplorer, CloseLocalTerminal, CloseTerminal, ConnectSession, CopyTerminal,
|
||||
DeleteGroup, DeleteSession, DisconnectSession, DuplicateSession, EditSession, FocusSearch,
|
||||
NewChildGroup, NewGroup, NewLocalTerminal, NewSession, NewSessionInGroup, OpenExplorer,
|
||||
PasteTerminal, ReconnectSession, RenameGroup, RestartLocalTerminal, ToggleSessionPanel,
|
||||
PasteTerminal, ReconnectTerminal, RenameGroup, RestartLocalTerminal, ToggleSessionPanel,
|
||||
ToggleTheme, ZoomIn, ZoomOut, ZoomReset,
|
||||
};
|
||||
use crate::explorer::{ExplorerPanel, ExplorerPanelEvent};
|
||||
@@ -21,7 +32,9 @@ use crate::session::{
|
||||
};
|
||||
use crate::terminal::{
|
||||
LocalPtyTransportFactory, LocalTerminalId, LocalTerminalPanel, LocalTerminalPanelEvent,
|
||||
SharedTerminalTransportFactory, TerminalPanel, TerminalPanelEvent,
|
||||
RemoteTerminalId, SharedRemoteTerminalTransportProvider, SharedTerminalTransportFactory,
|
||||
TerminalLifecycle, TerminalPanel, TerminalPanelEvent, TerminalPrompt, TerminalPromptField,
|
||||
TerminalPromptKind, TerminalPromptReply, TerminalSecret,
|
||||
};
|
||||
|
||||
use super::{
|
||||
@@ -53,7 +66,7 @@ pub fn window_options(cx: &mut App) -> WindowOptions {
|
||||
/// A center tab, by the session it belongs to.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum CenterTab {
|
||||
Terminal(SessionId),
|
||||
Terminal(RemoteTerminalId),
|
||||
Explorer(SessionId),
|
||||
LocalTerminal(LocalTerminalId),
|
||||
}
|
||||
@@ -69,13 +82,17 @@ pub struct Workspace {
|
||||
session_panel: Entity<SessionPanel>,
|
||||
/// The start page the dock skin shows while the center has no tab.
|
||||
recent: Entity<RecentSessions>,
|
||||
terminals: HashMap<SessionId, Entity<TerminalPanel>>,
|
||||
terminals: HashMap<RemoteTerminalId, Entity<TerminalPanel>>,
|
||||
explorers: HashMap<SessionId, Entity<ExplorerPanel>>,
|
||||
local_terminals: HashMap<LocalTerminalId, Entity<LocalTerminalPanel>>,
|
||||
local_terminal_factory: SharedTerminalTransportFactory,
|
||||
remote_terminal_provider: SharedRemoteTerminalTransportProvider,
|
||||
next_remote_terminal_id: u64,
|
||||
next_local_terminal_id: u64,
|
||||
/// The center tab displayed most recently; `CloseActiveTab` closes it.
|
||||
active_tab: Option<CenterTab>,
|
||||
prompt_queue: VecDeque<(RemoteTerminalId, SessionId, TerminalPrompt)>,
|
||||
active_prompt: Option<(RemoteTerminalId, SessionId, u64)>,
|
||||
/// Dispatch target for the title bar and start page: actions sent to it
|
||||
/// reach the workspace handlers whatever is focused.
|
||||
focus_handle: FocusHandle,
|
||||
@@ -86,7 +103,16 @@ impl Workspace {
|
||||
/// `store` is built by `main` from the database on disk, and by the UI
|
||||
/// tests from `SessionStore::seed`.
|
||||
pub fn new(store: Entity<SessionStore>, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
Self::new_with_local_terminal_factory(store, Arc::new(LocalPtyTransportFactory), window, cx)
|
||||
let known_hosts = crate::app::known_hosts_path()
|
||||
.unwrap_or_else(|_| crate::app::data_dir().join("known_hosts"));
|
||||
let remote = Arc::new(crate::ssh::SshTerminalTransportProvider::new(known_hosts));
|
||||
Self::new_with_transport_providers(
|
||||
store,
|
||||
remote,
|
||||
Arc::new(LocalPtyTransportFactory),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
}
|
||||
|
||||
/// Alternate constructor used by UI tests to avoid launching a real
|
||||
@@ -96,6 +122,21 @@ impl Workspace {
|
||||
local_terminal_factory: SharedTerminalTransportFactory,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let known_hosts = crate::app::known_hosts_path()
|
||||
.unwrap_or_else(|_| crate::app::data_dir().join("known_hosts"));
|
||||
let remote = Arc::new(crate::ssh::SshTerminalTransportProvider::new(known_hosts));
|
||||
Self::new_with_transport_providers(store, remote, local_terminal_factory, window, cx)
|
||||
}
|
||||
|
||||
/// Fully injectable constructor used by UI tests: remote sessions never
|
||||
/// touch the network, while production uses the SSH provider above.
|
||||
pub fn new_with_transport_providers(
|
||||
store: Entity<SessionStore>,
|
||||
remote_terminal_provider: SharedRemoteTerminalTransportProvider,
|
||||
local_terminal_factory: SharedTerminalTransportFactory,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let focus_handle = cx.focus_handle();
|
||||
let recent = cx.new(|cx| RecentSessions::new(store.clone(), focus_handle.clone(), cx));
|
||||
@@ -119,9 +160,27 @@ impl Workspace {
|
||||
cx.subscribe_in(
|
||||
&store,
|
||||
window,
|
||||
|_, _, event: &SessionStoreEvent, window, cx| {
|
||||
let SessionStoreEvent::PersistFailed(message) = event;
|
||||
window.push_notification(Notification::error(message.clone()), cx);
|
||||
|this, _, event: &SessionStoreEvent, window, cx| match event {
|
||||
SessionStoreEvent::PersistFailed(message) => {
|
||||
window.push_notification(Notification::error(message.clone()), cx);
|
||||
}
|
||||
SessionStoreEvent::ConnectionSettingsChanged(id) => {
|
||||
let panels: Vec<_> = this
|
||||
.terminals
|
||||
.values()
|
||||
.filter(|panel| panel.read(cx).session_id() == *id)
|
||||
.cloned()
|
||||
.collect();
|
||||
if !panels.is_empty() {
|
||||
this.cancel_prompts_for_session(*id, window, cx);
|
||||
this.store.update(cx, |store, cx| {
|
||||
store.set_state(*id, ConnectionState::Connecting, cx)
|
||||
});
|
||||
for panel in panels {
|
||||
panel.update(cx, |panel, cx| panel.reconnect(window, cx));
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
cx.subscribe_in(
|
||||
@@ -145,11 +204,21 @@ impl Workspace {
|
||||
.collect();
|
||||
let mut terminals = HashMap::new();
|
||||
let mut center = DockLayout::tabs();
|
||||
let mut next_remote_terminal_id = 1;
|
||||
for id in connected {
|
||||
let (panel, subscription) = new_terminal_panel(&store, id, window, cx);
|
||||
let terminal_id = RemoteTerminalId(next_remote_terminal_id);
|
||||
next_remote_terminal_id += 1;
|
||||
let (panel, subscription) = new_terminal_panel(
|
||||
&store,
|
||||
remote_terminal_provider.clone(),
|
||||
terminal_id,
|
||||
id,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
center = center.panel_view(panel_handle(panel.clone()), cx);
|
||||
subscriptions.push(subscription);
|
||||
terminals.insert(id, panel);
|
||||
terminals.insert(terminal_id, panel);
|
||||
}
|
||||
|
||||
dock_area.update(cx, |area, cx| {
|
||||
@@ -182,8 +251,12 @@ impl Workspace {
|
||||
explorers: HashMap::new(),
|
||||
local_terminals: HashMap::new(),
|
||||
local_terminal_factory,
|
||||
remote_terminal_provider,
|
||||
next_remote_terminal_id,
|
||||
next_local_terminal_id: 1,
|
||||
active_tab: None,
|
||||
prompt_queue: VecDeque::new(),
|
||||
active_prompt: None,
|
||||
focus_handle,
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
@@ -194,10 +267,24 @@ impl Workspace {
|
||||
&self.store
|
||||
}
|
||||
|
||||
pub fn terminal(&self, id: SessionId) -> Option<&Entity<TerminalPanel>> {
|
||||
pub fn terminal(&self, id: SessionId, cx: &App) -> Option<&Entity<TerminalPanel>> {
|
||||
self.terminals
|
||||
.values()
|
||||
.filter(|panel| panel.read(cx).session_id() == id)
|
||||
.max_by_key(|panel| panel.read(cx).id().0)
|
||||
}
|
||||
|
||||
pub fn remote_terminal(&self, id: RemoteTerminalId) -> Option<&Entity<TerminalPanel>> {
|
||||
self.terminals.get(&id)
|
||||
}
|
||||
|
||||
pub fn terminal_count(&self, id: SessionId, cx: &App) -> usize {
|
||||
self.terminals
|
||||
.values()
|
||||
.filter(|panel| panel.read(cx).session_id() == id)
|
||||
.count()
|
||||
}
|
||||
|
||||
pub fn explorer(&self, id: SessionId) -> Option<&Entity<ExplorerPanel>> {
|
||||
self.explorers.get(&id)
|
||||
}
|
||||
@@ -219,6 +306,308 @@ impl Workspace {
|
||||
}
|
||||
}
|
||||
|
||||
fn enqueue_prompt(
|
||||
&mut self,
|
||||
terminal_id: RemoteTerminalId,
|
||||
session_id: SessionId,
|
||||
prompt: TerminalPrompt,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.prompt_queue
|
||||
.push_back((terminal_id, session_id, prompt));
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
loop {
|
||||
cx.background_executor()
|
||||
.timer(Duration::from_millis(40))
|
||||
.await;
|
||||
let done = this
|
||||
.update_in(cx, |this, window, cx| {
|
||||
if this.prompt_queue.is_empty() {
|
||||
return true;
|
||||
}
|
||||
if this.active_prompt.is_some() || window.has_active_dialog(cx) {
|
||||
return false;
|
||||
}
|
||||
this.open_next_prompt(window, cx);
|
||||
true
|
||||
})
|
||||
.unwrap_or(true);
|
||||
if done {
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn open_next_prompt(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let (terminal_id, session_id, prompt) = loop {
|
||||
let Some(next) = self.prompt_queue.pop_front() else {
|
||||
return;
|
||||
};
|
||||
if self.terminals.contains_key(&next.0) {
|
||||
break next;
|
||||
}
|
||||
};
|
||||
let request_id = prompt.request_id();
|
||||
self.active_prompt = Some((terminal_id, session_id, request_id));
|
||||
let workspace = cx.entity().downgrade();
|
||||
match prompt.kind().clone() {
|
||||
TerminalPromptKind::UnknownHost(prompt) => {
|
||||
let description = format!(
|
||||
"主机:{}:{}\n算法:{}\nSHA-256 指纹:{}\n\n请先确认该指纹来自可信渠道。",
|
||||
prompt.host(),
|
||||
prompt.port(),
|
||||
prompt.algorithm(),
|
||||
prompt.fingerprint(),
|
||||
);
|
||||
window.open_alert_dialog(cx, move |alert, _, _| {
|
||||
alert
|
||||
.title("首次连接此主机")
|
||||
.description(description.clone())
|
||||
.button_props(
|
||||
DialogButtonProps::default()
|
||||
.ok_text("信任并连接")
|
||||
.cancel_text("取消"),
|
||||
)
|
||||
.show_cancel(true)
|
||||
.on_ok({
|
||||
let workspace = workspace.clone();
|
||||
move |_, _, cx| {
|
||||
resolve_prompt(
|
||||
&workspace,
|
||||
terminal_id,
|
||||
request_id,
|
||||
TerminalPromptReply::TrustAndSave,
|
||||
cx,
|
||||
);
|
||||
true
|
||||
}
|
||||
})
|
||||
.on_cancel({
|
||||
let workspace = workspace.clone();
|
||||
move |_, _, cx| {
|
||||
resolve_prompt(
|
||||
&workspace,
|
||||
terminal_id,
|
||||
request_id,
|
||||
TerminalPromptReply::Cancel,
|
||||
cx,
|
||||
);
|
||||
true
|
||||
}
|
||||
})
|
||||
.on_close({
|
||||
let workspace = workspace.clone();
|
||||
move |_, _, cx| {
|
||||
resolve_prompt(
|
||||
&workspace,
|
||||
terminal_id,
|
||||
request_id,
|
||||
TerminalPromptReply::Cancel,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
TerminalPromptKind::HostKeyChanged(prompt) => {
|
||||
let old = prompt.old_fingerprints().join("、");
|
||||
let description = format!(
|
||||
"主机:{}:{}\n算法:{}\n已保存指纹:{old}\n服务器当前指纹:{}\n\n连接已阻断。请核验服务器身份后手动处理:{}",
|
||||
prompt.host(),
|
||||
prompt.port(),
|
||||
prompt.algorithm(),
|
||||
prompt.fingerprint(),
|
||||
prompt.known_hosts_path().display(),
|
||||
);
|
||||
window.open_alert_dialog(cx, move |alert, _, _| {
|
||||
alert
|
||||
.title("服务器主机密钥已变更")
|
||||
.description(description.clone())
|
||||
.button_props(
|
||||
DialogButtonProps::default()
|
||||
.ok_text("关闭")
|
||||
.ok_variant(ButtonVariant::Danger),
|
||||
)
|
||||
.on_ok({
|
||||
let workspace = workspace.clone();
|
||||
move |_, _, cx| {
|
||||
resolve_prompt(
|
||||
&workspace,
|
||||
terminal_id,
|
||||
request_id,
|
||||
TerminalPromptReply::Cancel,
|
||||
cx,
|
||||
);
|
||||
true
|
||||
}
|
||||
})
|
||||
.on_close({
|
||||
let workspace = workspace.clone();
|
||||
move |_, _, cx| {
|
||||
resolve_prompt(
|
||||
&workspace,
|
||||
terminal_id,
|
||||
request_id,
|
||||
TerminalPromptReply::Cancel,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
TerminalPromptKind::Authentication(prompt) => {
|
||||
let title = prompt.title().to_string();
|
||||
let instructions = prompt.instructions().to_string();
|
||||
let fields = prompt.fields().to_vec();
|
||||
let dialog_title: SharedString = if title.trim().is_empty() {
|
||||
"SSH 认证".into()
|
||||
} else {
|
||||
title.clone().into()
|
||||
};
|
||||
let form = cx.new(|cx| AuthenticationPromptForm::new(fields, window, cx));
|
||||
window.open_dialog(cx, {
|
||||
let workspace_for_ok = workspace.clone();
|
||||
let workspace_for_cancel = workspace.clone();
|
||||
let form_for_ok = form.clone();
|
||||
move |dialog, _, _| {
|
||||
dialog
|
||||
.title(dialog_title.clone())
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.when(!instructions.trim().is_empty(), |view| {
|
||||
view.child(div().text_sm().child(instructions.clone()))
|
||||
})
|
||||
.child(form.clone()),
|
||||
)
|
||||
.footer(
|
||||
DialogFooter::new()
|
||||
.child(
|
||||
DialogClose::new().trigger(|button| button.label("取消")),
|
||||
)
|
||||
.child(DialogAction::new().child(
|
||||
Button::new("ssh-auth-submit").primary().label("继续"),
|
||||
)),
|
||||
)
|
||||
.on_ok({
|
||||
let workspace = workspace_for_ok.clone();
|
||||
let form = form_for_ok.clone();
|
||||
move |_, window, cx| {
|
||||
let answers =
|
||||
form.update(cx, |form, cx| form.take_answers(window, cx));
|
||||
resolve_prompt(
|
||||
&workspace,
|
||||
terminal_id,
|
||||
request_id,
|
||||
TerminalPromptReply::Answers(answers),
|
||||
cx,
|
||||
);
|
||||
true
|
||||
}
|
||||
})
|
||||
.on_cancel({
|
||||
let workspace = workspace_for_cancel.clone();
|
||||
move |_, _, cx| {
|
||||
resolve_prompt(
|
||||
&workspace,
|
||||
terminal_id,
|
||||
request_id,
|
||||
TerminalPromptReply::Cancel,
|
||||
cx,
|
||||
);
|
||||
true
|
||||
}
|
||||
})
|
||||
.on_close({
|
||||
let workspace = workspace.clone();
|
||||
move |_, _, cx| {
|
||||
resolve_prompt(
|
||||
&workspace,
|
||||
terminal_id,
|
||||
request_id,
|
||||
TerminalPromptReply::Cancel,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
})
|
||||
.overlay_closable(false)
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn finish_prompt(
|
||||
&mut self,
|
||||
terminal_id: RemoteTerminalId,
|
||||
request_id: u64,
|
||||
reply: TerminalPromptReply,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some((active_terminal, session_id, active_request)) = self.active_prompt else {
|
||||
return;
|
||||
};
|
||||
if (active_terminal, active_request) != (terminal_id, request_id) {
|
||||
return;
|
||||
}
|
||||
self.active_prompt = None;
|
||||
let canceled = matches!(&reply, TerminalPromptReply::Cancel);
|
||||
if let Some(panel) = self.terminals.get(&terminal_id).cloned() {
|
||||
panel.read(cx).reply_to_prompt(request_id, reply, cx);
|
||||
if canceled {
|
||||
panel.update(cx, |panel, cx| panel.cancel_connection(cx));
|
||||
self.refresh_session_connection_state(session_id, cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cancel_prompts_for_terminal(
|
||||
&mut self,
|
||||
terminal_id: RemoteTerminalId,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.prompt_queue.retain(|(id, _, _)| *id != terminal_id);
|
||||
if let Some((id, _, request_id)) = self.active_prompt
|
||||
&& id == terminal_id
|
||||
{
|
||||
self.active_prompt = None;
|
||||
if let Some(panel) = self.terminals.get(&terminal_id) {
|
||||
panel
|
||||
.read(cx)
|
||||
.reply_to_prompt(request_id, TerminalPromptReply::Cancel, cx);
|
||||
}
|
||||
if window.has_active_dialog(cx) {
|
||||
window.close_dialog(cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cancel_prompts_for_session(
|
||||
&mut self,
|
||||
session_id: SessionId,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.prompt_queue.retain(|(_, id, _)| *id != session_id);
|
||||
if let Some((terminal_id, id, request_id)) = self.active_prompt
|
||||
&& id == session_id
|
||||
{
|
||||
self.active_prompt = None;
|
||||
if let Some(panel) = self.terminals.get(&terminal_id) {
|
||||
panel
|
||||
.read(cx)
|
||||
.reply_to_prompt(request_id, TerminalPromptReply::Cancel, cx);
|
||||
}
|
||||
if window.has_active_dialog(cx) {
|
||||
window.close_dialog(cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Select the tab of an already open panel.
|
||||
fn activate_tab(
|
||||
&self,
|
||||
@@ -242,10 +631,31 @@ impl Workspace {
|
||||
});
|
||||
}
|
||||
|
||||
fn mark_connected(&mut self, id: SessionId, cx: &mut Context<Self>) {
|
||||
self.store.update(cx, |store, cx| {
|
||||
store.set_state(id, ConnectionState::Connected, cx);
|
||||
});
|
||||
fn refresh_session_connection_state(&mut self, session_id: SessionId, cx: &mut Context<Self>) {
|
||||
let mut has_starting = false;
|
||||
let mut has_running = false;
|
||||
for panel in self
|
||||
.terminals
|
||||
.values()
|
||||
.filter(|panel| panel.read(cx).session_id() == session_id)
|
||||
{
|
||||
match panel.read(cx).lifecycle(cx) {
|
||||
TerminalLifecycle::Running => has_running = true,
|
||||
TerminalLifecycle::Starting => has_starting = true,
|
||||
TerminalLifecycle::Exited { .. }
|
||||
| TerminalLifecycle::Failed(_)
|
||||
| TerminalLifecycle::Closing => {}
|
||||
}
|
||||
}
|
||||
let state = if has_running {
|
||||
ConnectionState::Connected
|
||||
} else if has_starting {
|
||||
ConnectionState::Connecting
|
||||
} else {
|
||||
ConnectionState::Disconnected
|
||||
};
|
||||
self.store
|
||||
.update(cx, |store, cx| store.set_state(session_id, state, cx));
|
||||
}
|
||||
|
||||
fn on_connect_session(
|
||||
@@ -254,19 +664,23 @@ impl Workspace {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let id = action.0;
|
||||
if let Some(panel) = self.terminals.get(&id).cloned() {
|
||||
let group = panel.read(cx).tab_group();
|
||||
self.activate_tab(group, panel.entity_id(), window, cx);
|
||||
let session_id = action.0;
|
||||
if self.store.read(cx).session(session_id).is_none() {
|
||||
return;
|
||||
}
|
||||
if self.store.read(cx).session(id).is_none() {
|
||||
return;
|
||||
}
|
||||
self.mark_connected(id, cx);
|
||||
let (panel, subscription) = new_terminal_panel(&self.store, id, window, cx);
|
||||
let terminal_id = RemoteTerminalId(self.next_remote_terminal_id);
|
||||
self.next_remote_terminal_id += 1;
|
||||
let (panel, subscription) = new_terminal_panel(
|
||||
&self.store,
|
||||
self.remote_terminal_provider.clone(),
|
||||
terminal_id,
|
||||
session_id,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
self._subscriptions.push(subscription);
|
||||
self.terminals.insert(id, panel.clone());
|
||||
self.terminals.insert(terminal_id, panel.clone());
|
||||
self.refresh_session_connection_state(session_id, cx);
|
||||
self.dock_area.update(cx, |area, cx| {
|
||||
area.add_panel_view(panel_handle(panel), DockPlacement::Center, None, window, cx);
|
||||
});
|
||||
@@ -287,7 +701,6 @@ impl Workspace {
|
||||
if self.store.read(cx).session(id).is_none() {
|
||||
return;
|
||||
}
|
||||
self.mark_connected(id, cx);
|
||||
let (panel, subscription) = new_explorer_panel(&self.store, id, window, cx);
|
||||
self._subscriptions.push(subscription);
|
||||
self.explorers.insert(id, panel.clone());
|
||||
@@ -416,48 +829,38 @@ impl Workspace {
|
||||
fn on_disconnect_session(
|
||||
&mut self,
|
||||
action: &DisconnectSession,
|
||||
_: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let id = action.0;
|
||||
self.store.update(cx, |store, cx| {
|
||||
store.set_state(id, ConnectionState::Disconnected, cx);
|
||||
});
|
||||
if let Some(terminal) = self.terminals.get(&id) {
|
||||
terminal.update(cx, |terminal, cx| terminal.disconnect(cx));
|
||||
}
|
||||
}
|
||||
|
||||
fn on_reconnect_session(
|
||||
&mut self,
|
||||
action: &ReconnectSession,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let id = action.0;
|
||||
if self.store.read(cx).session(id).is_none() {
|
||||
return;
|
||||
}
|
||||
self.cancel_prompts_for_session(id, window, cx);
|
||||
self.store.update(cx, |store, cx| {
|
||||
store.set_state(id, ConnectionState::Connecting, cx);
|
||||
store.set_state(id, ConnectionState::Disconnected, cx);
|
||||
});
|
||||
if let Some(terminal) = self.terminals.get(&id) {
|
||||
terminal.update(cx, |terminal, cx| terminal.reconnect(window, cx));
|
||||
let terminals: Vec<_> = self
|
||||
.terminals
|
||||
.values()
|
||||
.filter(|panel| panel.read(cx).session_id() == id)
|
||||
.cloned()
|
||||
.collect();
|
||||
for terminal in terminals {
|
||||
terminal.update(cx, |terminal, cx| terminal.disconnect(cx));
|
||||
}
|
||||
}
|
||||
|
||||
fn on_reconnect_terminal(
|
||||
&mut self,
|
||||
action: &ReconnectTerminal,
|
||||
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.reconnect(window, cx));
|
||||
self.refresh_session_connection_state(session_id, cx);
|
||||
}
|
||||
// The only asynchronous work in the mock: a short "connecting" state.
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
cx.background_executor()
|
||||
.timer(Duration::from_millis(600))
|
||||
.await;
|
||||
this.update_in(cx, |this, _, cx| {
|
||||
this.mark_connected(id, cx);
|
||||
if let Some(terminal) = this.terminals.get(&id) {
|
||||
terminal.update(cx, |terminal, cx| terminal.append_line("已重新连接", cx));
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn on_new_session(&mut self, _: &NewSession, window: &mut Window, cx: &mut Context<Self>) {
|
||||
@@ -500,7 +903,11 @@ impl Workspace {
|
||||
let Some(session) = self.store.read(cx).session(id).cloned() else {
|
||||
return;
|
||||
};
|
||||
let closes_tabs = self.terminals.contains_key(&id) || self.explorers.contains_key(&id);
|
||||
let closes_tabs = self
|
||||
.terminals
|
||||
.values()
|
||||
.any(|panel| panel.read(cx).session_id() == id)
|
||||
|| self.explorers.contains_key(&id);
|
||||
let workspace = cx.entity().downgrade();
|
||||
confirm_delete_session(
|
||||
&session,
|
||||
@@ -526,7 +933,14 @@ impl Workspace {
|
||||
/// alone. Deleting a session and deleting the group around it both need
|
||||
/// this, the latter for every session in the subtree.
|
||||
fn close_session_tabs(&mut self, id: SessionId, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if let Some(terminal) = self.terminals.remove(&id) {
|
||||
self.cancel_prompts_for_session(id, window, cx);
|
||||
let terminals: Vec<_> = self
|
||||
.terminals
|
||||
.values()
|
||||
.filter(|panel| panel.read(cx).session_id() == id)
|
||||
.cloned()
|
||||
.collect();
|
||||
for terminal in terminals {
|
||||
self.dock_area
|
||||
.update(cx, |area, cx| area.remove_panel(terminal, window, cx));
|
||||
}
|
||||
@@ -585,9 +999,12 @@ impl Workspace {
|
||||
let name = group.name.to_string();
|
||||
let subgroups = store.descendant_groups(id).len();
|
||||
let doomed = store.sessions_under(id);
|
||||
let closes_tabs = doomed
|
||||
.iter()
|
||||
.any(|id| self.terminals.contains_key(id) || self.explorers.contains_key(id));
|
||||
let closes_tabs = doomed.iter().any(|id| {
|
||||
self.terminals
|
||||
.values()
|
||||
.any(|panel| panel.read(cx).session_id() == *id)
|
||||
|| self.explorers.contains_key(id)
|
||||
});
|
||||
let workspace = cx.entity().downgrade();
|
||||
confirm_delete_group(
|
||||
&name,
|
||||
@@ -670,27 +1087,119 @@ impl Workspace {
|
||||
|
||||
fn new_terminal_panel(
|
||||
store: &Entity<SessionStore>,
|
||||
id: SessionId,
|
||||
remote_provider: SharedRemoteTerminalTransportProvider,
|
||||
terminal_id: RemoteTerminalId,
|
||||
session_id: SessionId,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Workspace>,
|
||||
) -> (Entity<TerminalPanel>, Subscription) {
|
||||
let panel = cx.new(|cx| TerminalPanel::new(id, store.clone(), window, cx));
|
||||
let subscription = cx.subscribe(
|
||||
let panel = cx.new(|cx| {
|
||||
TerminalPanel::new(
|
||||
terminal_id,
|
||||
session_id,
|
||||
store.clone(),
|
||||
remote_provider,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
let subscription = cx.subscribe_in(
|
||||
&panel,
|
||||
|this, _, event: &TerminalPanelEvent, cx| match event {
|
||||
TerminalPanelEvent::Activated(id) => this.active_tab = Some(CenterTab::Terminal(*id)),
|
||||
TerminalPanelEvent::Closed(id) => {
|
||||
this.terminals.remove(id);
|
||||
if this.active_tab == Some(CenterTab::Terminal(*id)) {
|
||||
window,
|
||||
|this, _, event: &TerminalPanelEvent, window, cx| match event {
|
||||
TerminalPanelEvent::Activated(terminal_id, _) => {
|
||||
this.active_tab = Some(CenterTab::Terminal(*terminal_id))
|
||||
}
|
||||
TerminalPanelEvent::Closed(terminal_id, session_id) => {
|
||||
this.cancel_prompts_for_terminal(*terminal_id, window, cx);
|
||||
this.terminals.remove(terminal_id);
|
||||
if this.active_tab == Some(CenterTab::Terminal(*terminal_id)) {
|
||||
this.active_tab = None;
|
||||
}
|
||||
this.refresh_session_connection_state(*session_id, cx);
|
||||
if !this
|
||||
.terminals
|
||||
.values()
|
||||
.any(|panel| panel.read(cx).session_id() == *session_id)
|
||||
&& !this.explorers.contains_key(session_id)
|
||||
&& this.store.read(cx).active().map(|session| session.id) == Some(*session_id)
|
||||
{
|
||||
this.store
|
||||
.update(cx, |store, cx| store.set_active(None, cx));
|
||||
}
|
||||
}
|
||||
TerminalPanelEvent::StatusChanged(_, session_id) => {
|
||||
this.refresh_session_connection_state(*session_id, cx);
|
||||
cx.notify();
|
||||
}
|
||||
TerminalPanelEvent::PromptRequested(terminal_id, session_id, prompt) => {
|
||||
this.enqueue_prompt(*terminal_id, *session_id, prompt.clone(), window, cx)
|
||||
}
|
||||
TerminalPanelEvent::StatusChanged(_) => cx.notify(),
|
||||
},
|
||||
);
|
||||
(panel, subscription)
|
||||
}
|
||||
|
||||
fn resolve_prompt(
|
||||
workspace: &WeakEntity<Workspace>,
|
||||
terminal_id: RemoteTerminalId,
|
||||
request_id: u64,
|
||||
reply: TerminalPromptReply,
|
||||
cx: &mut App,
|
||||
) {
|
||||
workspace
|
||||
.update(cx, |workspace, cx| {
|
||||
workspace.finish_prompt(terminal_id, request_id, reply, cx)
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
struct AuthenticationPromptForm {
|
||||
fields: Vec<(TerminalPromptField, Entity<InputState>)>,
|
||||
}
|
||||
|
||||
impl AuthenticationPromptForm {
|
||||
fn new(fields: Vec<TerminalPromptField>, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let fields = fields
|
||||
.into_iter()
|
||||
.map(|field| {
|
||||
let echo = field.echo();
|
||||
let input = cx.new(|cx| InputState::new(window, cx).masked(!echo));
|
||||
(field, input)
|
||||
})
|
||||
.collect();
|
||||
Self { fields }
|
||||
}
|
||||
|
||||
fn take_answers(&mut self, window: &mut Window, cx: &mut Context<Self>) -> Vec<TerminalSecret> {
|
||||
self.fields
|
||||
.iter()
|
||||
.map(|(_, input)| {
|
||||
let answer = TerminalSecret::new(input.read(cx).value().to_string());
|
||||
input.update(cx, |input, cx| input.set_value("", window, cx));
|
||||
answer
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for AuthenticationPromptForm {
|
||||
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
||||
Form::new().children(
|
||||
self.fields
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, (field, input))| {
|
||||
let mut input = Input::new(input).id(("ssh-auth-answer", index)).small();
|
||||
if !field.echo() {
|
||||
input = input.content_type(InputContentType::Password).mask_toggle();
|
||||
}
|
||||
Field::new().label(field.label().to_string()).child(input)
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn new_explorer_panel(
|
||||
store: &Entity<SessionStore>,
|
||||
id: SessionId,
|
||||
@@ -772,7 +1281,7 @@ impl Render for Workspace {
|
||||
.on_action(cx.listener(Self::on_delete_group))
|
||||
.on_action(cx.listener(Self::on_connect_session))
|
||||
.on_action(cx.listener(Self::on_disconnect_session))
|
||||
.on_action(cx.listener(Self::on_reconnect_session))
|
||||
.on_action(cx.listener(Self::on_reconnect_terminal))
|
||||
.on_action(cx.listener(Self::on_open_explorer))
|
||||
.on_action(cx.listener(Self::on_close_terminal))
|
||||
.on_action(cx.listener(Self::on_close_explorer))
|
||||
|
||||
+622
-38
@@ -12,11 +12,12 @@ use gpui_kit::{
|
||||
MouseMoveEvent, TestAppContext, WindowHandle, point, px, size,
|
||||
};
|
||||
|
||||
use shellr::app::{DeleteGroup, NewSessionInGroup, RenameGroup};
|
||||
use shellr::session::{GroupId, SessionDatabase, SessionId, SessionStore};
|
||||
use shellr::app::{ConnectSession, DeleteGroup, NewSessionInGroup, OpenExplorer, RenameGroup};
|
||||
use shellr::session::{AuthKind, GroupId, SessionDatabase, SessionDraft, SessionId, SessionStore};
|
||||
use shellr::terminal::{
|
||||
LocalTerminalId, TerminalLifecycle, TerminalSize, TerminalTransport, TerminalTransportCommand,
|
||||
TerminalTransportEvent, TerminalTransportFactory,
|
||||
FixedRemoteTerminalTransportProvider, LocalTerminalId, RemoteTerminalId, TerminalLifecycle,
|
||||
TerminalPrompt, TerminalPromptField, TerminalPromptKind, TerminalPromptReply, TerminalSize,
|
||||
TerminalTransport, TerminalTransportCommand, TerminalTransportEvent, TerminalTransportFactory,
|
||||
};
|
||||
use shellr::workspace::Workspace;
|
||||
|
||||
@@ -24,6 +25,9 @@ use shellr::workspace::Workspace;
|
||||
const WEB_01: u64 = 1;
|
||||
const DB_01: u64 = 3;
|
||||
const STAGING_API: u64 = 4;
|
||||
const INITIAL_WEB_TERMINAL: u64 = 1;
|
||||
const INITIAL_STAGING_TERMINAL: u64 = 2;
|
||||
const FIRST_NEW_TERMINAL: u64 = 3;
|
||||
/// Seeded group ids, in insertion order: 生产, 测试, 开发.
|
||||
const PRODUCTION: u64 = 1;
|
||||
const DEVELOPMENT: u64 = 3;
|
||||
@@ -42,7 +46,18 @@ fn open_workspace_with_store(
|
||||
let mut workspace = None;
|
||||
let handle = cx.open_window(size(px(1280.), px(800.)), |window, cx| {
|
||||
let store = cx.new(|_| store);
|
||||
let view = cx.new(|cx| Workspace::new(store, window, cx));
|
||||
let remote = Arc::new(FixedRemoteTerminalTransportProvider::new(Arc::new(
|
||||
FakeTerminalFactory::default(),
|
||||
)));
|
||||
let view = cx.new(|cx| {
|
||||
Workspace::new_with_transport_providers(
|
||||
store,
|
||||
remote,
|
||||
Arc::new(FakeTerminalFactory::default()),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
workspace = Some(view.clone());
|
||||
Root::new(view, window, cx)
|
||||
});
|
||||
@@ -91,6 +106,10 @@ impl FakeTerminalFactory {
|
||||
.collect();
|
||||
String::from_utf8(bytes).expect("fake transport receives UTF-8 test input")
|
||||
}
|
||||
|
||||
fn starts(&self) -> usize {
|
||||
self.starts.load(Ordering::SeqCst)
|
||||
}
|
||||
}
|
||||
|
||||
impl TerminalTransportFactory for FakeTerminalFactory {
|
||||
@@ -152,6 +171,7 @@ impl TerminalTransport for FakeTerminalTransport {
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
.push(size);
|
||||
}
|
||||
TerminalTransportCommand::PromptReply { .. } => {}
|
||||
TerminalTransportCommand::Shutdown => break,
|
||||
}
|
||||
}
|
||||
@@ -167,8 +187,11 @@ fn open_workspace_with_factory(
|
||||
let mut workspace = None;
|
||||
let handle = cx.open_window(size(px(1280.), px(800.)), |window, cx| {
|
||||
let store = cx.new(|_| SessionStore::seed());
|
||||
let remote = Arc::new(FixedRemoteTerminalTransportProvider::new(Arc::new(
|
||||
FakeTerminalFactory::default(),
|
||||
)));
|
||||
let view = cx.new(|cx| {
|
||||
Workspace::new_with_local_terminal_factory(store, factory.clone(), window, cx)
|
||||
Workspace::new_with_transport_providers(store, remote, factory.clone(), window, cx)
|
||||
});
|
||||
workspace = Some(view.clone());
|
||||
Root::new(view, window, cx)
|
||||
@@ -176,15 +199,437 @@ fn open_workspace_with_factory(
|
||||
(handle, workspace.expect("workspace created"))
|
||||
}
|
||||
|
||||
fn open_workspace_with_remote_factory(
|
||||
cx: &mut TestAppContext,
|
||||
store: SessionStore,
|
||||
factory: Arc<dyn TerminalTransportFactory>,
|
||||
) -> (WindowHandle<Root>, Entity<Workspace>) {
|
||||
cx.update(shellr::init);
|
||||
let mut workspace = None;
|
||||
let handle = cx.open_window(size(px(1280.), px(800.)), |window, cx| {
|
||||
let store = cx.new(|_| store);
|
||||
let remote = Arc::new(FixedRemoteTerminalTransportProvider::new(factory.clone()));
|
||||
let view = cx.new(|cx| {
|
||||
Workspace::new_with_transport_providers(
|
||||
store,
|
||||
remote,
|
||||
Arc::new(FakeTerminalFactory::default()),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
workspace = Some(view.clone());
|
||||
Root::new(view, window, cx)
|
||||
});
|
||||
(handle, workspace.expect("workspace created"))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum PromptBehavior {
|
||||
Authentication,
|
||||
UnknownHost,
|
||||
}
|
||||
|
||||
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 => TerminalPromptKind::authentication(
|
||||
"SSH 登录",
|
||||
"请输入密码",
|
||||
vec![TerminalPromptField::new("密码", false)],
|
||||
),
|
||||
PromptBehavior::UnknownHost => TerminalPromptKind::unknown_host(
|
||||
"example.test",
|
||||
22,
|
||||
"ssh-ed25519",
|
||||
"SHA256:test-fingerprint",
|
||||
),
|
||||
};
|
||||
events.send_blocking(TerminalTransportEvent::Prompt(TerminalPrompt::new(
|
||||
request_id, kind,
|
||||
)))?;
|
||||
loop {
|
||||
match commands.recv()? {
|
||||
TerminalTransportCommand::PromptReply {
|
||||
request_id: reply_id,
|
||||
reply,
|
||||
} if reply_id == request_id => match reply {
|
||||
TerminalPromptReply::TrustAndSave
|
||||
if matches!(self.behavior, PromptBehavior::UnknownHost) =>
|
||||
{
|
||||
self.accepted.fetch_add(1, Ordering::SeqCst);
|
||||
break;
|
||||
}
|
||||
TerminalPromptReply::Answers(answers)
|
||||
if matches!(self.behavior, PromptBehavior::Authentication)
|
||||
&& answers.len() == 1 =>
|
||||
{
|
||||
self.accepted.fetch_add(1, Ordering::SeqCst);
|
||||
break;
|
||||
}
|
||||
TerminalPromptReply::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(())
|
||||
}
|
||||
}
|
||||
|
||||
fn one_session_store(auth: AuthKind) -> (SessionStore, SessionId) {
|
||||
let mut store = SessionStore::empty();
|
||||
let id = store.insert_unnotified(SessionDraft::new(
|
||||
"prompt-host",
|
||||
"example.test",
|
||||
22,
|
||||
"tester",
|
||||
auth,
|
||||
None,
|
||||
));
|
||||
(store, id)
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
fn double_click_on_session_opens_terminal_and_updates_status(cx: &mut TestAppContext) {
|
||||
async fn session_form_switches_to_key_and_uses_native_path_picker(cx: &mut TestAppContext) {
|
||||
let (handle, _) = open_workspace_with_store(cx, SessionStore::empty());
|
||||
|
||||
cx.update_window(handle.into(), |_, window, cx| {
|
||||
window.render_frame(cx);
|
||||
window.click("new-session", cx);
|
||||
})
|
||||
.unwrap();
|
||||
cx.run_until_parked();
|
||||
|
||||
cx.update_window(handle.into(), |_, window, cx| {
|
||||
window.render_frame(cx);
|
||||
assert!(window.try_find("session-key-path").is_none());
|
||||
window.within("session-auth").click("input", cx);
|
||||
window.within("session-auth").press("down", cx);
|
||||
window.within("session-auth").press("down", cx);
|
||||
window.within("session-auth").press("enter", cx);
|
||||
})
|
||||
.unwrap();
|
||||
cx.run_until_parked();
|
||||
|
||||
cx.update_window(handle.into(), |_, window, cx| {
|
||||
window.render_frame(cx);
|
||||
assert!(window.find("session-key-path").visible());
|
||||
window.click("choose-key", cx);
|
||||
})
|
||||
.unwrap();
|
||||
assert!(cx.did_prompt_for_paths());
|
||||
cx.simulate_path_prompt_response(|options| {
|
||||
assert!(options.files);
|
||||
assert!(!options.directories);
|
||||
assert!(!options.multiple);
|
||||
Some(vec!["/tmp/id_ed25519".into()])
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
cx.update_window(handle.into(), |_, window, cx| {
|
||||
window.render_frame(cx);
|
||||
assert_eq!(
|
||||
window.find("session-key-path").value(),
|
||||
Some("/tmp/id_ed25519")
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
async fn authentication_prompt_is_masked_and_drives_connected_state(cx: &mut TestAppContext) {
|
||||
let (store, id) = one_session_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("session-tree")
|
||||
.double_click(("session-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);
|
||||
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")
|
||||
})
|
||||
.await;
|
||||
|
||||
assert_eq!(factory.accepted.load(Ordering::SeqCst), 1);
|
||||
assert!(workspace.read_with(cx, |workspace, cx| {
|
||||
workspace
|
||||
.store()
|
||||
.read(cx)
|
||||
.session(id)
|
||||
.unwrap()
|
||||
.state
|
||||
.is_connected()
|
||||
}));
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
async fn canceling_unknown_host_prompt_keeps_session_disconnected(cx: &mut TestAppContext) {
|
||||
let (store, id) = one_session_store(AuthKind::Auto);
|
||||
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("session-tree")
|
||||
.double_click(("session-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();
|
||||
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), 0);
|
||||
assert_eq!(factory.canceled.load(Ordering::SeqCst), 1);
|
||||
assert!(!workspace.read_with(cx, |workspace, cx| {
|
||||
workspace
|
||||
.store()
|
||||
.read(cx)
|
||||
.session(id)
|
||||
.unwrap()
|
||||
.state
|
||||
.is_connected()
|
||||
}));
|
||||
|
||||
// The failed terminal tab remains visible, but connecting the session
|
||||
// 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("session-tree")
|
||||
.double_click(("session-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(ConnectSession(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 connection_edits_reconnect_once_but_display_edits_do_not(cx: &mut TestAppContext) {
|
||||
let (store, id) = one_session_store(AuthKind::Auto);
|
||||
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("session-tree")
|
||||
.double_click(("session-row", id.0), cx);
|
||||
})
|
||||
.unwrap();
|
||||
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
|
||||
window.render_frame(cx);
|
||||
factory.starts() == 1
|
||||
&& window.find("status-connection").label() == Some("已连接 prompt-host")
|
||||
})
|
||||
.await;
|
||||
|
||||
cx.update(|cx| {
|
||||
let store = workspace.read(cx).store().clone();
|
||||
store.update(cx, |store, cx| {
|
||||
let mut draft = store.session(id).unwrap().draft();
|
||||
draft.host = "new.example.test".into();
|
||||
assert!(store.update(id, draft, cx));
|
||||
});
|
||||
});
|
||||
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
|
||||
window.render_frame(cx);
|
||||
factory.starts() == 2
|
||||
&& window.find("status-connection").label() == Some("已连接 prompt-host")
|
||||
})
|
||||
.await;
|
||||
assert_eq!(factory.starts(), 2);
|
||||
|
||||
cx.update(|cx| {
|
||||
let store = workspace.read(cx).store().clone();
|
||||
store.update(cx, |store, cx| {
|
||||
let mut draft = store.session(id).unwrap().draft();
|
||||
draft.name = "renamed".into();
|
||||
assert!(store.update(id, draft, cx));
|
||||
});
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert_eq!(factory.starts(), 2);
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
fn opening_mock_sftp_does_not_change_connection_state(cx: &mut TestAppContext) {
|
||||
let (store, id) = one_session_store(AuthKind::Auto);
|
||||
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.run_until_parked();
|
||||
|
||||
cx.update(|cx| {
|
||||
let workspace = workspace.read(cx);
|
||||
assert!(workspace.explorer(id).is_some());
|
||||
assert_eq!(
|
||||
workspace.store().read(cx).session(id).unwrap().state,
|
||||
shellr::session::ConnectionState::Disconnected
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
async fn double_click_on_session_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);
|
||||
// Sessions seeded as connected already have terminal tabs.
|
||||
assert!(window.find(("terminal", WEB_01)).visible());
|
||||
assert!(window.try_find(("terminal", DB_01)).is_none());
|
||||
assert!(window.find(("terminal", INITIAL_WEB_TERMINAL)).visible());
|
||||
assert!(window.try_find(("terminal", FIRST_NEW_TERMINAL)).is_none());
|
||||
|
||||
window
|
||||
.within("session-tree")
|
||||
@@ -193,9 +638,15 @@ fn double_click_on_session_opens_terminal_and_updates_status(cx: &mut TestAppCon
|
||||
.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", DB_01)).visible());
|
||||
assert!(window.find(("terminal", FIRST_NEW_TERMINAL)).visible());
|
||||
assert_eq!(
|
||||
window.find("status-connection").label(),
|
||||
Some("已连接 db-01")
|
||||
@@ -205,7 +656,7 @@ fn double_click_on_session_opens_terminal_and_updates_status(cx: &mut TestAppCon
|
||||
|
||||
cx.update(|cx| {
|
||||
let workspace = workspace.read(cx);
|
||||
assert!(workspace.terminal(SessionId(DB_01)).is_some());
|
||||
assert!(workspace.terminal(SessionId(DB_01), cx).is_some());
|
||||
let store = workspace.store().read(cx);
|
||||
assert!(
|
||||
store
|
||||
@@ -217,6 +668,99 @@ fn double_click_on_session_opens_terminal_and_updates_status(cx: &mut TestAppCon
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
async fn connected_session_opens_an_independent_terminal_each_time(cx: &mut TestAppContext) {
|
||||
let (store, id) = one_session_store(AuthKind::Auto);
|
||||
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("session-tree")
|
||||
.double_click(("session-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("session-tree")
|
||||
.double_click(("session-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(ConnectSession(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)
|
||||
.session(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)
|
||||
.session(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)
|
||||
.session(id)
|
||||
.unwrap()
|
||||
.state
|
||||
.is_connected()
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
async fn new_session_dialog_validates_then_inserts(cx: &mut TestAppContext) {
|
||||
let (handle, workspace) = open_workspace(cx);
|
||||
@@ -316,7 +860,7 @@ fn sftp_button_opens_explorer_and_navigates(cx: &mut TestAppContext) {
|
||||
cx.update_window(handle.into(), |_, window, cx| {
|
||||
window.render_frame(cx);
|
||||
// The active terminal tab's toolbar shows the SFTP button.
|
||||
window.click("sftp", cx);
|
||||
window.click(("sftp", FIRST_NEW_TERMINAL), cx);
|
||||
})
|
||||
.unwrap();
|
||||
cx.run_until_parked();
|
||||
@@ -377,24 +921,32 @@ fn tab_close_button_closes_the_terminal_and_disconnects(cx: &mut TestAppContext)
|
||||
|
||||
cx.update_window(handle.into(), |_, window, cx| {
|
||||
window.render_frame(cx);
|
||||
assert!(window.find(("terminal", DB_01)).visible());
|
||||
window.click(("close-terminal", DB_01), cx);
|
||||
assert!(window.find(("terminal", FIRST_NEW_TERMINAL)).visible());
|
||||
window.click(("close-terminal", FIRST_NEW_TERMINAL), cx);
|
||||
})
|
||||
.unwrap();
|
||||
cx.run_until_parked();
|
||||
|
||||
cx.update_window(handle.into(), |_, window, cx| {
|
||||
window.render_frame(cx);
|
||||
assert!(window.try_find(("terminal", DB_01)).is_none());
|
||||
assert!(window.try_find(("close-terminal", DB_01)).is_none());
|
||||
assert!(window.try_find(("terminal", FIRST_NEW_TERMINAL)).is_none());
|
||||
assert!(
|
||||
window
|
||||
.try_find(("close-terminal", FIRST_NEW_TERMINAL))
|
||||
.is_none()
|
||||
);
|
||||
// The other seeded tabs are untouched.
|
||||
assert!(window.find(("close-terminal", WEB_01)).visible());
|
||||
assert!(
|
||||
window
|
||||
.find(("close-terminal", INITIAL_WEB_TERMINAL))
|
||||
.visible()
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
cx.update(|cx| {
|
||||
let workspace = workspace.read(cx);
|
||||
assert!(workspace.terminal(SessionId(DB_01)).is_none());
|
||||
assert!(workspace.terminal(SessionId(DB_01), cx).is_none());
|
||||
let store = workspace.store().read(cx);
|
||||
assert!(
|
||||
!store
|
||||
@@ -407,31 +959,39 @@ fn tab_close_button_closes_the_terminal_and_disconnects(cx: &mut TestAppContext)
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
fn closing_every_tab_shows_the_recent_sessions(cx: &mut TestAppContext) {
|
||||
async fn closing_every_tab_shows_the_recent_sessions(cx: &mut TestAppContext) {
|
||||
let (handle, workspace) = open_workspace(cx);
|
||||
|
||||
cx.update_window(handle.into(), |_, window, cx| {
|
||||
window.render_frame(cx);
|
||||
// While tabs are open the start page stays out of the way.
|
||||
assert!(window.try_find("recent-sessions").is_none());
|
||||
window.click(("close-terminal", WEB_01), cx);
|
||||
window.click(("close-terminal", INITIAL_WEB_TERMINAL), cx);
|
||||
})
|
||||
.unwrap();
|
||||
cx.run_until_parked();
|
||||
|
||||
cx.update_window(handle.into(), |_, window, cx| {
|
||||
window.render_frame(cx);
|
||||
assert!(window.try_find(("terminal", WEB_01)).is_none());
|
||||
assert!(
|
||||
window
|
||||
.try_find(("terminal", INITIAL_WEB_TERMINAL))
|
||||
.is_none()
|
||||
);
|
||||
assert!(window.try_find("recent-sessions").is_none());
|
||||
// The last tab closes too (the tab group alone would refuse).
|
||||
window.click(("close-terminal", STAGING_API), cx);
|
||||
window.click(("close-terminal", INITIAL_STAGING_TERMINAL), cx);
|
||||
})
|
||||
.unwrap();
|
||||
cx.run_until_parked();
|
||||
|
||||
cx.update_window(handle.into(), |_, window, cx| {
|
||||
window.render_frame(cx);
|
||||
assert!(window.try_find(("terminal", STAGING_API)).is_none());
|
||||
assert!(
|
||||
window
|
||||
.try_find(("terminal", INITIAL_STAGING_TERMINAL))
|
||||
.is_none()
|
||||
);
|
||||
assert!(window.find("recent-sessions").visible());
|
||||
// The page takes the focus the closed tab held.
|
||||
assert_eq!(window.find("recent-sessions").focused(), Some(true));
|
||||
@@ -445,9 +1005,15 @@ fn closing_every_tab_shows_the_recent_sessions(cx: &mut TestAppContext) {
|
||||
.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("已连接 web-01")
|
||||
})
|
||||
.await;
|
||||
|
||||
cx.update_window(handle.into(), |_, window, cx| {
|
||||
window.render_frame(cx);
|
||||
assert!(window.find(("terminal", WEB_01)).visible());
|
||||
assert!(window.find(("terminal", FIRST_NEW_TERMINAL)).visible());
|
||||
assert!(window.try_find("recent-sessions").is_none());
|
||||
assert_eq!(
|
||||
window.find("status-connection").label(),
|
||||
@@ -483,7 +1049,7 @@ fn close_shortcut_closes_the_displayed_tab_down_to_none(cx: &mut TestAppContext)
|
||||
cx.update_window(handle.into(), |_, window, cx| {
|
||||
window.render_frame(cx);
|
||||
// The new tab is displayed, with its input focused.
|
||||
assert!(window.find(("terminal", DB_01)).visible());
|
||||
assert!(window.find(("terminal", FIRST_NEW_TERMINAL)).visible());
|
||||
window.press("cmd-w", cx);
|
||||
})
|
||||
.unwrap();
|
||||
@@ -491,7 +1057,7 @@ fn close_shortcut_closes_the_displayed_tab_down_to_none(cx: &mut TestAppContext)
|
||||
|
||||
cx.update_window(handle.into(), |_, window, cx| {
|
||||
window.render_frame(cx);
|
||||
assert!(window.try_find(("terminal", DB_01)).is_none());
|
||||
assert!(window.try_find(("terminal", FIRST_NEW_TERMINAL)).is_none());
|
||||
assert!(window.try_find("recent-sessions").is_none());
|
||||
// Keep closing whichever tab the dock displays next.
|
||||
window.press("cmd-w", cx);
|
||||
@@ -508,8 +1074,16 @@ fn close_shortcut_closes_the_displayed_tab_down_to_none(cx: &mut TestAppContext)
|
||||
|
||||
cx.update_window(handle.into(), |_, window, cx| {
|
||||
window.render_frame(cx);
|
||||
assert!(window.try_find(("terminal", WEB_01)).is_none());
|
||||
assert!(window.try_find(("terminal", STAGING_API)).is_none());
|
||||
assert!(
|
||||
window
|
||||
.try_find(("terminal", INITIAL_WEB_TERMINAL))
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
window
|
||||
.try_find(("terminal", INITIAL_STAGING_TERMINAL))
|
||||
.is_none()
|
||||
);
|
||||
assert!(window.find("recent-sessions").visible());
|
||||
// With nothing open the shortcut does nothing.
|
||||
window.press("cmd-w", cx);
|
||||
@@ -1046,8 +1620,8 @@ async fn deleting_a_group_removes_its_sessions_and_closes_their_tabs(cx: &mut Te
|
||||
|
||||
cx.update(|cx| {
|
||||
let workspace = workspace.read(cx);
|
||||
assert!(workspace.terminal(SessionId(WEB_01)).is_some());
|
||||
assert!(workspace.terminal(SessionId(DB_01)).is_some());
|
||||
assert!(workspace.terminal(SessionId(WEB_01), cx).is_some());
|
||||
assert!(workspace.terminal(SessionId(DB_01), cx).is_some());
|
||||
});
|
||||
|
||||
cx.update_window(handle.into(), |_, window, cx| {
|
||||
@@ -1066,10 +1640,10 @@ async fn deleting_a_group_removes_its_sessions_and_closes_their_tabs(cx: &mut Te
|
||||
|
||||
cx.update(|cx| {
|
||||
let workspace = workspace.read(cx);
|
||||
assert!(workspace.terminal(SessionId(WEB_01)).is_none());
|
||||
assert!(workspace.terminal(SessionId(DB_01)).is_none());
|
||||
assert!(workspace.terminal(SessionId(WEB_01), cx).is_none());
|
||||
assert!(workspace.terminal(SessionId(DB_01), cx).is_none());
|
||||
// staging-api is in another group and keeps its tab.
|
||||
assert!(workspace.terminal(SessionId(STAGING_API)).is_some());
|
||||
assert!(workspace.terminal(SessionId(STAGING_API), cx).is_some());
|
||||
|
||||
let store = workspace.store().read(cx);
|
||||
assert!(store.group(GroupId(PRODUCTION)).is_none());
|
||||
@@ -1151,6 +1725,12 @@ async fn groups_and_sessions_are_read_back_from_the_database(cx: &mut TestAppCon
|
||||
.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("已连接 web-01")
|
||||
})
|
||||
.await;
|
||||
|
||||
// Everything above went through the real write path; read it back with a
|
||||
// second connection to the same file.
|
||||
let reloaded = SessionStore::load(SessionDatabase::open(&path).expect("database reopened"))
|
||||
@@ -1192,7 +1772,7 @@ fn both_tabs_of_one_session_stay_closable(cx: &mut TestAppContext) {
|
||||
// for the same session beside it and activates that one.
|
||||
cx.update_window(handle.into(), |_, window, cx| {
|
||||
window.render_frame(cx);
|
||||
window.click("sftp", cx);
|
||||
window.click(("sftp", FIRST_NEW_TERMINAL), cx);
|
||||
})
|
||||
.unwrap();
|
||||
cx.run_until_parked();
|
||||
@@ -1209,21 +1789,25 @@ fn both_tabs_of_one_session_stay_closable(cx: &mut TestAppContext) {
|
||||
cx.update_window(handle.into(), |_, window, cx| {
|
||||
window.render_frame(cx);
|
||||
assert!(window.try_find(("close-explorer", DB_01)).is_none());
|
||||
assert!(window.find(("terminal", DB_01)).visible());
|
||||
window.click(("close-terminal", DB_01), cx);
|
||||
assert!(window.find(("terminal", FIRST_NEW_TERMINAL)).visible());
|
||||
window.click(("close-terminal", FIRST_NEW_TERMINAL), cx);
|
||||
})
|
||||
.unwrap();
|
||||
cx.run_until_parked();
|
||||
|
||||
cx.update_window(handle.into(), |_, window, cx| {
|
||||
window.render_frame(cx);
|
||||
assert!(window.try_find(("close-terminal", DB_01)).is_none());
|
||||
assert!(
|
||||
window
|
||||
.try_find(("close-terminal", FIRST_NEW_TERMINAL))
|
||||
.is_none()
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
cx.update(|cx| {
|
||||
let workspace = workspace.read(cx);
|
||||
assert!(workspace.explorer(SessionId(DB_01)).is_none());
|
||||
assert!(workspace.terminal(SessionId(DB_01)).is_none());
|
||||
assert!(workspace.terminal(SessionId(DB_01), cx).is_none());
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user