From: Danny Milosavljevic 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>, remote_client: Entity, cx: &mut App, -) -> Result<(Shell, HashMap)> { +) -> Result<(Shell, HashMap, Option)> { 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 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 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, + window: &mut Window, + cx: &mut Context, +) { + 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.weak_handle(); + struct GuixContainerSuggestionNotification; + + let notification_id = NotificationId::composite::( + 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::>(); + 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| { @@ -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::().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::(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::().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::(cx); + assert!( + modal.is_none(), + "Guix container modal should not open without manifest.scm" + ); + }) + .unwrap(); + } + fn init_test(cx: &mut TestAppContext) -> Arc { 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, 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, } +#[derive(Clone)] +struct EditGuixConnectionState { + connection: GuixContainerConnectionOptions, + allow_network: bool, + nesting: bool, + expose_editor: Entity, + share_editor: Entity, + extra_args_editor: Entity, + error: Option, +} + struct DevContainerPickerDelegate { selected_index: usize, candidates: Vec, @@ -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::>() + .join("\n") + } + + fn parse_mounts(text: &str) -> anyhow::Result> { + 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 { + 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, } @@ -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), CreateRemoteServer(CreateRemoteServer), CreateRemoteDevContainer(CreateRemoteDevContainer), @@ -855,6 +992,23 @@ impl RemoteServerProjects { this } + pub fn new_guix( + connection: GuixContainerConnectionOptions, + fs: Arc, + window: &mut Window, + workspace: WeakEntity, + cx: &mut Context, + ) -> Self { + Self::new_inner( + Mode::EditGuixConnection(EditGuixConnectionState::new(connection, window, cx)), + false, + fs, + window, + workspace, + cx, + ) + } + pub fn popover( fs: Arc, workspace: WeakEntity, @@ -863,7 +1017,17 @@ impl RemoteServerProjects { cx: &mut App, ) -> Entity { 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, + ) { + 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, + ) { + 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) { 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, + ) { + 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::("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, + ) { + let Some(workspace) = self.workspace.upgrade() else { + return; + }; + let Some(window_handle) = window.window_handle().downcast::() else { + return; + }; + + let app_state = workspace.read(cx).app_state().clone(); + let paths = workspace + .read(cx) + .root_paths(cx) + .iter() + .map(|path| path.to_path_buf()) + .collect::>(); + + cx.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, @@ -1839,6 +2214,47 @@ impl RemoteServerProjects { } } + fn init_guix_mode(&mut self, window: &mut Window, cx: &mut Context) { + 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::>(); + 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, @@ -2524,6 +2940,168 @@ impl RemoteServerProjects { ) } + fn render_edit_guix_connection( + &self, + state: &EditGuixConnectionState, + window: &mut Window, + cx: &mut Context, + ) -> 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, + _window: &mut Window, + cx: &mut Context| { + 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::().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::(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::(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, cx: &Context) -> 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, +} + +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, pub env: HashMap, + pub cwd: Option, } /// 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) } + RemoteConnectionOptions::GuixContainer(opts) => { + GuixContainerConnection::new(opts, delegate, cx) + .await + .map(|connection| Arc::new(connection) as Arc) + } #[cfg(any(test, feature = "test-support"))] RemoteConnectionOptions::Mock(opts) => match cx.update(|cx| { cx.default_global::() @@ -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 for RemoteConnectionOptions { } } +impl From for RemoteConnectionOptions { + fn from(opts: GuixContainerConnectionOptions) -> Self { + RemoteConnectionOptions::GuixContainer(opts) + } +} + #[cfg(any(test, feature = "test-support"))] impl From 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, +} + +impl From 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, + pub share: Vec, + pub extra_args: Vec, +} + +impl From 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>, + 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, + cx: &mut AsyncApp, + ) -> Result { + 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, Vec) { + 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, working_directory: PathBuf) -> Vec { + let mut args = vec![ + "shell".to_string(), + "--container".to_string(), + "--no-cwd".to_string(), + "-m".to_string(), + self.manifest_path().display().to_string(), "zed:remote".to_string(), + ]; + args.push(format!("--cwd={}", working_directory.display())); + 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) -> 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, + args: &[String], + env: &HashMap, working_directory: PathBuf + ) -> Vec { + let mut command = self.base_guix_args(working_directory); + + if env.is_empty() { + match program { + Some(program) => { + command.push(program); + command.extend(args.iter().cloned()); + } + None => { + command.push("bash".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 + } + + 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, + outgoing_rx: UnboundedReceiver, + connection_activity_tx: Sender<()>, + delegate: Arc, + cx: &mut AsyncApp, + ) -> Task> { + 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> { + 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, + args: &[String], + env: &HashMap, + working_dir: Option, + port_forward: Option<(u16, String, u16)>, + _interactive: Interactive, + ) -> Result { + 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, self.current_dir_for_command(working_dir.clone())), + env: HashMap::default(), + cwd: Some(self.current_dir_for_command(working_dir)), + }) + } + + fn build_forward_ports_command( + &self, + _forwards: Vec<(u16, String, u16)>, + ) -> Result { + 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"))); + ( + 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"))); + ( + 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(); + + ( + 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 { - 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, } 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, pub env: Option>, + pub working_directory: Option, /// Timeout for tool calls in seconds. Defaults to 60 if not specified. pub timeout: Option, } @@ -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>, pub wsl_connections: Option>, pub dev_container_connections: Option>, + pub guix_connections: Option>, pub read_ssh_config: Option, pub use_podman: Option, } @@ -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, + #[serde(default)] + pub share: Vec, + #[serde(default)] + pub extra_args: Vec, +} + +#[with_fallible_options] +#[derive( + Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema, MergeFrom, Hash, +)] +pub struct GuixMount { + pub source: String, + pub target: Option, +} + #[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::(&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, +) -> Option { + 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, +) -> Option { + 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, + cx: &AsyncApp, +) -> Option { + 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 = Arc::new(fs::RealFs::new(None, cx.executor())); + cx.update(|cx| ::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 = Arc::new(fs::RealFs::new(None, cx.executor())); + cx.update(|cx| ::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 = Arc::new(fs::RealFs::new(None, cx.executor())); + cx.update(|cx| ::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 = Arc::new(fs::RealFs::new(None, cx.executor())); + cx.update(|cx| ::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 { 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.