ssh remote: Handle disconnect on project and show overlay (#19014)

Demo:



https://github.com/user-attachments/assets/e5edf8f3-8c15-482e-a792-6eb619f83de4


Release Notes:

- N/A

---------

Co-authored-by: Bennet <bennet@zed.dev>
This commit is contained in:
Thorsten Ball 2024-10-10 12:59:09 +02:00 committed by GitHub
parent e3ff2ced79
commit b75532fad7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 264 additions and 78 deletions

View file

@ -1178,7 +1178,7 @@ impl Room {
this.update(&mut cx, |this, cx| { this.update(&mut cx, |this, cx| {
this.joined_projects.retain(|project| { this.joined_projects.retain(|project| {
if let Some(project) = project.upgrade() { if let Some(project) = project.upgrade() {
!project.read(cx).is_disconnected() !project.read(cx).is_disconnected(cx)
} else { } else {
false false
} }

View file

@ -50,7 +50,7 @@ async fn test_channel_guests(
project_b.read_with(cx_b, |project, _| project.remote_id()), project_b.read_with(cx_b, |project, _| project.remote_id()),
Some(project_id), Some(project_id),
); );
assert!(project_b.read_with(cx_b, |project, _| project.is_read_only())); assert!(project_b.read_with(cx_b, |project, cx| project.is_read_only(cx)));
assert!(project_b assert!(project_b
.update(cx_b, |project, cx| { .update(cx_b, |project, cx| {
let worktree_id = project.worktrees(cx).next().unwrap().read(cx).id(); let worktree_id = project.worktrees(cx).next().unwrap().read(cx).id();
@ -103,7 +103,7 @@ async fn test_channel_guest_promotion(cx_a: &mut TestAppContext, cx_b: &mut Test
workspace.active_item_as::<Editor>(cx).unwrap(), workspace.active_item_as::<Editor>(cx).unwrap(),
) )
}); });
assert!(project_b.read_with(cx_b, |project, _| project.is_read_only())); assert!(project_b.read_with(cx_b, |project, cx| project.is_read_only(cx)));
assert!(editor_b.update(cx_b, |e, cx| e.read_only(cx))); assert!(editor_b.update(cx_b, |e, cx| e.read_only(cx)));
assert!(room_b.read_with(cx_b, |room, _| !room.can_use_microphone())); assert!(room_b.read_with(cx_b, |room, _| !room.can_use_microphone()));
assert!(room_b assert!(room_b
@ -127,7 +127,7 @@ async fn test_channel_guest_promotion(cx_a: &mut TestAppContext, cx_b: &mut Test
cx_a.run_until_parked(); cx_a.run_until_parked();
// project and buffers are now editable // project and buffers are now editable
assert!(project_b.read_with(cx_b, |project, _| !project.is_read_only())); assert!(project_b.read_with(cx_b, |project, cx| !project.is_read_only(cx)));
assert!(editor_b.update(cx_b, |editor, cx| !editor.read_only(cx))); assert!(editor_b.update(cx_b, |editor, cx| !editor.read_only(cx)));
// B sees themselves as muted, and can unmute. // B sees themselves as muted, and can unmute.
@ -153,7 +153,7 @@ async fn test_channel_guest_promotion(cx_a: &mut TestAppContext, cx_b: &mut Test
cx_a.run_until_parked(); cx_a.run_until_parked();
// project and buffers are no longer editable // project and buffers are no longer editable
assert!(project_b.read_with(cx_b, |project, _| project.is_read_only())); assert!(project_b.read_with(cx_b, |project, cx| project.is_read_only(cx)));
assert!(editor_b.update(cx_b, |editor, cx| editor.read_only(cx))); assert!(editor_b.update(cx_b, |editor, cx| editor.read_only(cx)));
assert!(room_b assert!(room_b
.update(cx_b, |room, cx| room.share_microphone(cx)) .update(cx_b, |room, cx| room.share_microphone(cx))

View file

@ -262,7 +262,7 @@ async fn test_dev_server_leave_room(
cx1.executor().run_until_parked(); cx1.executor().run_until_parked();
let (workspace, cx2) = client2.active_workspace(cx2); let (workspace, cx2) = client2.active_workspace(cx2);
cx2.update(|cx| assert!(workspace.read(cx).project().read(cx).is_disconnected())); cx2.update(|cx| assert!(workspace.read(cx).project().read(cx).is_disconnected(cx)));
} }
#[gpui::test] #[gpui::test]
@ -308,7 +308,7 @@ async fn test_dev_server_delete(
cx1.executor().run_until_parked(); cx1.executor().run_until_parked();
let (workspace, cx2) = client2.active_workspace(cx2); let (workspace, cx2) = client2.active_workspace(cx2);
cx2.update(|cx| assert!(workspace.read(cx).project().read(cx).is_disconnected())); cx2.update(|cx| assert!(workspace.read(cx).project().read(cx).is_disconnected(cx)));
cx1.update(|cx| { cx1.update(|cx| {
dev_server_projects::Store::global(cx).update(cx, |store, _| { dev_server_projects::Store::global(cx).update(cx, |store, _| {
@ -418,12 +418,12 @@ async fn test_dev_server_refresh_access_token(
// Assert that the other client was disconnected // Assert that the other client was disconnected
let (workspace, cx2) = client2.active_workspace(cx2); let (workspace, cx2) = client2.active_workspace(cx2);
cx2.update(|cx| assert!(workspace.read(cx).project().read(cx).is_disconnected())); cx2.update(|cx| assert!(workspace.read(cx).project().read(cx).is_disconnected(cx)));
// Assert that the owner of the dev server does not see the dev server as online anymore // Assert that the owner of the dev server does not see the dev server as online anymore
let (workspace, cx1) = client1.active_workspace(cx1); let (workspace, cx1) = client1.active_workspace(cx1);
cx1.update(|cx| { cx1.update(|cx| {
assert!(workspace.read(cx).project().read(cx).is_disconnected()); assert!(workspace.read(cx).project().read(cx).is_disconnected(cx));
dev_server_projects::Store::global(cx).update(cx, |store, _| { dev_server_projects::Store::global(cx).update(cx, |store, _| {
assert_eq!( assert_eq!(
store.dev_servers().first().unwrap().status, store.dev_servers().first().unwrap().status,

View file

@ -114,7 +114,7 @@ async fn test_host_disconnect(
project_a.read_with(cx_a, |project, _| assert!(!project.is_shared())); project_a.read_with(cx_a, |project, _| assert!(!project.is_shared()));
project_b.read_with(cx_b, |project, _| project.is_read_only()); project_b.read_with(cx_b, |project, cx| project.is_read_only(cx));
assert!(worktree_a.read_with(cx_a, |tree, _| !tree.has_update_observer())); assert!(worktree_a.read_with(cx_a, |tree, _| !tree.has_update_observer()));

View file

@ -1389,7 +1389,7 @@ async fn test_unshare_project(
.unwrap(); .unwrap();
executor.run_until_parked(); executor.run_until_parked();
assert!(project_b.read_with(cx_b, |project, _| project.is_disconnected())); assert!(project_b.read_with(cx_b, |project, cx| project.is_disconnected(cx)));
// Client C opens the project. // Client C opens the project.
let project_c = client_c.join_remote_project(project_id, cx_c).await; let project_c = client_c.join_remote_project(project_id, cx_c).await;
@ -1402,7 +1402,7 @@ async fn test_unshare_project(
assert!(worktree_a.read_with(cx_a, |tree, _| !tree.has_update_observer())); assert!(worktree_a.read_with(cx_a, |tree, _| !tree.has_update_observer()));
assert!(project_c.read_with(cx_c, |project, _| project.is_disconnected())); assert!(project_c.read_with(cx_c, |project, cx| project.is_disconnected(cx)));
// Client C can open the project again after client A re-shares. // Client C can open the project again after client A re-shares.
let project_id = active_call_a let project_id = active_call_a
@ -1427,8 +1427,8 @@ async fn test_unshare_project(
project_a.read_with(cx_a, |project, _| assert!(!project.is_shared())); project_a.read_with(cx_a, |project, _| assert!(!project.is_shared()));
project_c2.read_with(cx_c, |project, _| { project_c2.read_with(cx_c, |project, cx| {
assert!(project.is_disconnected()); assert!(project.is_disconnected(cx));
assert!(project.collaborators().is_empty()); assert!(project.collaborators().is_empty());
}); });
} }
@ -1560,8 +1560,8 @@ async fn test_project_reconnect(
assert_eq!(project.collaborators().len(), 1); assert_eq!(project.collaborators().len(), 1);
}); });
project_b1.read_with(cx_b, |project, _| { project_b1.read_with(cx_b, |project, cx| {
assert!(!project.is_disconnected()); assert!(!project.is_disconnected(cx));
assert_eq!(project.collaborators().len(), 1); assert_eq!(project.collaborators().len(), 1);
}); });
@ -1661,7 +1661,7 @@ async fn test_project_reconnect(
}); });
project_b1.read_with(cx_b, |project, cx| { project_b1.read_with(cx_b, |project, cx| {
assert!(!project.is_disconnected()); assert!(!project.is_disconnected(cx));
assert_eq!( assert_eq!(
project project
.worktree_for_id(worktree1_id, cx) .worktree_for_id(worktree1_id, cx)
@ -1695,9 +1695,9 @@ async fn test_project_reconnect(
); );
}); });
project_b2.read_with(cx_b, |project, _| assert!(project.is_disconnected())); project_b2.read_with(cx_b, |project, cx| assert!(project.is_disconnected(cx)));
project_b3.read_with(cx_b, |project, _| assert!(!project.is_disconnected())); project_b3.read_with(cx_b, |project, cx| assert!(!project.is_disconnected(cx)));
buffer_a1.read_with(cx_a, |buffer, _| assert_eq!(buffer.text(), "WaZ")); buffer_a1.read_with(cx_a, |buffer, _| assert_eq!(buffer.text(), "WaZ"));
@ -1754,7 +1754,7 @@ async fn test_project_reconnect(
executor.run_until_parked(); executor.run_until_parked();
project_b1.read_with(cx_b, |project, cx| { project_b1.read_with(cx_b, |project, cx| {
assert!(!project.is_disconnected()); assert!(!project.is_disconnected(cx));
assert_eq!( assert_eq!(
project project
.worktree_for_id(worktree1_id, cx) .worktree_for_id(worktree1_id, cx)
@ -1788,7 +1788,7 @@ async fn test_project_reconnect(
); );
}); });
project_b3.read_with(cx_b, |project, _| assert!(project.is_disconnected())); project_b3.read_with(cx_b, |project, cx| assert!(project.is_disconnected(cx)));
buffer_a1.read_with(cx_a, |buffer, _| assert_eq!(buffer.text(), "WXaYZ")); buffer_a1.read_with(cx_a, |buffer, _| assert_eq!(buffer.text(), "WXaYZ"));
@ -3816,8 +3816,8 @@ async fn test_leaving_project(
assert_eq!(project.collaborators().len(), 1); assert_eq!(project.collaborators().len(), 1);
}); });
project_b2.read_with(cx_b, |project, _| { project_b2.read_with(cx_b, |project, cx| {
assert!(project.is_disconnected()); assert!(project.is_disconnected(cx));
}); });
project_c.read_with(cx_c, |project, _| { project_c.read_with(cx_c, |project, _| {
@ -3849,12 +3849,12 @@ async fn test_leaving_project(
assert_eq!(project.collaborators().len(), 0); assert_eq!(project.collaborators().len(), 0);
}); });
project_b2.read_with(cx_b, |project, _| { project_b2.read_with(cx_b, |project, cx| {
assert!(project.is_disconnected()); assert!(project.is_disconnected(cx));
}); });
project_c.read_with(cx_c, |project, _| { project_c.read_with(cx_c, |project, cx| {
assert!(project.is_disconnected()); assert!(project.is_disconnected(cx));
}); });
} }

View file

@ -1168,7 +1168,7 @@ impl RandomizedTest for ProjectCollaborationTest {
Some((project, cx)) Some((project, cx))
}); });
if !guest_project.is_disconnected() { if !guest_project.is_disconnected(cx) {
if let Some((host_project, host_cx)) = host_project { if let Some((host_project, host_cx)) = host_project {
let host_worktree_snapshots = let host_worktree_snapshots =
host_project.read_with(host_cx, |host_project, cx| { host_project.read_with(host_cx, |host_project, cx| {
@ -1254,8 +1254,8 @@ impl RandomizedTest for ProjectCollaborationTest {
let buffers = client.buffers().clone(); let buffers = client.buffers().clone();
for (guest_project, guest_buffers) in &buffers { for (guest_project, guest_buffers) in &buffers {
let project_id = if guest_project.read_with(client_cx, |project, _| { let project_id = if guest_project.read_with(client_cx, |project, cx| {
project.is_local() || project.is_disconnected() project.is_local() || project.is_disconnected(cx)
}) { }) {
continue; continue;
} else { } else {

View file

@ -532,9 +532,9 @@ impl<T: RandomizedTest> TestPlan<T> {
server.allow_connections(); server.allow_connections();
for project in client.dev_server_projects().iter() { for project in client.dev_server_projects().iter() {
project.read_with(&client_cx, |project, _| { project.read_with(&client_cx, |project, cx| {
assert!( assert!(
project.is_disconnected(), project.is_disconnected(cx),
"project {:?} should be read only", "project {:?} should be read only",
project.remote_id() project.remote_id()
) )

View file

@ -647,17 +647,10 @@ pub struct FormattableBuffer {
} }
pub struct RemoteLspStore { pub struct RemoteLspStore {
upstream_client: AnyProtoClient, upstream_client: Option<AnyProtoClient>,
upstream_project_id: u64, upstream_project_id: u64,
} }
impl RemoteLspStore {}
// pub struct SshLspStore {
// upstream_client: AnyProtoClient,
// current_lsp_settings: HashMap<LanguageServerName, LspSettings>,
// }
#[allow(clippy::large_enum_variant)] #[allow(clippy::large_enum_variant)]
pub enum LspStoreMode { pub enum LspStoreMode {
Local(LocalLspStore), // ssh host and collab host Local(LocalLspStore), // ssh host and collab host
@ -808,10 +801,15 @@ impl LspStore {
pub fn upstream_client(&self) -> Option<(AnyProtoClient, u64)> { pub fn upstream_client(&self) -> Option<(AnyProtoClient, u64)> {
match &self.mode { match &self.mode {
LspStoreMode::Remote(RemoteLspStore { LspStoreMode::Remote(RemoteLspStore {
upstream_client, upstream_client: Some(upstream_client),
upstream_project_id, upstream_project_id,
.. ..
}) => Some((upstream_client.clone(), *upstream_project_id)), }) => Some((upstream_client.clone(), *upstream_project_id)),
LspStoreMode::Remote(RemoteLspStore {
upstream_client: None,
..
}) => None,
LspStoreMode::Local(_) => None, LspStoreMode::Local(_) => None,
} }
} }
@ -924,7 +922,7 @@ impl LspStore {
Self { Self {
mode: LspStoreMode::Remote(RemoteLspStore { mode: LspStoreMode::Remote(RemoteLspStore {
upstream_client, upstream_client: Some(upstream_client),
upstream_project_id: project_id, upstream_project_id: project_id,
}), }),
downstream_client: None, downstream_client: None,
@ -3099,6 +3097,15 @@ impl LspStore {
self.downstream_client.take(); self.downstream_client.take();
} }
pub fn disconnected_from_ssh_remote(&mut self) {
if let LspStoreMode::Remote(RemoteLspStore {
upstream_client, ..
}) = &mut self.mode
{
upstream_client.take();
}
}
pub(crate) fn set_language_server_statuses_from_proto( pub(crate) fn set_language_server_statuses_from_proto(
&mut self, &mut self,
language_servers: Vec<proto::LanguageServer>, language_servers: Vec<proto::LanguageServer>,

View file

@ -58,7 +58,7 @@ use node_runtime::NodeRuntime;
use parking_lot::{Mutex, RwLock}; use parking_lot::{Mutex, RwLock};
pub use prettier_store::PrettierStore; pub use prettier_store::PrettierStore;
use project_settings::{ProjectSettings, SettingsObserver, SettingsObserverEvent}; use project_settings::{ProjectSettings, SettingsObserver, SettingsObserverEvent};
use remote::SshRemoteClient; use remote::{SshConnectionOptions, SshRemoteClient};
use rpc::{proto::SSH_PROJECT_ID, AnyProtoClient, ErrorCode}; use rpc::{proto::SSH_PROJECT_ID, AnyProtoClient, ErrorCode};
use search::{SearchInputKind, SearchQuery, SearchResult}; use search::{SearchInputKind, SearchQuery, SearchResult};
use search_history::SearchHistory; use search_history::SearchHistory;
@ -245,6 +245,7 @@ pub enum Event {
}, },
RemoteIdChanged(Option<u64>), RemoteIdChanged(Option<u64>),
DisconnectedFromHost, DisconnectedFromHost,
DisconnectedFromSshRemote,
Closed, Closed,
DeletedEntry(ProjectEntryId), DeletedEntry(ProjectEntryId),
CollaboratorUpdated { CollaboratorUpdated {
@ -755,6 +756,8 @@ impl Project {
} }
}) })
.detach(); .detach();
cx.subscribe(&ssh, Self::on_ssh_event).detach();
cx.observe(&ssh, |_, _, cx| cx.notify()).detach(); cx.observe(&ssh, |_, _, cx| cx.notify()).detach();
let this = Self { let this = Self {
@ -1313,6 +1316,12 @@ impl Project {
.map(|ssh| ssh.read(cx).connection_state()) .map(|ssh| ssh.read(cx).connection_state())
} }
pub fn ssh_connection_options(&self, cx: &AppContext) -> Option<SshConnectionOptions> {
self.ssh_client
.as_ref()
.map(|ssh| ssh.read(cx).connection_options())
}
pub fn replica_id(&self) -> ReplicaId { pub fn replica_id(&self) -> ReplicaId {
match self.client_state { match self.client_state {
ProjectClientState::Remote { replica_id, .. } => replica_id, ProjectClientState::Remote { replica_id, .. } => replica_id,
@ -1658,7 +1667,7 @@ impl Project {
} }
pub fn disconnected_from_host(&mut self, cx: &mut ModelContext<Self>) { pub fn disconnected_from_host(&mut self, cx: &mut ModelContext<Self>) {
if self.is_disconnected() { if self.is_disconnected(cx) {
return; return;
} }
self.disconnected_from_host_internal(cx); self.disconnected_from_host_internal(cx);
@ -1708,16 +1717,24 @@ impl Project {
cx.emit(Event::Closed); cx.emit(Event::Closed);
} }
pub fn is_disconnected(&self) -> bool { pub fn is_disconnected(&self, cx: &AppContext) -> bool {
match &self.client_state { match &self.client_state {
ProjectClientState::Remote { ProjectClientState::Remote {
sharing_has_stopped, sharing_has_stopped,
.. ..
} => *sharing_has_stopped, } => *sharing_has_stopped,
ProjectClientState::Local if self.is_via_ssh() => self.ssh_is_disconnected(cx),
_ => false, _ => false,
} }
} }
fn ssh_is_disconnected(&self, cx: &AppContext) -> bool {
self.ssh_client
.as_ref()
.map(|ssh| ssh.read(cx).is_disconnected())
.unwrap_or(false)
}
pub fn capability(&self) -> Capability { pub fn capability(&self) -> Capability {
match &self.client_state { match &self.client_state {
ProjectClientState::Remote { capability, .. } => *capability, ProjectClientState::Remote { capability, .. } => *capability,
@ -1725,8 +1742,8 @@ impl Project {
} }
} }
pub fn is_read_only(&self) -> bool { pub fn is_read_only(&self, cx: &AppContext) -> bool {
self.is_disconnected() || self.capability() == Capability::ReadOnly self.is_disconnected(cx) || self.capability() == Capability::ReadOnly
} }
pub fn is_local(&self) -> bool { pub fn is_local(&self) -> bool {
@ -1807,7 +1824,7 @@ impl Project {
path: impl Into<ProjectPath>, path: impl Into<ProjectPath>,
cx: &mut ModelContext<Self>, cx: &mut ModelContext<Self>,
) -> Task<Result<Model<Buffer>>> { ) -> Task<Result<Model<Buffer>>> {
if self.is_via_collab() && self.is_disconnected() { if (self.is_via_collab() || self.is_via_ssh()) && self.is_disconnected(cx) {
return Task::ready(Err(anyhow!(ErrorCode::Disconnected))); return Task::ready(Err(anyhow!(ErrorCode::Disconnected)));
} }
@ -2114,6 +2131,30 @@ impl Project {
} }
} }
fn on_ssh_event(
&mut self,
_: Model<SshRemoteClient>,
event: &remote::SshRemoteEvent,
cx: &mut ModelContext<Self>,
) {
match event {
remote::SshRemoteEvent::Disconnected => {
// if self.is_via_ssh() {
// self.collaborators.clear();
self.worktree_store.update(cx, |store, cx| {
store.disconnected_from_host(cx);
});
self.buffer_store.update(cx, |buffer_store, cx| {
buffer_store.disconnected_from_host(cx)
});
self.lsp_store.update(cx, |lsp_store, _cx| {
lsp_store.disconnected_from_ssh_remote()
});
cx.emit(Event::DisconnectedFromSshRemote);
}
}
}
fn on_settings_observer_event( fn on_settings_observer_event(
&mut self, &mut self,
_: Model<SettingsObserver>, _: Model<SettingsObserver>,

View file

@ -509,6 +509,11 @@ impl WorktreeStore {
for worktree in &self.worktrees { for worktree in &self.worktrees {
if let Some(worktree) = worktree.upgrade() { if let Some(worktree) = worktree.upgrade() {
worktree.update(cx, |worktree, _| { worktree.update(cx, |worktree, _| {
println!(
"worktree. is_local: {:?}, is_remote: {:?}",
worktree.is_local(),
worktree.is_remote()
);
if let Some(worktree) = worktree.as_remote_mut() { if let Some(worktree) = worktree.as_remote_mut() {
worktree.disconnected_from_host(); worktree.disconnected_from_host();
} }

View file

@ -354,6 +354,7 @@ impl ProjectPanel {
}); });
}).ok(); }).ok();
let is_via_ssh = project.read(cx).is_via_ssh();
workspace workspace
.open_path_preview( .open_path_preview(
@ -368,7 +369,11 @@ impl ProjectPanel {
) )
.detach_and_prompt_err("Failed to open file", cx, move |e, _| { .detach_and_prompt_err("Failed to open file", cx, move |e, _| {
match e.error_code() { match e.error_code() {
ErrorCode::Disconnected => Some("Disconnected from remote project".to_string()), ErrorCode::Disconnected => if is_via_ssh {
Some("Disconnected from SSH host".to_string())
} else {
Some("Disconnected from remote project".to_string())
},
ErrorCode::UnsharedItem => Some(format!( ErrorCode::UnsharedItem => Some(format!(
"{} is not shared by the host. This could be because it has been marked as `private`", "{} is not shared by the host. This could be because it has been marked as `private`",
file_path.display() file_path.display()
@ -493,7 +498,7 @@ impl ProjectPanel {
let is_foldable = auto_fold_dirs && self.is_foldable(entry, worktree); let is_foldable = auto_fold_dirs && self.is_foldable(entry, worktree);
let is_unfoldable = auto_fold_dirs && self.is_unfoldable(entry, worktree); let is_unfoldable = auto_fold_dirs && self.is_unfoldable(entry, worktree);
let worktree_id = worktree.id(); let worktree_id = worktree.id();
let is_read_only = project.is_read_only(); let is_read_only = project.is_read_only(cx);
let is_remote = project.is_via_collab() && project.dev_server_project_id().is_none(); let is_remote = project.is_via_collab() && project.dev_server_project_id().is_none();
let is_local = project.is_local(); let is_local = project.is_local();
@ -2901,7 +2906,7 @@ impl Render for ProjectPanel {
.on_action(cx.listener(Self::new_search_in_directory)) .on_action(cx.listener(Self::new_search_in_directory))
.on_action(cx.listener(Self::unfold_directory)) .on_action(cx.listener(Self::unfold_directory))
.on_action(cx.listener(Self::fold_directory)) .on_action(cx.listener(Self::fold_directory))
.when(!project.is_read_only(), |el| { .when(!project.is_read_only(cx), |el| {
el.on_action(cx.listener(Self::new_file)) el.on_action(cx.listener(Self::new_file))
.on_action(cx.listener(Self::new_directory)) .on_action(cx.listener(Self::new_directory))
.on_action(cx.listener(Self::rename)) .on_action(cx.listener(Self::rename))

View file

@ -1,19 +1,29 @@
use std::path::PathBuf;
use dev_server_projects::DevServer; use dev_server_projects::DevServer;
use gpui::{ClickEvent, DismissEvent, EventEmitter, FocusHandle, FocusableView, Render, WeakView}; use gpui::{ClickEvent, DismissEvent, EventEmitter, FocusHandle, FocusableView, Render, WeakView};
use remote::SshConnectionOptions;
use ui::{ use ui::{
div, h_flex, rems, Button, ButtonCommon, ButtonStyle, Clickable, ElevationIndex, FluentBuilder, div, h_flex, rems, Button, ButtonCommon, ButtonStyle, Clickable, ElevationIndex, FluentBuilder,
Headline, HeadlineSize, IconName, IconPosition, InteractiveElement, IntoElement, Label, Modal, Headline, HeadlineSize, IconName, IconPosition, InteractiveElement, IntoElement, Label, Modal,
ModalFooter, ModalHeader, ParentElement, Section, Styled, StyledExt, ViewContext, ModalFooter, ModalHeader, ParentElement, Section, Styled, StyledExt, ViewContext,
}; };
use workspace::{notifications::DetachAndPromptErr, ModalView, Workspace}; use workspace::{notifications::DetachAndPromptErr, ModalView, OpenOptions, Workspace};
use crate::{ use crate::{
dev_servers::reconnect_to_dev_server_project, open_dev_server_project, DevServerProjects, dev_servers::reconnect_to_dev_server_project, open_dev_server_project, open_ssh_project,
DevServerProjects,
}; };
enum Host {
RemoteProject,
DevServerProject(DevServer),
SshRemoteProject(SshConnectionOptions),
}
pub struct DisconnectedOverlay { pub struct DisconnectedOverlay {
workspace: WeakView<Workspace>, workspace: WeakView<Workspace>,
dev_server: Option<DevServer>, host: Host,
focus_handle: FocusHandle, focus_handle: FocusHandle,
} }
@ -32,7 +42,10 @@ impl ModalView for DisconnectedOverlay {
impl DisconnectedOverlay { impl DisconnectedOverlay {
pub fn register(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) { pub fn register(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) {
cx.subscribe(workspace.project(), |workspace, project, event, cx| { cx.subscribe(workspace.project(), |workspace, project, event, cx| {
if !matches!(event, project::Event::DisconnectedFromHost) { if !matches!(
event,
project::Event::DisconnectedFromHost | project::Event::DisconnectedFromSshRemote
) {
return; return;
} }
let handle = cx.view().downgrade(); let handle = cx.view().downgrade();
@ -45,9 +58,19 @@ impl DisconnectedOverlay {
.dev_server_for_project(id) .dev_server_for_project(id)
}) })
.cloned(); .cloned();
let ssh_connection_options = project.read(cx).ssh_connection_options(cx);
let host = if let Some(dev_server) = dev_server {
Host::DevServerProject(dev_server)
} else if let Some(ssh_connection_options) = ssh_connection_options {
Host::SshRemoteProject(ssh_connection_options)
} else {
Host::RemoteProject
};
workspace.toggle_modal(cx, |cx| DisconnectedOverlay { workspace.toggle_modal(cx, |cx| DisconnectedOverlay {
workspace: handle, workspace: handle,
dev_server, host,
focus_handle: cx.focus_handle(), focus_handle: cx.focus_handle(),
}); });
}) })
@ -56,12 +79,22 @@ impl DisconnectedOverlay {
fn handle_reconnect(&mut self, _: &ClickEvent, cx: &mut ViewContext<Self>) { fn handle_reconnect(&mut self, _: &ClickEvent, cx: &mut ViewContext<Self>) {
cx.emit(DismissEvent); cx.emit(DismissEvent);
match &self.host {
Host::DevServerProject(dev_server) => {
self.reconnect_to_dev_server(dev_server.clone(), cx);
}
Host::SshRemoteProject(ssh_connection_options) => {
self.reconnect_to_ssh_remote(ssh_connection_options.clone(), cx);
}
_ => {}
}
}
fn reconnect_to_dev_server(&self, dev_server: DevServer, cx: &mut ViewContext<Self>) {
let Some(workspace) = self.workspace.upgrade() else { let Some(workspace) = self.workspace.upgrade() else {
return; return;
}; };
let Some(dev_server) = self.dev_server.clone() else {
return;
};
let Some(dev_server_project_id) = workspace let Some(dev_server_project_id) = workspace
.read(cx) .read(cx)
.project() .project()
@ -102,6 +135,44 @@ impl DisconnectedOverlay {
} }
} }
fn reconnect_to_ssh_remote(
&self,
connection_options: SshConnectionOptions,
cx: &mut ViewContext<Self>,
) {
let Some(workspace) = self.workspace.upgrade() else {
return;
};
let Some(ssh_project) = workspace.read(cx).serialized_ssh_project() else {
return;
};
let Some(window) = cx.window_handle().downcast::<Workspace>() else {
return;
};
let app_state = workspace.read(cx).app_state().clone();
let paths = ssh_project.paths.iter().map(PathBuf::from).collect();
cx.spawn(move |_, mut cx| async move {
open_ssh_project(
connection_options,
paths,
app_state,
OpenOptions {
replace_window: Some(window),
..Default::default()
},
&mut cx,
)
.await?;
Ok(())
})
.detach_and_prompt_err("Failed to reconnect", cx, |_, _| None);
}
fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) { fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
cx.emit(DismissEvent) cx.emit(DismissEvent)
} }
@ -109,6 +180,23 @@ impl DisconnectedOverlay {
impl Render for DisconnectedOverlay { impl Render for DisconnectedOverlay {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement { fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
let can_reconnect = matches!(
self.host,
Host::DevServerProject(_) | Host::SshRemoteProject(_)
);
let message = match &self.host {
Host::RemoteProject | Host::DevServerProject(_) => {
"Your connection to the remote project has been lost.".to_string()
}
Host::SshRemoteProject(options) => {
format!(
"Your connection to {} has been lost",
options.connection_string()
)
}
};
div() div()
.track_focus(&self.focus_handle) .track_focus(&self.focus_handle)
.elevation_3(cx) .elevation_3(cx)
@ -123,9 +211,7 @@ impl Render for DisconnectedOverlay {
.show_dismiss_button(true) .show_dismiss_button(true)
.child(Headline::new("Disconnected").size(HeadlineSize::Small)), .child(Headline::new("Disconnected").size(HeadlineSize::Small)),
) )
.section(Section::new().child(Label::new( .section(Section::new().child(Label::new(message)))
"Your connection to the remote project has been lost.",
)))
.footer( .footer(
ModalFooter::new().end_slot( ModalFooter::new().end_slot(
h_flex() h_flex()
@ -138,7 +224,7 @@ impl Render for DisconnectedOverlay {
cx.remove_window(); cx.remove_window();
})), })),
) )
.when_some(self.dev_server.clone(), |el, _| { .when(can_reconnect, |el| {
el.child( el.child(
Button::new("reconnect", "Reconnect") Button::new("reconnect", "Reconnect")
.style(ButtonStyle::Filled) .style(ButtonStyle::Filled)

View file

@ -5,4 +5,5 @@ pub mod ssh_session;
pub use ssh_session::{ pub use ssh_session::{
ConnectionState, SshClientDelegate, SshConnectionOptions, SshPlatform, SshRemoteClient, ConnectionState, SshClientDelegate, SshConnectionOptions, SshPlatform, SshRemoteClient,
SshRemoteEvent,
}; };

View file

@ -17,7 +17,8 @@ use futures::{
StreamExt as _, StreamExt as _,
}; };
use gpui::{ use gpui::{
AppContext, AsyncAppContext, Context, Model, ModelContext, SemanticVersion, Task, WeakModel, AppContext, AsyncAppContext, Context, EventEmitter, Model, ModelContext, SemanticVersion, Task,
WeakModel,
}; };
use parking_lot::Mutex; use parking_lot::Mutex;
use rpc::{ use rpc::{
@ -315,6 +316,10 @@ impl State {
matches!(self, Self::ReconnectFailed { .. }) matches!(self, Self::ReconnectFailed { .. })
} }
fn is_reconnect_exhausted(&self) -> bool {
matches!(self, Self::ReconnectExhausted { .. })
}
fn is_reconnecting(&self) -> bool { fn is_reconnecting(&self) -> bool {
matches!(self, Self::Reconnecting { .. }) matches!(self, Self::Reconnecting { .. })
} }
@ -376,7 +381,7 @@ impl State {
} }
/// The state of the ssh connection. /// The state of the ssh connection.
#[derive(Clone, Copy, Debug)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ConnectionState { pub enum ConnectionState {
Connecting, Connecting,
Connected, Connected,
@ -411,6 +416,13 @@ impl Drop for SshRemoteClient {
} }
} }
#[derive(Debug)]
pub enum SshRemoteEvent {
Disconnected,
}
impl EventEmitter<SshRemoteEvent> for SshRemoteClient {}
impl SshRemoteClient { impl SshRemoteClient {
pub fn new( pub fn new(
unique_identifier: String, unique_identifier: String,
@ -672,6 +684,9 @@ impl SshRemoteClient {
if this.state_is(State::is_reconnect_failed) { if this.state_is(State::is_reconnect_failed) {
this.reconnect(cx) this.reconnect(cx)
} else if this.state_is(State::is_reconnect_exhausted) {
cx.emit(SshRemoteEvent::Disconnected);
Ok(())
} else { } else {
log::debug!("State has transition from Reconnecting into new state while attempting reconnect. Ignoring new state."); log::debug!("State has transition from Reconnecting into new state while attempting reconnect. Ignoring new state.");
Ok(()) Ok(())
@ -851,11 +866,15 @@ impl SshRemoteClient {
log::error!("failed to reconnect because server is not running"); log::error!("failed to reconnect because server is not running");
this.update(&mut cx, |this, cx| { this.update(&mut cx, |this, cx| {
this.set_state(State::ServerNotRunning, cx); this.set_state(State::ServerNotRunning, cx);
cx.emit(SshRemoteEvent::Disconnected);
})?; })?;
} }
} }
} else if exit_code > 0 { } else if exit_code > 0 {
log::error!("proxy process terminated unexpectedly"); log::error!("proxy process terminated unexpectedly");
this.update(&mut cx, |this, cx| {
this.reconnect(cx).ok();
})?;
} }
} }
Ok(None) => {} Ok(None) => {}
@ -963,6 +982,11 @@ impl SshRemoteClient {
self.connection_options.connection_string() self.connection_options.connection_string()
} }
pub fn connection_options(&self) -> SshConnectionOptions {
self.connection_options.clone()
}
#[cfg(not(any(test, feature = "test-support")))]
pub fn connection_state(&self) -> ConnectionState { pub fn connection_state(&self) -> ConnectionState {
self.state self.state
.lock() .lock()
@ -971,6 +995,15 @@ impl SshRemoteClient {
.unwrap_or(ConnectionState::Disconnected) .unwrap_or(ConnectionState::Disconnected)
} }
#[cfg(any(test, feature = "test-support"))]
pub fn connection_state(&self) -> ConnectionState {
ConnectionState::Connected
}
pub fn is_disconnected(&self) -> bool {
self.connection_state() == ConnectionState::Disconnected
}
#[cfg(any(test, feature = "test-support"))] #[cfg(any(test, feature = "test-support"))]
pub fn fake( pub fn fake(
client_cx: &mut gpui::TestAppContext, client_cx: &mut gpui::TestAppContext,

View file

@ -366,7 +366,7 @@ impl TitleBar {
return self.render_ssh_project_host(cx); return self.render_ssh_project_host(cx);
} }
if self.project.read(cx).is_disconnected() { if self.project.read(cx).is_disconnected(cx) {
return Some( return Some(
Button::new("disconnected", "Disconnected") Button::new("disconnected", "Disconnected")
.disabled(true) .disabled(true)

View file

@ -823,6 +823,10 @@ impl Workspace {
} }
} }
project::Event::DisconnectedFromSshRemote => {
this.update_window_edited(cx);
}
project::Event::Closed => { project::Event::Closed => {
cx.remove_window(); cx.remove_window();
} }
@ -1464,6 +1468,10 @@ impl Workspace {
self.on_prompt_for_open_path = Some(prompt) self.on_prompt_for_open_path = Some(prompt)
} }
pub fn serialized_ssh_project(&self) -> Option<SerializedSshProject> {
self.serialized_ssh_project.clone()
}
pub fn set_serialized_ssh_project(&mut self, serialized_ssh_project: SerializedSshProject) { pub fn set_serialized_ssh_project(&mut self, serialized_ssh_project: SerializedSshProject) {
self.serialized_ssh_project = Some(serialized_ssh_project); self.serialized_ssh_project = Some(serialized_ssh_project);
} }
@ -1791,7 +1799,7 @@ impl Workspace {
mut save_intent: SaveIntent, mut save_intent: SaveIntent,
cx: &mut ViewContext<Self>, cx: &mut ViewContext<Self>,
) -> Task<Result<bool>> { ) -> Task<Result<bool>> {
if self.project.read(cx).is_disconnected() { if self.project.read(cx).is_disconnected(cx) {
return Task::ready(Ok(true)); return Task::ready(Ok(true));
} }
let dirty_items = self let dirty_items = self
@ -3447,7 +3455,7 @@ impl Workspace {
} }
fn update_window_edited(&mut self, cx: &mut WindowContext) { fn update_window_edited(&mut self, cx: &mut WindowContext) {
let is_edited = !self.project.read(cx).is_disconnected() let is_edited = !self.project.read(cx).is_disconnected(cx)
&& self && self
.items(cx) .items(cx)
.any(|item| item.has_conflict(cx) || item.is_dirty(cx)); .any(|item| item.has_conflict(cx) || item.is_dirty(cx));
@ -4858,7 +4866,7 @@ impl Render for Workspace {
.children(self.render_notifications(cx)), .children(self.render_notifications(cx)),
) )
.child(self.status_bar.clone()) .child(self.status_bar.clone())
.children(if self.project.read(cx).is_disconnected() { .children(if self.project.read(cx).is_disconnected(cx) {
if let Some(render) = self.render_disconnected_overlay.take() { if let Some(render) = self.render_disconnected_overlay.take() {
let result = render(self, cx); let result = render(self, cx);
self.render_disconnected_overlay = Some(render); self.render_disconnected_overlay = Some(render);