1
0
forked from tribes/guix
Files
guix-fork/gnu/packages/patches/zed-0.225.10-add-guix-container-support.patch
Danny Milosavljevic 2c51b803e3 gnu: Add zed.
* 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
2026-04-15 03:19:10 +02:00

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(&current).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.