Merge branch 'sqlite-session-persistence' of https://github.com/since2006/shellr into sqlite-session-persistence

This commit is contained in:
since2006 committed 2026-09-12 09:34:02 +08:00
commit e5f8790dba
23 files changed
+4354 -513

No files matched your search

Generated
+1063 -21
View File
File diff suppressed because it is too large. Load diff
+3
View File
@@ -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
View File
@@ -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
View File
@@ -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::*;
+8
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+92 -10
View File
@@ -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
View File
@@ -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);
}
+5
View File
@@ -0,0 +1,5 @@
//! Real SSH transport. SFTP intentionally remains a separate mock capability.
mod transport;
pub use transport::SshTerminalTransportProvider;
+1404
View File
File diff suppressed because it is too large. Load diff
+38 -6
View File
@@ -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>),
}
+5 -1
View File
@@ -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,
}
-128
View File
@@ -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:~# ");
}
}
-153
View File
@@ -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
View File
@@ -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,
};
+4
View File
@@ -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 {
+66 -40
View File
@@ -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)
}),
])
}
+34 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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());
});
}