Introduce MVP Dev Containers support (#44442)
Partially addresses #11473 MVP of dev containers with the following capabilities: - If in a project with `.devcontainer/devcontainer.json`, a pop-up notification will ask if you want to open the project in a dev container. This can be dismissed: <img width="1478" height="1191" alt="Screenshot 2025-12-08 at 3 15 23 PM" src="https://github.com/user-attachments/assets/ec2e20d6-28ec-4495-8f23-4c1d48a9ce78" /> - Similarly, if a `devcontainer.json` file is in the project, you can open a devcontainer (or go the devcontainer.json file for further editing) via the `open remote` modal: https://github.com/user-attachments/assets/61f2fdaa-2808-4efc-994c-7b444a92c0b1 *Limitations* This is a first release, and comes with some limitations: - Zed extensions are not managed in `devcontainer.json` yet. They will need to be installed either on host or in the container. Host + Container sync their extensions, so there is not currently a concept of what is installed in the container vs what is installed on host: they come from the same list of manifests - This implementation uses the [devcontainer CLI](https://github.com/devcontainers/cli) for its control plane. Hence, it does not yet support the `forwardPorts` directive. A single port can be opened with `appPort`. See reference in docs [here](https://github.com/devcontainers/cli/tree/main/example-usage#how-the-tool-examples-work) - Editing devcontainer.json does not automatically cause the dev container to be rebuilt. So if you add features, change images, etc, you will need to `docker kill` the existing dev container before proceeding. - Currently takes a hard dependency on `docker` being available in the user's `PATH`. Release Notes: - Added ability to Open a project in a DevContainer, provided a `.devcontainer/devcontainer.json` is present --------- Co-authored-by: Danilo Leal <daniloleal09@gmail.com> Co-authored-by: Danilo Leal <67129314+danilo-leal@users.noreply.github.com>
This commit is contained in:
co-authored by
Danilo Leal
Danilo Leal
parent
a61bf33fb0
commit
3a84ec38ac
@@ -0,0 +1,295 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use gpui::AsyncWindowContext;
|
||||
use node_runtime::NodeRuntime;
|
||||
use serde::Deserialize;
|
||||
use settings::DevContainerConnection;
|
||||
use smol::fs;
|
||||
use workspace::Workspace;
|
||||
|
||||
use crate::remote_connections::Connection;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DevContainerUp {
|
||||
_outcome: String,
|
||||
container_id: String,
|
||||
_remote_user: String,
|
||||
remote_workspace_folder: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DevContainerConfiguration {
|
||||
name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DevContainerConfigurationOutput {
|
||||
configuration: DevContainerConfiguration,
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn dev_container_cli() -> String {
|
||||
"devcontainer".to_string()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn dev_container_cli() -> String {
|
||||
"devcontainer.cmd".to_string()
|
||||
}
|
||||
|
||||
async fn check_for_docker() -> Result<(), DevContainerError> {
|
||||
let mut command = util::command::new_smol_command("docker");
|
||||
command.arg("--version");
|
||||
|
||||
match command.output().await {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => {
|
||||
log::error!("Unable to find docker in $PATH: {:?}", e);
|
||||
Err(DevContainerError::DockerNotAvailable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn ensure_devcontainer_cli(node_runtime: NodeRuntime) -> Result<PathBuf, DevContainerError> {
|
||||
let mut command = util::command::new_smol_command(&dev_container_cli());
|
||||
command.arg("--version");
|
||||
|
||||
if let Err(e) = command.output().await {
|
||||
log::error!(
|
||||
"Unable to find devcontainer CLI in $PATH. Checking for a zed installed version. Error: {:?}",
|
||||
e
|
||||
);
|
||||
|
||||
let datadir_cli_path = paths::devcontainer_dir()
|
||||
.join("node_modules")
|
||||
.join(".bin")
|
||||
.join(&dev_container_cli());
|
||||
|
||||
let mut command =
|
||||
util::command::new_smol_command(&datadir_cli_path.as_os_str().display().to_string());
|
||||
command.arg("--version");
|
||||
|
||||
if let Err(e) = command.output().await {
|
||||
log::error!(
|
||||
"Unable to find devcontainer CLI in Data dir. Will try to install. Error: {:?}",
|
||||
e
|
||||
);
|
||||
} else {
|
||||
log::info!("Found devcontainer CLI in Data dir");
|
||||
return Ok(datadir_cli_path.clone());
|
||||
}
|
||||
|
||||
if let Err(e) = fs::create_dir_all(paths::devcontainer_dir()).await {
|
||||
log::error!("Unable to create devcontainer directory. Error: {:?}", e);
|
||||
return Err(DevContainerError::DevContainerCliNotAvailable);
|
||||
}
|
||||
|
||||
if let Err(e) = node_runtime
|
||||
.npm_install_packages(
|
||||
&paths::devcontainer_dir(),
|
||||
&[("@devcontainers/cli", "latest")],
|
||||
)
|
||||
.await
|
||||
{
|
||||
log::error!(
|
||||
"Unable to install devcontainer CLI to data directory. Error: {:?}",
|
||||
e
|
||||
);
|
||||
return Err(DevContainerError::DevContainerCliNotAvailable);
|
||||
};
|
||||
|
||||
let mut command = util::command::new_smol_command(&datadir_cli_path.display().to_string());
|
||||
command.arg("--version");
|
||||
if let Err(e) = command.output().await {
|
||||
log::error!(
|
||||
"Unable to find devcontainer cli after NPM install. Error: {:?}",
|
||||
e
|
||||
);
|
||||
Err(DevContainerError::DevContainerCliNotAvailable)
|
||||
} else {
|
||||
Ok(datadir_cli_path)
|
||||
}
|
||||
} else {
|
||||
log::info!("Found devcontainer cli on $PATH, using it");
|
||||
Ok(PathBuf::from(&dev_container_cli()))
|
||||
}
|
||||
}
|
||||
|
||||
async fn devcontainer_up(
|
||||
path_to_cli: &PathBuf,
|
||||
path: Arc<Path>,
|
||||
) -> Result<DevContainerUp, DevContainerError> {
|
||||
let mut command = util::command::new_smol_command(path_to_cli.display().to_string());
|
||||
command.arg("up");
|
||||
command.arg("--workspace-folder");
|
||||
command.arg(path.display().to_string());
|
||||
|
||||
match command.output().await {
|
||||
Ok(output) => {
|
||||
if output.status.success() {
|
||||
let raw = String::from_utf8_lossy(&output.stdout);
|
||||
serde_json::from_str::<DevContainerUp>(&raw).map_err(|e| {
|
||||
log::error!(
|
||||
"Unable to parse response from 'devcontainer up' command, error: {:?}",
|
||||
e
|
||||
);
|
||||
DevContainerError::DevContainerParseFailed
|
||||
})
|
||||
} else {
|
||||
log::error!(
|
||||
"Non-success status running devcontainer up for workspace: out: {:?}, err: {:?}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
Err(DevContainerError::DevContainerUpFailed)
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Error running devcontainer up: {:?}", e);
|
||||
Err(DevContainerError::DevContainerUpFailed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn devcontainer_read_configuration(
|
||||
path_to_cli: &PathBuf,
|
||||
path: Arc<Path>,
|
||||
) -> Result<DevContainerConfigurationOutput, DevContainerError> {
|
||||
let mut command = util::command::new_smol_command(path_to_cli.display().to_string());
|
||||
command.arg("read-configuration");
|
||||
command.arg("--workspace-folder");
|
||||
command.arg(path.display().to_string());
|
||||
match command.output().await {
|
||||
Ok(output) => {
|
||||
if output.status.success() {
|
||||
let raw = String::from_utf8_lossy(&output.stdout);
|
||||
serde_json::from_str::<DevContainerConfigurationOutput>(&raw).map_err(|e| {
|
||||
log::error!(
|
||||
"Unable to parse response from 'devcontainer read-configuration' command, error: {:?}",
|
||||
e
|
||||
);
|
||||
DevContainerError::DevContainerParseFailed
|
||||
})
|
||||
} else {
|
||||
log::error!(
|
||||
"Non-success status running devcontainer read-configuration for workspace: out: {:?}, err: {:?}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
Err(DevContainerError::DevContainerUpFailed)
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Error running devcontainer read-configuration: {:?}", e);
|
||||
Err(DevContainerError::DevContainerUpFailed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Name the project with two fallbacks
|
||||
async fn get_project_name(
|
||||
path_to_cli: &PathBuf,
|
||||
path: Arc<Path>,
|
||||
remote_workspace_folder: String,
|
||||
container_id: String,
|
||||
) -> Result<String, DevContainerError> {
|
||||
if let Ok(dev_container_configuration) =
|
||||
devcontainer_read_configuration(path_to_cli, path).await
|
||||
&& let Some(name) = dev_container_configuration.configuration.name
|
||||
{
|
||||
// Ideally, name the project after the name defined in devcontainer.json
|
||||
Ok(name)
|
||||
} else {
|
||||
// Otherwise, name the project after the remote workspace folder name
|
||||
Ok(Path::new(&remote_workspace_folder)
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.map(|string| string.into())
|
||||
// Finally, name the project after the container ID as a last resort
|
||||
.unwrap_or_else(|| container_id.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
fn project_directory(cx: &mut AsyncWindowContext) -> Option<Arc<Path>> {
|
||||
let Some(workspace) = cx.window_handle().downcast::<Workspace>() else {
|
||||
return None;
|
||||
};
|
||||
|
||||
match workspace.update(cx, |workspace, _, cx| {
|
||||
workspace.project().read(cx).active_project_directory(cx)
|
||||
}) {
|
||||
Ok(dir) => dir,
|
||||
Err(e) => {
|
||||
log::error!("Error getting project directory from workspace: {:?}", e);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn start_dev_container(
|
||||
cx: &mut AsyncWindowContext,
|
||||
node_runtime: NodeRuntime,
|
||||
) -> Result<(Connection, String), DevContainerError> {
|
||||
check_for_docker().await?;
|
||||
|
||||
let path_to_devcontainer_cli = ensure_devcontainer_cli(node_runtime).await?;
|
||||
|
||||
let Some(directory) = project_directory(cx) else {
|
||||
return Err(DevContainerError::DevContainerNotFound);
|
||||
};
|
||||
|
||||
if let Ok(DevContainerUp {
|
||||
container_id,
|
||||
remote_workspace_folder,
|
||||
..
|
||||
}) = devcontainer_up(&path_to_devcontainer_cli, directory.clone()).await
|
||||
{
|
||||
let project_name = get_project_name(
|
||||
&path_to_devcontainer_cli,
|
||||
directory,
|
||||
remote_workspace_folder.clone(),
|
||||
container_id.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let connection = Connection::DevContainer(DevContainerConnection {
|
||||
name: project_name.into(),
|
||||
container_id: container_id.into(),
|
||||
});
|
||||
|
||||
Ok((connection, remote_workspace_folder))
|
||||
} else {
|
||||
Err(DevContainerError::DevContainerUpFailed)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum DevContainerError {
|
||||
DockerNotAvailable,
|
||||
DevContainerCliNotAvailable,
|
||||
DevContainerUpFailed,
|
||||
DevContainerNotFound,
|
||||
DevContainerParseFailed,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
|
||||
use crate::dev_container::DevContainerUp;
|
||||
|
||||
#[test]
|
||||
fn should_parse_from_devcontainer_json() {
|
||||
let json = r#"{"outcome":"success","containerId":"826abcac45afd412abff083ab30793daff2f3c8ce2c831df728baf39933cb37a","remoteUser":"vscode","remoteWorkspaceFolder":"/workspaces/zed"}"#;
|
||||
let up: DevContainerUp = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(up._outcome, "success");
|
||||
assert_eq!(
|
||||
up.container_id,
|
||||
"826abcac45afd412abff083ab30793daff2f3c8ce2c831df728baf39933cb37a"
|
||||
);
|
||||
assert_eq!(up._remote_user, "vscode");
|
||||
assert_eq!(up.remote_workspace_folder, "/workspaces/zed");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
use db::kvp::KEY_VALUE_STORE;
|
||||
use gpui::{SharedString, Window};
|
||||
use project::{Project, WorktreeId};
|
||||
use std::sync::LazyLock;
|
||||
use ui::prelude::*;
|
||||
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_path() -> &'static RelPath {
|
||||
static PATH: LazyLock<&'static RelPath> =
|
||||
LazyLock::new(|| RelPath::unix(".devcontainer").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(
|
||||
worktree_id: WorktreeId,
|
||||
updated_entries: &UpdatedEntriesSet,
|
||||
project: &gpui::Entity<Project>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Workspace>,
|
||||
) {
|
||||
let devcontainer_updated = updated_entries
|
||||
.iter()
|
||||
.any(|(path, _, _)| path.as_ref() == devcontainer_path());
|
||||
|
||||
if !devcontainer_updated {
|
||||
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_devcontainer = worktree
|
||||
.entry_for_path(devcontainer_path())
|
||||
.is_some_and(|entry| entry.is_dir());
|
||||
|
||||
if !has_devcontainer {
|
||||
return;
|
||||
}
|
||||
|
||||
let abs_path = worktree.abs_path();
|
||||
let project_path = abs_path.to_string_lossy().to_string();
|
||||
let key_for_dismiss = project_devcontainer_key(&project_path);
|
||||
|
||||
let already_dismissed = KEY_VALUE_STORE
|
||||
.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| {
|
||||
MessageNotification::new(
|
||||
"This project contains a Dev Container configuration file. Would you like to re-open it in a container?",
|
||||
cx,
|
||||
)
|
||||
.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();
|
||||
db::write_and_log(cx, move || {
|
||||
KEY_VALUE_STORE.write_kvp(key, "dismissed".to_string())
|
||||
});
|
||||
}
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
mod dev_container;
|
||||
mod dev_container_suggest;
|
||||
pub mod disconnected_overlay;
|
||||
mod remote_connections;
|
||||
mod remote_servers;
|
||||
mod ssh_config;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod wsl_picker;
|
||||
|
||||
@@ -31,7 +35,7 @@ use workspace::{
|
||||
WORKSPACE_DB, Workspace, WorkspaceId, notifications::DetachAndPromptErr,
|
||||
with_active_or_new_workspace,
|
||||
};
|
||||
use zed_actions::{OpenRecent, OpenRemote};
|
||||
use zed_actions::{OpenDevContainer, OpenRecent, OpenRemote};
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -161,6 +165,95 @@ pub fn init(cx: &mut App) {
|
||||
});
|
||||
|
||||
cx.observe_new(DisconnectedOverlay::register).detach();
|
||||
|
||||
cx.on_action(|_: &OpenDevContainer, cx| {
|
||||
with_active_or_new_workspace(cx, move |workspace, window, cx| {
|
||||
let app_state = workspace.app_state().clone();
|
||||
let replace_window = window.window_handle().downcast::<Workspace>();
|
||||
|
||||
cx.spawn_in(window, async move |_, mut cx| {
|
||||
let (connection, starting_dir) = match dev_container::start_dev_container(
|
||||
&mut cx,
|
||||
app_state.node_runtime.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok((c, s)) => (c, s),
|
||||
Err(e) => {
|
||||
log::error!("Failed to start Dev Container: {:?}", e);
|
||||
cx.prompt(
|
||||
gpui::PromptLevel::Critical,
|
||||
"Failed to start Dev Container",
|
||||
Some(&format!("{:?}", e)),
|
||||
&["Ok"],
|
||||
)
|
||||
.await
|
||||
.ok();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let result = open_remote_project(
|
||||
connection.into(),
|
||||
vec![starting_dir].into_iter().map(PathBuf::from).collect(),
|
||||
app_state,
|
||||
OpenOptions {
|
||||
replace_window,
|
||||
..OpenOptions::default()
|
||||
},
|
||||
&mut cx,
|
||||
)
|
||||
.await;
|
||||
|
||||
if let Err(e) = result {
|
||||
log::error!("Failed to connect: {e:#}");
|
||||
cx.prompt(
|
||||
gpui::PromptLevel::Critical,
|
||||
"Failed to connect",
|
||||
Some(&e.to_string()),
|
||||
&["Ok"],
|
||||
)
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
let fs = workspace.project().read(cx).fs().clone();
|
||||
let handle = cx.entity().downgrade();
|
||||
workspace.toggle_modal(window, cx, |window, cx| {
|
||||
RemoteServerProjects::new_dev_container(fs, window, handle, cx)
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Subscribe to worktree additions to suggest opening the project in a dev container
|
||||
cx.observe_new(
|
||||
|workspace: &mut Workspace, window: Option<&mut Window>, cx: &mut Context<Workspace>| {
|
||||
let Some(window) = window else {
|
||||
return;
|
||||
};
|
||||
cx.subscribe_in(
|
||||
workspace.project(),
|
||||
window,
|
||||
move |_, project, event, window, cx| {
|
||||
if let project::Event::WorktreeUpdatedEntries(worktree_id, updated_entries) =
|
||||
event
|
||||
{
|
||||
dev_container_suggest::suggest_on_worktree_updated(
|
||||
*worktree_id,
|
||||
updated_entries,
|
||||
project,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
},
|
||||
)
|
||||
.detach();
|
||||
},
|
||||
)
|
||||
.detach();
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -609,6 +702,7 @@ impl PickerDelegate for RecentProjectsDelegate {
|
||||
Icon::new(match options {
|
||||
RemoteConnectionOptions::Ssh { .. } => IconName::Server,
|
||||
RemoteConnectionOptions::Wsl { .. } => IconName::Linux,
|
||||
RemoteConnectionOptions::Docker(_) => IconName::Box,
|
||||
})
|
||||
.color(Color::Muted)
|
||||
.into_any_element()
|
||||
|
||||
@@ -18,16 +18,16 @@ use language::{CursorShape, Point};
|
||||
use markdown::{Markdown, MarkdownElement, MarkdownStyle};
|
||||
use release_channel::ReleaseChannel;
|
||||
use remote::{
|
||||
ConnectionIdentifier, RemoteClient, RemoteConnection, RemoteConnectionOptions, RemotePlatform,
|
||||
SshConnectionOptions,
|
||||
ConnectionIdentifier, DockerConnectionOptions, RemoteClient, RemoteConnection,
|
||||
RemoteConnectionOptions, RemotePlatform, SshConnectionOptions,
|
||||
};
|
||||
use semver::Version;
|
||||
pub use settings::SshConnection;
|
||||
use settings::{ExtendingVec, RegisterSetting, Settings, WslConnection};
|
||||
use settings::{DevContainerConnection, ExtendingVec, RegisterSetting, Settings, WslConnection};
|
||||
use theme::ThemeSettings;
|
||||
use ui::{
|
||||
ActiveTheme, Color, CommonAnimationExt, Context, Icon, IconName, IconSize, InteractiveElement,
|
||||
IntoElement, Label, LabelCommon, Styled, Window, prelude::*,
|
||||
ActiveTheme, Color, CommonAnimationExt, Context, InteractiveElement, IntoElement, KeyBinding,
|
||||
LabelCommon, ListItem, Styled, Window, prelude::*,
|
||||
};
|
||||
use util::paths::PathWithPosition;
|
||||
use workspace::{AppState, ModalView, Workspace};
|
||||
@@ -85,6 +85,7 @@ impl SshSettings {
|
||||
pub enum Connection {
|
||||
Ssh(SshConnection),
|
||||
Wsl(WslConnection),
|
||||
DevContainer(DevContainerConnection),
|
||||
}
|
||||
|
||||
impl From<Connection> for RemoteConnectionOptions {
|
||||
@@ -92,6 +93,13 @@ impl From<Connection> for RemoteConnectionOptions {
|
||||
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.to_string(),
|
||||
container_id: conn.container_id.to_string(),
|
||||
upload_binary_over_docker_exec: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -123,6 +131,7 @@ pub struct RemoteConnectionPrompt {
|
||||
connection_string: SharedString,
|
||||
nickname: Option<SharedString>,
|
||||
is_wsl: bool,
|
||||
is_devcontainer: bool,
|
||||
status_message: Option<SharedString>,
|
||||
prompt: Option<(Entity<Markdown>, oneshot::Sender<EncryptedPassword>)>,
|
||||
cancellation: Option<oneshot::Sender<()>>,
|
||||
@@ -148,6 +157,7 @@ impl RemoteConnectionPrompt {
|
||||
connection_string: String,
|
||||
nickname: Option<String>,
|
||||
is_wsl: bool,
|
||||
is_devcontainer: bool,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
@@ -155,6 +165,7 @@ impl RemoteConnectionPrompt {
|
||||
connection_string: connection_string.into(),
|
||||
nickname: nickname.map(|nickname| nickname.into()),
|
||||
is_wsl,
|
||||
is_devcontainer,
|
||||
editor: cx.new(|cx| Editor::single_line(window, cx)),
|
||||
status_message: None,
|
||||
cancellation: None,
|
||||
@@ -244,17 +255,16 @@ impl Render for RemoteConnectionPrompt {
|
||||
|
||||
v_flex()
|
||||
.key_context("PasswordPrompt")
|
||||
.py_2()
|
||||
.px_3()
|
||||
.p_2()
|
||||
.size_full()
|
||||
.text_buffer(cx)
|
||||
.when_some(self.status_message.clone(), |el, status_message| {
|
||||
el.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.gap_2()
|
||||
.child(
|
||||
Icon::new(IconName::ArrowCircle)
|
||||
.size(IconSize::Medium)
|
||||
.color(Color::Muted)
|
||||
.with_rotate_animation(2),
|
||||
)
|
||||
.child(
|
||||
@@ -287,15 +297,28 @@ impl RemoteConnectionModal {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let (connection_string, nickname, is_wsl) = match connection_options {
|
||||
RemoteConnectionOptions::Ssh(options) => {
|
||||
(options.connection_string(), options.nickname.clone(), false)
|
||||
let (connection_string, nickname, is_wsl, is_devcontainer) = match connection_options {
|
||||
RemoteConnectionOptions::Ssh(options) => (
|
||||
options.connection_string(),
|
||||
options.nickname.clone(),
|
||||
false,
|
||||
false,
|
||||
),
|
||||
RemoteConnectionOptions::Wsl(options) => {
|
||||
(options.distro_name.clone(), None, true, false)
|
||||
}
|
||||
RemoteConnectionOptions::Wsl(options) => (options.distro_name.clone(), None, true),
|
||||
RemoteConnectionOptions::Docker(options) => (options.name.clone(), None, false, true),
|
||||
};
|
||||
Self {
|
||||
prompt: cx.new(|cx| {
|
||||
RemoteConnectionPrompt::new(connection_string, nickname, is_wsl, window, cx)
|
||||
RemoteConnectionPrompt::new(
|
||||
connection_string,
|
||||
nickname,
|
||||
is_wsl,
|
||||
is_devcontainer,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
}),
|
||||
finished: false,
|
||||
paths,
|
||||
@@ -328,6 +351,7 @@ pub(crate) struct SshConnectionHeader {
|
||||
pub(crate) paths: Vec<PathBuf>,
|
||||
pub(crate) nickname: Option<SharedString>,
|
||||
pub(crate) is_wsl: bool,
|
||||
pub(crate) is_devcontainer: bool,
|
||||
}
|
||||
|
||||
impl RenderOnce for SshConnectionHeader {
|
||||
@@ -343,9 +367,12 @@ impl RenderOnce for SshConnectionHeader {
|
||||
(self.connection_string, None)
|
||||
};
|
||||
|
||||
let icon = match self.is_wsl {
|
||||
true => IconName::Linux,
|
||||
false => IconName::Server,
|
||||
let icon = if self.is_wsl {
|
||||
IconName::Linux
|
||||
} else if self.is_devcontainer {
|
||||
IconName::Box
|
||||
} else {
|
||||
IconName::Server
|
||||
};
|
||||
|
||||
h_flex()
|
||||
@@ -388,6 +415,7 @@ impl Render for RemoteConnectionModal {
|
||||
let nickname = self.prompt.read(cx).nickname.clone();
|
||||
let connection_string = self.prompt.read(cx).connection_string.clone();
|
||||
let is_wsl = self.prompt.read(cx).is_wsl;
|
||||
let is_devcontainer = self.prompt.read(cx).is_devcontainer;
|
||||
|
||||
let theme = cx.theme().clone();
|
||||
let body_color = theme.colors().editor_background;
|
||||
@@ -407,18 +435,34 @@ impl Render for RemoteConnectionModal {
|
||||
connection_string,
|
||||
nickname,
|
||||
is_wsl,
|
||||
is_devcontainer,
|
||||
}
|
||||
.render(window, cx),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.w_full()
|
||||
.rounded_b_lg()
|
||||
.bg(body_color)
|
||||
.border_t_1()
|
||||
.border_y_1()
|
||||
.border_color(theme.colors().border_variant)
|
||||
.child(self.prompt.clone()),
|
||||
)
|
||||
.child(
|
||||
div().w_full().py_1().child(
|
||||
ListItem::new("li-devcontainer-go-back")
|
||||
.inset(true)
|
||||
.spacing(ui::ListItemSpacing::Sparse)
|
||||
.start_slot(Icon::new(IconName::Close).color(Color::Muted))
|
||||
.child(Label::new("Cancel"))
|
||||
.end_slot(
|
||||
KeyBinding::for_action_in(&menu::Cancel, &self.focus_handle(cx), cx)
|
||||
.size(rems_from_px(12.)),
|
||||
)
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.dismiss(&menu::Cancel, window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -671,6 +715,9 @@ pub async fn open_remote_project(
|
||||
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"
|
||||
}
|
||||
},
|
||||
Some(&format!("{e:#}")),
|
||||
&["Retry", "Cancel"],
|
||||
@@ -727,6 +774,9 @@ pub async fn open_remote_project(
|
||||
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"
|
||||
}
|
||||
},
|
||||
Some(&format!("{e:#}")),
|
||||
&["Retry", "Cancel"],
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::{
|
||||
dev_container::start_dev_container,
|
||||
remote_connections::{
|
||||
Connection, RemoteConnectionModal, RemoteConnectionPrompt, SshConnection,
|
||||
SshConnectionHeader, SshSettings, connect, determine_paths_with_positions,
|
||||
@@ -24,7 +25,7 @@ use remote::{
|
||||
remote_client::ConnectionIdentifier,
|
||||
};
|
||||
use settings::{
|
||||
RemoteSettingsContent, Settings as _, SettingsStore, SshProject, update_settings_file,
|
||||
RemoteProject, RemoteSettingsContent, Settings as _, SettingsStore, update_settings_file,
|
||||
watch_config_file,
|
||||
};
|
||||
use smol::stream::StreamExt as _;
|
||||
@@ -39,12 +40,13 @@ use std::{
|
||||
},
|
||||
};
|
||||
use ui::{
|
||||
IconButtonShape, List, ListItem, ListSeparator, Modal, ModalHeader, Navigable, NavigableEntry,
|
||||
Section, Tooltip, WithScrollbar, prelude::*,
|
||||
CommonAnimationExt, IconButtonShape, KeyBinding, List, ListItem, ListSeparator, Modal,
|
||||
ModalHeader, Navigable, NavigableEntry, Section, Tooltip, WithScrollbar, prelude::*,
|
||||
};
|
||||
use util::{
|
||||
ResultExt,
|
||||
paths::{PathStyle, RemotePathBuf},
|
||||
rel_path::RelPath,
|
||||
};
|
||||
use workspace::{
|
||||
ModalView, OpenOptions, Toast, Workspace,
|
||||
@@ -85,6 +87,39 @@ impl CreateRemoteServer {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
enum DevContainerCreationProgress {
|
||||
Initial,
|
||||
Creating,
|
||||
Error(String),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct CreateRemoteDevContainer {
|
||||
// 3 Navigable Options
|
||||
// - Create from devcontainer.json
|
||||
// - Edit devcontainer.json
|
||||
// - Go back
|
||||
entries: [NavigableEntry; 3],
|
||||
progress: DevContainerCreationProgress,
|
||||
}
|
||||
|
||||
impl CreateRemoteDevContainer {
|
||||
fn new(window: &mut Window, cx: &mut Context<RemoteServerProjects>) -> Self {
|
||||
let entries = std::array::from_fn(|_| NavigableEntry::focusable(cx));
|
||||
entries[0].focus_handle.focus(window);
|
||||
Self {
|
||||
entries,
|
||||
progress: DevContainerCreationProgress::Initial,
|
||||
}
|
||||
}
|
||||
|
||||
fn progress(&mut self, progress: DevContainerCreationProgress) -> Self {
|
||||
self.progress = progress;
|
||||
self.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
struct AddWslDistro {
|
||||
picker: Entity<Picker<crate::wsl_picker::WslPickerDelegate>>,
|
||||
@@ -207,6 +242,11 @@ impl ProjectPicker {
|
||||
RemoteConnectionOptions::Wsl(connection) => ProjectPickerData::Wsl {
|
||||
distro_name: connection.distro_name.clone().into(),
|
||||
},
|
||||
RemoteConnectionOptions::Docker(_) => ProjectPickerData::Ssh {
|
||||
// Not implemented as a project picker at this time
|
||||
connection_string: "".into(),
|
||||
nickname: None,
|
||||
},
|
||||
};
|
||||
let _path_task = cx
|
||||
.spawn_in(window, {
|
||||
@@ -259,7 +299,7 @@ impl ProjectPicker {
|
||||
.as_mut()
|
||||
.and_then(|connections| connections.get_mut(index.0))
|
||||
{
|
||||
server.projects.insert(SshProject { paths });
|
||||
server.projects.insert(RemoteProject { paths });
|
||||
};
|
||||
}
|
||||
ServerIndex::Wsl(index) => {
|
||||
@@ -269,7 +309,7 @@ impl ProjectPicker {
|
||||
.as_mut()
|
||||
.and_then(|connections| connections.get_mut(index.0))
|
||||
{
|
||||
server.projects.insert(SshProject { paths });
|
||||
server.projects.insert(RemoteProject { paths });
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -349,6 +389,7 @@ impl gpui::Render for ProjectPicker {
|
||||
paths: Default::default(),
|
||||
nickname: nickname.clone(),
|
||||
is_wsl: false,
|
||||
is_devcontainer: false,
|
||||
}
|
||||
.render(window, cx),
|
||||
ProjectPickerData::Wsl { distro_name } => SshConnectionHeader {
|
||||
@@ -356,6 +397,7 @@ impl gpui::Render for ProjectPicker {
|
||||
paths: Default::default(),
|
||||
nickname: None,
|
||||
is_wsl: true,
|
||||
is_devcontainer: false,
|
||||
}
|
||||
.render(window, cx),
|
||||
})
|
||||
@@ -406,7 +448,7 @@ impl From<WslServerIndex> for ServerIndex {
|
||||
enum RemoteEntry {
|
||||
Project {
|
||||
open_folder: NavigableEntry,
|
||||
projects: Vec<(NavigableEntry, SshProject)>,
|
||||
projects: Vec<(NavigableEntry, RemoteProject)>,
|
||||
configure: NavigableEntry,
|
||||
connection: Connection,
|
||||
index: ServerIndex,
|
||||
@@ -440,6 +482,7 @@ impl RemoteEntry {
|
||||
struct DefaultState {
|
||||
scroll_handle: ScrollHandle,
|
||||
add_new_server: NavigableEntry,
|
||||
add_new_devcontainer: NavigableEntry,
|
||||
add_new_wsl: NavigableEntry,
|
||||
servers: Vec<RemoteEntry>,
|
||||
}
|
||||
@@ -448,6 +491,7 @@ impl DefaultState {
|
||||
fn new(ssh_config_servers: &BTreeSet<SharedString>, cx: &mut App) -> Self {
|
||||
let handle = ScrollHandle::new();
|
||||
let add_new_server = NavigableEntry::new(&handle, cx);
|
||||
let add_new_devcontainer = NavigableEntry::new(&handle, cx);
|
||||
let add_new_wsl = NavigableEntry::new(&handle, cx);
|
||||
|
||||
let ssh_settings = SshSettings::get_global(cx);
|
||||
@@ -517,6 +561,7 @@ impl DefaultState {
|
||||
Self {
|
||||
scroll_handle: handle,
|
||||
add_new_server,
|
||||
add_new_devcontainer,
|
||||
add_new_wsl,
|
||||
servers,
|
||||
}
|
||||
@@ -552,6 +597,7 @@ enum Mode {
|
||||
EditNickname(EditNicknameState),
|
||||
ProjectPicker(Entity<ProjectPicker>),
|
||||
CreateRemoteServer(CreateRemoteServer),
|
||||
CreateRemoteDevContainer(CreateRemoteDevContainer),
|
||||
#[cfg(target_os = "windows")]
|
||||
AddWslDistro(AddWslDistro),
|
||||
}
|
||||
@@ -598,6 +644,27 @@ impl RemoteServerProjects {
|
||||
)
|
||||
}
|
||||
|
||||
/// Creates a new RemoteServerProjects modal that opens directly in dev container creation mode.
|
||||
/// Used when suggesting dev container connection from toast notification.
|
||||
pub fn new_dev_container(
|
||||
fs: Arc<dyn Fs>,
|
||||
window: &mut Window,
|
||||
workspace: WeakEntity<Workspace>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
Self::new_inner(
|
||||
Mode::CreateRemoteDevContainer(
|
||||
CreateRemoteDevContainer::new(window, cx)
|
||||
.progress(DevContainerCreationProgress::Creating),
|
||||
),
|
||||
false,
|
||||
fs,
|
||||
window,
|
||||
workspace,
|
||||
cx,
|
||||
)
|
||||
}
|
||||
|
||||
fn new_inner(
|
||||
mode: Mode,
|
||||
create_new_window: bool,
|
||||
@@ -703,6 +770,7 @@ impl RemoteServerProjects {
|
||||
connection_options.connection_string(),
|
||||
connection_options.nickname.clone(),
|
||||
false,
|
||||
false,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
@@ -778,6 +846,7 @@ impl RemoteServerProjects {
|
||||
connection_options.distro_name.clone(),
|
||||
None,
|
||||
true,
|
||||
false,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
@@ -862,6 +931,15 @@ impl RemoteServerProjects {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn view_in_progress_dev_container(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.mode = Mode::CreateRemoteDevContainer(
|
||||
CreateRemoteDevContainer::new(window, cx)
|
||||
.progress(DevContainerCreationProgress::Creating),
|
||||
);
|
||||
self.focus_handle(cx).focus(window);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn create_remote_project(
|
||||
&mut self,
|
||||
index: ServerIndex,
|
||||
@@ -981,6 +1059,7 @@ impl RemoteServerProjects {
|
||||
|
||||
self.create_ssh_server(state.address_editor.clone(), window, cx);
|
||||
}
|
||||
Mode::CreateRemoteDevContainer(_) => {}
|
||||
Mode::EditNickname(state) => {
|
||||
let text = Some(state.editor.read(cx).text(cx)).filter(|text| !text.is_empty());
|
||||
let index = state.index;
|
||||
@@ -1024,14 +1103,14 @@ impl RemoteServerProjects {
|
||||
}
|
||||
}
|
||||
|
||||
fn render_ssh_connection(
|
||||
fn render_remote_connection(
|
||||
&mut self,
|
||||
ix: usize,
|
||||
ssh_server: RemoteEntry,
|
||||
remote_server: RemoteEntry,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
let connection = ssh_server.connection().into_owned();
|
||||
let connection = remote_server.connection().into_owned();
|
||||
|
||||
let (main_label, aux_label, is_wsl) = match &connection {
|
||||
Connection::Ssh(connection) => {
|
||||
@@ -1045,6 +1124,9 @@ impl RemoteServerProjects {
|
||||
Connection::Wsl(wsl_connection_options) => {
|
||||
(wsl_connection_options.distro_name.clone(), None, true)
|
||||
}
|
||||
Connection::DevContainer(dev_container_options) => {
|
||||
(dev_container_options.name.clone(), None, false)
|
||||
}
|
||||
};
|
||||
v_flex()
|
||||
.w_full()
|
||||
@@ -1082,7 +1164,7 @@ impl RemoteServerProjects {
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(match &ssh_server {
|
||||
.child(match &remote_server {
|
||||
RemoteEntry::Project {
|
||||
open_folder,
|
||||
projects,
|
||||
@@ -1094,9 +1176,9 @@ impl RemoteServerProjects {
|
||||
List::new()
|
||||
.empty_message("No projects.")
|
||||
.children(projects.iter().enumerate().map(|(pix, p)| {
|
||||
v_flex().gap_0p5().child(self.render_ssh_project(
|
||||
v_flex().gap_0p5().child(self.render_remote_project(
|
||||
index,
|
||||
ssh_server.clone(),
|
||||
remote_server.clone(),
|
||||
pix,
|
||||
p,
|
||||
window,
|
||||
@@ -1222,12 +1304,12 @@ impl RemoteServerProjects {
|
||||
})
|
||||
}
|
||||
|
||||
fn render_ssh_project(
|
||||
fn render_remote_project(
|
||||
&mut self,
|
||||
server_ix: ServerIndex,
|
||||
server: RemoteEntry,
|
||||
ix: usize,
|
||||
(navigation, project): &(NavigableEntry, SshProject),
|
||||
(navigation, project): &(NavigableEntry, RemoteProject),
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
@@ -1372,7 +1454,7 @@ impl RemoteServerProjects {
|
||||
fn delete_remote_project(
|
||||
&mut self,
|
||||
server: ServerIndex,
|
||||
project: &SshProject,
|
||||
project: &RemoteProject,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
match server {
|
||||
@@ -1388,7 +1470,7 @@ impl RemoteServerProjects {
|
||||
fn delete_ssh_project(
|
||||
&mut self,
|
||||
server: SshServerIndex,
|
||||
project: &SshProject,
|
||||
project: &RemoteProject,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let project = project.clone();
|
||||
@@ -1406,7 +1488,7 @@ impl RemoteServerProjects {
|
||||
fn delete_wsl_project(
|
||||
&mut self,
|
||||
server: WslServerIndex,
|
||||
project: &SshProject,
|
||||
project: &RemoteProject,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let project = project.clone();
|
||||
@@ -1451,6 +1533,342 @@ impl RemoteServerProjects {
|
||||
});
|
||||
}
|
||||
|
||||
fn edit_in_dev_container_json(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(workspace) = self.workspace.upgrade() else {
|
||||
cx.emit(DismissEvent);
|
||||
cx.notify();
|
||||
return;
|
||||
};
|
||||
|
||||
workspace.update(cx, |workspace, cx| {
|
||||
let project = workspace.project().clone();
|
||||
|
||||
let worktree = project
|
||||
.read(cx)
|
||||
.visible_worktrees(cx)
|
||||
.find_map(|tree| tree.read(cx).root_entry()?.is_dir().then_some(tree));
|
||||
|
||||
if let Some(worktree) = worktree {
|
||||
let tree_id = worktree.read(cx).id();
|
||||
let devcontainer_path = RelPath::unix(".devcontainer/devcontainer.json").unwrap();
|
||||
cx.spawn_in(window, async move |workspace, cx| {
|
||||
workspace
|
||||
.update_in(cx, |workspace, window, cx| {
|
||||
workspace.open_path(
|
||||
(tree_id, devcontainer_path),
|
||||
None,
|
||||
true,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
})?
|
||||
.await
|
||||
})
|
||||
.detach();
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
});
|
||||
cx.emit(DismissEvent);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn open_dev_container(&self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(app_state) = self
|
||||
.workspace
|
||||
.read_with(cx, |workspace, _| workspace.app_state().clone())
|
||||
.log_err()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let replace_window = window.window_handle().downcast::<Workspace>();
|
||||
|
||||
cx.spawn_in(window, async move |entity, cx| {
|
||||
let (connection, starting_dir) =
|
||||
match start_dev_container(cx, app_state.node_runtime.clone()).await {
|
||||
Ok((c, s)) => (c, s),
|
||||
Err(e) => {
|
||||
log::error!("Failed to start dev container: {:?}", e);
|
||||
entity
|
||||
.update_in(cx, |remote_server_projects, window, cx| {
|
||||
remote_server_projects.mode = Mode::CreateRemoteDevContainer(
|
||||
CreateRemoteDevContainer::new(window, cx).progress(
|
||||
DevContainerCreationProgress::Error(format!("{:?}", e)),
|
||||
),
|
||||
);
|
||||
})
|
||||
.log_err();
|
||||
return;
|
||||
}
|
||||
};
|
||||
entity
|
||||
.update(cx, |_, cx| {
|
||||
cx.emit(DismissEvent);
|
||||
})
|
||||
.log_err();
|
||||
|
||||
let result = open_remote_project(
|
||||
connection.into(),
|
||||
vec![starting_dir].into_iter().map(PathBuf::from).collect(),
|
||||
app_state,
|
||||
OpenOptions {
|
||||
replace_window,
|
||||
..OpenOptions::default()
|
||||
},
|
||||
cx,
|
||||
)
|
||||
.await;
|
||||
if let Err(e) = result {
|
||||
log::error!("Failed to connect: {e:#}");
|
||||
cx.prompt(
|
||||
gpui::PromptLevel::Critical,
|
||||
"Failed to connect",
|
||||
Some(&e.to_string()),
|
||||
&["Ok"],
|
||||
)
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn render_create_dev_container(
|
||||
&self,
|
||||
state: &CreateRemoteDevContainer,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
match &state.progress {
|
||||
DevContainerCreationProgress::Error(message) => {
|
||||
self.focus_handle(cx).focus(window);
|
||||
return div()
|
||||
.track_focus(&self.focus_handle(cx))
|
||||
.size_full()
|
||||
.child(
|
||||
v_flex()
|
||||
.py_1()
|
||||
.child(
|
||||
ListItem::new("Error")
|
||||
.inset(true)
|
||||
.selectable(false)
|
||||
.spacing(ui::ListItemSpacing::Sparse)
|
||||
.start_slot(Icon::new(IconName::XCircle).color(Color::Error))
|
||||
.child(Label::new("Error Creating Dev Container:"))
|
||||
.child(Label::new(message).buffer_font(cx)),
|
||||
)
|
||||
.child(ListSeparator)
|
||||
.child(
|
||||
div()
|
||||
.id("devcontainer-go-back")
|
||||
.track_focus(&state.entries[0].focus_handle)
|
||||
.on_action(cx.listener(
|
||||
|this, _: &menu::Confirm, window, cx| {
|
||||
this.mode =
|
||||
Mode::default_mode(&this.ssh_config_servers, cx);
|
||||
cx.focus_self(window);
|
||||
cx.notify();
|
||||
},
|
||||
))
|
||||
.child(
|
||||
ListItem::new("li-devcontainer-go-back")
|
||||
.toggle_state(
|
||||
state.entries[0]
|
||||
.focus_handle
|
||||
.contains_focused(window, cx),
|
||||
)
|
||||
.inset(true)
|
||||
.spacing(ui::ListItemSpacing::Sparse)
|
||||
.start_slot(
|
||||
Icon::new(IconName::ArrowLeft).color(Color::Muted),
|
||||
)
|
||||
.child(Label::new("Go Back"))
|
||||
.end_slot(
|
||||
KeyBinding::for_action_in(
|
||||
&menu::Cancel,
|
||||
&self.focus_handle,
|
||||
cx,
|
||||
)
|
||||
.size(rems_from_px(12.)),
|
||||
)
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
let state =
|
||||
CreateRemoteDevContainer::new(window, cx);
|
||||
this.mode = Mode::CreateRemoteDevContainer(state);
|
||||
|
||||
cx.notify();
|
||||
})),
|
||||
),
|
||||
),
|
||||
)
|
||||
.into_any_element();
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
|
||||
let mut view = Navigable::new(
|
||||
div()
|
||||
.track_focus(&self.focus_handle(cx))
|
||||
.size_full()
|
||||
.child(
|
||||
v_flex()
|
||||
.pb_1()
|
||||
.child(
|
||||
ModalHeader::new()
|
||||
.child(Headline::new("Dev Containers").size(HeadlineSize::XSmall)),
|
||||
)
|
||||
.child(ListSeparator)
|
||||
.child(
|
||||
div()
|
||||
.id("confirm-create-from-devcontainer-json")
|
||||
.track_focus(&state.entries[0].focus_handle)
|
||||
.on_action(cx.listener({
|
||||
move |this, _: &menu::Confirm, window, cx| {
|
||||
this.open_dev_container(window, cx);
|
||||
this.view_in_progress_dev_container(window, cx);
|
||||
}
|
||||
}))
|
||||
.map(|this| {
|
||||
if state.progress == DevContainerCreationProgress::Creating {
|
||||
this.child(
|
||||
ListItem::new("creating")
|
||||
.inset(true)
|
||||
.spacing(ui::ListItemSpacing::Sparse)
|
||||
.disabled(true)
|
||||
.start_slot(
|
||||
Icon::new(IconName::ArrowCircle)
|
||||
.color(Color::Muted)
|
||||
.with_rotate_animation(2),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.opacity(0.6)
|
||||
.gap_1()
|
||||
.child(Label::new("Creating From"))
|
||||
.child(
|
||||
Label::new("devcontainer.json")
|
||||
.buffer_font(cx),
|
||||
)
|
||||
.child(LoadingLabel::new("")),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
this.child(
|
||||
ListItem::new(
|
||||
"li-confirm-create-from-devcontainer-json",
|
||||
)
|
||||
.toggle_state(
|
||||
state.entries[0]
|
||||
.focus_handle
|
||||
.contains_focused(window, cx),
|
||||
)
|
||||
.inset(true)
|
||||
.spacing(ui::ListItemSpacing::Sparse)
|
||||
.start_slot(
|
||||
Icon::new(IconName::Plus).color(Color::Muted),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(Label::new("Open or Create New From"))
|
||||
.child(
|
||||
Label::new("devcontainer.json")
|
||||
.buffer_font(cx),
|
||||
),
|
||||
)
|
||||
.on_click(
|
||||
cx.listener({
|
||||
move |this, _, window, cx| {
|
||||
this.open_dev_container(window, cx);
|
||||
this.view_in_progress_dev_container(
|
||||
window, cx,
|
||||
);
|
||||
cx.notify();
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.id("edit-devcontainer-json")
|
||||
.track_focus(&state.entries[1].focus_handle)
|
||||
.on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
|
||||
this.edit_in_dev_container_json(window, cx);
|
||||
}))
|
||||
.child(
|
||||
ListItem::new("li-edit-devcontainer-json")
|
||||
.toggle_state(
|
||||
state.entries[1]
|
||||
.focus_handle
|
||||
.contains_focused(window, cx),
|
||||
)
|
||||
.inset(true)
|
||||
.spacing(ui::ListItemSpacing::Sparse)
|
||||
.start_slot(Icon::new(IconName::Pencil).color(Color::Muted))
|
||||
.child(
|
||||
h_flex().gap_1().child(Label::new("Edit")).child(
|
||||
Label::new("devcontainer.json").buffer_font(cx),
|
||||
),
|
||||
)
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.edit_in_dev_container_json(window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
.child(ListSeparator)
|
||||
.child(
|
||||
div()
|
||||
.id("devcontainer-go-back")
|
||||
.track_focus(&state.entries[2].focus_handle)
|
||||
.on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
|
||||
this.mode = Mode::default_mode(&this.ssh_config_servers, cx);
|
||||
cx.focus_self(window);
|
||||
cx.notify();
|
||||
}))
|
||||
.child(
|
||||
ListItem::new("li-devcontainer-go-back")
|
||||
.toggle_state(
|
||||
state.entries[2]
|
||||
.focus_handle
|
||||
.contains_focused(window, cx),
|
||||
)
|
||||
.inset(true)
|
||||
.spacing(ui::ListItemSpacing::Sparse)
|
||||
.start_slot(
|
||||
Icon::new(IconName::ArrowLeft).color(Color::Muted),
|
||||
)
|
||||
.child(Label::new("Go Back"))
|
||||
.end_slot(
|
||||
KeyBinding::for_action_in(
|
||||
&menu::Cancel,
|
||||
&self.focus_handle,
|
||||
cx,
|
||||
)
|
||||
.size(rems_from_px(12.)),
|
||||
)
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.mode =
|
||||
Mode::default_mode(&this.ssh_config_servers, cx);
|
||||
cx.focus_self(window);
|
||||
cx.notify()
|
||||
})),
|
||||
),
|
||||
),
|
||||
)
|
||||
.into_any_element(),
|
||||
);
|
||||
|
||||
view = view.entry(state.entries[0].clone());
|
||||
view = view.entry(state.entries[1].clone());
|
||||
view = view.entry(state.entries[2].clone());
|
||||
|
||||
view.render(window, cx).into_any_element()
|
||||
}
|
||||
|
||||
fn render_create_remote_server(
|
||||
&self,
|
||||
state: &CreateRemoteServer,
|
||||
@@ -1571,6 +1989,7 @@ impl RemoteServerProjects {
|
||||
paths: Default::default(),
|
||||
nickname: connection.nickname.clone().map(|s| s.into()),
|
||||
is_wsl: false,
|
||||
is_devcontainer: false,
|
||||
}
|
||||
.render(window, cx)
|
||||
.into_any_element(),
|
||||
@@ -1579,6 +1998,7 @@ impl RemoteServerProjects {
|
||||
paths: Default::default(),
|
||||
nickname: None,
|
||||
is_wsl: true,
|
||||
is_devcontainer: false,
|
||||
}
|
||||
.render(window, cx)
|
||||
.into_any_element(),
|
||||
@@ -1917,6 +2337,7 @@ impl RemoteServerProjects {
|
||||
paths: Default::default(),
|
||||
nickname,
|
||||
is_wsl: false,
|
||||
is_devcontainer: false,
|
||||
}
|
||||
.render(window, cx),
|
||||
)
|
||||
@@ -1998,7 +2419,7 @@ impl RemoteServerProjects {
|
||||
.track_focus(&state.add_new_server.focus_handle)
|
||||
.anchor_scroll(state.add_new_server.scroll_anchor.clone())
|
||||
.child(
|
||||
ListItem::new("register-remove-server-button")
|
||||
ListItem::new("register-remote-server-button")
|
||||
.toggle_state(
|
||||
state
|
||||
.add_new_server
|
||||
@@ -2008,7 +2429,7 @@ impl RemoteServerProjects {
|
||||
.inset(true)
|
||||
.spacing(ui::ListItemSpacing::Sparse)
|
||||
.start_slot(Icon::new(IconName::Plus).color(Color::Muted))
|
||||
.child(Label::new("Connect New Server"))
|
||||
.child(Label::new("Connect SSH Server"))
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
let state = CreateRemoteServer::new(window, cx);
|
||||
this.mode = Mode::CreateRemoteServer(state);
|
||||
@@ -2023,6 +2444,36 @@ impl RemoteServerProjects {
|
||||
cx.notify();
|
||||
}));
|
||||
|
||||
let connect_dev_container_button = div()
|
||||
.id("connect-new-dev-container")
|
||||
.track_focus(&state.add_new_devcontainer.focus_handle)
|
||||
.anchor_scroll(state.add_new_devcontainer.scroll_anchor.clone())
|
||||
.child(
|
||||
ListItem::new("register-dev-container-button")
|
||||
.toggle_state(
|
||||
state
|
||||
.add_new_devcontainer
|
||||
.focus_handle
|
||||
.contains_focused(window, cx),
|
||||
)
|
||||
.inset(true)
|
||||
.spacing(ui::ListItemSpacing::Sparse)
|
||||
.start_slot(Icon::new(IconName::Plus).color(Color::Muted))
|
||||
.child(Label::new("Connect Dev Container"))
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
let state = CreateRemoteDevContainer::new(window, cx);
|
||||
this.mode = Mode::CreateRemoteDevContainer(state);
|
||||
|
||||
cx.notify();
|
||||
})),
|
||||
)
|
||||
.on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
|
||||
let state = CreateRemoteDevContainer::new(window, cx);
|
||||
this.mode = Mode::CreateRemoteDevContainer(state);
|
||||
|
||||
cx.notify();
|
||||
}));
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
let wsl_connect_button = div()
|
||||
.id("wsl-connect-new-server")
|
||||
@@ -2049,13 +2500,30 @@ impl RemoteServerProjects {
|
||||
cx.notify();
|
||||
}));
|
||||
|
||||
let has_open_project = self
|
||||
.workspace
|
||||
.upgrade()
|
||||
.map(|workspace| {
|
||||
workspace
|
||||
.read(cx)
|
||||
.project()
|
||||
.read(cx)
|
||||
.visible_worktrees(cx)
|
||||
.next()
|
||||
.is_some()
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
||||
let modal_section = v_flex()
|
||||
.track_focus(&self.focus_handle(cx))
|
||||
.id("ssh-server-list")
|
||||
.overflow_y_scroll()
|
||||
.track_scroll(&state.scroll_handle)
|
||||
.size_full()
|
||||
.child(connect_button);
|
||||
.child(connect_button)
|
||||
.when(has_open_project, |this| {
|
||||
this.child(connect_dev_container_button)
|
||||
});
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
let modal_section = modal_section.child(wsl_connect_button);
|
||||
@@ -2067,17 +2535,20 @@ impl RemoteServerProjects {
|
||||
.child(
|
||||
List::new()
|
||||
.empty_message(
|
||||
v_flex()
|
||||
h_flex()
|
||||
.size_full()
|
||||
.p_2()
|
||||
.justify_center()
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().colors().border_variant)
|
||||
.child(
|
||||
div().px_3().child(
|
||||
Label::new("No remote servers registered yet.")
|
||||
.color(Color::Muted),
|
||||
),
|
||||
Label::new("No remote servers registered yet.")
|
||||
.color(Color::Muted),
|
||||
)
|
||||
.into_any_element(),
|
||||
)
|
||||
.children(state.servers.iter().enumerate().map(|(ix, connection)| {
|
||||
self.render_ssh_connection(ix, connection.clone(), window, cx)
|
||||
self.render_remote_connection(ix, connection.clone(), window, cx)
|
||||
.into_any_element()
|
||||
})),
|
||||
)
|
||||
@@ -2085,6 +2556,10 @@ impl RemoteServerProjects {
|
||||
)
|
||||
.entry(state.add_new_server.clone());
|
||||
|
||||
if has_open_project {
|
||||
modal_section = modal_section.entry(state.add_new_devcontainer.clone());
|
||||
}
|
||||
|
||||
if cfg!(target_os = "windows") {
|
||||
modal_section = modal_section.entry(state.add_new_wsl.clone());
|
||||
}
|
||||
@@ -2297,6 +2772,9 @@ impl Render for RemoteServerProjects {
|
||||
Mode::CreateRemoteServer(state) => self
|
||||
.render_create_remote_server(state, window, cx)
|
||||
.into_any_element(),
|
||||
Mode::CreateRemoteDevContainer(state) => self
|
||||
.render_create_dev_container(state, window, cx)
|
||||
.into_any_element(),
|
||||
Mode::EditNickname(state) => self
|
||||
.render_edit_nickname(state, window, cx)
|
||||
.into_any_element(),
|
||||
|
||||
Reference in New Issue
Block a user