forked from tribes/guix
2c51b803e3
* gnu/packages/patches/zed-0.225.10-add-guix-container-support.patch: New file. * gnu/packages/patches/zed-0.225.10-collapse-multiline-git-deps.patch: New file. * gnu/packages/patches/zed-0.225.10-disable-dlopen.patch: New file. * gnu/packages/patches/zed-0.225.10-exclude-libwebrtc-from-audio.patch: New file. * gnu/packages/patches/zed-0.225.10-fix-sqlite-memory-mode.patch: New file. * gnu/packages/patches/zed-0.225.10-fix-test-db-isolation.patch: New file. * gnu/packages/patches/zed-0.225.10-fix-workspace-race.patch: New file. * gnu/packages/patches/zed-0.225.10-keep-regular-file-workspaces.patch: New file. * gnu/packages/patches/zed-0.225.10-remove-patch-crates-io.patch: New file. * gnu/packages/patches/zed-0.225.10-use-mock-livekit-on-linux.patch: New file. * gnu/packages/patches/rust-candle-0.9.1-add-candle-onnx-to-workspace.patch: New file. * gnu/local.mk (dist_patch_DATA): Register them. * gnu/packages/rust-sources.scm (rust-alacritty-0.25.1.9d9640d, rust-candle-0.9.1.724d75e, rust-dap-types-0.0.1.1b461b3, rust-gh-workflow-0.8.0.c9eac0e, rust-livekit-0.7.8.5f04705, rust-notify-8.2.0.ce58c24, rust-pet-0.1.0.d5b5bb0, rust-tiktoken-rs-0.9.1.2570c43, rust-zed-xim-0.4.0-zed.16f35a2): New variables. * gnu/packages/rust-crates.scm (lookup-cargo-inputs): Modify. * gnu/packages/text-editors.scm (zed): New variable. Change-Id: I16d4c5431e3398261ac4eb74483747c09cf74449
3071 lines
112 KiB
Diff
3071 lines
112 KiB
Diff
From: Danny Milosavljevic <dannym@friendly-machines.com>
|
|
Date: 2026-03-07
|
|
Subject: [PATCH] Add Guix container support.
|
|
License: agpl3+
|
|
|
|
diff --git a/Cargo.lock b/Cargo.lock
|
|
index ca2c53a028..06eea2fcad 100644
|
|
--- a/Cargo.lock
|
|
+++ b/Cargo.lock
|
|
@@ -13644,6 +13644,7 @@ dependencies = [
|
|
"fs",
|
|
"futures 0.3.31",
|
|
"gpui",
|
|
+ "libc",
|
|
"log",
|
|
"parking_lot",
|
|
"paths",
|
|
diff --git a/crates/agent/src/tests/mod.rs b/crates/agent/src/tests/mod.rs
|
|
index 25bdcea4d8..7d351dbf1d 100644
|
|
--- a/crates/agent/src/tests/mod.rs
|
|
+++ b/crates/agent/src/tests/mod.rs
|
|
@@ -3734,6 +3734,7 @@ fn setup_context_server(
|
|
path: "somebinary".into(),
|
|
args: Vec::new(),
|
|
env: None,
|
|
+ working_directory: None,
|
|
timeout: None,
|
|
},
|
|
},
|
|
diff --git a/crates/project/src/context_server_store.rs b/crates/project/src/context_server_store.rs
|
|
index e4cac4768d..598701f2b1 100644
|
|
--- a/crates/project/src/context_server_store.rs
|
|
+++ b/crates/project/src/context_server_store.rs
|
|
@@ -349,6 +349,7 @@ impl ContextServerStore {
|
|
path: "test".into(),
|
|
args: vec![],
|
|
env: None,
|
|
+ working_directory: None,
|
|
timeout: None,
|
|
},
|
|
remote: false,
|
|
@@ -688,12 +689,13 @@ impl ContextServerStore {
|
|
})
|
|
.await?;
|
|
|
|
+ let working_directory = response.working_directory.or(root_dir);
|
|
let remote_command = upstream_client.update(cx, |client, _| {
|
|
client.build_command(
|
|
Some(response.path),
|
|
&response.args,
|
|
&response.env.into_iter().collect(),
|
|
- root_dir,
|
|
+ working_directory,
|
|
None,
|
|
)
|
|
})?;
|
|
@@ -702,6 +704,7 @@ impl ContextServerStore {
|
|
path: remote_command.program.into(),
|
|
args: remote_command.args,
|
|
env: Some(remote_command.env.into_iter().collect()),
|
|
+ working_directory: remote_command.cwd,
|
|
timeout: None,
|
|
};
|
|
|
|
@@ -745,8 +748,17 @@ impl ContextServerStore {
|
|
.min(MAX_TIMEOUT_SECS),
|
|
);
|
|
|
|
- // Don't pass remote paths as working directory for locally-spawned processes
|
|
- let working_directory = if is_remote_project { None } else { root_path };
|
|
+ let working_directory = command
|
|
+ .working_directory
|
|
+ .clone()
|
|
+ .map(Arc::from)
|
|
+ .or_else(|| {
|
|
+ if is_remote_project {
|
|
+ None
|
|
+ } else {
|
|
+ root_path.clone()
|
|
+ }
|
|
+ });
|
|
anyhow::Ok(Arc::new(ContextServer::stdio(
|
|
id,
|
|
command,
|
|
@@ -811,6 +823,10 @@ impl ContextServerStore {
|
|
.clone()
|
|
.map(|env| env.into_iter().collect())
|
|
.unwrap_or_default(),
|
|
+ working_directory: command
|
|
+ .working_directory
|
|
+ .as_ref()
|
|
+ .map(|path| path.display().to_string()),
|
|
})
|
|
}
|
|
|
|
diff --git a/crates/project/src/context_server_store/extension.rs b/crates/project/src/context_server_store/extension.rs
|
|
index 6ad8bd806c..865904aaad 100644
|
|
--- a/crates/project/src/context_server_store/extension.rs
|
|
+++ b/crates/project/src/context_server_store/extension.rs
|
|
@@ -69,6 +69,7 @@ impl registry::ContextServerDescriptor for ContextServerDescriptor {
|
|
path: command.command,
|
|
args: command.args,
|
|
env: Some(command.env.into_iter().collect()),
|
|
+ working_directory: None,
|
|
timeout: None,
|
|
})
|
|
})
|
|
diff --git a/crates/project/src/debugger/dap_store.rs b/crates/project/src/debugger/dap_store.rs
|
|
index 6d320bc06e..846d1e7c29 100644
|
|
--- a/crates/project/src/debugger/dap_store.rs
|
|
+++ b/crates/project/src/debugger/dap_store.rs
|
|
@@ -357,7 +357,7 @@ impl DapStore {
|
|
command: Some(command.program),
|
|
arguments: command.args,
|
|
envs: command.env,
|
|
- cwd: None,
|
|
+ cwd: command.cwd,
|
|
connection,
|
|
request_args: binary.request_args,
|
|
})
|
|
diff --git a/crates/project/src/debugger/session.rs b/crates/project/src/debugger/session.rs
|
|
index 2430d6c102..76d2fa81f9 100644
|
|
--- a/crates/project/src/debugger/session.rs
|
|
+++ b/crates/project/src/debugger/session.rs
|
|
@@ -2883,9 +2883,13 @@ impl Session {
|
|
|
|
let child = remote_client.update(cx, |client, _| {
|
|
let command = client.build_forward_ports_command(port_forwards)?;
|
|
- let child = new_command(command.program)
|
|
- .args(command.args)
|
|
- .envs(command.env)
|
|
+ let mut child = new_command(command.program);
|
|
+ child.args(command.args);
|
|
+ child.envs(command.env);
|
|
+ if let Some(path) = command.cwd {
|
|
+ child.current_dir(path);
|
|
+ }
|
|
+ let child = child
|
|
.spawn()
|
|
.context("spawning port forwarding process")?;
|
|
anyhow::Ok(child)
|
|
diff --git a/crates/project/src/terminals.rs b/crates/project/src/terminals.rs
|
|
index 6efddcdf77..1e7f840ab2 100644
|
|
--- a/crates/project/src/terminals.rs
|
|
+++ b/crates/project/src/terminals.rs
|
|
@@ -170,7 +170,7 @@ impl Project {
|
|
}
|
|
};
|
|
|
|
- let (shell, env) = {
|
|
+ let (shell, env, remote_working_directory) = {
|
|
env.extend(spawn_task.env);
|
|
match remote_client {
|
|
Some(remote_client) => match activation_script.clone() {
|
|
@@ -223,6 +223,7 @@ impl Project {
|
|
title_override: None,
|
|
},
|
|
env,
|
|
+ None,
|
|
)
|
|
}
|
|
_ => (
|
|
@@ -236,12 +237,17 @@ impl Project {
|
|
Shell::System
|
|
},
|
|
env,
|
|
+ None,
|
|
),
|
|
},
|
|
}
|
|
};
|
|
anyhow::Ok(TerminalBuilder::new(
|
|
- local_path.map(|path| path.to_path_buf()),
|
|
+ if is_via_remote {
|
|
+ remote_working_directory
|
|
+ } else {
|
|
+ local_path.map(|path| path.to_path_buf())
|
|
+ },
|
|
task_state,
|
|
shell,
|
|
env,
|
|
@@ -397,16 +403,20 @@ impl Project {
|
|
|
|
let builder = project
|
|
.update(cx, move |_, cx| {
|
|
- let (shell, env) = {
|
|
+ let (shell, env, remote_working_directory) = {
|
|
match remote_client {
|
|
Some(remote_client) => {
|
|
create_remote_shell(None, env, path, remote_client, cx)?
|
|
}
|
|
- None => (settings.shell, env),
|
|
+ None => (settings.shell, env, None),
|
|
}
|
|
};
|
|
anyhow::Ok(TerminalBuilder::new(
|
|
- local_path.map(|path| path.to_path_buf()),
|
|
+ if is_via_remote {
|
|
+ remote_working_directory
|
|
+ } else {
|
|
+ local_path.map(|path| path.to_path_buf())
|
|
+ },
|
|
None,
|
|
shell,
|
|
env,
|
|
@@ -555,6 +565,9 @@ impl Project {
|
|
let mut command = new_std_command(command_template.program);
|
|
command.args(command_template.args);
|
|
command.envs(command_template.env);
|
|
+ if let Some(path) = command_template.cwd {
|
|
+ command.current_dir(path);
|
|
+ }
|
|
Ok(command)
|
|
}
|
|
None => {
|
|
@@ -610,7 +623,7 @@ fn create_remote_shell(
|
|
working_directory: Option<Arc<Path>>,
|
|
remote_client: Entity<RemoteClient>,
|
|
cx: &mut App,
|
|
-) -> Result<(Shell, HashMap<String, String>)> {
|
|
+) -> Result<(Shell, HashMap<String, String>, Option<PathBuf>)> {
|
|
insert_zed_terminal_env(&mut env, &release_channel::AppVersion::global(cx));
|
|
|
|
let (program, args) = match spawn_command {
|
|
@@ -636,5 +649,6 @@ fn create_remote_shell(
|
|
title_override: Some(format!("{} — Terminal", host)),
|
|
},
|
|
command.env,
|
|
+ command.cwd,
|
|
))
|
|
}
|
|
diff --git a/crates/project/src/trusted_worktrees.rs b/crates/project/src/trusted_worktrees.rs
|
|
index 69d410adc6..d5ece7aa0b 100644
|
|
--- a/crates/project/src/trusted_worktrees.rs
|
|
+++ b/crates/project/src/trusted_worktrees.rs
|
|
@@ -160,6 +160,9 @@ impl From<RemoteConnectionOptions> for RemoteHostLocation {
|
|
Some(SharedString::new(docker_connection_options.name)),
|
|
SharedString::new(docker_connection_options.container_id),
|
|
),
|
|
+ RemoteConnectionOptions::GuixContainer(guix) => {
|
|
+ (None, SharedString::new(guix.project_root))
|
|
+ }
|
|
#[cfg(feature = "test-support")]
|
|
RemoteConnectionOptions::Mock(mock) => {
|
|
(None, SharedString::new(format!("mock-{}", mock.id)))
|
|
diff --git a/crates/project/tests/integration/context_server_store.rs b/crates/project/tests/integration/context_server_store.rs
|
|
index 56bdaed41c..a77f1149a9 100644
|
|
--- a/crates/project/tests/integration/context_server_store.rs
|
|
+++ b/crates/project/tests/integration/context_server_store.rs
|
|
@@ -335,6 +335,7 @@ async fn test_context_server_maintain_servers_loop(cx: &mut TestAppContext) {
|
|
path: "somebinary".into(),
|
|
args: vec!["arg".to_string()],
|
|
env: None,
|
|
+ working_directory: None,
|
|
timeout: None,
|
|
},
|
|
},
|
|
@@ -378,6 +379,7 @@ async fn test_context_server_maintain_servers_loop(cx: &mut TestAppContext) {
|
|
path: "somebinary".into(),
|
|
args: vec!["anotherArg".to_string()],
|
|
env: None,
|
|
+ working_directory: None,
|
|
timeout: None,
|
|
},
|
|
},
|
|
@@ -475,6 +477,7 @@ async fn test_context_server_enabled_disabled(cx: &mut TestAppContext) {
|
|
path: "somebinary".into(),
|
|
args: vec!["arg".to_string()],
|
|
env: None,
|
|
+ working_directory: None,
|
|
timeout: None,
|
|
},
|
|
},
|
|
@@ -512,6 +515,7 @@ async fn test_context_server_enabled_disabled(cx: &mut TestAppContext) {
|
|
path: "somebinary".into(),
|
|
args: vec!["arg".to_string()],
|
|
env: None,
|
|
+ working_directory: None,
|
|
timeout: None,
|
|
},
|
|
},
|
|
@@ -541,6 +545,7 @@ async fn test_context_server_enabled_disabled(cx: &mut TestAppContext) {
|
|
command: ContextServerCommand {
|
|
path: "somebinary".into(),
|
|
args: vec!["arg".to_string()],
|
|
+ working_directory: None,
|
|
timeout: None,
|
|
env: None,
|
|
},
|
|
@@ -586,6 +591,7 @@ async fn test_server_ids_includes_disabled_servers(cx: &mut TestAppContext) {
|
|
path: "somebinary".into(),
|
|
args: vec![],
|
|
env: None,
|
|
+ working_directory: None,
|
|
timeout: None,
|
|
},
|
|
},
|
|
@@ -599,6 +605,7 @@ async fn test_server_ids_includes_disabled_servers(cx: &mut TestAppContext) {
|
|
path: "somebinary".into(),
|
|
args: vec![],
|
|
env: None,
|
|
+ working_directory: None,
|
|
timeout: None,
|
|
},
|
|
},
|
|
@@ -857,6 +864,7 @@ async fn test_context_server_stdio_timeout(cx: &mut TestAppContext) {
|
|
path: "/usr/bin/node".into(),
|
|
args: vec!["server.js".into()],
|
|
env: None,
|
|
+ working_directory: None,
|
|
timeout: Some(180000),
|
|
},
|
|
remote: false,
|
|
@@ -953,6 +961,7 @@ impl ContextServerDescriptor for FakeContextServerDescriptor {
|
|
path: self.path.clone(),
|
|
args: vec!["arg1".to_string(), "arg2".to_string()],
|
|
env: None,
|
|
+ working_directory: None,
|
|
timeout: None,
|
|
}))
|
|
}
|
|
diff --git a/crates/proto/proto/ai.proto b/crates/proto/proto/ai.proto
|
|
index b2a8a371c4..e124617c6c 100644
|
|
--- a/crates/proto/proto/ai.proto
|
|
+++ b/crates/proto/proto/ai.proto
|
|
@@ -182,6 +182,7 @@ message ContextServerCommand {
|
|
string path = 1;
|
|
repeated string args = 2;
|
|
map<string, string> env = 3;
|
|
+ optional string working_directory = 4;
|
|
}
|
|
|
|
message AgentServerCommand {
|
|
diff --git a/crates/recent_projects/src/guix_suggest.rs b/crates/recent_projects/src/guix_suggest.rs
|
|
new file mode 100644
|
|
index 0000000000..3b02c4549f
|
|
--- /dev/null
|
|
+++ b/crates/recent_projects/src/guix_suggest.rs
|
|
@@ -0,0 +1,127 @@
|
|
+use db::kvp::KEY_VALUE_STORE;
|
|
+use gpui::{SharedString, WeakEntity, 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 GUIX_CONTAINER_SUGGEST_KEY: &str = "guix_container_suggest_dismissed";
|
|
+
|
|
+fn manifest_path() -> &'static RelPath {
|
|
+ static PATH: LazyLock<&'static RelPath> =
|
|
+ LazyLock::new(|| RelPath::unix("manifest.scm").expect("valid path"));
|
|
+ *PATH
|
|
+}
|
|
+
|
|
+fn project_guix_key(project_path: &str) -> String {
|
|
+ format!("{}_{}", GUIX_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 manifest_updated = updated_entries
|
|
+ .iter()
|
|
+ .any(|(path, _, _)| path.as_ref() == manifest_path());
|
|
+
|
|
+ if !manifest_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 abs_path = worktree.abs_path();
|
|
+ let project_path = abs_path.to_string_lossy().to_string();
|
|
+ let manifest_path = abs_path.join("manifest.scm");
|
|
+ let key_for_dismiss = project_guix_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| {
|
|
+ let workspace_weak: WeakEntity<Workspace> = workspace.weak_handle();
|
|
+ struct GuixContainerSuggestionNotification;
|
|
+
|
|
+ let notification_id = NotificationId::composite::<GuixContainerSuggestionNotification>(
|
|
+ SharedString::from(project_path.clone()),
|
|
+ );
|
|
+
|
|
+ workspace.show_notification(notification_id, cx, |cx| {
|
|
+ cx.new(move |cx| {
|
|
+ MessageNotification::new(
|
|
+ "This project contains a Guix manifest. 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::OpenGuixContainer), cx);
|
|
+ }
|
|
+ })
|
|
+ .more_info_message("Open manifest.scm")
|
|
+ .more_info_on_click({
|
|
+ let workspace_weak = workspace_weak.clone();
|
|
+ let manifest_path = manifest_path.clone();
|
|
+ move |window, cx| {
|
|
+ let Some(workspace) = workspace_weak.upgrade() else {
|
|
+ return;
|
|
+ };
|
|
+
|
|
+ workspace.update(cx, |_workspace, cx| {
|
|
+ let manifest_path = manifest_path.clone();
|
|
+ cx.spawn_in(window, async move |workspace, cx| {
|
|
+ workspace
|
|
+ .update_in(cx, |workspace, window, cx| {
|
|
+ workspace.open_abs_path(
|
|
+ manifest_path.clone(),
|
|
+ Default::default(),
|
|
+ window,
|
|
+ cx,
|
|
+ )
|
|
+ })?
|
|
+ .await
|
|
+ })
|
|
+ .detach();
|
|
+ });
|
|
+ }
|
|
+ })
|
|
+ .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())
|
|
+ });
|
|
+ }
|
|
+ })
|
|
+ })
|
|
+ });
|
|
+ });
|
|
+}
|
|
diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs
|
|
index 110a702437..90fe82912d 100644
|
|
--- a/crates/recent_projects/src/recent_projects.rs
|
|
+++ b/crates/recent_projects/src/recent_projects.rs
|
|
@@ -1,4 +1,5 @@
|
|
mod dev_container_suggest;
|
|
+mod guix_suggest;
|
|
pub mod disconnected_overlay;
|
|
mod remote_connections;
|
|
mod remote_servers;
|
|
@@ -48,7 +49,7 @@ use workspace::{
|
|
SerializedWorkspaceLocation, WORKSPACE_DB, Workspace, WorkspaceId,
|
|
notifications::DetachAndPromptErr, with_active_or_new_workspace,
|
|
};
|
|
-use zed_actions::{OpenDevContainer, OpenRecent, OpenRemote};
|
|
+use zed_actions::{OpenDevContainer, OpenGuixContainer, OpenRecent, OpenRemote};
|
|
|
|
actions!(recent_projects, [ToggleActionsMenu]);
|
|
|
|
@@ -405,6 +406,65 @@ pub fn init(cx: &mut App) {
|
|
});
|
|
});
|
|
|
|
+ cx.on_action(|_: &OpenGuixContainer, cx| {
|
|
+ with_active_or_new_workspace(cx, move |workspace, window, cx| {
|
|
+ if !workspace.project().read(cx).is_local() {
|
|
+ cx.spawn_in(window, async move |_, cx| {
|
|
+ cx.prompt(
|
|
+ gpui::PromptLevel::Critical,
|
|
+ "Cannot open Guix container from remote project",
|
|
+ None,
|
|
+ &["Ok"],
|
|
+ )
|
|
+ .await
|
|
+ .ok();
|
|
+ })
|
|
+ .detach();
|
|
+ return;
|
|
+ }
|
|
+
|
|
+ let app_state = workspace.app_state().clone();
|
|
+ let fs = workspace.project().read(cx).fs().clone();
|
|
+ let paths = workspace
|
|
+ .root_paths(cx)
|
|
+ .into_iter()
|
|
+ .map(|path| path.as_ref().to_path_buf())
|
|
+ .collect::<Vec<_>>();
|
|
+ let handle = cx.entity().downgrade();
|
|
+
|
|
+ cx.spawn_in(window, async move |workspace, cx| {
|
|
+ let Some(remote::RemoteConnectionOptions::GuixContainer(connection)) =
|
|
+ workspace::guix_connection_options_for_paths(&paths, &app_state, cx).await
|
|
+ else {
|
|
+ let _ = cx
|
|
+ .prompt(
|
|
+ gpui::PromptLevel::Critical,
|
|
+ "No Guix manifest found",
|
|
+ Some("This project no longer contains a detectable manifest.scm."),
|
|
+ &["Ok"],
|
|
+ )
|
|
+ .await;
|
|
+ return;
|
|
+ };
|
|
+
|
|
+ workspace
|
|
+ .update_in(cx, |workspace, window, cx| {
|
|
+ workspace.toggle_modal(window, cx, |window, cx| {
|
|
+ RemoteServerProjects::new_guix(
|
|
+ connection,
|
|
+ fs.clone(),
|
|
+ window,
|
|
+ handle.clone(),
|
|
+ cx,
|
|
+ )
|
|
+ });
|
|
+ })
|
|
+ .log_err();
|
|
+ })
|
|
+ .detach();
|
|
+ });
|
|
+ });
|
|
+
|
|
// 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>| {
|
|
@@ -425,6 +485,13 @@ pub fn init(cx: &mut App) {
|
|
window,
|
|
cx,
|
|
);
|
|
+ guix_suggest::suggest_on_worktree_updated(
|
|
+ *worktree_id,
|
|
+ updated_entries,
|
|
+ project,
|
|
+ window,
|
|
+ cx,
|
|
+ );
|
|
}
|
|
},
|
|
)
|
|
@@ -1394,6 +1461,7 @@ fn icon_for_remote_connection(options: Option<&RemoteConnectionOptions>) -> Icon
|
|
RemoteConnectionOptions::Ssh(_) => IconName::Server,
|
|
RemoteConnectionOptions::Wsl(_) => IconName::Linux,
|
|
RemoteConnectionOptions::Docker(_) => IconName::Box,
|
|
+ RemoteConnectionOptions::GuixContainer(_) => IconName::Box,
|
|
#[cfg(any(test, feature = "test-support"))]
|
|
RemoteConnectionOptions::Mock(_) => IconName::Server,
|
|
},
|
|
@@ -1868,6 +1936,106 @@ mod tests {
|
|
.unwrap();
|
|
}
|
|
|
|
+ #[gpui::test]
|
|
+ async fn test_open_guix_container_action_opens_modal(cx: &mut TestAppContext) {
|
|
+ let app_state = init_test(cx);
|
|
+
|
|
+ app_state
|
|
+ .fs
|
|
+ .as_fake()
|
|
+ .insert_tree(
|
|
+ path!("/project"),
|
|
+ json!({
|
|
+ "manifest.scm": "specifications->manifest '()",
|
|
+ "src": {
|
|
+ "main.rs": "fn main() {}"
|
|
+ }
|
|
+ }),
|
|
+ )
|
|
+ .await;
|
|
+
|
|
+ cx.update(|cx| {
|
|
+ open_paths(
|
|
+ &[PathBuf::from(path!("/project"))],
|
|
+ app_state,
|
|
+ workspace::OpenOptions::default(),
|
|
+ cx,
|
|
+ )
|
|
+ })
|
|
+ .await
|
|
+ .unwrap();
|
|
+
|
|
+ assert_eq!(cx.update(|cx| cx.windows().len()), 1);
|
|
+ let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
|
|
+
|
|
+ cx.run_until_parked();
|
|
+
|
|
+ cx.dispatch_action(*multi_workspace, OpenGuixContainer);
|
|
+
|
|
+ multi_workspace
|
|
+ .update(cx, |multi_workspace, _, cx| {
|
|
+ let modal = multi_workspace
|
|
+ .workspace()
|
|
+ .read(cx)
|
|
+ .active_modal::<RemoteServerProjects>(cx);
|
|
+ assert!(
|
|
+ modal.is_some(),
|
|
+ "Guix container modal should be open after dispatching OpenGuixContainer"
|
|
+ );
|
|
+ })
|
|
+ .unwrap();
|
|
+ }
|
|
+
|
|
+ #[gpui::test]
|
|
+ async fn test_open_guix_container_action_without_manifest_does_not_open_modal(
|
|
+ cx: &mut TestAppContext,
|
|
+ ) {
|
|
+ let app_state = init_test(cx);
|
|
+
|
|
+ app_state
|
|
+ .fs
|
|
+ .as_fake()
|
|
+ .insert_tree(
|
|
+ path!("/project"),
|
|
+ json!({
|
|
+ "src": {
|
|
+ "main.rs": "fn main() {}"
|
|
+ }
|
|
+ }),
|
|
+ )
|
|
+ .await;
|
|
+
|
|
+ cx.update(|cx| {
|
|
+ open_paths(
|
|
+ &[PathBuf::from(path!("/project"))],
|
|
+ app_state,
|
|
+ workspace::OpenOptions::default(),
|
|
+ cx,
|
|
+ )
|
|
+ })
|
|
+ .await
|
|
+ .unwrap();
|
|
+
|
|
+ let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
|
|
+
|
|
+ cx.run_until_parked();
|
|
+ cx.dispatch_action(*multi_workspace, OpenGuixContainer);
|
|
+ cx.run_until_parked();
|
|
+
|
|
+ multi_workspace
|
|
+ .update(cx, |multi_workspace, _, cx| {
|
|
+ let modal = multi_workspace
|
|
+ .workspace()
|
|
+ .read(cx)
|
|
+ .active_modal::<RemoteServerProjects>(cx);
|
|
+ assert!(
|
|
+ modal.is_none(),
|
|
+ "Guix container modal should not open without manifest.scm"
|
|
+ );
|
|
+ })
|
|
+ .unwrap();
|
|
+ }
|
|
+
|
|
fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
|
|
cx.update(|cx| {
|
|
let state = AppState::test(cx);
|
|
diff --git a/crates/recent_projects/src/remote_connections.rs b/crates/recent_projects/src/remote_connections.rs
|
|
index 5e901facba..73272d9be0 100644
|
|
--- a/crates/recent_projects/src/remote_connections.rs
|
|
+++ b/crates/recent_projects/src/remote_connections.rs
|
|
@@ -320,6 +320,9 @@ pub async fn open_remote_project(
|
|
RemoteConnectionOptions::Docker(_) => {
|
|
"Failed to connect to Dev Container"
|
|
}
|
|
+ RemoteConnectionOptions::GuixContainer(_) => {
|
|
+ "Failed to connect to Guix container"
|
|
+ }
|
|
#[cfg(any(test, feature = "test-support"))]
|
|
RemoteConnectionOptions::Mock(_) => {
|
|
"Failed to connect to mock server"
|
|
@@ -381,6 +384,9 @@ pub async fn open_remote_project(
|
|
RemoteConnectionOptions::Docker(_) => {
|
|
"Failed to connect to Dev Container"
|
|
}
|
|
+ RemoteConnectionOptions::GuixContainer(_) => {
|
|
+ "Failed to connect to Guix container"
|
|
+ }
|
|
#[cfg(any(test, feature = "test-support"))]
|
|
RemoteConnectionOptions::Mock(_) => {
|
|
"Failed to connect to mock server"
|
|
@@ -498,11 +504,13 @@ async fn path_exists(connection: &Arc<dyn RemoteConnection>, path: &Path) -> boo
|
|
) else {
|
|
return false;
|
|
};
|
|
- let Ok(mut child) = util::command::new_command(command.program)
|
|
- .args(command.args)
|
|
- .envs(command.env)
|
|
- .spawn()
|
|
- else {
|
|
+ let mut child = util::command::new_command(command.program);
|
|
+ child.args(command.args);
|
|
+ child.envs(command.env);
|
|
+ if let Some(path) = command.cwd {
|
|
+ child.current_dir(path);
|
|
+ }
|
|
+ let Ok(mut child) = child.spawn() else {
|
|
return false;
|
|
};
|
|
child.status().await.is_ok_and(|status| status.success())
|
|
diff --git a/crates/recent_projects/src/remote_servers.rs b/crates/recent_projects/src/remote_servers.rs
|
|
index 8bddcf3727..bc4a020c0e 100644
|
|
--- a/crates/recent_projects/src/remote_servers.rs
|
|
+++ b/crates/recent_projects/src/remote_servers.rs
|
|
@@ -24,12 +24,13 @@ use paths::{global_ssh_config_file, user_ssh_config_file};
|
|
use picker::{Picker, PickerDelegate};
|
|
use project::{Fs, Project};
|
|
use remote::{
|
|
- RemoteClient, RemoteConnectionOptions, SshConnectionOptions, WslConnectionOptions,
|
|
+ GuixContainerConnectionOptions, GuixMount, GuixSettings, GuixShellOptions, RemoteClient,
|
|
+ RemoteConnectionOptions, SshConnectionOptions, WslConnectionOptions,
|
|
remote_client::ConnectionIdentifier,
|
|
};
|
|
use settings::{
|
|
- RemoteProject, RemoteSettingsContent, Settings as _, SettingsStore, update_settings_file,
|
|
- watch_config_file,
|
|
+ GuixConnection, RemoteProject, RemoteSettingsContent, Settings as _, SettingsStore,
|
|
+ update_settings_file, watch_config_file,
|
|
};
|
|
use smol::stream::StreamExt as _;
|
|
use std::{
|
|
@@ -43,9 +44,9 @@ use std::{
|
|
},
|
|
};
|
|
use ui::{
|
|
- CommonAnimationExt, IconButtonShape, KeyBinding, List, ListItem, ListSeparator, Modal,
|
|
- ModalFooter, ModalHeader, Navigable, NavigableEntry, Section, Tooltip, WithScrollbar,
|
|
- prelude::*,
|
|
+ Checkbox, CommonAnimationExt, IconButtonShape, KeyBinding, List, ListItem, ListSeparator,
|
|
+ Modal, ModalFooter, ModalHeader, Navigable, NavigableEntry, Section, ToggleState, Tooltip,
|
|
+ WithScrollbar, prelude::*,
|
|
};
|
|
use util::{
|
|
ResultExt,
|
|
@@ -180,6 +181,17 @@ struct EditNicknameState {
|
|
editor: Entity<Editor>,
|
|
}
|
|
|
|
+#[derive(Clone)]
|
|
+struct EditGuixConnectionState {
|
|
+ connection: GuixContainerConnectionOptions,
|
|
+ allow_network: bool,
|
|
+ nesting: bool,
|
|
+ expose_editor: Entity<Editor>,
|
|
+ share_editor: Entity<Editor>,
|
|
+ extra_args_editor: Entity<Editor>,
|
|
+ error: Option<SharedString>,
|
|
+}
|
|
+
|
|
struct DevContainerPickerDelegate {
|
|
selected_index: usize,
|
|
candidates: Vec<DevContainerConfig>,
|
|
@@ -371,6 +383,123 @@ impl EditNicknameState {
|
|
}
|
|
}
|
|
|
|
+impl EditGuixConnectionState {
|
|
+ fn new(
|
|
+ connection: GuixContainerConnectionOptions,
|
|
+ window: &mut Window,
|
|
+ cx: &mut App,
|
|
+ ) -> Self {
|
|
+ let shell_options = GuixSettings::get_global(cx)
|
|
+ .shell_options_for(
|
|
+ PathBuf::from(&connection.manifest_path).as_path(),
|
|
+ PathBuf::from(&connection.project_root).as_path(),
|
|
+ );
|
|
+ let shell_options = if shell_options == GuixShellOptions::default() {
|
|
+ connection.shell_options.clone()
|
|
+ } else {
|
|
+ shell_options
|
|
+ };
|
|
+
|
|
+ let expose_editor = cx.new(|cx| Editor::auto_height(2, 6, window, cx));
|
|
+ let share_editor = cx.new(|cx| Editor::auto_height(2, 6, window, cx));
|
|
+ let extra_args_editor = cx.new(|cx| Editor::auto_height(2, 6, window, cx));
|
|
+ let expose_text = Self::format_mounts(&shell_options.expose);
|
|
+ let share_text = Self::format_mounts(&shell_options.share);
|
|
+ let extra_args_text = shell_options.extra_args.join("\n");
|
|
+
|
|
+ expose_editor.update(cx, |editor, cx| {
|
|
+ editor.set_placeholder_text("source or source=target, one per line", window, cx);
|
|
+ editor.set_text(expose_text, window, cx);
|
|
+ });
|
|
+ share_editor.update(cx, |editor, cx| {
|
|
+ editor.set_placeholder_text("source or source=target, one per line", window, cx);
|
|
+ editor.set_text(share_text, window, cx);
|
|
+ });
|
|
+ extra_args_editor.update(cx, |editor, cx| {
|
|
+ editor.set_placeholder_text("one argument per line", window, cx);
|
|
+ editor.set_text(extra_args_text, window, cx);
|
|
+ });
|
|
+ expose_editor.focus_handle(cx).focus(window, cx);
|
|
+
|
|
+ Self {
|
|
+ connection,
|
|
+ allow_network: shell_options.allow_network,
|
|
+ nesting: shell_options.nesting,
|
|
+ expose_editor,
|
|
+ share_editor,
|
|
+ extra_args_editor,
|
|
+ error: None,
|
|
+ }
|
|
+ }
|
|
+
|
|
+ fn format_mounts(mounts: &[GuixMount]) -> String {
|
|
+ mounts
|
|
+ .iter()
|
|
+ .map(|mount| match &mount.target {
|
|
+ Some(target) => format!("{}={target}", mount.source),
|
|
+ None => mount.source.clone(),
|
|
+ })
|
|
+ .collect::<Vec<_>>()
|
|
+ .join("\n")
|
|
+ }
|
|
+
|
|
+ fn parse_mounts(text: &str) -> anyhow::Result<Vec<GuixMount>> {
|
|
+ text.lines()
|
|
+ .enumerate()
|
|
+ .filter_map(|(ix, line)| {
|
|
+ let trimmed = line.trim();
|
|
+ if trimmed.is_empty() {
|
|
+ None
|
|
+ } else {
|
|
+ Some((ix + 1, trimmed))
|
|
+ }
|
|
+ })
|
|
+ .map(|(line_no, spec)| {
|
|
+ let (source, target) = match spec.split_once('=') {
|
|
+ Some((source, target)) => (source.trim(), Some(target.trim())),
|
|
+ None => (spec, None),
|
|
+ };
|
|
+ if source.is_empty() {
|
|
+ anyhow::bail!("line {line_no}: missing source path")
|
|
+ }
|
|
+ if let Some(target) = target && target.is_empty() {
|
|
+ anyhow::bail!("line {line_no}: missing target path")
|
|
+ }
|
|
+ Ok(GuixMount {
|
|
+ source: source.to_string(),
|
|
+ target: target.map(ToOwned::to_owned),
|
|
+ })
|
|
+ })
|
|
+ .collect()
|
|
+ }
|
|
+
|
|
+ fn shell_options(&self, cx: &App) -> anyhow::Result<GuixShellOptions> {
|
|
+ Ok(GuixShellOptions {
|
|
+ allow_network: self.allow_network,
|
|
+ nesting: self.nesting,
|
|
+ expose: Self::parse_mounts(&self.expose_editor.read(cx).text(cx))?,
|
|
+ share: Self::parse_mounts(&self.share_editor.read(cx).text(cx))?,
|
|
+ extra_args: self
|
|
+ .extra_args_editor
|
|
+ .read(cx)
|
|
+ .text(cx)
|
|
+ .lines()
|
|
+ .map(str::trim)
|
|
+ .filter(|line| !line.is_empty())
|
|
+ .map(ToOwned::to_owned)
|
|
+ .collect(),
|
|
+ })
|
|
+ }
|
|
+}
|
|
+
|
|
+fn guix_secondary_action_label(is_local_workspace: bool) -> &'static str {
|
|
+ if is_local_workspace {
|
|
+ "Save & Open in Container"
|
|
+ } else {
|
|
+ "Save & Reconnect"
|
|
+ }
|
|
+}
|
|
+
|
|
impl Focusable for ProjectPicker {
|
|
fn focus_handle(&self, cx: &App) -> FocusHandle {
|
|
self.picker.focus_handle(cx)
|
|
@@ -413,6 +542,10 @@ impl ProjectPicker {
|
|
connection_string: "".into(),
|
|
nickname: None,
|
|
},
|
|
+ RemoteConnectionOptions::GuixContainer(connection) => ProjectPickerData::Ssh {
|
|
+ connection_string: connection.project_root.clone().into(),
|
|
+ nickname: Some("Guix container".into()),
|
|
+ },
|
|
#[cfg(any(test, feature = "test-support"))]
|
|
RemoteConnectionOptions::Mock(options) => ProjectPickerData::Ssh {
|
|
connection_string: format!("mock-{}", options.id).into(),
|
|
@@ -653,6 +786,7 @@ struct DefaultState {
|
|
scroll_handle: ScrollHandle,
|
|
add_new_server: NavigableEntry,
|
|
add_new_devcontainer: NavigableEntry,
|
|
+ add_new_guix: NavigableEntry,
|
|
add_new_wsl: NavigableEntry,
|
|
servers: Vec<RemoteEntry>,
|
|
}
|
|
@@ -662,6 +796,7 @@ impl DefaultState {
|
|
let handle = ScrollHandle::new();
|
|
let add_new_server = NavigableEntry::new(&handle, cx);
|
|
let add_new_devcontainer = NavigableEntry::new(&handle, cx);
|
|
+ let add_new_guix = NavigableEntry::new(&handle, cx);
|
|
let add_new_wsl = NavigableEntry::new(&handle, cx);
|
|
|
|
let ssh_settings = RemoteSettings::get_global(cx);
|
|
@@ -732,6 +867,7 @@ impl DefaultState {
|
|
scroll_handle: handle,
|
|
add_new_server,
|
|
add_new_devcontainer,
|
|
+ add_new_guix,
|
|
add_new_wsl,
|
|
servers,
|
|
}
|
|
@@ -765,6 +901,7 @@ enum Mode {
|
|
Default(DefaultState),
|
|
ViewServerOptions(ViewServerOptionsState),
|
|
EditNickname(EditNicknameState),
|
|
+ EditGuixConnection(EditGuixConnectionState),
|
|
ProjectPicker(Entity<ProjectPicker>),
|
|
CreateRemoteServer(CreateRemoteServer),
|
|
CreateRemoteDevContainer(CreateRemoteDevContainer),
|
|
@@ -855,6 +992,23 @@ impl RemoteServerProjects {
|
|
this
|
|
}
|
|
|
|
+ pub fn new_guix(
|
|
+ connection: GuixContainerConnectionOptions,
|
|
+ fs: Arc<dyn Fs>,
|
|
+ window: &mut Window,
|
|
+ workspace: WeakEntity<Workspace>,
|
|
+ cx: &mut Context<Self>,
|
|
+ ) -> Self {
|
|
+ Self::new_inner(
|
|
+ Mode::EditGuixConnection(EditGuixConnectionState::new(connection, window, cx)),
|
|
+ false,
|
|
+ fs,
|
|
+ window,
|
|
+ workspace,
|
|
+ cx,
|
|
+ )
|
|
+ }
|
|
+
|
|
pub fn popover(
|
|
fs: Arc<dyn Fs>,
|
|
workspace: WeakEntity<Workspace>,
|
|
@@ -863,7 +1017,17 @@ impl RemoteServerProjects {
|
|
cx: &mut App,
|
|
) -> Entity<Self> {
|
|
cx.new(|cx| {
|
|
- let server = Self::new(create_new_window, fs, window, workspace, cx);
|
|
+ let mut server = Self::new(create_new_window, fs, window, workspace.clone(), cx);
|
|
+ if let Some(workspace) = workspace.upgrade()
|
|
+ && let Some(RemoteConnectionOptions::GuixContainer(connection)) = workspace
|
|
+ .read(cx)
|
|
+ .project()
|
|
+ .read(cx)
|
|
+ .remote_connection_options(cx)
|
|
+ {
|
|
+ server.mode =
|
|
+ Mode::EditGuixConnection(EditGuixConnectionState::new(connection, window, cx));
|
|
+ }
|
|
server.focus_handle(cx).focus(window, cx);
|
|
server
|
|
})
|
|
@@ -1276,6 +1440,10 @@ impl RemoteServerProjects {
|
|
self.mode = Mode::default_mode(&self.ssh_config_servers, cx);
|
|
self.focus_handle.focus(window, cx);
|
|
}
|
|
+ Mode::EditGuixConnection(state) => {
|
|
+ let state = state.clone();
|
|
+ self.save_guix_connection_options(&state, false, window, cx);
|
|
+ }
|
|
#[cfg(target_os = "windows")]
|
|
Mode::AddWslDistro(state) => {
|
|
let delegate = &state.picker.read(cx).delegate;
|
|
@@ -1285,6 +1453,56 @@ impl RemoteServerProjects {
|
|
}
|
|
}
|
|
|
|
+ fn secondary_confirm(
|
|
+ &mut self,
|
|
+ _: &menu::SecondaryConfirm,
|
|
+ window: &mut Window,
|
|
+ cx: &mut Context<Self>,
|
|
+ ) {
|
|
+ if let Mode::EditGuixConnection(state) = &self.mode {
|
|
+ let state = state.clone();
|
|
+ self.save_guix_connection_options(&state, true, window, cx);
|
|
+ }
|
|
+ }
|
|
+
|
|
+ fn open_guix_manifest(
|
|
+ &mut self,
|
|
+ state: &EditGuixConnectionState,
|
|
+ window: &mut Window,
|
|
+ cx: &mut Context<Self>,
|
|
+ ) {
|
|
+ let Some(workspace) = self.workspace.upgrade() else {
|
|
+ cx.emit(DismissEvent);
|
|
+ cx.notify();
|
|
+ return;
|
|
+ };
|
|
+
|
|
+ let manifest_path = {
|
|
+ let path = PathBuf::from(&state.connection.manifest_path);
|
|
+ if path.is_absolute() {
|
|
+ path
|
|
+ } else {
|
|
+ PathBuf::from(&state.connection.project_root).join(path)
|
|
+ }
|
|
+ };
|
|
+
|
|
+ workspace.update(cx, |_workspace, cx| {
|
|
+ cx.spawn_in(window, async move |workspace, cx| {
|
|
+ workspace
|
|
+ .update_in(cx, |workspace, window, cx| {
|
|
+ workspace.open_abs_path(
|
|
+ manifest_path.clone(),
|
|
+ Default::default(),
|
|
+ window,
|
|
+ cx,
|
|
+ )
|
|
+ })?
|
|
+ .await
|
|
+ })
|
|
+ .detach();
|
|
+ });
|
|
+ }
|
|
+
|
|
fn cancel(&mut self, _: &menu::Cancel, window: &mut Window, cx: &mut Context<Self>) {
|
|
match &self.mode {
|
|
Mode::Default(_) => cx.emit(DismissEvent),
|
|
@@ -1298,6 +1516,9 @@ impl RemoteServerProjects {
|
|
self.mode = Mode::CreateRemoteServer(new_state);
|
|
cx.notify();
|
|
}
|
|
+ Mode::EditGuixConnection(_) => {
|
|
+ cx.emit(DismissEvent);
|
|
+ }
|
|
Mode::CreateRemoteDevContainer(CreateRemoteDevContainer {
|
|
progress: DevContainerCreationProgress::Error(_),
|
|
..
|
|
@@ -1746,6 +1967,160 @@ impl RemoteServerProjects {
|
|
});
|
|
}
|
|
|
|
+ fn save_guix_connection_options(
|
|
+ &mut self,
|
|
+ state: &EditGuixConnectionState,
|
|
+ reconnect: bool,
|
|
+ window: &mut Window,
|
|
+ cx: &mut Context<Self>,
|
|
+ ) {
|
|
+ let shell_options = match state.shell_options(cx) {
|
|
+ Ok(shell_options) => shell_options,
|
|
+ Err(error) => {
|
|
+ if let Mode::EditGuixConnection(current_state) = &mut self.mode {
|
|
+ current_state.error = Some(error.to_string().into());
|
|
+ }
|
|
+ cx.notify();
|
|
+ return;
|
|
+ }
|
|
+ };
|
|
+
|
|
+ let manifest_path = state.connection.manifest_path.clone();
|
|
+ let project_root = state.connection.project_root.clone();
|
|
+ let remove_entry = shell_options == GuixShellOptions::default();
|
|
+ let stored_options = shell_options.clone();
|
|
+
|
|
+ self.update_settings_file(cx, move |setting, _| {
|
|
+ let connections = setting.guix_connections.get_or_insert_default();
|
|
+ if let Some(index) = connections.iter().position(|connection| {
|
|
+ connection.manifest_path == manifest_path || connection.project_root == project_root
|
|
+ }) {
|
|
+ if remove_entry {
|
|
+ connections.remove(index);
|
|
+ } else {
|
|
+ connections[index] = GuixConnection {
|
|
+ manifest_path,
|
|
+ project_root,
|
|
+ options: settings::GuixShellOptions {
|
|
+ allow_network: stored_options.allow_network,
|
|
+ nesting: stored_options.nesting,
|
|
+ expose: stored_options
|
|
+ .expose
|
|
+ .iter()
|
|
+ .cloned()
|
|
+ .map(|mount| settings::GuixMount {
|
|
+ source: mount.source,
|
|
+ target: mount.target,
|
|
+ })
|
|
+ .collect(),
|
|
+ share: stored_options
|
|
+ .share
|
|
+ .iter()
|
|
+ .cloned()
|
|
+ .map(|mount| settings::GuixMount {
|
|
+ source: mount.source,
|
|
+ target: mount.target,
|
|
+ })
|
|
+ .collect(),
|
|
+ extra_args: stored_options.extra_args.clone(),
|
|
+ },
|
|
+ };
|
|
+ }
|
|
+ } else if !remove_entry {
|
|
+ connections.push(GuixConnection {
|
|
+ manifest_path,
|
|
+ project_root,
|
|
+ options: settings::GuixShellOptions {
|
|
+ allow_network: stored_options.allow_network,
|
|
+ nesting: stored_options.nesting,
|
|
+ expose: stored_options
|
|
+ .expose
|
|
+ .iter()
|
|
+ .cloned()
|
|
+ .map(|mount| settings::GuixMount {
|
|
+ source: mount.source,
|
|
+ target: mount.target,
|
|
+ })
|
|
+ .collect(),
|
|
+ share: stored_options
|
|
+ .share
|
|
+ .iter()
|
|
+ .cloned()
|
|
+ .map(|mount| settings::GuixMount {
|
|
+ source: mount.source,
|
|
+ target: mount.target,
|
|
+ })
|
|
+ .collect(),
|
|
+ extra_args: stored_options.extra_args.clone(),
|
|
+ },
|
|
+ });
|
|
+ }
|
|
+ });
|
|
+
|
|
+ let updated_connection = RemoteConnectionOptions::GuixContainer(
|
|
+ GuixContainerConnectionOptions {
|
|
+ shell_options,
|
|
+ ..state.connection.clone()
|
|
+ },
|
|
+ );
|
|
+
|
|
+ if reconnect {
|
|
+ self.open_current_project_with_connection_options(updated_connection, window, cx);
|
|
+ } else {
|
|
+ if let Some(workspace) = self.workspace.upgrade() {
|
|
+ workspace.update(cx, |workspace, cx| {
|
|
+ struct GuixOptionsSaved;
|
|
+ workspace.show_toast(
|
|
+ Toast::new(
|
|
+ NotificationId::composite::<GuixOptionsSaved>("guix-options-saved"),
|
|
+ "Saved Guix container options",
|
|
+ ),
|
|
+ cx,
|
|
+ );
|
|
+ });
|
|
+ }
|
|
+ cx.emit(DismissEvent);
|
|
+ }
|
|
+ }
|
|
+
|
|
+ fn open_current_project_with_connection_options(
|
|
+ &mut 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::<Vec<_>>();
|
|
+
|
|
+ cx.emit(DismissEvent);
|
|
+ cx.spawn_in(window, async move |_, cx| {
|
|
+ open_remote_project(
|
|
+ connection_options,
|
|
+ paths,
|
|
+ app_state,
|
|
+ OpenOptions {
|
|
+ replace_window: Some(window_handle),
|
|
+ ..Default::default()
|
|
+ },
|
|
+ cx,
|
|
+ )
|
|
+ .await
|
|
+ })
|
|
+ .detach_and_prompt_err("Failed to open remote project", window, cx, |_, _, _| None);
|
|
+ }
|
|
+
|
|
fn edit_in_dev_container_json(
|
|
&mut self,
|
|
config: Option<DevContainerConfig>,
|
|
@@ -1839,6 +2214,47 @@ impl RemoteServerProjects {
|
|
}
|
|
}
|
|
|
|
+ fn init_guix_mode(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
+ let Some(workspace) = self.workspace.upgrade() 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::<Vec<_>>();
|
|
+ let entity = cx.entity().downgrade();
|
|
+
|
|
+ cx.spawn_in(window, async move |_, cx| {
|
|
+ let Some(remote::RemoteConnectionOptions::GuixContainer(connection)) =
|
|
+ workspace::guix_connection_options_for_paths(&paths, &app_state, cx).await
|
|
+ else {
|
|
+ let _ = cx
|
|
+ .prompt(
|
|
+ gpui::PromptLevel::Critical,
|
|
+ "No Guix manifest found",
|
|
+ Some("This project no longer contains a detectable manifest.scm."),
|
|
+ &["Ok"],
|
|
+ )
|
|
+ .await;
|
|
+ return;
|
|
+ };
|
|
+
|
|
+ entity
|
|
+ .update_in(cx, |this, window, cx| {
|
|
+ this.mode =
|
|
+ Mode::EditGuixConnection(EditGuixConnectionState::new(connection, window, cx));
|
|
+ this.focus_handle(cx).focus(window, cx);
|
|
+ cx.notify();
|
|
+ })
|
|
+ .ok();
|
|
+ })
|
|
+ .detach();
|
|
+ }
|
|
+
|
|
fn open_dev_container(
|
|
&self,
|
|
config: Option<DevContainerConfig>,
|
|
@@ -2524,6 +2940,168 @@ impl RemoteServerProjects {
|
|
)
|
|
}
|
|
|
|
+ fn render_edit_guix_connection(
|
|
+ &self,
|
|
+ state: &EditGuixConnectionState,
|
|
+ window: &mut Window,
|
|
+ cx: &mut Context<Self>,
|
|
+ ) -> impl IntoElement {
|
|
+ let is_local_workspace = self
|
|
+ .workspace
|
|
+ .upgrade()
|
|
+ .is_some_and(|workspace| workspace.read(cx).project().read(cx).is_local());
|
|
+ let secondary_label = guix_secondary_action_label(is_local_workspace);
|
|
+
|
|
+ let entity = cx.entity();
|
|
+ let allow_network_entity = entity.clone();
|
|
+ let nesting_entity = entity.clone();
|
|
+ let section = |title: &'static str,
|
|
+ detail: &'static str,
|
|
+ editor: Entity<Editor>,
|
|
+ _window: &mut Window,
|
|
+ cx: &mut Context<Self>| {
|
|
+ v_flex()
|
|
+ .gap_1()
|
|
+ .child(Label::new(title))
|
|
+ .child(Label::new(detail).size(LabelSize::Small).color(Color::Muted))
|
|
+ .child(
|
|
+ div()
|
|
+ .rounded_md()
|
|
+ .border_1()
|
|
+ .border_color(cx.theme().colors().border_variant)
|
|
+ .bg(cx.theme().colors().editor_background)
|
|
+ .p_2()
|
|
+ .child(editor.clone()),
|
|
+ )
|
|
+ };
|
|
+
|
|
+ Modal::new("guix-connection-options", None)
|
|
+ .header(ModalHeader::new().headline("Guix Container Options"))
|
|
+ .section(
|
|
+ Section::new().child(
|
|
+ v_flex()
|
|
+ .gap_3()
|
|
+ .child(
|
|
+ v_flex()
|
|
+ .gap_1()
|
|
+ .child(Label::new("Project Root"))
|
|
+ .child(
|
|
+ Label::new(state.connection.project_root.clone())
|
|
+ .size(LabelSize::Small)
|
|
+ .color(Color::Muted),
|
|
+ )
|
|
+ .child(Label::new("Manifest"))
|
|
+ .child(
|
|
+ Label::new(state.connection.manifest_path.clone())
|
|
+ .size(LabelSize::Small)
|
|
+ .color(Color::Muted),
|
|
+ ),
|
|
+ )
|
|
+ .child(
|
|
+ Checkbox::new(
|
|
+ "guix-allow-network",
|
|
+ ToggleState::from(state.allow_network),
|
|
+ )
|
|
+ .label("Allow network access (-N)")
|
|
+ .on_click(move |_, _, app| {
|
|
+ let _ = allow_network_entity.update(app, |this, cx| {
|
|
+ if let Mode::EditGuixConnection(state) = &mut this.mode {
|
|
+ state.allow_network = !state.allow_network;
|
|
+ state.error = None;
|
|
+ cx.notify();
|
|
+ }
|
|
+ });
|
|
+ }),
|
|
+ )
|
|
+ .child(
|
|
+ Checkbox::new("guix-nesting", ToggleState::from(state.nesting))
|
|
+ .label("Allow nested Guix invocations (--nesting)")
|
|
+ .on_click(move |_, _, app| {
|
|
+ let _ = nesting_entity.update(app, |this, cx| {
|
|
+ if let Mode::EditGuixConnection(state) = &mut this.mode {
|
|
+ state.nesting = !state.nesting;
|
|
+ state.error = None;
|
|
+ cx.notify();
|
|
+ }
|
|
+ });
|
|
+ }),
|
|
+ )
|
|
+ .child(section(
|
|
+ "Extra --expose mounts",
|
|
+ "One entry per line. Format: source or source=target",
|
|
+ state.expose_editor.clone(),
|
|
+ window,
|
|
+ cx,
|
|
+ ))
|
|
+ .child(section(
|
|
+ "Extra --share mounts",
|
|
+ "One entry per line. Format: source or source=target",
|
|
+ state.share_editor.clone(),
|
|
+ window,
|
|
+ cx,
|
|
+ ))
|
|
+ .child(section(
|
|
+ "Extra guix shell arguments",
|
|
+ "One argument per line. These are appended before `--`.",
|
|
+ state.extra_args_editor.clone(),
|
|
+ window,
|
|
+ cx,
|
|
+ ))
|
|
+ .when_some(state.error.clone(), |this, error| {
|
|
+ this.child(Label::new(error).size(LabelSize::Small).color(Color::Error))
|
|
+ }),
|
|
+ ),
|
|
+ )
|
|
+ .footer(
|
|
+ ModalFooter::new().end_slot(
|
|
+ h_flex()
|
|
+ .gap_2()
|
|
+ .child(
|
|
+ Button::new("guix-cancel", "Cancel")
|
|
+ .color(Color::Muted)
|
|
+ .on_click(cx.listener(|this, _, _, cx| {
|
|
+ cx.emit(DismissEvent);
|
|
+ this.mode = Mode::default_mode(&this.ssh_config_servers, cx);
|
|
+ })),
|
|
+ )
|
|
+ .child(
|
|
+ Button::new("guix-save", "Save")
|
|
+ .key_binding(KeyBinding::for_action(&menu::Confirm, cx))
|
|
+ .on_click(cx.listener(|this, _, window, cx| {
|
|
+ if let Mode::EditGuixConnection(state) = &this.mode {
|
|
+ let state = state.clone();
|
|
+ this.save_guix_connection_options(
|
|
+ &state, false, window, cx,
|
|
+ );
|
|
+ }
|
|
+ })),
|
|
+ )
|
|
+ .child(
|
|
+ Button::new("guix-open-manifest", "Open manifest.scm").on_click(
|
|
+ cx.listener(|this, _, window, cx| {
|
|
+ if let Mode::EditGuixConnection(state) = &this.mode {
|
|
+ let state = state.clone();
|
|
+ this.open_guix_manifest(&state, window, cx);
|
|
+ }
|
|
+ }),
|
|
+ ),
|
|
+ )
|
|
+ .child(
|
|
+ Button::new("guix-save-reconnect", secondary_label)
|
|
+ .key_binding(KeyBinding::for_action(&menu::SecondaryConfirm, cx))
|
|
+ .on_click(cx.listener(|this, _, window, cx| {
|
|
+ if let Mode::EditGuixConnection(state) = &this.mode {
|
|
+ let state = state.clone();
|
|
+ this.save_guix_connection_options(
|
|
+ &state, true, window, cx,
|
|
+ );
|
|
+ }
|
|
+ })),
|
|
+ ),
|
|
+ ),
|
|
+ )
|
|
+ }
|
|
+
|
|
fn render_default(
|
|
&mut self,
|
|
mut state: DefaultState,
|
|
@@ -2642,6 +3220,30 @@ impl RemoteServerProjects {
|
|
this.init_dev_container_mode(window, cx);
|
|
}));
|
|
|
|
+ let connect_guix_container_button = div()
|
|
+ .id("connect-new-guix-container")
|
|
+ .track_focus(&state.add_new_guix.focus_handle)
|
|
+ .anchor_scroll(state.add_new_guix.scroll_anchor.clone())
|
|
+ .child(
|
|
+ ListItem::new("register-guix-container-button")
|
|
+ .toggle_state(
|
|
+ state
|
|
+ .add_new_guix
|
|
+ .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 Guix Container"))
|
|
+ .on_click(cx.listener(|this, _, window, cx| {
|
|
+ this.init_guix_mode(window, cx);
|
|
+ })),
|
|
+ )
|
|
+ .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
|
|
+ this.init_guix_mode(window, cx);
|
|
+ }));
|
|
+
|
|
#[cfg(target_os = "windows")]
|
|
let wsl_connect_button = div()
|
|
.id("wsl-connect-new-server")
|
|
@@ -2698,6 +3300,7 @@ impl RemoteServerProjects {
|
|
.child(connect_button)
|
|
.when(has_open_project && is_local, |this| {
|
|
this.child(connect_dev_container_button)
|
|
+ .child(connect_guix_container_button)
|
|
});
|
|
|
|
#[cfg(target_os = "windows")]
|
|
@@ -2855,6 +3458,113 @@ impl RemoteServerProjects {
|
|
}
|
|
}
|
|
|
|
+#[cfg(test)]
|
|
+mod tests {
|
|
+ use super::{Mode, RemoteServerProjects, guix_secondary_action_label};
|
|
+ use crate::init;
|
|
+ use editor;
|
|
+ use serde_json::json;
|
|
+ use std::path::PathBuf;
|
|
+ use util::path;
|
|
+ use workspace::{AppState, MultiWorkspace, open_paths};
|
|
+
|
|
+ #[test]
|
|
+ fn test_guix_secondary_action_label_for_local_workspace() {
|
|
+ assert_eq!(
|
|
+ guix_secondary_action_label(true),
|
|
+ "Save & Open in Container"
|
|
+ );
|
|
+ }
|
|
+
|
|
+ #[test]
|
|
+ fn test_guix_secondary_action_label_for_remote_workspace() {
|
|
+ assert_eq!(guix_secondary_action_label(false), "Save & Reconnect");
|
|
+ }
|
|
+
|
|
+ #[gpui::test]
|
|
+ async fn test_init_guix_mode_switches_remote_projects_modal_to_guix_editor(
|
|
+ cx: &mut gpui::TestAppContext,
|
|
+ ) {
|
|
+ let app_state = cx.update(|cx| {
|
|
+ let state = AppState::test(cx);
|
|
+ init(cx);
|
|
+ editor::init(cx);
|
|
+ state
|
|
+ });
|
|
+
|
|
+ app_state
|
|
+ .fs
|
|
+ .as_fake()
|
|
+ .insert_tree(
|
|
+ path!("/project"),
|
|
+ json!({
|
|
+ "manifest.scm": "specifications->manifest '()",
|
|
+ "src": {
|
|
+ "main.rs": "fn main() {}"
|
|
+ }
|
|
+ }),
|
|
+ )
|
|
+ .await;
|
|
+
|
|
+ cx.update(|cx| {
|
|
+ open_paths(
|
|
+ &[PathBuf::from(path!("/project"))],
|
|
+ app_state,
|
|
+ workspace::OpenOptions::default(),
|
|
+ cx,
|
|
+ )
|
|
+ })
|
|
+ .await
|
|
+ .unwrap();
|
|
+
|
|
+ let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
|
|
+
|
|
+ multi_workspace
|
|
+ .update(cx, |multi_workspace, window, cx| {
|
|
+ let workspace = multi_workspace.workspace().clone();
|
|
+ let weak = workspace.downgrade();
|
|
+ let fs = workspace.read(cx).project().read(cx).fs().clone();
|
|
+ workspace.update(cx, |workspace, cx| {
|
|
+ workspace.toggle_modal(window, cx, |window, cx| {
|
|
+ RemoteServerProjects::new(false, fs.clone(), window, weak.clone(), cx)
|
|
+ });
|
|
+ });
|
|
+ })
|
|
+ .unwrap();
|
|
+
|
|
+ cx.run_until_parked();
|
|
+
|
|
+ multi_workspace
|
|
+ .update(cx, |multi_workspace, window, cx| {
|
|
+ let modal = multi_workspace
|
|
+ .workspace()
|
|
+ .read(cx)
|
|
+ .active_modal::<RemoteServerProjects>(cx)
|
|
+ .expect("remote projects modal should be open");
|
|
+ modal.update(cx, |modal, cx| {
|
|
+ modal.init_guix_mode(window, cx);
|
|
+ });
|
|
+ })
|
|
+ .unwrap();
|
|
+
|
|
+ cx.run_until_parked();
|
|
+
|
|
+ multi_workspace
|
|
+ .update(cx, |multi_workspace, _, cx| {
|
|
+ let modal = multi_workspace
|
|
+ .workspace()
|
|
+ .read(cx)
|
|
+ .active_modal::<RemoteServerProjects>(cx)
|
|
+ .expect("remote projects modal should still be open");
|
|
+ assert!(
|
|
+ matches!(&modal.read(cx).mode, Mode::EditGuixConnection(_)),
|
|
+ "top-level Guix entry should switch the modal into Guix edit mode"
|
|
+ );
|
|
+ })
|
|
+ .unwrap();
|
|
+ }
|
|
+}
|
|
+
|
|
fn spawn_ssh_config_watch(fs: Arc<dyn Fs>, cx: &Context<RemoteServerProjects>) -> Task<()> {
|
|
enum ConfigSource {
|
|
User(String),
|
|
@@ -2945,6 +3655,7 @@ impl Render for RemoteServerProjects {
|
|
.key_context("RemoteServerModal")
|
|
.on_action(cx.listener(Self::cancel))
|
|
.on_action(cx.listener(Self::confirm))
|
|
+ .on_action(cx.listener(Self::secondary_confirm))
|
|
.capture_any_mouse_down(cx.listener(|this, _, window, cx| {
|
|
this.focus_handle(cx).focus(window, cx);
|
|
}))
|
|
@@ -2970,6 +3681,9 @@ impl Render for RemoteServerProjects {
|
|
Mode::EditNickname(state) => self
|
|
.render_edit_nickname(state, window, cx)
|
|
.into_any_element(),
|
|
+ Mode::EditGuixConnection(state) => self
|
|
+ .render_edit_guix_connection(state, window, cx)
|
|
+ .into_any_element(),
|
|
#[cfg(target_os = "windows")]
|
|
Mode::AddWslDistro(state) => self
|
|
.render_add_wsl_distro(state, window, cx)
|
|
diff --git a/crates/remote/Cargo.toml b/crates/remote/Cargo.toml
|
|
index 50026904a8..5ece3b26d5 100644
|
|
--- a/crates/remote/Cargo.toml
|
|
+++ b/crates/remote/Cargo.toml
|
|
@@ -27,6 +27,7 @@ collections.workspace = true
|
|
fs.workspace = true
|
|
futures.workspace = true
|
|
gpui.workspace = true
|
|
+libc.workspace = true
|
|
log.workspace = true
|
|
parking_lot.workspace = true
|
|
paths.workspace = true
|
|
diff --git a/crates/remote/src/guix_settings.rs b/crates/remote/src/guix_settings.rs
|
|
new file mode 100644
|
|
index 0000000000..045f4520bb
|
|
--- /dev/null
|
|
+++ b/crates/remote/src/guix_settings.rs
|
|
@@ -0,0 +1,37 @@
|
|
+use std::path::Path;
|
|
+
|
|
+use settings::{
|
|
+ ExtendingVec, GuixConnection, RegisterSetting, Settings,
|
|
+};
|
|
+
|
|
+use crate::transport::guix::GuixShellOptions;
|
|
+
|
|
+#[derive(RegisterSetting)]
|
|
+pub struct GuixSettings {
|
|
+ pub guix_connections: ExtendingVec<GuixConnection>,
|
|
+}
|
|
+
|
|
+impl GuixSettings {
|
|
+ pub fn shell_options_for(&self, manifest_path: &Path, project_root: &Path) -> GuixShellOptions {
|
|
+ let manifest_path = manifest_path.to_string_lossy();
|
|
+ let project_root = project_root.to_string_lossy();
|
|
+
|
|
+ self.guix_connections
|
|
+ .0
|
|
+ .iter()
|
|
+ .find(|connection| {
|
|
+ connection.manifest_path == manifest_path || connection.project_root == project_root
|
|
+ })
|
|
+ .map(|connection| connection.options.clone().into())
|
|
+ .unwrap_or_default()
|
|
+ }
|
|
+}
|
|
+
|
|
+impl Settings for GuixSettings {
|
|
+ fn from_settings(content: &settings::SettingsContent) -> Self {
|
|
+ let remote = &content.remote;
|
|
+ Self {
|
|
+ guix_connections: remote.guix_connections.clone().unwrap_or_default().into(),
|
|
+ }
|
|
+ }
|
|
+}
|
|
diff --git a/crates/remote/src/remote.rs b/crates/remote/src/remote.rs
|
|
index d3b093cdb8..857aedcf22 100644
|
|
--- a/crates/remote/src/remote.rs
|
|
+++ b/crates/remote/src/remote.rs
|
|
@@ -2,6 +2,7 @@ pub mod json_log;
|
|
pub mod protocol;
|
|
pub mod proxy;
|
|
pub mod remote_client;
|
|
+mod guix_settings;
|
|
mod transport;
|
|
|
|
#[cfg(target_os = "windows")]
|
|
@@ -11,7 +12,11 @@ pub use remote_client::{
|
|
RemoteClientDelegate, RemoteClientEvent, RemoteConnection, RemoteConnectionOptions, RemoteOs,
|
|
RemotePlatform, connect,
|
|
};
|
|
+pub use guix_settings::GuixSettings;
|
|
pub use transport::docker::DockerConnectionOptions;
|
|
+pub use transport::guix::{
|
|
+ GuixContainerConnectionOptions, GuixMount, GuixShellOptions,
|
|
+};
|
|
pub use transport::ssh::{SshConnectionOptions, SshPortForwardOption};
|
|
pub use transport::wsl::WslConnectionOptions;
|
|
#[cfg(target_os = "windows")]
|
|
diff --git a/crates/remote/src/remote_client.rs b/crates/remote/src/remote_client.rs
|
|
index f31fc9ebec..daafce1614 100644
|
|
--- a/crates/remote/src/remote_client.rs
|
|
+++ b/crates/remote/src/remote_client.rs
|
|
@@ -6,6 +6,7 @@ use crate::{
|
|
proxy::ProxyLaunchError,
|
|
transport::{
|
|
docker::{DockerConnectionOptions, DockerExecConnection},
|
|
+ guix::{GuixContainerConnection, GuixContainerConnectionOptions},
|
|
ssh::SshRemoteConnection,
|
|
wsl::{WslConnectionOptions, WslRemoteConnection},
|
|
},
|
|
@@ -110,6 +111,7 @@ pub struct CommandTemplate {
|
|
pub program: String,
|
|
pub args: Vec<String>,
|
|
pub env: HashMap<String, String>,
|
|
+ pub cwd: Option<PathBuf>,
|
|
}
|
|
|
|
/// Whether a command should be run with TTY allocation for interactive use.
|
|
@@ -1231,6 +1233,11 @@ impl ConnectionPool {
|
|
.await
|
|
.map(|connection| Arc::new(connection) as Arc<dyn RemoteConnection>)
|
|
}
|
|
+ RemoteConnectionOptions::GuixContainer(opts) => {
|
|
+ GuixContainerConnection::new(opts, delegate, cx)
|
|
+ .await
|
|
+ .map(|connection| Arc::new(connection) as Arc<dyn RemoteConnection>)
|
|
+ }
|
|
#[cfg(any(test, feature = "test-support"))]
|
|
RemoteConnectionOptions::Mock(opts) => match cx.update(|cx| {
|
|
cx.default_global::<crate::transport::mock::MockConnectionRegistry>()
|
|
@@ -1278,6 +1285,7 @@ pub enum RemoteConnectionOptions {
|
|
Ssh(SshConnectionOptions),
|
|
Wsl(WslConnectionOptions),
|
|
Docker(DockerConnectionOptions),
|
|
+ GuixContainer(GuixContainerConnectionOptions),
|
|
#[cfg(any(test, feature = "test-support"))]
|
|
Mock(crate::transport::mock::MockConnectionOptions),
|
|
}
|
|
@@ -1294,6 +1302,7 @@ impl RemoteConnectionOptions {
|
|
opts.name.clone()
|
|
}
|
|
}
|
|
+ RemoteConnectionOptions::GuixContainer(opts) => opts.project_root.clone(),
|
|
#[cfg(any(test, feature = "test-support"))]
|
|
RemoteConnectionOptions::Mock(opts) => format!("mock-{}", opts.id),
|
|
}
|
|
@@ -1312,6 +1321,12 @@ impl From<WslConnectionOptions> for RemoteConnectionOptions {
|
|
}
|
|
}
|
|
|
|
+impl From<GuixContainerConnectionOptions> for RemoteConnectionOptions {
|
|
+ fn from(opts: GuixContainerConnectionOptions) -> Self {
|
|
+ RemoteConnectionOptions::GuixContainer(opts)
|
|
+ }
|
|
+}
|
|
+
|
|
#[cfg(any(test, feature = "test-support"))]
|
|
impl From<crate::transport::mock::MockConnectionOptions> for RemoteConnectionOptions {
|
|
fn from(opts: crate::transport::mock::MockConnectionOptions) -> Self {
|
|
diff --git a/crates/remote/src/transport.rs b/crates/remote/src/transport.rs
|
|
index 09bb22ddbe..ee57223ad7 100644
|
|
--- a/crates/remote/src/transport.rs
|
|
+++ b/crates/remote/src/transport.rs
|
|
@@ -13,6 +13,7 @@ use rpc::proto::Envelope;
|
|
use util::command::Child;
|
|
|
|
pub mod docker;
|
|
+pub mod guix;
|
|
#[cfg(any(test, feature = "test-support"))]
|
|
pub mod mock;
|
|
pub mod ssh;
|
|
diff --git a/crates/remote/src/transport/docker.rs b/crates/remote/src/transport/docker.rs
|
|
index 1bcf80880a..ab26466431 100644
|
|
--- a/crates/remote/src/transport/docker.rs
|
|
+++ b/crates/remote/src/transport/docker.rs
|
|
@@ -786,6 +786,7 @@ impl RemoteConnection for DockerExecConnection {
|
|
args: docker_args,
|
|
// Docker-exec pipes in environment via the "-e" argument
|
|
env: Default::default(),
|
|
+ cwd: None,
|
|
})
|
|
}
|
|
|
|
diff --git a/crates/remote/src/transport/guix.rs b/crates/remote/src/transport/guix.rs
|
|
new file mode 100644
|
|
index 0000000000..9e4e7919ec
|
|
--- /dev/null
|
|
+++ b/crates/remote/src/transport/guix.rs
|
|
@@ -0,0 +1,566 @@
|
|
+use std::{
|
|
+ env,
|
|
+ path::{Path, PathBuf},
|
|
+ sync::Arc,
|
|
+};
|
|
+
|
|
+use anyhow::{Context as _, Result, anyhow};
|
|
+use async_trait::async_trait;
|
|
+use collections::HashMap;
|
|
+use fs::{CopyOptions, RealFs, copy_recursive};
|
|
+use futures::channel::mpsc::{Sender, UnboundedReceiver, UnboundedSender};
|
|
+use gpui::{App, AppContext, AsyncApp, Task};
|
|
+use libc::SIGTERM;
|
|
+use rpc::proto::Envelope;
|
|
+use schemars::JsonSchema;
|
|
+use serde::{Deserialize, Serialize};
|
|
+use util::{
|
|
+ command::{Stdio, new_command},
|
|
+ paths::{PathStyle, RemotePathBuf},
|
|
+};
|
|
+
|
|
+use crate::remote_client::CommandTemplate;
|
|
+use crate::{
|
|
+Interactive, RemoteClientDelegate, RemoteConnection, RemoteConnectionOptions,
|
|
+};
|
|
+
|
|
+#[derive(Debug, Default, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
|
|
+pub struct GuixMount {
|
|
+ pub source: String,
|
|
+ pub target: Option<String>,
|
|
+}
|
|
+
|
|
+impl From<settings::GuixMount> for GuixMount {
|
|
+ fn from(value: settings::GuixMount) -> Self {
|
|
+ Self {
|
|
+ source: value.source,
|
|
+ target: value.target,
|
|
+ }
|
|
+ }
|
|
+}
|
|
+
|
|
+#[derive(Debug, Default, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
|
|
+pub struct GuixShellOptions {
|
|
+ pub allow_network: bool,
|
|
+ pub nesting: bool,
|
|
+ pub expose: Vec<GuixMount>,
|
|
+ pub share: Vec<GuixMount>,
|
|
+ pub extra_args: Vec<String>,
|
|
+}
|
|
+
|
|
+impl From<settings::GuixShellOptions> for GuixShellOptions {
|
|
+ fn from(value: settings::GuixShellOptions) -> Self {
|
|
+ Self {
|
|
+ allow_network: value.allow_network,
|
|
+ nesting: value.nesting,
|
|
+ expose: value.expose.into_iter().map(Into::into).collect(),
|
|
+ share: value.share.into_iter().map(Into::into).collect(),
|
|
+ extra_args: value.extra_args,
|
|
+ }
|
|
+ }
|
|
+}
|
|
+
|
|
+#[derive(Debug, Default, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
|
|
+pub struct GuixContainerConnectionOptions {
|
|
+ pub manifest_path: String,
|
|
+ pub project_root: String,
|
|
+ #[serde(default)]
|
|
+ pub shell_options: GuixShellOptions,
|
|
+}
|
|
+
|
|
+pub(crate) struct GuixContainerConnection {
|
|
+ proxy_process: parking_lot::Mutex<Option<u32>>,
|
|
+ connection_options: GuixContainerConnectionOptions,
|
|
+}
|
|
+
|
|
+impl GuixContainerConnection {
|
|
+ fn remote_server_exit_error(status: i32) -> anyhow::Error {
|
|
+ match status {
|
|
+ 127 => anyhow!(
|
|
+ "The Guix container command chain exited with status 127. This often means \
|
|
+ a required command was not available inside the container environment, \
|
|
+ for example zed-remote-server."
|
|
+ ),
|
|
+ 126 => anyhow!(
|
|
+ "zed-remote-server could not be executed inside the Guix container \
|
|
+ environment (exit status 126). Check that it is executable and available \
|
|
+ inside the container."
|
|
+ ),
|
|
+ _ => anyhow!("Remote server exited with status {status}"),
|
|
+ }
|
|
+ }
|
|
+
|
|
+ pub async fn new(
|
|
+ connection_options: GuixContainerConnectionOptions,
|
|
+ delegate: Arc<dyn RemoteClientDelegate>,
|
|
+ cx: &mut AsyncApp,
|
|
+ ) -> Result<Self> {
|
|
+ delegate.set_status(Some("Detecting Guix container environment"), cx);
|
|
+
|
|
+ Ok(Self {
|
|
+ proxy_process: parking_lot::Mutex::new(None),
|
|
+ connection_options,
|
|
+ })
|
|
+ }
|
|
+
|
|
+ fn manifest_path(&self) -> &Path {
|
|
+ Path::new(&self.connection_options.manifest_path)
|
|
+ }
|
|
+
|
|
+ fn project_root(&self) -> PathBuf {
|
|
+ PathBuf::from(&self.connection_options.project_root)
|
|
+ }
|
|
+
|
|
+ fn project_user_data_dir(&self) -> PathBuf {
|
|
+ self.project_root().join(".zed").join("guix")
|
|
+ }
|
|
+
|
|
+ fn mount_arg(flag: &str, mount: &GuixMount) -> String {
|
|
+ match &mount.target {
|
|
+ Some(target) => format!("{flag}={}={}", mount.source, target),
|
|
+ None => format!("{flag}={}", mount.source),
|
|
+ }
|
|
+ }
|
|
+
|
|
+ fn required_mounts(&self) -> (Vec<String>, Vec<String>) {
|
|
+ let mut expose = Vec::new();
|
|
+ let mut share = vec![format!("--share={}", self.connection_options.project_root)];
|
|
+
|
|
+ for mount in &self.connection_options.shell_options.expose {
|
|
+ expose.push(Self::mount_arg("--expose", mount));
|
|
+ }
|
|
+ for mount in &self.connection_options.shell_options.share {
|
|
+ share.push(Self::mount_arg("--share", mount));
|
|
+ }
|
|
+
|
|
+ (expose, share)
|
|
+ }
|
|
+
|
|
+ fn base_guix_args(&self) -> Vec<String> {
|
|
+ let mut args = vec![
|
|
+ "shell".to_string(),
|
|
+ "--container".to_string(),
|
|
+ "--no-cwd".to_string(),
|
|
+ "-m".to_string(),
|
|
+ self.manifest_path().display().to_string(),
|
|
+ ];
|
|
+
|
|
+ if self.connection_options.shell_options.allow_network {
|
|
+ args.push("-N".to_string());
|
|
+ }
|
|
+ if self.connection_options.shell_options.nesting {
|
|
+ args.push("--nesting".to_string());
|
|
+ }
|
|
+
|
|
+ let (expose, share) = self.required_mounts();
|
|
+ args.extend(expose);
|
|
+ args.extend(share);
|
|
+ args.extend(self.connection_options.shell_options.extra_args.iter().cloned());
|
|
+ args.push("--".to_string());
|
|
+
|
|
+ args
|
|
+ }
|
|
+
|
|
+ fn current_dir_for_command(&self, working_dir: Option<String>) -> PathBuf {
|
|
+ match working_dir {
|
|
+ Some(path) => {
|
|
+ let path = PathBuf::from(path);
|
|
+ if path.is_absolute() {
|
|
+ path
|
|
+ } else {
|
|
+ self.project_root().join(path)
|
|
+ }
|
|
+ }
|
|
+ None => self.project_root(),
|
|
+ }
|
|
+ }
|
|
+
|
|
+ fn command_inside_container(
|
|
+ &self,
|
|
+ program: Option<String>,
|
|
+ args: &[String],
|
|
+ env: &HashMap<String, String>,
|
|
+ ) -> Vec<String> {
|
|
+ let mut command = self.base_guix_args();
|
|
+
|
|
+ if env.is_empty() {
|
|
+ match program {
|
|
+ Some(program) => {
|
|
+ command.push(program);
|
|
+ command.extend(args.iter().cloned());
|
|
+ }
|
|
+ None => {
|
|
+ command.push("bash".to_string());
|
|
+ command.push("-l".to_string());
|
|
+ }
|
|
+ }
|
|
+ } else {
|
|
+ command.push("env".to_string());
|
|
+ for (key, value) in env {
|
|
+ command.push(format!("{key}={value}"));
|
|
+ }
|
|
+ match program {
|
|
+ Some(program) => {
|
|
+ command.push(program);
|
|
+ command.extend(args.iter().cloned());
|
|
+ }
|
|
+ None => {
|
|
+ command.push("bash".to_string());
|
|
+ command.push("-l".to_string());
|
|
+ }
|
|
+ }
|
|
+ }
|
|
+
|
|
+ command
|
|
+ }
|
|
+
|
|
+ fn kill_inner(&self) -> Result<()> {
|
|
+ if let Some(pid) = self.proxy_process.lock().take() {
|
|
+ let pid = i32::try_from(pid).context("proxy process id does not fit in i32")?;
|
|
+ let result = unsafe { libc::kill(pid, SIGTERM) };
|
|
+ if result == 0 {
|
|
+ Ok(())
|
|
+ } else {
|
|
+ let error = std::io::Error::last_os_error();
|
|
+ if error.raw_os_error() == Some(libc::ESRCH) {
|
|
+ Ok(())
|
|
+ } else {
|
|
+ Err(anyhow!("failed to kill proxy process: {error}"))
|
|
+ }
|
|
+ }
|
|
+ } else {
|
|
+ Ok(())
|
|
+ }
|
|
+ }
|
|
+}
|
|
+
|
|
+#[async_trait(?Send)]
|
|
+impl RemoteConnection for GuixContainerConnection {
|
|
+ fn start_proxy(
|
|
+ &self,
|
|
+ unique_identifier: String,
|
|
+ reconnect: bool,
|
|
+ incoming_tx: UnboundedSender<Envelope>,
|
|
+ outgoing_rx: UnboundedReceiver<Envelope>,
|
|
+ connection_activity_tx: Sender<()>,
|
|
+ delegate: Arc<dyn RemoteClientDelegate>,
|
|
+ cx: &mut AsyncApp,
|
|
+ ) -> Task<Result<i32>> {
|
|
+ if !self.has_been_killed() {
|
|
+ if let Err(error) = self.kill_inner() {
|
|
+ return Task::ready(Err(error));
|
|
+ }
|
|
+ }
|
|
+
|
|
+ delegate.set_status(Some("Starting Guix container proxy"), cx);
|
|
+
|
|
+ let mut env = HashMap::default();
|
|
+ for env_var in ["RUST_LOG", "RUST_BACKTRACE", "ZED_GENERATE_MINIDUMPS"] {
|
|
+ if let Ok(value) = env::var(env_var) {
|
|
+ env.insert(env_var.to_string(), value);
|
|
+ }
|
|
+ }
|
|
+
|
|
+ let user_data_dir = self.project_user_data_dir();
|
|
+ if let Err(error) = std::fs::create_dir_all(&user_data_dir) {
|
|
+ return Task::ready(Err(anyhow!(
|
|
+ "failed to create Guix remote user data directory {}: {error}",
|
|
+ user_data_dir.display()
|
|
+ )));
|
|
+ }
|
|
+
|
|
+ let mut args = vec![
|
|
+ "--user-data-dir".to_string(),
|
|
+ user_data_dir.display().to_string(),
|
|
+ "proxy".to_string(),
|
|
+ "--identifier".to_string(),
|
|
+ unique_identifier,
|
|
+ ];
|
|
+ if reconnect {
|
|
+ args.push("--reconnect".to_string());
|
|
+ }
|
|
+
|
|
+ let command_template = match self.build_command(
|
|
+ Some("zed-remote-server".to_string()),
|
|
+ &args,
|
|
+ &env,
|
|
+ Some(self.connection_options.project_root.clone()),
|
|
+ None,
|
|
+ Interactive::No,
|
|
+ ) {
|
|
+ Ok(command) => command,
|
|
+ Err(error) => return Task::ready(Err(error)),
|
|
+ };
|
|
+
|
|
+ let mut command = new_command(&command_template.program);
|
|
+ command
|
|
+ .kill_on_drop(true)
|
|
+ .stdin(Stdio::piped())
|
|
+ .stdout(Stdio::piped())
|
|
+ .stderr(Stdio::piped())
|
|
+ .args(&command_template.args)
|
|
+ .envs(&command_template.env);
|
|
+ if let Some(path) = &command_template.cwd {
|
|
+ command.current_dir(path);
|
|
+ }
|
|
+
|
|
+ let Ok(child) = command.spawn() else {
|
|
+ return Task::ready(Err(anyhow!("failed to start guix remote server process")));
|
|
+ };
|
|
+
|
|
+ *self.proxy_process.lock() = Some(child.id());
|
|
+
|
|
+ cx.spawn(async move |cx| {
|
|
+ super::handle_rpc_messages_over_child_process_stdio(
|
|
+ child,
|
|
+ incoming_tx,
|
|
+ outgoing_rx,
|
|
+ connection_activity_tx,
|
|
+ cx,
|
|
+ )
|
|
+ .await
|
|
+ .and_then(|status| {
|
|
+ if status != 0 {
|
|
+ return Err(Self::remote_server_exit_error(status));
|
|
+ }
|
|
+ Ok(0)
|
|
+ })
|
|
+ })
|
|
+ }
|
|
+
|
|
+ fn upload_directory(
|
|
+ &self,
|
|
+ src_path: PathBuf,
|
|
+ dest_path: RemotePathBuf,
|
|
+ cx: &App,
|
|
+ ) -> Task<Result<()>> {
|
|
+ let dest_path = PathBuf::from(dest_path.to_string());
|
|
+ let fs = RealFs::new(None, cx.background_executor().clone());
|
|
+ cx.background_spawn(async move {
|
|
+ copy_recursive(&fs, &src_path, &dest_path, CopyOptions::default())
|
|
+ .await
|
|
+ .with_context(|| {
|
|
+ format!(
|
|
+ "failed to copy uploaded directory {} to {}",
|
|
+ src_path.display(),
|
|
+ dest_path.display()
|
|
+ )
|
|
+ })
|
|
+ })
|
|
+ }
|
|
+
|
|
+ async fn kill(&self) -> Result<()> {
|
|
+ self.kill_inner()
|
|
+ }
|
|
+
|
|
+ fn has_been_killed(&self) -> bool {
|
|
+ self.proxy_process.lock().is_none()
|
|
+ }
|
|
+
|
|
+ fn build_command(
|
|
+ &self,
|
|
+ program: Option<String>,
|
|
+ args: &[String],
|
|
+ env: &HashMap<String, String>,
|
|
+ working_dir: Option<String>,
|
|
+ port_forward: Option<(u16, String, u16)>,
|
|
+ _interactive: Interactive,
|
|
+ ) -> Result<CommandTemplate> {
|
|
+ if port_forward.is_some() {
|
|
+ anyhow::bail!("Guix container transport does not support port forwarding commands");
|
|
+ }
|
|
+
|
|
+ Ok(CommandTemplate {
|
|
+ program: "guix".to_string(),
|
|
+ args: self.command_inside_container(program, args, env),
|
|
+ env: HashMap::default(),
|
|
+ cwd: Some(self.current_dir_for_command(working_dir)),
|
|
+ })
|
|
+ }
|
|
+
|
|
+ fn build_forward_ports_command(
|
|
+ &self,
|
|
+ _forwards: Vec<(u16, String, u16)>,
|
|
+ ) -> Result<CommandTemplate> {
|
|
+ Err(anyhow!("Guix container transport does not support port forwarding"))
|
|
+ }
|
|
+
|
|
+ fn connection_options(&self) -> RemoteConnectionOptions {
|
|
+ RemoteConnectionOptions::GuixContainer(self.connection_options.clone())
|
|
+ }
|
|
+
|
|
+ fn path_style(&self) -> PathStyle {
|
|
+ PathStyle::Posix
|
|
+ }
|
|
+
|
|
+ fn shell(&self) -> String {
|
|
+ "bash".to_string()
|
|
+ }
|
|
+
|
|
+ fn default_system_shell(&self) -> String {
|
|
+ "bash".to_string()
|
|
+ }
|
|
+
|
|
+ fn has_wsl_interop(&self) -> bool {
|
|
+ false
|
|
+ }
|
|
+}
|
|
+
|
|
+#[cfg(test)]
|
|
+mod tests {
|
|
+ use super::*;
|
|
+ use crate::Interactive;
|
|
+
|
|
+ fn test_connection(shell_options: GuixShellOptions) -> GuixContainerConnection {
|
|
+ GuixContainerConnection {
|
|
+ proxy_process: parking_lot::Mutex::new(None),
|
|
+ connection_options: GuixContainerConnectionOptions {
|
|
+ manifest_path: "/project/manifest.scm".to_string(),
|
|
+ project_root: "/project".to_string(),
|
|
+ shell_options,
|
|
+ },
|
|
+ }
|
|
+ }
|
|
+
|
|
+ #[test]
|
|
+ fn test_build_command_uses_guix_shell_with_required_mounts() {
|
|
+ let connection = test_connection(GuixShellOptions::default());
|
|
+ let command = connection
|
|
+ .build_command(
|
|
+ Some("zed-remote-server".to_string()),
|
|
+ &["proxy".to_string()],
|
|
+ &HashMap::default(),
|
|
+ Some("/project".to_string()),
|
|
+ None,
|
|
+ Interactive::No,
|
|
+ )
|
|
+ .unwrap();
|
|
+
|
|
+ assert_eq!(command.program, "guix");
|
|
+ assert_eq!(command.cwd, Some(PathBuf::from("/project")));
|
|
+ assert_eq!(
|
|
+ command.args,
|
|
+ vec![
|
|
+ "shell",
|
|
+ "--container",
|
|
+ "--nesting",
|
|
+ "--no-cwd",
|
|
+ "-m",
|
|
+ "/project/manifest.scm",
|
|
+ "--share=/project",
|
|
+ "--",
|
|
+ "zed-remote-server",
|
|
+ "proxy",
|
|
+ ]
|
|
+ );
|
|
+ }
|
|
+
|
|
+ #[test]
|
|
+ fn test_build_command_merges_network_mounts_and_env() {
|
|
+ let connection = test_connection(GuixShellOptions {
|
|
+ allow_network: true,
|
|
+ nesting: true,
|
|
+ expose: vec![GuixMount {
|
|
+ source: "/nix/store".to_string(),
|
|
+ target: Some("/gnu/store-alt".to_string()),
|
|
+ }],
|
|
+ share: vec![GuixMount {
|
|
+ source: "/tmp/cache".to_string(),
|
|
+ target: Some("/cache".to_string()),
|
|
+ }],
|
|
+ extra_args: vec!["--pure".to_string()],
|
|
+ });
|
|
+ let mut env = HashMap::default();
|
|
+ env.insert("FOO".to_string(), "BAR".to_string());
|
|
+
|
|
+ let command = connection
|
|
+ .build_command(
|
|
+ Some("env".to_string()),
|
|
+ &["true".to_string()],
|
|
+ &env,
|
|
+ Some("src".to_string()),
|
|
+ None,
|
|
+ Interactive::No,
|
|
+ )
|
|
+ .unwrap();
|
|
+
|
|
+ assert_eq!(command.program, "guix");
|
|
+ assert_eq!(command.cwd, Some(PathBuf::from("/project/src")));
|
|
+ assert_eq!(
|
|
+ command.args,
|
|
+ vec![
|
|
+ "shell",
|
|
+ "--container",
|
|
+ "--no-cwd",
|
|
+ "-m",
|
|
+ "/project/manifest.scm",
|
|
+ "-N",
|
|
+ "--nesting",
|
|
+ "--expose=/nix/store=/gnu/store-alt",
|
|
+ "--share=/project",
|
|
+ "--share=/tmp/cache=/cache",
|
|
+ "--pure",
|
|
+ "--",
|
|
+ "env",
|
|
+ "FOO=BAR",
|
|
+ "env",
|
|
+ "true",
|
|
+ ]
|
|
+ );
|
|
+ }
|
|
+
|
|
+ #[test]
|
|
+ fn test_build_command_preserves_absolute_working_directory() {
|
|
+ let connection = test_connection(GuixShellOptions::default());
|
|
+ let command = connection
|
|
+ .build_command(
|
|
+ Some("pwd".to_string()),
|
|
+ &[],
|
|
+ &HashMap::default(),
|
|
+ Some("/elsewhere".to_string()),
|
|
+ None,
|
|
+ Interactive::No,
|
|
+ )
|
|
+ .unwrap();
|
|
+
|
|
+ assert_eq!(command.cwd, Some(PathBuf::from("/elsewhere")));
|
|
+ }
|
|
+
|
|
+ #[test]
|
|
+ fn test_build_command_without_program_opens_bash_login_shell() {
|
|
+ let connection = test_connection(GuixShellOptions::default());
|
|
+ let command = connection
|
|
+ .build_command(
|
|
+ None,
|
|
+ &[],
|
|
+ &HashMap::default(),
|
|
+ None,
|
|
+ None,
|
|
+ Interactive::No,
|
|
+ )
|
|
+ .unwrap();
|
|
+
|
|
+ assert_eq!(
|
|
+ command.args,
|
|
+ vec![
|
|
+ "shell",
|
|
+ "--container",
|
|
+ "--no-cwd",
|
|
+ "-m",
|
|
+ "/project/manifest.scm",
|
|
+ "--share=/project",
|
|
+ "--",
|
|
+ "bash",
|
|
+ "-l",
|
|
+ ]
|
|
+ );
|
|
+ }
|
|
+
|
|
+ #[test]
|
|
+ fn test_remote_server_exit_error_for_missing_binary() {
|
|
+ let error = GuixContainerConnection::remote_server_exit_error(127);
|
|
+ let message = error.to_string();
|
|
+ assert!(message.contains("command chain exited with status 127"));
|
|
+ assert!(message.contains("for example zed-remote-server"));
|
|
+ }
|
|
+}
|
|
diff --git a/crates/remote/src/transport/mock.rs b/crates/remote/src/transport/mock.rs
|
|
index 06e1319658..d320b1a83b 100644
|
|
--- a/crates/remote/src/transport/mock.rs
|
|
+++ b/crates/remote/src/transport/mock.rs
|
|
@@ -206,24 +206,26 @@ impl RemoteConnection for MockRemoteConnection {
|
|
let mut shell_args = Vec::new();
|
|
shell_args.push(shell_program);
|
|
shell_args.extend(args.iter().cloned());
|
|
- Ok(CommandTemplate {
|
|
- program: "mock".into(),
|
|
- args: shell_args,
|
|
- env: env.clone(),
|
|
- })
|
|
- }
|
|
+ Ok(CommandTemplate {
|
|
+ program: "mock".into(),
|
|
+ args: shell_args,
|
|
+ env: env.clone(),
|
|
+ cwd: None,
|
|
+ })
|
|
+ }
|
|
|
|
fn build_forward_ports_command(
|
|
&self,
|
|
forwards: Vec<(u16, String, u16)>,
|
|
) -> Result<CommandTemplate> {
|
|
- Ok(CommandTemplate {
|
|
- program: "mock".into(),
|
|
- args: std::iter::once("-N".to_owned())
|
|
- .chain(forwards.into_iter().map(|(local_port, host, remote_port)| {
|
|
- format!("{local_port}:{host}:{remote_port}")
|
|
- }))
|
|
- .collect(),
|
|
+ Ok(CommandTemplate {
|
|
+ program: "mock".into(),
|
|
+ args: std::iter::once("-N".to_owned())
|
|
+ .chain(forwards.into_iter().map(|(local_port, host, remote_port)| {
|
|
+ format!("{local_port}:{host}:{remote_port}")
|
|
+ }))
|
|
+ .collect(),
|
|
+ cwd: None,
|
|
env: Default::default(),
|
|
})
|
|
}
|
|
diff --git a/crates/remote/src/transport/ssh.rs b/crates/remote/src/transport/ssh.rs
|
|
index 83733306e7..59a1fdb4a1 100644
|
|
--- a/crates/remote/src/transport/ssh.rs
|
|
+++ b/crates/remote/src/transport/ssh.rs
|
|
@@ -351,6 +351,7 @@ impl RemoteConnection for SshRemoteConnection {
|
|
program: "ssh".into(),
|
|
args,
|
|
env: Default::default(),
|
|
+ cwd: None,
|
|
})
|
|
}
|
|
|
|
@@ -1661,6 +1662,7 @@ fn build_command_posix(
|
|
program: "ssh".into(),
|
|
args,
|
|
env: ssh_env,
|
|
+ cwd: None,
|
|
})
|
|
}
|
|
|
|
@@ -1759,6 +1761,7 @@ fn build_command_windows(
|
|
program: "ssh".into(),
|
|
args,
|
|
env: ssh_env,
|
|
+ cwd: None,
|
|
})
|
|
}
|
|
|
|
diff --git a/crates/remote/src/transport/wsl.rs b/crates/remote/src/transport/wsl.rs
|
|
index 69619f13c2..ce03f3395d 100644
|
|
--- a/crates/remote/src/transport/wsl.rs
|
|
+++ b/crates/remote/src/transport/wsl.rs
|
|
@@ -498,6 +498,7 @@ impl RemoteConnection for WslRemoteConnection {
|
|
program: "wsl.exe".to_string(),
|
|
args: wsl_args,
|
|
env: HashMap::default(),
|
|
+ cwd: None,
|
|
})
|
|
}
|
|
|
|
diff --git a/crates/remote_connection/src/remote_connection.rs b/crates/remote_connection/src/remote_connection.rs
|
|
index d4df85d7b9..7f6b1cf2ab 100644
|
|
--- a/crates/remote_connection/src/remote_connection.rs
|
|
+++ b/crates/remote_connection/src/remote_connection.rs
|
|
@@ -188,6 +188,9 @@ impl RemoteConnectionModal {
|
|
(options.distro_name.clone(), None, true, false)
|
|
}
|
|
RemoteConnectionOptions::Docker(options) => (options.name.clone(), None, false, true),
|
|
+ RemoteConnectionOptions::GuixContainer(options) => {
|
|
+ (options.project_root.clone(), None, false, true)
|
|
+ }
|
|
#[cfg(any(test, feature = "test-support"))]
|
|
RemoteConnectionOptions::Mock(options) => {
|
|
(format!("mock-{}", options.id), None, false, false)
|
|
diff --git a/crates/remote_server/src/main.rs b/crates/remote_server/src/main.rs
|
|
index 66ffcc1631..adefc5a122 100644
|
|
--- a/crates/remote_server/src/main.rs
|
|
+++ b/crates/remote_server/src/main.rs
|
|
@@ -3,6 +3,8 @@ use remote_server::Commands;
|
|
use std::io::Write as _;
|
|
use std::path::PathBuf;
|
|
|
|
+use paths;
|
|
+
|
|
#[derive(Parser)]
|
|
#[command(disable_version_flag = true)]
|
|
struct Cli {
|
|
@@ -19,11 +21,18 @@ struct Cli {
|
|
/// Used for loading the environment from the project.
|
|
#[arg(long, hide = true)]
|
|
printenv: bool,
|
|
+ /// Sets a custom directory for user data such as databases, extensions, and logs.
|
|
+ #[arg(long, value_name = "DIR")]
|
|
+ user_data_dir: Option<String>,
|
|
}
|
|
|
|
fn main() -> anyhow::Result<()> {
|
|
let cli = Cli::parse();
|
|
|
|
+ if let Some(dir) = &cli.user_data_dir {
|
|
+ paths::set_custom_data_dir(dir);
|
|
+ }
|
|
+
|
|
if let Some(socket_path) = &cli.askpass {
|
|
askpass::main(socket_path);
|
|
return Ok(());
|
|
diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs
|
|
index d0643be3bb..d5c08077b8 100644
|
|
--- a/crates/settings/src/vscode_import.rs
|
|
+++ b/crates/settings/src/vscode_import.rs
|
|
@@ -697,6 +697,7 @@ impl VsCodeSettings {
|
|
path: cmd.command,
|
|
args: cmd.args.unwrap_or_default(),
|
|
env: cmd.env,
|
|
+ working_directory: None,
|
|
timeout: None,
|
|
})?,
|
|
},
|
|
diff --git a/crates/settings_content/src/project.rs b/crates/settings_content/src/project.rs
|
|
index 59576651de..23bdfc4f2a 100644
|
|
--- a/crates/settings_content/src/project.rs
|
|
+++ b/crates/settings_content/src/project.rs
|
|
@@ -409,6 +409,7 @@ pub struct ContextServerCommand {
|
|
pub path: PathBuf,
|
|
pub args: Vec<String>,
|
|
pub env: Option<HashMap<String, String>>,
|
|
+ pub working_directory: Option<PathBuf>,
|
|
/// Timeout for tool calls in seconds. Defaults to 60 if not specified.
|
|
pub timeout: Option<u64>,
|
|
}
|
|
@@ -434,6 +435,7 @@ impl std::fmt::Debug for ContextServerCommand {
|
|
.field("path", &self.path)
|
|
.field("args", &self.args)
|
|
.field("env", &filtered_env)
|
|
+ .field("working_directory", &self.working_directory)
|
|
.finish()
|
|
}
|
|
}
|
|
diff --git a/crates/settings_content/src/settings_content.rs b/crates/settings_content/src/settings_content.rs
|
|
index 788917b5eb..b6153216be 100644
|
|
--- a/crates/settings_content/src/settings_content.rs
|
|
+++ b/crates/settings_content/src/settings_content.rs
|
|
@@ -1055,6 +1055,7 @@ pub struct RemoteSettingsContent {
|
|
pub ssh_connections: Option<Vec<SshConnection>>,
|
|
pub wsl_connections: Option<Vec<WslConnection>>,
|
|
pub dev_container_connections: Option<Vec<DevContainerConnection>>,
|
|
+ pub guix_connections: Option<Vec<GuixConnection>>,
|
|
pub read_ssh_config: Option<bool>,
|
|
pub use_podman: Option<bool>,
|
|
}
|
|
@@ -1070,6 +1071,43 @@ pub struct DevContainerConnection {
|
|
pub use_podman: bool,
|
|
}
|
|
|
|
+#[with_fallible_options]
|
|
+#[derive(
|
|
+ Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema, MergeFrom, Hash,
|
|
+)]
|
|
+pub struct GuixConnection {
|
|
+ pub manifest_path: String,
|
|
+ pub project_root: String,
|
|
+ #[serde(default)]
|
|
+ pub options: GuixShellOptions,
|
|
+}
|
|
+
|
|
+#[with_fallible_options]
|
|
+#[derive(
|
|
+ Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema, MergeFrom, Hash,
|
|
+)]
|
|
+pub struct GuixShellOptions {
|
|
+ #[serde(default)]
|
|
+ pub allow_network: bool,
|
|
+ #[serde(default)]
|
|
+ pub nesting: bool,
|
|
+ #[serde(default)]
|
|
+ pub expose: Vec<GuixMount>,
|
|
+ #[serde(default)]
|
|
+ pub share: Vec<GuixMount>,
|
|
+ #[serde(default)]
|
|
+ pub extra_args: Vec<String>,
|
|
+}
|
|
+
|
|
+#[with_fallible_options]
|
|
+#[derive(
|
|
+ Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema, MergeFrom, Hash,
|
|
+)]
|
|
+pub struct GuixMount {
|
|
+ pub source: String,
|
|
+ pub target: Option<String>,
|
|
+}
|
|
+
|
|
#[with_fallible_options]
|
|
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, JsonSchema, MergeFrom)]
|
|
pub struct SshConnection {
|
|
diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs
|
|
index 08ef3e7634..58f86da117 100644
|
|
--- a/crates/title_bar/src/title_bar.rs
|
|
+++ b/crates/title_bar/src/title_bar.rs
|
|
@@ -502,6 +502,9 @@ impl TitleBar {
|
|
RemoteConnectionOptions::Docker(_dev_container_connection) => {
|
|
(None, "Dev Container", IconName::Box)
|
|
}
|
|
+ RemoteConnectionOptions::GuixContainer(_) => {
|
|
+ (None, "Guix Container", IconName::Box)
|
|
+ }
|
|
#[cfg(any(test, feature = "test-support"))]
|
|
RemoteConnectionOptions::Mock(_) => (None, "Mock Remote Project", IconName::Server),
|
|
};
|
|
diff --git a/crates/workspace/src/persistence.rs b/crates/workspace/src/persistence.rs
|
|
index e0ad046e8d..c928415260 100644
|
|
--- a/crates/workspace/src/persistence.rs
|
|
+++ b/crates/workspace/src/persistence.rs
|
|
@@ -27,7 +27,8 @@ use project::{
|
|
|
|
use language::{LanguageName, Toolchain, ToolchainScope};
|
|
use remote::{
|
|
- DockerConnectionOptions, RemoteConnectionOptions, SshConnectionOptions, WslConnectionOptions,
|
|
+ DockerConnectionOptions, GuixContainerConnectionOptions, GuixShellOptions,
|
|
+ RemoteConnectionOptions, SshConnectionOptions, WslConnectionOptions,
|
|
};
|
|
use serde::{Deserialize, Serialize};
|
|
use sqlez::{
|
|
@@ -1518,6 +1519,13 @@ impl WorkspaceDb {
|
|
use_podman = Some(options.use_podman);
|
|
user = Some(options.remote_user);
|
|
}
|
|
+ RemoteConnectionOptions::GuixContainer(options) => {
|
|
+ kind = RemoteConnectionKind::GuixContainer;
|
|
+ host = Some(options.manifest_path);
|
|
+ name = Some(options.project_root);
|
|
+ distro = Some(serde_json::to_string(&options.shell_options)?);
|
|
+ user = None;
|
|
+ }
|
|
#[cfg(any(test, feature = "test-support"))]
|
|
RemoteConnectionOptions::Mock(options) => {
|
|
kind = RemoteConnectionKind::Ssh;
|
|
@@ -1559,7 +1567,8 @@ impl WorkspaceDb {
|
|
user IS ? AND
|
|
distro IS ? AND
|
|
name IS ? AND
|
|
- container_id IS ?
|
|
+ container_id IS ? AND
|
|
+ ifnull(use_podman, 0) IS ifnull(?, 0)
|
|
LIMIT 1
|
|
))?((
|
|
kind.serialize(),
|
|
@@ -1569,6 +1578,7 @@ impl WorkspaceDb {
|
|
distro.clone(),
|
|
name.clone(),
|
|
container_id.clone(),
|
|
+ use_podman,
|
|
))? {
|
|
Ok(RemoteConnectionId(id))
|
|
} else {
|
|
@@ -1773,6 +1783,18 @@ impl WorkspaceDb {
|
|
use_podman: use_podman?,
|
|
}))
|
|
}
|
|
+ RemoteConnectionKind::GuixContainer => {
|
|
+ let shell_options = distro
|
|
+ .and_then(|value| serde_json::from_str::<GuixShellOptions>(&value).ok())
|
|
+ .unwrap_or_default();
|
|
+ Some(RemoteConnectionOptions::GuixContainer(
|
|
+ GuixContainerConnectionOptions {
|
|
+ manifest_path: host?,
|
|
+ project_root: name?,
|
|
+ shell_options,
|
|
+ },
|
|
+ ))
|
|
+ }
|
|
}
|
|
}
|
|
|
|
diff --git a/crates/workspace/src/persistence/model.rs b/crates/workspace/src/persistence/model.rs
|
|
index cdb646ec3b..463c086dfe 100644
|
|
--- a/crates/workspace/src/persistence/model.rs
|
|
+++ b/crates/workspace/src/persistence/model.rs
|
|
@@ -34,6 +34,7 @@ pub(crate) enum RemoteConnectionKind {
|
|
Ssh,
|
|
Wsl,
|
|
Docker,
|
|
+ GuixContainer,
|
|
}
|
|
|
|
#[derive(Debug, PartialEq, Clone)]
|
|
@@ -104,6 +105,7 @@ impl RemoteConnectionKind {
|
|
RemoteConnectionKind::Ssh => "ssh",
|
|
RemoteConnectionKind::Wsl => "wsl",
|
|
RemoteConnectionKind::Docker => "docker",
|
|
+ RemoteConnectionKind::GuixContainer => "guix_container",
|
|
}
|
|
}
|
|
|
|
@@ -112,6 +114,7 @@ impl RemoteConnectionKind {
|
|
"ssh" => Some(Self::Ssh),
|
|
"wsl" => Some(Self::Wsl),
|
|
"docker" => Some(Self::Docker),
|
|
+ "guix_container" => Some(Self::GuixContainer),
|
|
_ => None,
|
|
}
|
|
}
|
|
diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs
|
|
index 4141952cc7..93737b5cd5 100644
|
|
--- a/crates/workspace/src/workspace.rs
|
|
+++ b/crates/workspace/src/workspace.rs
|
|
@@ -38,6 +38,7 @@ use client::{
|
|
};
|
|
use collections::{HashMap, HashSet, hash_map};
|
|
use dock::{Dock, DockPosition, PanelButtons, PanelHandle, RESIZE_HANDLE_SIZE};
|
|
+use fs::Fs;
|
|
use futures::{
|
|
Future, FutureExt, StreamExt,
|
|
channel::{
|
|
@@ -88,7 +89,8 @@ use project::{
|
|
trusted_worktrees::{RemoteHostLocation, TrustedWorktrees, TrustedWorktreesEvent},
|
|
};
|
|
use remote::{
|
|
- RemoteClientDelegate, RemoteConnection, RemoteConnectionOptions,
|
|
+ GuixContainerConnectionOptions, GuixSettings, RemoteClientDelegate, RemoteConnection,
|
|
+ RemoteConnectionOptions,
|
|
remote_client::ConnectionIdentifier,
|
|
};
|
|
use schemars::JsonSchema;
|
|
@@ -8584,6 +8586,77 @@ pub fn open_workspace_by_id(
|
|
})
|
|
}
|
|
|
|
+#[derive(Clone, Debug, PartialEq, Eq)]
|
|
+struct GuixWorkspaceDescriptor {
|
|
+ manifest_path: PathBuf,
|
|
+ project_root: PathBuf,
|
|
+}
|
|
+
|
|
+async fn find_guix_workspace_for_path(
|
|
+ path: &Path,
|
|
+ fs: &Arc<dyn Fs>,
|
|
+) -> Option<GuixWorkspaceDescriptor> {
|
|
+ let mut current = if fs.is_dir(path).await {
|
|
+ path.to_path_buf()
|
|
+ } else {
|
|
+ path.parent()?.to_path_buf()
|
|
+ };
|
|
+
|
|
+ if let Ok(canonical) = fs.canonicalize(¤t).await {
|
|
+ current = canonical;
|
|
+ }
|
|
+
|
|
+ loop {
|
|
+ let manifest_path = current.join("manifest.scm");
|
|
+ if fs.is_file(&manifest_path).await {
|
|
+ return Some(GuixWorkspaceDescriptor {
|
|
+ manifest_path,
|
|
+ project_root: current,
|
|
+ });
|
|
+ }
|
|
+ if !current.pop() {
|
|
+ return None;
|
|
+ }
|
|
+ }
|
|
+}
|
|
+
|
|
+async fn detect_guix_workspace(
|
|
+ abs_paths: &[PathBuf],
|
|
+ fs: Arc<dyn Fs>,
|
|
+) -> Option<GuixWorkspaceDescriptor> {
|
|
+ let mut descriptor = None;
|
|
+ for path in abs_paths {
|
|
+ let candidate = find_guix_workspace_for_path(path, &fs).await?;
|
|
+ if descriptor
|
|
+ .as_ref()
|
|
+ .is_some_and(|existing: &GuixWorkspaceDescriptor| existing.project_root != candidate.project_root)
|
|
+ {
|
|
+ return None;
|
|
+ }
|
|
+ descriptor = Some(candidate);
|
|
+ }
|
|
+ descriptor
|
|
+}
|
|
+
|
|
+pub async fn guix_connection_options_for_paths(
|
|
+ abs_paths: &[PathBuf],
|
|
+ app_state: &Arc<AppState>,
|
|
+ cx: &AsyncApp,
|
|
+) -> Option<RemoteConnectionOptions> {
|
|
+ let descriptor = detect_guix_workspace(abs_paths, app_state.fs.clone()).await?;
|
|
+ let shell_options = cx.update(|cx| {
|
|
+ GuixSettings::get_global(cx)
|
|
+ .shell_options_for(&descriptor.manifest_path, &descriptor.project_root)
|
|
+ });
|
|
+ Some(RemoteConnectionOptions::GuixContainer(
|
|
+ GuixContainerConnectionOptions {
|
|
+ manifest_path: descriptor.manifest_path.display().to_string(),
|
|
+ project_root: descriptor.project_root.display().to_string(),
|
|
+ shell_options,
|
|
+ },
|
|
+ ))
|
|
+}
|
|
+
|
|
#[allow(clippy::type_complexity)]
|
|
pub fn open_paths(
|
|
abs_paths: &[PathBuf],
|
|
@@ -9701,7 +9774,7 @@ pub fn with_active_or_new_workspace(
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
- use std::{cell::RefCell, rc::Rc};
|
|
+ use std::{cell::RefCell, rc::Rc, sync::Arc};
|
|
|
|
use super::*;
|
|
use crate::{
|
|
@@ -9717,8 +9790,10 @@ mod tests {
|
|
UpdateGlobal, VisualTestContext, px,
|
|
};
|
|
use project::{Project, ProjectEntryId};
|
|
+ use remote::GuixShellOptions;
|
|
use serde_json::json;
|
|
use settings::SettingsStore;
|
|
+ use tempfile::tempdir;
|
|
use util::rel_path::rel_path;
|
|
|
|
#[gpui::test]
|
|
@@ -13060,6 +13135,135 @@ mod tests {
|
|
});
|
|
}
|
|
|
|
+ #[gpui::test]
|
|
+ async fn test_manifest_workspace_detects_guix_connection_options(cx: &mut TestAppContext) {
|
|
+ init_test(cx);
|
|
+
|
|
+ let mut app_state = cx.update(AppState::test);
|
|
+ let real_fs: Arc<dyn fs::Fs> = Arc::new(fs::RealFs::new(None, cx.executor()));
|
|
+ cx.update(|cx| <dyn fs::Fs>::set_global(real_fs.clone(), cx));
|
|
+ Arc::get_mut(&mut app_state).unwrap().fs = real_fs;
|
|
+
|
|
+ let dir = tempdir().unwrap();
|
|
+ let project_root = dir.path().join("project");
|
|
+ let src_dir = project_root.join("src");
|
|
+ let manifest_path = project_root.join("manifest.scm");
|
|
+ let source_path = src_dir.join("main.rs");
|
|
+
|
|
+ std::fs::create_dir_all(&src_dir).unwrap();
|
|
+ std::fs::write(&manifest_path, "(specifications->manifest '())\n").unwrap();
|
|
+ std::fs::write(&source_path, "fn main() {}\n").unwrap();
|
|
+
|
|
+ let async_cx = cx.to_async();
|
|
+ let options =
|
|
+ guix_connection_options_for_paths(&[source_path.clone()], &app_state, &async_cx)
|
|
+ .await
|
|
+ .expect("manifest.scm should select the Guix transport");
|
|
+
|
|
+ let RemoteConnectionOptions::GuixContainer(options) = options else {
|
|
+ panic!("expected GuixContainer options");
|
|
+ };
|
|
+
|
|
+ assert_eq!(PathBuf::from(options.project_root), project_root);
|
|
+ assert_eq!(PathBuf::from(options.manifest_path), manifest_path);
|
|
+ assert_eq!(options.shell_options, GuixShellOptions::default());
|
|
+ }
|
|
+
|
|
+ #[gpui::test]
|
|
+ async fn test_manifest_workspace_detects_same_root_for_multiple_paths(
|
|
+ cx: &mut TestAppContext,
|
|
+ ) {
|
|
+ init_test(cx);
|
|
+
|
|
+ let mut app_state = cx.update(AppState::test);
|
|
+ let real_fs: Arc<dyn fs::Fs> = Arc::new(fs::RealFs::new(None, cx.executor()));
|
|
+ cx.update(|cx| <dyn fs::Fs>::set_global(real_fs.clone(), cx));
|
|
+ Arc::get_mut(&mut app_state).unwrap().fs = real_fs;
|
|
+
|
|
+ let dir = tempdir().unwrap();
|
|
+ let project_root = dir.path().join("project");
|
|
+ let src_dir = project_root.join("src");
|
|
+ let nested_dir = project_root.join("tests");
|
|
+ let manifest_path = project_root.join("manifest.scm");
|
|
+ let source_path = src_dir.join("main.rs");
|
|
+ let test_path = nested_dir.join("integration.rs");
|
|
+
|
|
+ std::fs::create_dir_all(&src_dir).unwrap();
|
|
+ std::fs::create_dir_all(&nested_dir).unwrap();
|
|
+ std::fs::write(&manifest_path, "(specifications->manifest '())\n").unwrap();
|
|
+ std::fs::write(&source_path, "fn main() {}\n").unwrap();
|
|
+ std::fs::write(&test_path, "#[test] fn it_works() {}\n").unwrap();
|
|
+
|
|
+ let async_cx = cx.to_async();
|
|
+ let options =
|
|
+ guix_connection_options_for_paths(&[source_path.clone(), test_path], &app_state, &async_cx)
|
|
+ .await
|
|
+ .expect("paths under one manifest should select the Guix transport");
|
|
+
|
|
+ let RemoteConnectionOptions::GuixContainer(options) = options else {
|
|
+ panic!("expected GuixContainer options");
|
|
+ };
|
|
+
|
|
+ assert_eq!(PathBuf::from(options.project_root), project_root);
|
|
+ assert_eq!(PathBuf::from(options.manifest_path), manifest_path);
|
|
+ }
|
|
+
|
|
+ #[gpui::test]
|
|
+ async fn test_manifest_workspace_rejects_paths_from_different_manifests(
|
|
+ cx: &mut TestAppContext,
|
|
+ ) {
|
|
+ init_test(cx);
|
|
+
|
|
+ let mut app_state = cx.update(AppState::test);
|
|
+ let real_fs: Arc<dyn fs::Fs> = Arc::new(fs::RealFs::new(None, cx.executor()));
|
|
+ cx.update(|cx| <dyn fs::Fs>::set_global(real_fs.clone(), cx));
|
|
+ Arc::get_mut(&mut app_state).unwrap().fs = real_fs;
|
|
+
|
|
+ let dir = tempdir().unwrap();
|
|
+ let project_a = dir.path().join("project-a");
|
|
+ let project_b = dir.path().join("project-b");
|
|
+ let source_a = project_a.join("src/main.rs");
|
|
+ let source_b = project_b.join("src/main.rs");
|
|
+
|
|
+ std::fs::create_dir_all(source_a.parent().unwrap()).unwrap();
|
|
+ std::fs::create_dir_all(source_b.parent().unwrap()).unwrap();
|
|
+ std::fs::write(project_a.join("manifest.scm"), "(specifications->manifest '())\n").unwrap();
|
|
+ std::fs::write(project_b.join("manifest.scm"), "(specifications->manifest '())\n").unwrap();
|
|
+ std::fs::write(&source_a, "fn main() {}\n").unwrap();
|
|
+ std::fs::write(&source_b, "fn main() {}\n").unwrap();
|
|
+
|
|
+ let async_cx = cx.to_async();
|
|
+ let options =
|
|
+ guix_connection_options_for_paths(&[source_a, source_b], &app_state, &async_cx).await;
|
|
+
|
|
+ assert!(
|
|
+ options.is_none(),
|
|
+ "paths under different manifests should not collapse into one Guix connection"
|
|
+ );
|
|
+ }
|
|
+
|
|
+ #[gpui::test]
|
|
+ async fn test_manifest_workspace_returns_none_without_manifest(cx: &mut TestAppContext) {
|
|
+ init_test(cx);
|
|
+
|
|
+ let mut app_state = cx.update(AppState::test);
|
|
+ let real_fs: Arc<dyn fs::Fs> = Arc::new(fs::RealFs::new(None, cx.executor()));
|
|
+ cx.update(|cx| <dyn fs::Fs>::set_global(real_fs.clone(), cx));
|
|
+ Arc::get_mut(&mut app_state).unwrap().fs = real_fs;
|
|
+
|
|
+ let dir = tempdir().unwrap();
|
|
+ let project_root = dir.path().join("project");
|
|
+ let source_path = project_root.join("src/main.rs");
|
|
+
|
|
+ std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
|
|
+ std::fs::write(&source_path, "fn main() {}\n").unwrap();
|
|
+
|
|
+ let async_cx = cx.to_async();
|
|
+ let options = guix_connection_options_for_paths(&[source_path], &app_state, &async_cx).await;
|
|
+
|
|
+ assert!(options.is_none(), "workspace without manifest.scm should stay local");
|
|
+ }
|
|
+
|
|
fn dirty_project_item(id: u64, path: &str, cx: &mut App) -> Entity<TestProjectItem> {
|
|
let item = TestProjectItem::new(id, path, cx);
|
|
item.update(cx, |item, _| {
|
|
diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs
|
|
index 356011e4da..2fad6a34a6 100644
|
|
--- a/crates/zed_actions/src/lib.rs
|
|
+++ b/crates/zed_actions/src/lib.rs
|
|
@@ -526,6 +526,12 @@ pub struct OpenRemote {
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct OpenDevContainer;
|
|
|
|
+/// Opens the Guix container options modal for the current local project.
|
|
+#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)]
|
|
+#[action(namespace = projects)]
|
|
+#[serde(deny_unknown_fields)]
|
|
+pub struct OpenGuixContainer;
|
|
+
|
|
/// Where to spawn the task in the UI.
|
|
#[derive(Default, Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
|
#[serde(rename_all = "snake_case")]
|
|
diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md
|
|
index 2b45c58168..6ce1df517f 100644
|
|
--- a/docs/src/SUMMARY.md
|
|
+++ b/docs/src/SUMMARY.md
|
|
@@ -57,6 +57,7 @@
|
|
- [Overview](./remote-development.md)
|
|
- [Environment Variables](./environment.md)
|
|
- [Dev Containers](./dev-containers.md)
|
|
+- [Guix Containers](./guix-containers.md)
|
|
|
|
# Platform Support
|
|
|
|
diff --git a/docs/src/guix-containers.md b/docs/src/guix-containers.md
|
|
new file mode 100644
|
|
index 0000000000..0de40bf1f8
|
|
--- /dev/null
|
|
+++ b/docs/src/guix-containers.md
|
|
@@ -0,0 +1,106 @@
|
|
+---
|
|
+title: Guix Containers - Zed
|
|
+description: Open projects in Guix containers with Zed. Use manifest.scm to run language servers, tasks, and terminals inside guix shell --container.
|
|
+---
|
|
+
|
|
+# Guix Containers
|
|
+
|
|
+Guix Containers let you reopen a project inside a `guix shell --container` environment while keeping the Zed UI local.
|
|
+
|
|
+If your project contains a `manifest.scm`, Zed can reopen that project in a Guix container so project-side commands run inside the container environment instead of on the host.
|
|
+
|
|
+## Requirements
|
|
+
|
|
+- `guix` must be installed and available in your `PATH`.
|
|
+- Your project must contain a `manifest.scm`.
|
|
+- The paths you want to open in one Guix workspace must resolve to the same `manifest.scm`.
|
|
+
|
|
+## Using Guix Containers in Zed
|
|
+
|
|
+### Automatic prompt
|
|
+
|
|
+When you open a local project that contains `manifest.scm`, Zed will display a prompt asking whether to re-open the project in a container.
|
|
+
|
|
+Choosing "Open in Container" opens the Guix container options modal for the current project. From there you can save options and reopen the project in the container.
|
|
+
|
|
+### Manual open
|
|
+
|
|
+If you dismiss the prompt or want to reopen the project in a container later, you can:
|
|
+
|
|
+- run `projects: open guix container` from the command palette while the local project is open
|
|
+- open the Remote Projects modal with {#kb projects::OpenRemote} and choose `Connect Guix Container`
|
|
+
|
|
+Both paths open the same Guix container options modal used by the automatic prompt flow.
|
|
+
|
|
+## Guix Container Options
|
|
+
|
|
+The Guix container options modal lets you configure:
|
|
+
|
|
+- `Allow network access (-N)`
|
|
+- `Allow nested Guix invocations (--nesting)`
|
|
+- extra `--expose` mounts
|
|
+- extra `--share` mounts
|
|
+- extra `guix shell` arguments
|
|
+
|
|
+For `--expose` and `--share`, enter one mount per line using either:
|
|
+
|
|
+- `source`
|
|
+- `source=target`
|
|
+
|
|
+Saving writes per-project settings for the detected `manifest.scm` and project root.
|
|
+
|
|
+## Settings
|
|
+
|
|
+Guix container settings are stored under `remote.guix_connections` in your settings file.
|
|
+
|
|
+```json [settings]
|
|
+{
|
|
+ "remote": {
|
|
+ "guix_connections": [
|
|
+ {
|
|
+ "manifest_path": "/home/me/code/project/manifest.scm",
|
|
+ "project_root": "/home/me/code/project",
|
|
+ "options": {
|
|
+ "allow_network": true,
|
|
+ "nesting": false,
|
|
+ "expose": [
|
|
+ { "source": "/var/cache/guix" }
|
|
+ ],
|
|
+ "share": [
|
|
+ { "source": "/tmp/project-cache", "target": "/cache" }
|
|
+ ],
|
|
+ "extra_args": ["--pure"]
|
|
+ }
|
|
+ }
|
|
+ ]
|
|
+ }
|
|
+}
|
|
+```
|
|
+
|
|
+In most cases it is simpler to edit these options through the Guix container modal rather than by hand.
|
|
+
|
|
+## Working in a Guix Container
|
|
+
|
|
+Once connected, Zed runs project-side commands inside the Guix container environment, including:
|
|
+
|
|
+- language servers
|
|
+- tasks
|
|
+- terminals
|
|
+- other remote-project command execution
|
|
+
|
|
+Zed keeps its UI local while the project host runs inside the container.
|
|
+
|
|
+## Known Limitations
|
|
+
|
|
+> **Note:** This feature is still in development.
|
|
+
|
|
+- Guix containers are opened from an existing local project. There is not a separate standalone Guix project picker.
|
|
+- If opened paths resolve to different `manifest.scm` roots, Zed will not combine them into one Guix container workspace.
|
|
+- If you change `manifest.scm` or Guix container options, reopen the project in the container to apply the new environment.
|
|
+- Port forwarding is not currently supported by the Guix transport.
|
|
+
|
|
+## See also
|
|
+
|
|
+- [Remote Development](./remote-development.md) for SSH- and WSL-based remote projects.
|
|
+- [Dev Containers](./dev-containers.md) for `devcontainer.json`-based container workflows.
|
|
+- [Tasks](./tasks.md) for running commands in project environments.
|