logiguard fork v3: full patch set on verified 8c74db0 tree
Includes prior-session patches (carry forward so the app compiles): - crates/gpui/build.rs: cross-compile manifest fix - crates/gpui/src/platform.rs: PlatformWindow::activate_with_token trait method - crates/gpui/src/window.rs: Window::activate_with_token public API - crates/gpui_linux/src/linux/wayland/window.rs: WaylandWindow::activate_with_token + activate() keyboard-serial fix Plus the focus-serial fix: - serial.rs: SerialKind::KeyboardEnter - client.rs: store wl_keyboard.enter serial; latest_serial_of() Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
75
crates/recent_projects/Cargo.toml
Normal file
75
crates/recent_projects/Cargo.toml
Normal file
@@ -0,0 +1,75 @@
|
||||
[package]
|
||||
name = "recent_projects"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
license = "GPL-3.0-or-later"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[lib]
|
||||
path = "src/recent_projects.rs"
|
||||
doctest = false
|
||||
|
||||
[features]
|
||||
default = []
|
||||
test-support = ["remote/test-support", "remote_connection/test-support", "project/test-support", "workspace/test-support"]
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
chrono.workspace = true
|
||||
askpass.workspace = true
|
||||
db.workspace = true
|
||||
dev_container.workspace = true
|
||||
editor.workspace = true
|
||||
extension_host.workspace = true
|
||||
fs.workspace = true
|
||||
futures.workspace = true
|
||||
fuzzy_nucleo.workspace = true
|
||||
gpui.workspace = true
|
||||
language.workspace = true
|
||||
log.workspace = true
|
||||
menu.workspace = true
|
||||
node_runtime.workspace = true
|
||||
open_path_prompt.workspace = true
|
||||
ordered-float.workspace = true
|
||||
paths.workspace = true
|
||||
picker.workspace = true
|
||||
project.workspace = true
|
||||
release_channel.workspace = true
|
||||
remote.workspace = true
|
||||
remote_connection.workspace = true
|
||||
semver.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
settings.workspace = true
|
||||
task.workspace = true
|
||||
telemetry.workspace = true
|
||||
ui.workspace = true
|
||||
ui_input.workspace = true
|
||||
util.workspace = true
|
||||
workspace.workspace = true
|
||||
worktree.workspace = true
|
||||
zed_actions.workspace = true
|
||||
indoc.workspace = true
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
windows-registry = "0.6.0"
|
||||
|
||||
[dev-dependencies]
|
||||
editor = { workspace = true, features = ["test-support"] }
|
||||
extension.workspace = true
|
||||
fs.workspace = true
|
||||
gpui = { workspace = true, features = ["test-support"] }
|
||||
http_client.workspace = true
|
||||
language = { workspace = true, features = ["test-support"] }
|
||||
picker = { workspace = true, features = ["test-support"] }
|
||||
project = { workspace = true, features = ["test-support"] }
|
||||
release_channel.workspace = true
|
||||
remote = { workspace = true, features = ["test-support"] }
|
||||
remote_connection = { workspace = true, features = ["test-support"] }
|
||||
remote_server.workspace = true
|
||||
serde_json.workspace = true
|
||||
settings = { workspace = true, features = ["test-support"] }
|
||||
workspace = { workspace = true, features = ["test-support"] }
|
||||
1
crates/recent_projects/LICENSE-GPL
Symbolic link
1
crates/recent_projects/LICENSE-GPL
Symbolic link
@@ -0,0 +1 @@
|
||||
../../LICENSE-GPL
|
||||
155
crates/recent_projects/src/dev_container_suggest.rs
Normal file
155
crates/recent_projects/src/dev_container_suggest.rs
Normal file
@@ -0,0 +1,155 @@
|
||||
use db::kvp::KeyValueStore;
|
||||
use dev_container::find_configs_in_snapshot;
|
||||
use gpui::{SharedString, Window};
|
||||
use project::{Project, WorktreeId};
|
||||
use std::sync::LazyLock;
|
||||
use ui::Tooltip;
|
||||
use ui::prelude::*;
|
||||
use util::ResultExt;
|
||||
use util::rel_path::RelPath;
|
||||
use workspace::Workspace;
|
||||
use workspace::notifications::NotificationId;
|
||||
use workspace::notifications::simple_message_notification::MessageNotification;
|
||||
use worktree::UpdatedEntriesSet;
|
||||
|
||||
const DEV_CONTAINER_SUGGEST_KEY: &str = "dev_container_suggest_dismissed";
|
||||
|
||||
fn devcontainer_dir_path() -> &'static RelPath {
|
||||
static PATH: LazyLock<&'static RelPath> =
|
||||
LazyLock::new(|| RelPath::unix(".devcontainer").expect("valid path"));
|
||||
*PATH
|
||||
}
|
||||
|
||||
fn devcontainer_json_path() -> &'static RelPath {
|
||||
static PATH: LazyLock<&'static RelPath> =
|
||||
LazyLock::new(|| RelPath::unix(".devcontainer.json").expect("valid path"));
|
||||
*PATH
|
||||
}
|
||||
|
||||
fn project_devcontainer_key(project_path: &str) -> String {
|
||||
format!("{}_{}", DEV_CONTAINER_SUGGEST_KEY, project_path)
|
||||
}
|
||||
|
||||
pub fn suggest_on_worktree_updated(
|
||||
workspace: &mut Workspace,
|
||||
worktree_id: WorktreeId,
|
||||
updated_entries: &UpdatedEntriesSet,
|
||||
project: &gpui::Entity<Project>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Workspace>,
|
||||
) {
|
||||
let cli_auto_open = workspace.open_in_dev_container();
|
||||
|
||||
let devcontainer_updated = updated_entries.iter().any(|(path, _, _)| {
|
||||
path.as_ref() == devcontainer_dir_path() || path.as_ref() == devcontainer_json_path()
|
||||
});
|
||||
|
||||
if !devcontainer_updated && !cli_auto_open {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(worktree) = project.read(cx).worktree_for_id(worktree_id, cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let worktree = worktree.read(cx);
|
||||
|
||||
if !worktree.is_local() {
|
||||
return;
|
||||
}
|
||||
|
||||
let has_configs = !find_configs_in_snapshot(worktree).is_empty();
|
||||
|
||||
if cli_auto_open {
|
||||
workspace.set_open_in_dev_container(false);
|
||||
let task = cx.spawn_in(window, async move |workspace, cx| {
|
||||
let scans_complete =
|
||||
workspace.update(cx, |workspace, cx| workspace.worktree_scans_complete(cx))?;
|
||||
scans_complete.await;
|
||||
|
||||
workspace.update_in(cx, |workspace, window, cx| {
|
||||
let has_configs = workspace
|
||||
.project()
|
||||
.read(cx)
|
||||
.worktrees(cx)
|
||||
.any(|wt| !find_configs_in_snapshot(wt.read(cx)).is_empty());
|
||||
if has_configs {
|
||||
cx.on_next_frame(window, move |_workspace, window, cx| {
|
||||
window.dispatch_action(Box::new(zed_actions::OpenDevContainer), cx);
|
||||
});
|
||||
} else {
|
||||
log::warn!("--dev-container: no devcontainer configuration found in project");
|
||||
}
|
||||
})
|
||||
});
|
||||
workspace.set_dev_container_task(task);
|
||||
return;
|
||||
}
|
||||
|
||||
if !has_configs {
|
||||
return;
|
||||
}
|
||||
|
||||
let abs_path = worktree.abs_path();
|
||||
let project_path = abs_path.to_string_lossy().to_string();
|
||||
let worktree_name = worktree.root_name_str().to_string();
|
||||
let key_for_dismiss = project_devcontainer_key(&project_path);
|
||||
|
||||
let already_dismissed = KeyValueStore::global(cx)
|
||||
.read_kvp(&key_for_dismiss)
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some();
|
||||
|
||||
if already_dismissed {
|
||||
return;
|
||||
}
|
||||
|
||||
cx.on_next_frame(window, move |workspace, _window, cx| {
|
||||
struct DevContainerSuggestionNotification;
|
||||
|
||||
let notification_id = NotificationId::composite::<DevContainerSuggestionNotification>(
|
||||
SharedString::from(project_path.clone()),
|
||||
);
|
||||
|
||||
workspace.show_notification(notification_id, cx, |cx| {
|
||||
cx.new(move |cx| {
|
||||
let message: SharedString = format!(
|
||||
"{worktree_name} contains a Dev Container configuration file. Would you like to re-open it in a container?"
|
||||
)
|
||||
.into();
|
||||
let tooltip_text: SharedString = project_path.clone().into();
|
||||
MessageNotification::new_from_builder(cx, move |_window, _cx| {
|
||||
div()
|
||||
.id("dev-container-suggest-message")
|
||||
.child(Label::new(message.clone()))
|
||||
.tooltip(Tooltip::text(tooltip_text.clone()))
|
||||
.into_any_element()
|
||||
})
|
||||
.primary_message("Yes, Open in Container")
|
||||
.primary_icon(IconName::Check)
|
||||
.primary_icon_color(Color::Success)
|
||||
.primary_on_click({
|
||||
move |window, cx| {
|
||||
window.dispatch_action(Box::new(zed_actions::OpenDevContainer), cx);
|
||||
}
|
||||
})
|
||||
.secondary_message("Don't Show Again")
|
||||
.secondary_icon(IconName::Close)
|
||||
.secondary_icon_color(Color::Error)
|
||||
.secondary_on_click({
|
||||
move |_window, cx| {
|
||||
let key = key_for_dismiss.clone();
|
||||
let kvp = KeyValueStore::global(cx);
|
||||
cx.background_spawn(async move {
|
||||
kvp.write_kvp(key, "dismissed".to_string())
|
||||
.await
|
||||
.log_err();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
}
|
||||
214
crates/recent_projects/src/disconnected_overlay.rs
Normal file
214
crates/recent_projects/src/disconnected_overlay.rs
Normal file
@@ -0,0 +1,214 @@
|
||||
use gpui::{ClickEvent, DismissEvent, EventEmitter, FocusHandle, Focusable, Render, WeakEntity};
|
||||
use project::project_settings::ProjectSettings;
|
||||
use remote::RemoteConnectionOptions;
|
||||
use settings::Settings;
|
||||
use ui::{ElevationIndex, Modal, ModalFooter, ModalHeader, Section, prelude::*};
|
||||
use workspace::{
|
||||
ModalView, MultiWorkspace, OpenOptions, Workspace, notifications::DetachAndPromptErr,
|
||||
};
|
||||
|
||||
use crate::open_remote_project;
|
||||
|
||||
enum Host {
|
||||
CollabGuestProject,
|
||||
RemoteServerProject(RemoteConnectionOptions, bool),
|
||||
}
|
||||
|
||||
pub struct DisconnectedOverlay {
|
||||
workspace: WeakEntity<Workspace>,
|
||||
host: Host,
|
||||
focus_handle: FocusHandle,
|
||||
finished: bool,
|
||||
}
|
||||
|
||||
impl EventEmitter<DismissEvent> for DisconnectedOverlay {}
|
||||
impl Focusable for DisconnectedOverlay {
|
||||
fn focus_handle(&self, _cx: &gpui::App) -> gpui::FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
impl ModalView for DisconnectedOverlay {
|
||||
fn on_before_dismiss(
|
||||
&mut self,
|
||||
_window: &mut Window,
|
||||
_: &mut Context<Self>,
|
||||
) -> workspace::DismissDecision {
|
||||
workspace::DismissDecision::Dismiss(self.finished)
|
||||
}
|
||||
fn fade_out_background(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl DisconnectedOverlay {
|
||||
pub fn register(
|
||||
workspace: &mut Workspace,
|
||||
window: Option<&mut Window>,
|
||||
cx: &mut Context<Workspace>,
|
||||
) {
|
||||
let Some(window) = window else {
|
||||
return;
|
||||
};
|
||||
cx.subscribe_in(
|
||||
workspace.project(),
|
||||
window,
|
||||
|workspace, project, event, window, cx| {
|
||||
if !matches!(
|
||||
event,
|
||||
project::Event::DisconnectedFromHost
|
||||
| project::Event::DisconnectedFromRemote { .. }
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let handle = cx.entity().downgrade();
|
||||
|
||||
let remote_connection_options = project.read(cx).remote_connection_options(cx);
|
||||
let host = if let Some(remote_connection_options) = remote_connection_options {
|
||||
Host::RemoteServerProject(
|
||||
remote_connection_options,
|
||||
matches!(
|
||||
event,
|
||||
project::Event::DisconnectedFromRemote {
|
||||
server_not_running: true
|
||||
}
|
||||
),
|
||||
)
|
||||
} else {
|
||||
Host::CollabGuestProject
|
||||
};
|
||||
|
||||
workspace.toggle_modal(window, cx, |_, cx| DisconnectedOverlay {
|
||||
finished: false,
|
||||
workspace: handle,
|
||||
host,
|
||||
focus_handle: cx.focus_handle(),
|
||||
});
|
||||
},
|
||||
)
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn handle_reconnect(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.finished = true;
|
||||
cx.emit(DismissEvent);
|
||||
|
||||
if let Host::RemoteServerProject(remote_connection_options, _) = &self.host {
|
||||
self.reconnect_to_remote_project(remote_connection_options.clone(), window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn reconnect_to_remote_project(
|
||||
&self,
|
||||
connection_options: RemoteConnectionOptions,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(workspace) = self.workspace.upgrade() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(window_handle) = window.window_handle().downcast::<MultiWorkspace>() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let app_state = workspace.read(cx).app_state().clone();
|
||||
let paths = workspace
|
||||
.read(cx)
|
||||
.root_paths(cx)
|
||||
.iter()
|
||||
.map(|path| path.to_path_buf())
|
||||
.collect();
|
||||
|
||||
cx.spawn_in(window, async move |_, cx| {
|
||||
open_remote_project(
|
||||
connection_options,
|
||||
paths,
|
||||
app_state,
|
||||
OpenOptions {
|
||||
requesting_window: Some(window_handle),
|
||||
..Default::default()
|
||||
},
|
||||
cx,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
})
|
||||
.detach_and_prompt_err("Failed to reconnect", window, cx, |_, _, _| None);
|
||||
}
|
||||
|
||||
fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
|
||||
self.finished = true;
|
||||
cx.emit(DismissEvent)
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for DisconnectedOverlay {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let can_reconnect = matches!(self.host, Host::RemoteServerProject(..));
|
||||
|
||||
let message = match &self.host {
|
||||
Host::CollabGuestProject => {
|
||||
"Your connection to the remote project has been lost.".to_string()
|
||||
}
|
||||
Host::RemoteServerProject(options, server_not_running) => {
|
||||
let autosave = if ProjectSettings::get_global(cx)
|
||||
.session
|
||||
.restore_unsaved_buffers
|
||||
{
|
||||
"\nUnsaved changes are stored locally."
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let reason = if *server_not_running {
|
||||
"process exiting unexpectedly"
|
||||
} else {
|
||||
"not responding"
|
||||
};
|
||||
format!(
|
||||
"Your connection to {} has been lost due to the server {reason}.{autosave}",
|
||||
options.display_name(),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
div()
|
||||
.track_focus(&self.focus_handle(cx))
|
||||
.elevation_3(cx)
|
||||
.on_action(cx.listener(Self::cancel))
|
||||
.occlude()
|
||||
.w(rems(24.))
|
||||
.max_h(rems(40.))
|
||||
.child(
|
||||
Modal::new("disconnected", None)
|
||||
.header(
|
||||
ModalHeader::new()
|
||||
.show_dismiss_button(true)
|
||||
.child(Headline::new("Disconnected").size(HeadlineSize::Small)),
|
||||
)
|
||||
.section(Section::new().child(Label::new(message)))
|
||||
.footer(
|
||||
ModalFooter::new().end_slot(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
Button::new("close-window", "Close Window")
|
||||
.style(ButtonStyle::Filled)
|
||||
.layer(ElevationIndex::ModalSurface)
|
||||
.on_click(cx.listener(move |_, _, window, _| {
|
||||
window.remove_window();
|
||||
})),
|
||||
)
|
||||
.when(can_reconnect, |el| {
|
||||
el.child(
|
||||
Button::new("reconnect", "Reconnect")
|
||||
.style(ButtonStyle::Filled)
|
||||
.layer(ElevationIndex::ModalSurface)
|
||||
.start_icon(Icon::new(IconName::ArrowCircle))
|
||||
.on_click(cx.listener(Self::handle_reconnect)),
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
2831
crates/recent_projects/src/recent_projects.rs
Normal file
2831
crates/recent_projects/src/recent_projects.rs
Normal file
File diff suppressed because it is too large
Load Diff
897
crates/recent_projects/src/remote_connections.rs
Normal file
897
crates/recent_projects/src/remote_connections.rs
Normal file
@@ -0,0 +1,897 @@
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
use askpass::EncryptedPassword;
|
||||
use editor::Editor;
|
||||
use extension_host::ExtensionStore;
|
||||
use futures::{FutureExt as _, channel::oneshot, select};
|
||||
use gpui::{AppContext, AsyncApp, PromptLevel, WindowHandle};
|
||||
|
||||
use project::trusted_worktrees;
|
||||
use remote::{
|
||||
DockerConnectionOptions, Interactive, RemoteConnection, RemoteConnectionOptions,
|
||||
SshConnectionOptions,
|
||||
};
|
||||
pub use settings::SshConnection;
|
||||
use settings::{DevContainerConnection, ExtendingVec, RegisterSetting, Settings, WslConnection};
|
||||
use util::paths::PathWithPosition;
|
||||
use workspace::{
|
||||
AppState, MultiWorkspace, OpenOptions, SerializedWorkspaceLocation, Workspace,
|
||||
find_existing_workspace,
|
||||
};
|
||||
|
||||
pub use remote_connection::{
|
||||
RemoteClientDelegate, RemoteConnectionModal, RemoteConnectionPrompt, SshConnectionHeader,
|
||||
connect,
|
||||
};
|
||||
|
||||
#[derive(RegisterSetting)]
|
||||
pub struct RemoteSettings {
|
||||
pub ssh_connections: ExtendingVec<SshConnection>,
|
||||
pub wsl_connections: ExtendingVec<WslConnection>,
|
||||
/// Whether to read ~/.ssh/config for ssh connection sources.
|
||||
pub read_ssh_config: bool,
|
||||
}
|
||||
|
||||
impl RemoteSettings {
|
||||
pub fn ssh_connections(&self) -> impl Iterator<Item = SshConnection> + use<> {
|
||||
self.ssh_connections.clone().0.into_iter()
|
||||
}
|
||||
|
||||
pub fn wsl_connections(&self) -> impl Iterator<Item = WslConnection> + use<> {
|
||||
self.wsl_connections.clone().0.into_iter()
|
||||
}
|
||||
|
||||
pub fn fill_connection_options_from_settings(&self, options: &mut SshConnectionOptions) {
|
||||
for conn in self.ssh_connections() {
|
||||
if conn.host == options.host.to_string()
|
||||
&& conn.username == options.username
|
||||
&& conn.port == options.port
|
||||
{
|
||||
options.nickname = conn.nickname;
|
||||
options.upload_binary_over_ssh = conn.upload_binary_over_ssh.unwrap_or_default();
|
||||
options.args = Some(conn.args);
|
||||
options.port_forwards = conn.port_forwards;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn connection_options_for(
|
||||
&self,
|
||||
host: String,
|
||||
port: Option<u16>,
|
||||
username: Option<String>,
|
||||
) -> SshConnectionOptions {
|
||||
let mut options = SshConnectionOptions {
|
||||
host: host.into(),
|
||||
port,
|
||||
username,
|
||||
..Default::default()
|
||||
};
|
||||
self.fill_connection_options_from_settings(&mut options);
|
||||
options
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub enum Connection {
|
||||
Ssh(SshConnection),
|
||||
Wsl(WslConnection),
|
||||
DevContainer(DevContainerConnection),
|
||||
}
|
||||
|
||||
impl From<Connection> for RemoteConnectionOptions {
|
||||
fn from(val: Connection) -> Self {
|
||||
match val {
|
||||
Connection::Ssh(conn) => RemoteConnectionOptions::Ssh(conn.into()),
|
||||
Connection::Wsl(conn) => RemoteConnectionOptions::Wsl(conn.into()),
|
||||
Connection::DevContainer(conn) => {
|
||||
RemoteConnectionOptions::Docker(DockerConnectionOptions {
|
||||
name: conn.name,
|
||||
remote_user: conn.remote_user,
|
||||
container_id: conn.container_id,
|
||||
upload_binary_over_docker_exec: false,
|
||||
use_podman: conn.use_podman,
|
||||
remote_env: conn.remote_env,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SshConnection> for Connection {
|
||||
fn from(val: SshConnection) -> Self {
|
||||
Connection::Ssh(val)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<WslConnection> for Connection {
|
||||
fn from(val: WslConnection) -> Self {
|
||||
Connection::Wsl(val)
|
||||
}
|
||||
}
|
||||
|
||||
impl Settings for RemoteSettings {
|
||||
fn from_settings(content: &settings::SettingsContent) -> Self {
|
||||
let remote = &content.remote;
|
||||
Self {
|
||||
ssh_connections: remote.ssh_connections.clone().unwrap_or_default().into(),
|
||||
wsl_connections: remote.wsl_connections.clone().unwrap_or_default().into(),
|
||||
read_ssh_config: remote.read_ssh_config.unwrap(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn open_remote_project(
|
||||
connection_options: RemoteConnectionOptions,
|
||||
paths: Vec<PathBuf>,
|
||||
app_state: Arc<AppState>,
|
||||
open_options: workspace::OpenOptions,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Result<WindowHandle<MultiWorkspace>> {
|
||||
let created_new_window = open_options.requesting_window.is_none();
|
||||
|
||||
let (existing, open_visible) = find_existing_workspace(
|
||||
&paths,
|
||||
&open_options,
|
||||
&SerializedWorkspaceLocation::Remote(connection_options.clone()),
|
||||
cx,
|
||||
)
|
||||
.await;
|
||||
|
||||
if let Some((existing_window, existing_workspace)) = existing {
|
||||
let remote_connection = cx.update(|cx| {
|
||||
existing_workspace
|
||||
.read(cx)
|
||||
.project()
|
||||
.read(cx)
|
||||
.remote_client()
|
||||
.and_then(|client| client.read(cx).remote_connection())
|
||||
});
|
||||
|
||||
if let Some(remote_connection) = remote_connection {
|
||||
let (resolved_paths, paths_with_positions) =
|
||||
determine_paths_with_positions(&remote_connection, paths).await;
|
||||
|
||||
let open_results = existing_window
|
||||
.update(cx, |multi_workspace, window, cx| {
|
||||
window.activate_window();
|
||||
multi_workspace.activate(existing_workspace.clone(), None, window, cx);
|
||||
existing_workspace.update(cx, |workspace, cx| {
|
||||
workspace.open_paths(
|
||||
resolved_paths,
|
||||
OpenOptions {
|
||||
visible: Some(open_visible),
|
||||
..Default::default()
|
||||
},
|
||||
None,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
})?
|
||||
.await;
|
||||
|
||||
_ = existing_window.update(cx, |multi_workspace, _, cx| {
|
||||
let workspace = multi_workspace.workspace().clone();
|
||||
workspace.update(cx, |workspace, cx| {
|
||||
for item in open_results.iter().flatten() {
|
||||
if let Err(e) = item {
|
||||
workspace.show_error(&e, cx);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
let items = open_results
|
||||
.into_iter()
|
||||
.map(|r| r.and_then(|r| r.ok()))
|
||||
.collect::<Vec<_>>();
|
||||
navigate_to_positions(&existing_window, items, &paths_with_positions, cx);
|
||||
|
||||
return Ok(existing_window);
|
||||
}
|
||||
// If the remote connection is dead (e.g. server not running after failed reconnect),
|
||||
// fall through to establish a fresh connection instead of showing an error.
|
||||
log::info!(
|
||||
"existing remote workspace found but connection is dead, starting fresh connection"
|
||||
);
|
||||
}
|
||||
|
||||
let (window, initial_workspace) = if let Some(window) = open_options.requesting_window {
|
||||
let workspace = window.update(cx, |multi_workspace, _, _| {
|
||||
multi_workspace.workspace().clone()
|
||||
})?;
|
||||
(window, workspace)
|
||||
} else {
|
||||
let workspace_position = cx
|
||||
.update(|cx| {
|
||||
workspace::remote_workspace_position_from_db(connection_options.clone(), &paths, cx)
|
||||
})
|
||||
.await
|
||||
.context("fetching remote workspace position from db")?;
|
||||
|
||||
let mut options =
|
||||
cx.update(|cx| (app_state.build_window_options)(workspace_position.display, cx));
|
||||
options.window_bounds = workspace_position.window_bounds;
|
||||
|
||||
let window = cx.open_window(options, |window, cx| {
|
||||
let project = project::Project::local(
|
||||
app_state.client.clone(),
|
||||
app_state.node_runtime.clone(),
|
||||
app_state.user_store.clone(),
|
||||
app_state.languages.clone(),
|
||||
app_state.fs.clone(),
|
||||
None,
|
||||
project::LocalProjectFlags {
|
||||
init_worktree_trust: false,
|
||||
..Default::default()
|
||||
},
|
||||
cx,
|
||||
);
|
||||
let workspace = cx.new(|cx| {
|
||||
let mut workspace = Workspace::new(None, project, app_state.clone(), window, cx);
|
||||
workspace.centered_layout = workspace_position.centered_layout;
|
||||
workspace
|
||||
});
|
||||
cx.new(|cx| MultiWorkspace::new(workspace, window, cx))
|
||||
})?;
|
||||
let workspace = window.update(cx, |multi_workspace, _, _cx| {
|
||||
multi_workspace.workspace().clone()
|
||||
})?;
|
||||
(window, workspace)
|
||||
};
|
||||
|
||||
loop {
|
||||
let (cancel_tx, mut cancel_rx) = oneshot::channel();
|
||||
let delegate = window.update(cx, {
|
||||
let paths = paths.clone();
|
||||
let connection_options = connection_options.clone();
|
||||
let initial_workspace = initial_workspace.clone();
|
||||
move |_multi_workspace: &mut MultiWorkspace, window, cx| {
|
||||
window.activate_window();
|
||||
initial_workspace.update(cx, |workspace, cx| {
|
||||
workspace.hide_modal(window, cx);
|
||||
workspace.toggle_modal(window, cx, |window, cx| {
|
||||
RemoteConnectionModal::new(&connection_options, paths, window, cx)
|
||||
});
|
||||
|
||||
let ui = workspace
|
||||
.active_modal::<RemoteConnectionModal>(cx)?
|
||||
.read(cx)
|
||||
.prompt
|
||||
.clone();
|
||||
|
||||
ui.update(cx, |ui, _cx| {
|
||||
ui.set_cancellation_tx(cancel_tx);
|
||||
});
|
||||
|
||||
Some(Arc::new(RemoteClientDelegate::new(
|
||||
window.window_handle(),
|
||||
ui.downgrade(),
|
||||
if let RemoteConnectionOptions::Ssh(options) = &connection_options {
|
||||
options
|
||||
.password
|
||||
.as_deref()
|
||||
.and_then(|pw| EncryptedPassword::try_from(pw).ok())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
)))
|
||||
})
|
||||
}
|
||||
})?;
|
||||
|
||||
let Some(delegate) = delegate else { break };
|
||||
|
||||
let connection = remote::connect(connection_options.clone(), delegate.clone(), cx);
|
||||
let connection = select! {
|
||||
_ = cancel_rx => {
|
||||
initial_workspace.update(cx, |workspace, cx| {
|
||||
if let Some(ui) = workspace.active_modal::<RemoteConnectionModal>(cx) {
|
||||
ui.update(cx, |modal, cx| modal.finished(cx))
|
||||
}
|
||||
});
|
||||
|
||||
break;
|
||||
},
|
||||
result = connection.fuse() => result,
|
||||
};
|
||||
let remote_connection = match connection {
|
||||
Ok(connection) => connection,
|
||||
Err(e) => {
|
||||
initial_workspace.update(cx, |workspace, cx| {
|
||||
if let Some(ui) = workspace.active_modal::<RemoteConnectionModal>(cx) {
|
||||
ui.update(cx, |modal, cx| modal.finished(cx))
|
||||
}
|
||||
});
|
||||
log::error!("Failed to open project: {e:#}");
|
||||
let response = window
|
||||
.update(cx, |_, window, cx| {
|
||||
window.prompt(
|
||||
PromptLevel::Critical,
|
||||
match connection_options {
|
||||
RemoteConnectionOptions::Ssh(_) => "Failed to connect over SSH",
|
||||
RemoteConnectionOptions::Wsl(_) => "Failed to connect to WSL",
|
||||
RemoteConnectionOptions::Docker(_) => {
|
||||
"Failed to connect to Dev Container"
|
||||
}
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
RemoteConnectionOptions::Mock(_) => {
|
||||
"Failed to connect to mock server"
|
||||
}
|
||||
},
|
||||
Some(&format!("{e:#}")),
|
||||
&["Retry", "Cancel"],
|
||||
cx,
|
||||
)
|
||||
})?
|
||||
.await;
|
||||
|
||||
if response == Ok(0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if created_new_window {
|
||||
window
|
||||
.update(cx, |_, window, _| window.remove_window())
|
||||
.ok();
|
||||
}
|
||||
return Ok(window);
|
||||
}
|
||||
};
|
||||
|
||||
let (paths, paths_with_positions) =
|
||||
determine_paths_with_positions(&remote_connection, paths.clone()).await;
|
||||
|
||||
let opened_items = cx
|
||||
.update(|cx| {
|
||||
workspace::open_remote_project_with_new_connection(
|
||||
window,
|
||||
remote_connection,
|
||||
cancel_rx,
|
||||
delegate.clone(),
|
||||
app_state.clone(),
|
||||
paths.clone(),
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.await;
|
||||
|
||||
initial_workspace.update(cx, |workspace, cx| {
|
||||
if let Some(ui) = workspace.active_modal::<RemoteConnectionModal>(cx) {
|
||||
ui.update(cx, |modal, cx| modal.finished(cx))
|
||||
}
|
||||
});
|
||||
|
||||
match opened_items {
|
||||
Err(e) => {
|
||||
log::error!("Failed to open project: {e:#}");
|
||||
let response = window
|
||||
.update(cx, |_, window, cx| {
|
||||
window.prompt(
|
||||
PromptLevel::Critical,
|
||||
match connection_options {
|
||||
RemoteConnectionOptions::Ssh(_) => "Failed to connect over SSH",
|
||||
RemoteConnectionOptions::Wsl(_) => "Failed to connect to WSL",
|
||||
RemoteConnectionOptions::Docker(_) => {
|
||||
"Failed to connect to Dev Container"
|
||||
}
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
RemoteConnectionOptions::Mock(_) => {
|
||||
"Failed to connect to mock server"
|
||||
}
|
||||
},
|
||||
Some(&format!("{e:#}")),
|
||||
&["Retry", "Cancel"],
|
||||
cx,
|
||||
)
|
||||
})?
|
||||
.await;
|
||||
if response == Ok(0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if created_new_window {
|
||||
window
|
||||
.update(cx, |_, window, _| window.remove_window())
|
||||
.ok();
|
||||
}
|
||||
initial_workspace.update(cx, |workspace, cx| {
|
||||
trusted_worktrees::track_worktree_trust(
|
||||
workspace.project().read(cx).worktree_store(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Ok(items) => {
|
||||
navigate_to_positions(&window, items, &paths_with_positions, cx);
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
// Register the remote client with extensions. We use `multi_workspace.workspace()` here
|
||||
// (not `initial_workspace`) because `open_remote_project_inner` activated the new remote
|
||||
// workspace, so the active workspace is now the one with the remote project.
|
||||
window
|
||||
.update(cx, |multi_workspace: &mut MultiWorkspace, _, cx| {
|
||||
let workspace = multi_workspace.workspace().clone();
|
||||
workspace.update(cx, |workspace, cx| {
|
||||
if let Some(client) = workspace.project().read(cx).remote_client() {
|
||||
if let Some(extension_store) = ExtensionStore::try_global(cx) {
|
||||
extension_store
|
||||
.update(cx, |store, cx| store.register_remote_client(client, cx));
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
Ok(window)
|
||||
}
|
||||
|
||||
pub fn navigate_to_positions(
|
||||
window: &WindowHandle<MultiWorkspace>,
|
||||
items: impl IntoIterator<Item = Option<Box<dyn workspace::item::ItemHandle>>>,
|
||||
positions: &[PathWithPosition],
|
||||
cx: &mut AsyncApp,
|
||||
) {
|
||||
for (item, path) in items.into_iter().zip(positions) {
|
||||
let Some(item) = item else {
|
||||
continue;
|
||||
};
|
||||
let Some(row) = path.row else {
|
||||
continue;
|
||||
};
|
||||
if let Some(active_editor) = item.downcast::<Editor>() {
|
||||
window
|
||||
.update(cx, |_, window, cx| {
|
||||
active_editor.update(cx, |editor, cx| {
|
||||
let row = row.saturating_sub(1);
|
||||
let col = path.column.unwrap_or(0).saturating_sub(1);
|
||||
let Some(buffer) = editor.buffer().read(cx).as_singleton() else {
|
||||
return;
|
||||
};
|
||||
let buffer_snapshot = buffer.read(cx).snapshot();
|
||||
let point = buffer_snapshot.point_from_external_input(row, col);
|
||||
editor.go_to_singleton_buffer_point(point, window, cx);
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn determine_paths_with_positions(
|
||||
remote_connection: &Arc<dyn RemoteConnection>,
|
||||
mut paths: Vec<PathBuf>,
|
||||
) -> (Vec<PathBuf>, Vec<PathWithPosition>) {
|
||||
let mut paths_with_positions = Vec::<PathWithPosition>::new();
|
||||
for path in &mut paths {
|
||||
if let Some(path_str) = path.to_str() {
|
||||
let path_with_position = PathWithPosition::parse_str(&path_str);
|
||||
if path_with_position.row.is_some() {
|
||||
if !path_exists(&remote_connection, &path).await {
|
||||
*path = path_with_position.path.clone();
|
||||
paths_with_positions.push(path_with_position);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
paths_with_positions.push(PathWithPosition::from_path(path.clone()))
|
||||
}
|
||||
(paths, paths_with_positions)
|
||||
}
|
||||
|
||||
async fn path_exists(connection: &Arc<dyn RemoteConnection>, path: &Path) -> bool {
|
||||
let Ok(command) = connection.build_command(
|
||||
Some("test".to_string()),
|
||||
&["-e".to_owned(), path.to_string_lossy().to_string()],
|
||||
&Default::default(),
|
||||
None,
|
||||
None,
|
||||
Interactive::No,
|
||||
) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(mut child) = util::command::new_command(command.program)
|
||||
.args(command.args)
|
||||
.envs(command.env)
|
||||
.spawn()
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
child.status().await.is_ok_and(|status| status.success())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use extension::ExtensionHostProxy;
|
||||
use fs::FakeFs;
|
||||
use gpui::{AppContext, TestAppContext};
|
||||
use http_client::BlockedHttpClient;
|
||||
use node_runtime::NodeRuntime;
|
||||
use remote::RemoteClient;
|
||||
use remote_server::{HeadlessAppState, HeadlessProject};
|
||||
use serde_json::json;
|
||||
use util::path;
|
||||
use workspace::find_existing_workspace;
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_open_remote_project_with_mock_connection(
|
||||
cx: &mut TestAppContext,
|
||||
server_cx: &mut TestAppContext,
|
||||
) {
|
||||
let app_state = init_test(cx);
|
||||
let executor = cx.executor();
|
||||
|
||||
cx.update(|cx| {
|
||||
release_channel::init(semver::Version::new(0, 0, 0), cx);
|
||||
});
|
||||
server_cx.update(|cx| {
|
||||
release_channel::init(semver::Version::new(0, 0, 0), cx);
|
||||
});
|
||||
|
||||
let (opts, server_session, connect_guard) = RemoteClient::fake_server(cx, server_cx);
|
||||
|
||||
let remote_fs = FakeFs::new(server_cx.executor());
|
||||
remote_fs
|
||||
.insert_tree(
|
||||
path!("/project"),
|
||||
json!({
|
||||
"src": {
|
||||
"main.rs": "fn main() {}",
|
||||
},
|
||||
"README.md": "# Test Project",
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
server_cx.update(HeadlessProject::init);
|
||||
let http_client = Arc::new(BlockedHttpClient);
|
||||
let node_runtime = NodeRuntime::unavailable();
|
||||
let languages = Arc::new(language::LanguageRegistry::new(server_cx.executor()));
|
||||
let proxy = Arc::new(ExtensionHostProxy::new());
|
||||
|
||||
let _headless = server_cx.new(|cx| {
|
||||
HeadlessProject::new(
|
||||
HeadlessAppState {
|
||||
session: server_session,
|
||||
fs: remote_fs.clone(),
|
||||
http_client,
|
||||
node_runtime,
|
||||
languages,
|
||||
extension_host_proxy: proxy,
|
||||
startup_time: std::time::Instant::now(),
|
||||
},
|
||||
false,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
drop(connect_guard);
|
||||
|
||||
let paths = vec![PathBuf::from(path!("/project"))];
|
||||
let open_options = workspace::OpenOptions::default();
|
||||
|
||||
let mut async_cx = cx.to_async();
|
||||
let result = open_remote_project(opts, paths, app_state, open_options, &mut async_cx).await;
|
||||
|
||||
executor.run_until_parked();
|
||||
|
||||
assert!(result.is_ok(), "open_remote_project should succeed");
|
||||
|
||||
let windows = cx.update(|cx| cx.windows().len());
|
||||
assert_eq!(windows, 1, "Should have opened a window");
|
||||
|
||||
let multi_workspace_handle =
|
||||
cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
|
||||
|
||||
multi_workspace_handle
|
||||
.update(cx, |multi_workspace, _, cx| {
|
||||
let workspace = multi_workspace.workspace().clone();
|
||||
workspace.update(cx, |workspace, cx| {
|
||||
let project = workspace.project().read(cx);
|
||||
assert!(project.is_remote(), "Project should be a remote project");
|
||||
});
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_reuse_existing_remote_workspace_window(
|
||||
cx: &mut TestAppContext,
|
||||
server_cx: &mut TestAppContext,
|
||||
) {
|
||||
let app_state = init_test(cx);
|
||||
let executor = cx.executor();
|
||||
|
||||
cx.update(|cx| {
|
||||
release_channel::init(semver::Version::new(0, 0, 0), cx);
|
||||
});
|
||||
server_cx.update(|cx| {
|
||||
release_channel::init(semver::Version::new(0, 0, 0), cx);
|
||||
});
|
||||
|
||||
let (opts, server_session, connect_guard) = RemoteClient::fake_server(cx, server_cx);
|
||||
|
||||
let remote_fs = FakeFs::new(server_cx.executor());
|
||||
remote_fs
|
||||
.insert_tree(
|
||||
path!("/project"),
|
||||
json!({
|
||||
"src": {
|
||||
"main.rs": "fn main() {}",
|
||||
"lib.rs": "pub fn hello() {}",
|
||||
},
|
||||
"README.md": "# Test Project",
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
server_cx.update(HeadlessProject::init);
|
||||
let http_client = Arc::new(BlockedHttpClient);
|
||||
let node_runtime = NodeRuntime::unavailable();
|
||||
let languages = Arc::new(language::LanguageRegistry::new(server_cx.executor()));
|
||||
let proxy = Arc::new(ExtensionHostProxy::new());
|
||||
|
||||
let _headless = server_cx.new(|cx| {
|
||||
HeadlessProject::new(
|
||||
HeadlessAppState {
|
||||
session: server_session,
|
||||
fs: remote_fs.clone(),
|
||||
http_client,
|
||||
node_runtime,
|
||||
languages,
|
||||
extension_host_proxy: proxy,
|
||||
startup_time: std::time::Instant::now(),
|
||||
},
|
||||
false,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
drop(connect_guard);
|
||||
|
||||
// First open: create a new window for the remote project.
|
||||
let paths = vec![PathBuf::from(path!("/project"))];
|
||||
let mut async_cx = cx.to_async();
|
||||
open_remote_project(
|
||||
opts.clone(),
|
||||
paths,
|
||||
app_state.clone(),
|
||||
workspace::OpenOptions::default(),
|
||||
&mut async_cx,
|
||||
)
|
||||
.await
|
||||
.expect("first open_remote_project should succeed");
|
||||
|
||||
executor.run_until_parked();
|
||||
|
||||
assert_eq!(
|
||||
cx.update(|cx| cx.windows().len()),
|
||||
1,
|
||||
"First open should create exactly one window"
|
||||
);
|
||||
|
||||
let first_window = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
|
||||
|
||||
// Verify find_existing_workspace discovers the remote workspace.
|
||||
let search_paths = vec![PathBuf::from(path!("/project/src/lib.rs"))];
|
||||
let (found, _open_visible) = find_existing_workspace(
|
||||
&search_paths,
|
||||
&workspace::OpenOptions::default(),
|
||||
&SerializedWorkspaceLocation::Remote(opts.clone()),
|
||||
&mut async_cx,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
found.is_some(),
|
||||
"find_existing_workspace should locate the existing remote workspace"
|
||||
);
|
||||
let (found_window, _found_workspace) = found.unwrap();
|
||||
assert_eq!(
|
||||
found_window, first_window,
|
||||
"find_existing_workspace should return the same window"
|
||||
);
|
||||
|
||||
// Second open with the same connection options should reuse the window.
|
||||
let second_paths = vec![PathBuf::from(path!("/project/src/lib.rs"))];
|
||||
open_remote_project(
|
||||
opts.clone(),
|
||||
second_paths,
|
||||
app_state.clone(),
|
||||
workspace::OpenOptions::default(),
|
||||
&mut async_cx,
|
||||
)
|
||||
.await
|
||||
.expect("second open_remote_project should succeed via reuse");
|
||||
|
||||
executor.run_until_parked();
|
||||
|
||||
assert_eq!(
|
||||
cx.update(|cx| cx.windows().len()),
|
||||
1,
|
||||
"Second open should reuse the existing window, not create a new one"
|
||||
);
|
||||
|
||||
let still_first_window =
|
||||
cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
|
||||
assert_eq!(
|
||||
still_first_window, first_window,
|
||||
"The window handle should be the same after reuse"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_reconnect_when_server_not_running(
|
||||
cx: &mut TestAppContext,
|
||||
server_cx: &mut TestAppContext,
|
||||
) {
|
||||
let app_state = init_test(cx);
|
||||
let executor = cx.executor();
|
||||
|
||||
cx.update(|cx| {
|
||||
release_channel::init(semver::Version::new(0, 0, 0), cx);
|
||||
});
|
||||
server_cx.update(|cx| {
|
||||
release_channel::init(semver::Version::new(0, 0, 0), cx);
|
||||
});
|
||||
|
||||
let (opts, server_session, connect_guard) = RemoteClient::fake_server(cx, server_cx);
|
||||
|
||||
let remote_fs = FakeFs::new(server_cx.executor());
|
||||
remote_fs
|
||||
.insert_tree(
|
||||
path!("/project"),
|
||||
json!({
|
||||
"src": {
|
||||
"main.rs": "fn main() {}",
|
||||
},
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
server_cx.update(HeadlessProject::init);
|
||||
let http_client = Arc::new(BlockedHttpClient);
|
||||
let node_runtime = NodeRuntime::unavailable();
|
||||
let languages = Arc::new(language::LanguageRegistry::new(server_cx.executor()));
|
||||
let proxy = Arc::new(ExtensionHostProxy::new());
|
||||
|
||||
let _headless = server_cx.new(|cx| {
|
||||
HeadlessProject::new(
|
||||
HeadlessAppState {
|
||||
session: server_session,
|
||||
fs: remote_fs.clone(),
|
||||
http_client: http_client.clone(),
|
||||
node_runtime: node_runtime.clone(),
|
||||
languages: languages.clone(),
|
||||
extension_host_proxy: proxy.clone(),
|
||||
startup_time: std::time::Instant::now(),
|
||||
},
|
||||
false,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
drop(connect_guard);
|
||||
|
||||
// Open the remote project normally.
|
||||
let paths = vec![PathBuf::from(path!("/project"))];
|
||||
let mut async_cx = cx.to_async();
|
||||
open_remote_project(
|
||||
opts.clone(),
|
||||
paths.clone(),
|
||||
app_state.clone(),
|
||||
workspace::OpenOptions::default(),
|
||||
&mut async_cx,
|
||||
)
|
||||
.await
|
||||
.expect("initial open should succeed");
|
||||
|
||||
executor.run_until_parked();
|
||||
|
||||
assert_eq!(cx.update(|cx| cx.windows().len()), 1);
|
||||
let window = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
|
||||
|
||||
// Force the remote client into ServerNotRunning state (simulates the
|
||||
// scenario where the remote server died and reconnection failed).
|
||||
window
|
||||
.update(cx, |multi_workspace, _, cx| {
|
||||
let workspace = multi_workspace.workspace().clone();
|
||||
workspace.update(cx, |workspace, cx| {
|
||||
let client = workspace
|
||||
.project()
|
||||
.read(cx)
|
||||
.remote_client()
|
||||
.expect("should have remote client");
|
||||
client.update(cx, |client, cx| {
|
||||
client.force_server_not_running(cx);
|
||||
});
|
||||
});
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
executor.run_until_parked();
|
||||
|
||||
// Register a new mock server under the same options so the reconnect
|
||||
// path can establish a fresh connection.
|
||||
let (server_session_2, connect_guard_2) =
|
||||
RemoteClient::fake_server_with_opts(&opts, cx, server_cx);
|
||||
|
||||
let _headless_2 = server_cx.new(|cx| {
|
||||
HeadlessProject::new(
|
||||
HeadlessAppState {
|
||||
session: server_session_2,
|
||||
fs: remote_fs.clone(),
|
||||
http_client,
|
||||
node_runtime,
|
||||
languages,
|
||||
extension_host_proxy: proxy,
|
||||
startup_time: std::time::Instant::now(),
|
||||
},
|
||||
false,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
drop(connect_guard_2);
|
||||
|
||||
// Simulate clicking "Reconnect": calls open_remote_project with
|
||||
// replace_window pointing to the existing window.
|
||||
let result = open_remote_project(
|
||||
opts,
|
||||
paths,
|
||||
app_state,
|
||||
workspace::OpenOptions {
|
||||
requesting_window: Some(window),
|
||||
..Default::default()
|
||||
},
|
||||
&mut async_cx,
|
||||
)
|
||||
.await;
|
||||
|
||||
executor.run_until_parked();
|
||||
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"reconnect should succeed but got: {:?}",
|
||||
result.err()
|
||||
);
|
||||
|
||||
// Should still be a single window with a working remote project.
|
||||
assert_eq!(cx.update(|cx| cx.windows().len()), 1);
|
||||
|
||||
window
|
||||
.update(cx, |multi_workspace, _, cx| {
|
||||
let workspace = multi_workspace.workspace().clone();
|
||||
workspace.update(cx, |workspace, cx| {
|
||||
assert!(
|
||||
workspace.project().read(cx).is_remote(),
|
||||
"project should be remote after reconnect"
|
||||
);
|
||||
});
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
|
||||
cx.update(|cx| {
|
||||
let state = AppState::test(cx);
|
||||
crate::init(cx);
|
||||
editor::init(cx);
|
||||
state
|
||||
})
|
||||
}
|
||||
}
|
||||
3024
crates/recent_projects/src/remote_servers.rs
Normal file
3024
crates/recent_projects/src/remote_servers.rs
Normal file
File diff suppressed because it is too large
Load Diff
450
crates/recent_projects/src/sidebar_recent_projects.rs
Normal file
450
crates/recent_projects/src/sidebar_recent_projects.rs
Normal file
@@ -0,0 +1,450 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use fuzzy_nucleo::{StringMatch, StringMatchCandidate, match_strings};
|
||||
use gpui::{
|
||||
Action, AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
|
||||
Subscription, Task, TaskExt, WeakEntity, Window,
|
||||
};
|
||||
use picker::{
|
||||
Picker, PickerDelegate,
|
||||
highlighted_match_with_paths::{HighlightedMatch, HighlightedMatchWithPaths},
|
||||
};
|
||||
use remote::RemoteConnectionOptions;
|
||||
use settings::Settings;
|
||||
use ui::{ButtonLike, KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*};
|
||||
use ui_input::ErasedEditor;
|
||||
use util::{ResultExt, paths::PathExt};
|
||||
use workspace::{
|
||||
MultiWorkspace, OpenMode, OpenOptions, ProjectGroupKey, RecentWorkspace,
|
||||
SerializedWorkspaceLocation, Workspace, WorkspaceDb, notifications::DetachAndPromptErr,
|
||||
};
|
||||
|
||||
use zed_actions::OpenRemote;
|
||||
|
||||
use crate::{highlights_for_path, icon_for_remote_connection, open_remote_project};
|
||||
|
||||
pub struct SidebarRecentProjects {
|
||||
pub picker: Entity<Picker<SidebarRecentProjectsDelegate>>,
|
||||
_subscription: Subscription,
|
||||
}
|
||||
|
||||
impl SidebarRecentProjects {
|
||||
pub fn popover(
|
||||
workspace: WeakEntity<Workspace>,
|
||||
window_project_groups: Vec<ProjectGroupKey>,
|
||||
_focus_handle: FocusHandle,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Entity<Self> {
|
||||
let fs = workspace
|
||||
.upgrade()
|
||||
.map(|ws| ws.read(cx).app_state().fs.clone());
|
||||
|
||||
cx.new(|cx| {
|
||||
let delegate = SidebarRecentProjectsDelegate {
|
||||
workspace,
|
||||
window_project_groups,
|
||||
workspaces: Vec::new(),
|
||||
filtered_workspaces: Vec::new(),
|
||||
selected_index: 0,
|
||||
has_any_non_local_projects: false,
|
||||
focus_handle: cx.focus_handle(),
|
||||
};
|
||||
|
||||
let picker: Entity<Picker<SidebarRecentProjectsDelegate>> = cx.new(|cx| {
|
||||
Picker::list(delegate, window, cx)
|
||||
.list_measure_all()
|
||||
.show_scrollbar(true)
|
||||
});
|
||||
|
||||
let picker_focus_handle = picker.focus_handle(cx);
|
||||
picker.update(cx, |picker, _| {
|
||||
picker.delegate.focus_handle = picker_focus_handle;
|
||||
});
|
||||
|
||||
let _subscription =
|
||||
cx.subscribe(&picker, |_this: &mut Self, _, _, cx| cx.emit(DismissEvent));
|
||||
|
||||
let db = WorkspaceDb::global(cx);
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let Some(fs) = fs else { return };
|
||||
let workspaces = db
|
||||
.recent_project_workspaces(fs.as_ref())
|
||||
.await
|
||||
.log_err()
|
||||
.unwrap_or_default();
|
||||
this.update_in(cx, move |this, window, cx| {
|
||||
this.picker.update(cx, move |picker, cx| {
|
||||
picker.delegate.set_workspaces(workspaces);
|
||||
picker.update_matches(picker.query(cx), window, cx)
|
||||
})
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
|
||||
picker.focus_handle(cx).focus(window, cx);
|
||||
|
||||
Self {
|
||||
picker,
|
||||
_subscription,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<DismissEvent> for SidebarRecentProjects {}
|
||||
|
||||
impl Focusable for SidebarRecentProjects {
|
||||
fn focus_handle(&self, cx: &App) -> FocusHandle {
|
||||
self.picker.focus_handle(cx)
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for SidebarRecentProjects {
|
||||
fn render(&mut self, _: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
v_flex()
|
||||
.key_context("SidebarRecentProjects")
|
||||
.w(rems(18.))
|
||||
.child(self.picker.clone())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SidebarRecentProjectsDelegate {
|
||||
workspace: WeakEntity<Workspace>,
|
||||
window_project_groups: Vec<ProjectGroupKey>,
|
||||
workspaces: Vec<RecentWorkspace>,
|
||||
filtered_workspaces: Vec<StringMatch>,
|
||||
selected_index: usize,
|
||||
has_any_non_local_projects: bool,
|
||||
focus_handle: FocusHandle,
|
||||
}
|
||||
|
||||
impl SidebarRecentProjectsDelegate {
|
||||
pub fn set_workspaces(&mut self, workspaces: Vec<RecentWorkspace>) {
|
||||
self.has_any_non_local_projects = workspaces
|
||||
.iter()
|
||||
.any(|workspace| !matches!(workspace.location, SerializedWorkspaceLocation::Local));
|
||||
self.workspaces = workspaces;
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<DismissEvent> for SidebarRecentProjectsDelegate {}
|
||||
|
||||
impl PickerDelegate for SidebarRecentProjectsDelegate {
|
||||
type ListItem = AnyElement;
|
||||
|
||||
fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
|
||||
"Search recent projects…".into()
|
||||
}
|
||||
|
||||
fn render_editor(
|
||||
&self,
|
||||
editor: &Arc<dyn ErasedEditor>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Picker<Self>>,
|
||||
) -> Div {
|
||||
h_flex()
|
||||
.flex_none()
|
||||
.h_9()
|
||||
.px_2p5()
|
||||
.justify_between()
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().colors().border_variant)
|
||||
.child(editor.render(window, cx))
|
||||
}
|
||||
|
||||
fn match_count(&self) -> usize {
|
||||
self.filtered_workspaces.len()
|
||||
}
|
||||
|
||||
fn selected_index(&self) -> usize {
|
||||
self.selected_index
|
||||
}
|
||||
|
||||
fn set_selected_index(
|
||||
&mut self,
|
||||
ix: usize,
|
||||
_window: &mut Window,
|
||||
_cx: &mut Context<Picker<Self>>,
|
||||
) {
|
||||
self.selected_index = ix;
|
||||
}
|
||||
|
||||
fn update_matches(
|
||||
&mut self,
|
||||
query: String,
|
||||
_: &mut Window,
|
||||
cx: &mut Context<Picker<Self>>,
|
||||
) -> Task<()> {
|
||||
let query = query.trim_start();
|
||||
let case = fuzzy_nucleo::Case::smart_if_uppercase_in(query);
|
||||
let is_empty_query = query.is_empty();
|
||||
|
||||
let current_workspace_id = self
|
||||
.workspace
|
||||
.upgrade()
|
||||
.and_then(|ws| ws.read(cx).database_id());
|
||||
|
||||
let candidates: Vec<_> = self
|
||||
.workspaces
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, workspace)| {
|
||||
Some(workspace.workspace_id) != current_workspace_id
|
||||
&& !self
|
||||
.window_project_groups
|
||||
.iter()
|
||||
.any(|key| key.matches(&workspace.project_group_key()))
|
||||
})
|
||||
.map(|(id, workspace)| {
|
||||
let combined_string = workspace
|
||||
.identity_paths
|
||||
.ordered_paths()
|
||||
.map(|path| path.compact().to_string_lossy().into_owned())
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
StringMatchCandidate::new(id, &combined_string)
|
||||
})
|
||||
.collect();
|
||||
|
||||
if is_empty_query {
|
||||
self.filtered_workspaces = candidates
|
||||
.into_iter()
|
||||
.map(|candidate| StringMatch {
|
||||
candidate_id: candidate.id,
|
||||
score: 0.0,
|
||||
positions: Vec::new(),
|
||||
string: candidate.string,
|
||||
})
|
||||
.collect();
|
||||
} else {
|
||||
self.filtered_workspaces = match_strings(
|
||||
&candidates,
|
||||
query,
|
||||
case,
|
||||
fuzzy_nucleo::LengthPenalty::On,
|
||||
100,
|
||||
);
|
||||
}
|
||||
|
||||
self.selected_index = 0;
|
||||
Task::ready(())
|
||||
}
|
||||
|
||||
fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
|
||||
let Some(hit) = self.filtered_workspaces.get(self.selected_index) else {
|
||||
return;
|
||||
};
|
||||
let Some(recent_workspace) = self.workspaces.get(hit.candidate_id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(workspace) = self.workspace.upgrade() else {
|
||||
return;
|
||||
};
|
||||
|
||||
match &recent_workspace.location {
|
||||
SerializedWorkspaceLocation::Local => {
|
||||
if let Some(handle) = window.window_handle().downcast::<MultiWorkspace>() {
|
||||
let paths = recent_workspace.paths.paths().to_vec();
|
||||
cx.defer(move |cx| {
|
||||
if let Some(task) = handle
|
||||
.update(cx, |multi_workspace, window, cx| {
|
||||
multi_workspace.open_project(paths, OpenMode::Activate, window, cx)
|
||||
})
|
||||
.log_err()
|
||||
{
|
||||
task.detach_and_log_err(cx);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
SerializedWorkspaceLocation::Remote(connection) => {
|
||||
let mut connection = connection.clone();
|
||||
workspace.update(cx, |workspace, cx| {
|
||||
let app_state = workspace.app_state().clone();
|
||||
let replace_window = window.window_handle().downcast::<MultiWorkspace>();
|
||||
let open_options = OpenOptions {
|
||||
requesting_window: replace_window,
|
||||
..Default::default()
|
||||
};
|
||||
if let RemoteConnectionOptions::Ssh(connection) = &mut connection {
|
||||
crate::RemoteSettings::get_global(cx)
|
||||
.fill_connection_options_from_settings(connection);
|
||||
};
|
||||
let paths = recent_workspace.paths.paths().to_vec();
|
||||
cx.spawn_in(window, async move |_, cx| {
|
||||
open_remote_project(connection.clone(), paths, app_state, open_options, cx)
|
||||
.await
|
||||
})
|
||||
.detach_and_prompt_err(
|
||||
"Failed to open project",
|
||||
window,
|
||||
cx,
|
||||
|_, _, _| None,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
cx.emit(DismissEvent);
|
||||
}
|
||||
|
||||
fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context<Picker<Self>>) {}
|
||||
|
||||
fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
|
||||
let text = if self.workspaces.is_empty() {
|
||||
"Recently opened projects will show up here"
|
||||
} else {
|
||||
"No matches"
|
||||
};
|
||||
Some(text.into())
|
||||
}
|
||||
|
||||
fn render_match(
|
||||
&self,
|
||||
ix: usize,
|
||||
selected: bool,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Picker<Self>>,
|
||||
) -> Option<Self::ListItem> {
|
||||
let hit = self.filtered_workspaces.get(ix)?;
|
||||
let workspace = self.workspaces.get(hit.candidate_id)?;
|
||||
|
||||
let ordered_paths: Vec<_> = workspace
|
||||
.identity_paths
|
||||
.ordered_paths()
|
||||
.map(|p| p.compact().to_string_lossy().to_string())
|
||||
.collect();
|
||||
|
||||
let tooltip_path: SharedString = match &workspace.location {
|
||||
SerializedWorkspaceLocation::Remote(options) => {
|
||||
let host = options.display_name();
|
||||
if ordered_paths.len() == 1 {
|
||||
format!("{} ({})", ordered_paths[0], host).into()
|
||||
} else {
|
||||
format!("{}\n({})", ordered_paths.join("\n"), host).into()
|
||||
}
|
||||
}
|
||||
_ => ordered_paths.join("\n").into(),
|
||||
};
|
||||
|
||||
let mut path_start_offset = 0;
|
||||
let match_labels: Vec<_> = workspace
|
||||
.identity_paths
|
||||
.ordered_paths()
|
||||
.map(|p| p.compact())
|
||||
.map(|path| {
|
||||
let (label, path_match) =
|
||||
highlights_for_path(path.as_ref(), &hit.positions, path_start_offset);
|
||||
path_start_offset += path_match.text.len();
|
||||
label
|
||||
})
|
||||
.collect();
|
||||
|
||||
let prefix = match &workspace.location {
|
||||
SerializedWorkspaceLocation::Remote(options) => {
|
||||
Some(SharedString::from(options.display_name()))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let highlighted_match = HighlightedMatchWithPaths {
|
||||
prefix,
|
||||
match_label: HighlightedMatch::join(match_labels.into_iter().flatten(), ", "),
|
||||
paths: Vec::new(),
|
||||
active: false,
|
||||
};
|
||||
|
||||
let icon = icon_for_remote_connection(match &workspace.location {
|
||||
SerializedWorkspaceLocation::Local => None,
|
||||
SerializedWorkspaceLocation::Remote(options) => Some(options),
|
||||
});
|
||||
|
||||
Some(
|
||||
ListItem::new(ix)
|
||||
.toggle_state(selected)
|
||||
.inset(true)
|
||||
.spacing(ListItemSpacing::Sparse)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_3()
|
||||
.flex_grow()
|
||||
.when(self.has_any_non_local_projects, |this| {
|
||||
this.child(Icon::new(icon).color(Color::Muted))
|
||||
})
|
||||
.child(highlighted_match.render(window, cx)),
|
||||
)
|
||||
.tooltip(move |_, cx| {
|
||||
Tooltip::with_meta(
|
||||
"Open Project in This Window",
|
||||
None,
|
||||
tooltip_path.clone(),
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.into_any_element(),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_footer(&self, _: &mut Window, cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
|
||||
let focus_handle = self.focus_handle.clone();
|
||||
|
||||
Some(
|
||||
v_flex()
|
||||
.p_1p5()
|
||||
.flex_1()
|
||||
.gap_1()
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().colors().border_variant)
|
||||
.child({
|
||||
let open_action = workspace::Open {
|
||||
create_new_window: false,
|
||||
};
|
||||
|
||||
ButtonLike::new("open_local_folder")
|
||||
.child(
|
||||
h_flex()
|
||||
.w_full()
|
||||
.gap_1()
|
||||
.justify_between()
|
||||
.child(Label::new("Add Local Folders"))
|
||||
.child(KeyBinding::for_action_in(&open_action, &focus_handle, cx)),
|
||||
)
|
||||
.on_click(cx.listener(move |_, _, window, cx| {
|
||||
window.dispatch_action(open_action.boxed_clone(), cx);
|
||||
cx.emit(DismissEvent);
|
||||
}))
|
||||
})
|
||||
.child(
|
||||
ButtonLike::new("open_remote_folder")
|
||||
.child(
|
||||
h_flex()
|
||||
.w_full()
|
||||
.gap_1()
|
||||
.justify_between()
|
||||
.child(Label::new("Add Remote Folder"))
|
||||
.child(KeyBinding::for_action(
|
||||
&OpenRemote {
|
||||
from_existing_connection: false,
|
||||
create_new_window: false,
|
||||
},
|
||||
cx,
|
||||
)),
|
||||
)
|
||||
.on_click(cx.listener(|_, _, window, cx| {
|
||||
window.dispatch_action(
|
||||
OpenRemote {
|
||||
from_existing_connection: false,
|
||||
create_new_window: false,
|
||||
}
|
||||
.boxed_clone(),
|
||||
cx,
|
||||
);
|
||||
cx.emit(DismissEvent);
|
||||
})),
|
||||
)
|
||||
.into_any(),
|
||||
)
|
||||
}
|
||||
}
|
||||
217
crates/recent_projects/src/ssh_config.rs
Normal file
217
crates/recent_projects/src/ssh_config.rs
Normal file
@@ -0,0 +1,217 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
const FILTERED_GIT_PROVIDER_HOSTNAMES: &[&str] = &[
|
||||
"dev.azure.com",
|
||||
"bitbucket.org",
|
||||
"chromium.googlesource.com",
|
||||
"codeberg.org",
|
||||
"gitea.com",
|
||||
"gitee.com",
|
||||
"github.com",
|
||||
"gist.github.com",
|
||||
"gitlab.com",
|
||||
"sourcehut.org",
|
||||
"git.sr.ht",
|
||||
];
|
||||
|
||||
pub fn parse_ssh_config_hosts(config: &str) -> BTreeSet<String> {
|
||||
parse_host_blocks(config)
|
||||
.into_iter()
|
||||
.flat_map(HostBlock::non_git_provider_hosts)
|
||||
.collect()
|
||||
}
|
||||
|
||||
struct HostBlock {
|
||||
aliases: BTreeSet<String>,
|
||||
hostname: Option<String>,
|
||||
}
|
||||
|
||||
impl HostBlock {
|
||||
fn non_git_provider_hosts(self) -> impl Iterator<Item = String> {
|
||||
let hostname = self.hostname;
|
||||
let hostname_ref = hostname.as_deref().map(is_git_provider_domain);
|
||||
self.aliases
|
||||
.into_iter()
|
||||
.filter(move |alias| !hostname_ref.unwrap_or_else(|| is_git_provider_domain(alias)))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_host_blocks(config: &str) -> Vec<HostBlock> {
|
||||
let mut blocks = Vec::new();
|
||||
let mut aliases = BTreeSet::new();
|
||||
let mut hostname = None;
|
||||
let mut needs_continuation = false;
|
||||
|
||||
for line in config.lines() {
|
||||
let line = line.trim_start();
|
||||
|
||||
if needs_continuation {
|
||||
needs_continuation = line.trim_end().ends_with('\\');
|
||||
parse_hosts(line, &mut aliases);
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some((keyword, value)) = split_keyword_and_value(line) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if keyword.eq_ignore_ascii_case("host") {
|
||||
if !aliases.is_empty() {
|
||||
blocks.push(HostBlock { aliases, hostname });
|
||||
aliases = BTreeSet::new();
|
||||
hostname = None;
|
||||
}
|
||||
parse_hosts(value, &mut aliases);
|
||||
needs_continuation = line.trim_end().ends_with('\\');
|
||||
} else if keyword.eq_ignore_ascii_case("hostname") {
|
||||
hostname = value.split_whitespace().next().map(ToOwned::to_owned);
|
||||
}
|
||||
}
|
||||
|
||||
if !aliases.is_empty() {
|
||||
blocks.push(HostBlock { aliases, hostname });
|
||||
}
|
||||
|
||||
blocks
|
||||
}
|
||||
|
||||
fn parse_hosts(line: &str, hosts: &mut BTreeSet<String>) {
|
||||
hosts.extend(
|
||||
line.split_whitespace()
|
||||
.map(|field| field.trim_end_matches('\\'))
|
||||
.filter(|field| !field.starts_with("!"))
|
||||
.filter(|field| !field.contains("*"))
|
||||
.filter(|field| *field != "\\")
|
||||
.filter(|field| !field.is_empty())
|
||||
.map(|field| field.to_owned()),
|
||||
);
|
||||
}
|
||||
|
||||
fn split_keyword_and_value(line: &str) -> Option<(&str, &str)> {
|
||||
let keyword_end = line.find(char::is_whitespace).unwrap_or(line.len());
|
||||
let keyword = &line[..keyword_end];
|
||||
if keyword.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let value = line[keyword_end..].trim_start();
|
||||
Some((keyword, value))
|
||||
}
|
||||
|
||||
fn is_git_provider_domain(host: &str) -> bool {
|
||||
let host = host.to_ascii_lowercase();
|
||||
FILTERED_GIT_PROVIDER_HOSTNAMES.contains(&host.as_str())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use indoc::indoc;
|
||||
|
||||
#[test]
|
||||
fn test_thank_you_bjorn3() {
|
||||
let hosts = indoc! {"
|
||||
Host *
|
||||
AddKeysToAgent yes
|
||||
UseKeychain yes
|
||||
IdentityFile ~/.ssh/id_ed25519
|
||||
|
||||
Host whatever.*
|
||||
User another
|
||||
|
||||
Host !not_this
|
||||
User not_me
|
||||
|
||||
Host something
|
||||
HostName whatever.tld
|
||||
|
||||
Host linux bsd host3
|
||||
User bjorn
|
||||
|
||||
Host rpi
|
||||
user rpi
|
||||
hostname rpi.local
|
||||
|
||||
Host \\
|
||||
somehost \\
|
||||
anotherhost
|
||||
Hostname 192.168.3.3
|
||||
"};
|
||||
|
||||
let expected_hosts = BTreeSet::from_iter([
|
||||
"something".to_owned(),
|
||||
"linux".to_owned(),
|
||||
"host3".to_owned(),
|
||||
"bsd".to_owned(),
|
||||
"rpi".to_owned(),
|
||||
"somehost".to_owned(),
|
||||
"anotherhost".to_owned(),
|
||||
]);
|
||||
|
||||
assert_eq!(expected_hosts, parse_ssh_config_hosts(hosts));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filters_git_provider_domains_from_hostname() {
|
||||
let hosts = indoc! {"
|
||||
Host github-personal
|
||||
HostName github.com
|
||||
|
||||
Host gitlab-work
|
||||
HostName GITLAB.COM
|
||||
|
||||
Host local
|
||||
HostName example.com
|
||||
"};
|
||||
|
||||
assert_eq!(
|
||||
BTreeSet::from_iter(["local".to_owned()]),
|
||||
parse_ssh_config_hosts(hosts)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_host_when_hostname_is_absent() {
|
||||
let hosts = indoc! {"
|
||||
Host github.com bitbucket.org keep-me
|
||||
User git
|
||||
"};
|
||||
|
||||
assert_eq!(
|
||||
BTreeSet::from_iter(["keep-me".to_owned()]),
|
||||
parse_ssh_config_hosts(hosts)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_fuzzy_match_host_aliases() {
|
||||
let hosts = indoc! {"
|
||||
Host GitHub GitLab Bitbucket GITHUB github
|
||||
User git
|
||||
"};
|
||||
|
||||
assert_eq!(
|
||||
BTreeSet::from_iter([
|
||||
"Bitbucket".to_owned(),
|
||||
"GITHUB".to_owned(),
|
||||
"GitHub".to_owned(),
|
||||
"GitLab".to_owned(),
|
||||
"github".to_owned(),
|
||||
]),
|
||||
parse_ssh_config_hosts(hosts)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uses_hostname_before_host_filtering() {
|
||||
let hosts = indoc! {"
|
||||
Host github.com keep-me
|
||||
HostName example.com
|
||||
"};
|
||||
|
||||
assert_eq!(
|
||||
BTreeSet::from_iter(["github.com".to_owned(), "keep-me".to_owned()]),
|
||||
parse_ssh_config_hosts(hosts)
|
||||
);
|
||||
}
|
||||
}
|
||||
292
crates/recent_projects/src/wsl_picker.rs
Normal file
292
crates/recent_projects/src/wsl_picker.rs
Normal file
@@ -0,0 +1,292 @@
|
||||
use std::{path::PathBuf, sync::Arc};
|
||||
|
||||
use gpui::{AppContext, DismissEvent, Entity, EventEmitter, Focusable, Subscription, Task};
|
||||
use picker::Picker;
|
||||
use remote::{RemoteConnectionOptions, WslConnectionOptions};
|
||||
use ui::{
|
||||
App, Context, HighlightedLabel, Icon, IconName, InteractiveElement, ListItem, ParentElement,
|
||||
Render, Styled, StyledExt, Toggleable, Window, div, h_flex, rems, v_flex,
|
||||
};
|
||||
use util::ResultExt as _;
|
||||
use workspace::{ModalView, MultiWorkspace};
|
||||
|
||||
use crate::open_remote_project;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct WslDistroSelected {
|
||||
pub secondary: bool,
|
||||
pub distro: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct WslPickerDismissed;
|
||||
|
||||
pub(crate) struct WslPickerDelegate {
|
||||
selected_index: usize,
|
||||
distro_list: Option<Vec<String>>,
|
||||
matches: Vec<fuzzy_nucleo::StringMatch>,
|
||||
}
|
||||
|
||||
impl WslPickerDelegate {
|
||||
pub fn new() -> Self {
|
||||
WslPickerDelegate {
|
||||
selected_index: 0,
|
||||
distro_list: None,
|
||||
matches: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn selected_distro(&self) -> Option<String> {
|
||||
self.matches
|
||||
.get(self.selected_index)
|
||||
.map(|m| m.string.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl WslPickerDelegate {
|
||||
fn fetch_distros() -> anyhow::Result<Vec<String>> {
|
||||
use anyhow::Context;
|
||||
use windows_registry::CURRENT_USER;
|
||||
|
||||
let lxss_key = CURRENT_USER
|
||||
.open("Software\\Microsoft\\Windows\\CurrentVersion\\Lxss")
|
||||
.context("failed to get lxss wsl key")?;
|
||||
|
||||
let distros = lxss_key
|
||||
.keys()
|
||||
.context("failed to get wsl distros")?
|
||||
.filter_map(|key| {
|
||||
lxss_key
|
||||
.open(&key)
|
||||
.context("failed to open subkey for distro")
|
||||
.log_err()
|
||||
})
|
||||
.filter_map(|distro| distro.get_string("DistributionName").ok())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Ok(distros)
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<WslDistroSelected> for Picker<WslPickerDelegate> {}
|
||||
|
||||
impl EventEmitter<WslPickerDismissed> for Picker<WslPickerDelegate> {}
|
||||
|
||||
impl picker::PickerDelegate for WslPickerDelegate {
|
||||
type ListItem = ListItem;
|
||||
|
||||
fn match_count(&self) -> usize {
|
||||
self.matches.len()
|
||||
}
|
||||
|
||||
fn selected_index(&self) -> usize {
|
||||
self.selected_index
|
||||
}
|
||||
|
||||
fn set_selected_index(
|
||||
&mut self,
|
||||
ix: usize,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Picker<Self>>,
|
||||
) {
|
||||
self.selected_index = ix;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
|
||||
Arc::from("Enter WSL distro name")
|
||||
}
|
||||
|
||||
fn update_matches(
|
||||
&mut self,
|
||||
query: String,
|
||||
_window: &mut Window,
|
||||
_cx: &mut Context<Picker<Self>>,
|
||||
) -> Task<()> {
|
||||
use fuzzy_nucleo::StringMatchCandidate;
|
||||
|
||||
let needs_fetch = self.distro_list.is_none();
|
||||
if needs_fetch {
|
||||
let distros = Self::fetch_distros().log_err();
|
||||
self.distro_list = distros;
|
||||
}
|
||||
|
||||
if let Some(distro_list) = &self.distro_list {
|
||||
use ordered_float::OrderedFloat;
|
||||
|
||||
let candidates = distro_list
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(id, distro)| StringMatchCandidate::new(id, distro))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let query = query.trim_start();
|
||||
let case = fuzzy_nucleo::Case::smart_if_uppercase_in(query);
|
||||
self.matches = fuzzy_nucleo::match_strings(
|
||||
&candidates,
|
||||
query,
|
||||
case,
|
||||
fuzzy_nucleo::LengthPenalty::On,
|
||||
100,
|
||||
);
|
||||
self.matches.sort_unstable_by_key(|m| m.candidate_id);
|
||||
|
||||
self.selected_index = self
|
||||
.matches
|
||||
.iter()
|
||||
.enumerate()
|
||||
.rev()
|
||||
.max_by_key(|(_, m)| OrderedFloat(m.score))
|
||||
.map(|(index, _)| index)
|
||||
.unwrap_or(0);
|
||||
}
|
||||
|
||||
Task::ready(())
|
||||
}
|
||||
|
||||
fn confirm(&mut self, secondary: bool, _window: &mut Window, cx: &mut Context<Picker<Self>>) {
|
||||
if let Some(distro) = self.matches.get(self.selected_index) {
|
||||
cx.emit(WslDistroSelected {
|
||||
secondary,
|
||||
distro: distro.string.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn dismissed(&mut self, _window: &mut Window, cx: &mut Context<Picker<Self>>) {
|
||||
cx.emit(WslPickerDismissed);
|
||||
}
|
||||
|
||||
fn render_match(
|
||||
&self,
|
||||
ix: usize,
|
||||
selected: bool,
|
||||
_: &mut Window,
|
||||
_: &mut Context<Picker<Self>>,
|
||||
) -> Option<Self::ListItem> {
|
||||
let matched = self.matches.get(ix)?;
|
||||
Some(
|
||||
ListItem::new(ix)
|
||||
.toggle_state(selected)
|
||||
.inset(true)
|
||||
.spacing(ui::ListItemSpacing::Sparse)
|
||||
.child(
|
||||
h_flex()
|
||||
.flex_grow()
|
||||
.gap_3()
|
||||
.child(Icon::new(IconName::Linux))
|
||||
.child(v_flex().child(HighlightedLabel::new(
|
||||
matched.string.clone(),
|
||||
matched.positions.clone(),
|
||||
))),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct WslOpenModal {
|
||||
paths: Vec<PathBuf>,
|
||||
create_new_window: bool,
|
||||
picker: Entity<Picker<WslPickerDelegate>>,
|
||||
_subscriptions: [Subscription; 2],
|
||||
}
|
||||
|
||||
impl WslOpenModal {
|
||||
pub fn new(
|
||||
paths: Vec<PathBuf>,
|
||||
create_new_window: bool,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let delegate = WslPickerDelegate::new();
|
||||
let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx).modal(false));
|
||||
|
||||
let selected = cx.subscribe_in(
|
||||
&picker,
|
||||
window,
|
||||
|this, _, event: &WslDistroSelected, window, cx| {
|
||||
this.confirm(&event.distro, event.secondary, window, cx);
|
||||
},
|
||||
);
|
||||
|
||||
let dismissed = cx.subscribe_in(
|
||||
&picker,
|
||||
window,
|
||||
|this, _, _: &WslPickerDismissed, window, cx| {
|
||||
this.cancel(&menu::Cancel, window, cx);
|
||||
},
|
||||
);
|
||||
|
||||
WslOpenModal {
|
||||
paths,
|
||||
create_new_window,
|
||||
picker,
|
||||
_subscriptions: [selected, dismissed],
|
||||
}
|
||||
}
|
||||
|
||||
fn confirm(
|
||||
&mut self,
|
||||
distro: &str,
|
||||
secondary: bool,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let app_state = workspace::AppState::global(cx);
|
||||
|
||||
let connection_options = RemoteConnectionOptions::Wsl(WslConnectionOptions {
|
||||
distro_name: distro.to_string(),
|
||||
user: None,
|
||||
});
|
||||
|
||||
let replace_current_window = match self.create_new_window {
|
||||
true => secondary,
|
||||
false => !secondary,
|
||||
};
|
||||
let open_mode = if replace_current_window {
|
||||
workspace::OpenMode::Activate
|
||||
} else {
|
||||
workspace::OpenMode::NewWindow
|
||||
};
|
||||
|
||||
let paths = self.paths.clone();
|
||||
let open_options = workspace::OpenOptions {
|
||||
requesting_window: window.window_handle().downcast::<MultiWorkspace>(),
|
||||
open_mode,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
cx.emit(DismissEvent);
|
||||
cx.spawn_in(window, async move |_, cx| {
|
||||
open_remote_project(connection_options, paths, app_state, open_options, cx).await
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
|
||||
cx.emit(DismissEvent);
|
||||
}
|
||||
}
|
||||
|
||||
impl ModalView for WslOpenModal {}
|
||||
|
||||
impl Focusable for WslOpenModal {
|
||||
fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
|
||||
self.picker.focus_handle(cx)
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<DismissEvent> for WslOpenModal {}
|
||||
|
||||
impl Render for WslOpenModal {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl ui::IntoElement {
|
||||
div()
|
||||
.on_mouse_down_out(cx.listener(|_, _, _, cx| cx.emit(DismissEvent)))
|
||||
.on_action(cx.listener(Self::cancel))
|
||||
.elevation_3(cx)
|
||||
.w(rems(34.))
|
||||
.flex_1()
|
||||
.overflow_hidden()
|
||||
.child(self.picker.clone())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user