diff --git a/Cargo.lock b/Cargo.lock index c835b503ad..ef06443b5a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9269,6 +9269,7 @@ dependencies = [ "snippet_provider", "task", "tempfile", + "terminal", "text", "theme", "toml 0.8.20", diff --git a/crates/agent2/src/tools/terminal_tool.rs b/crates/agent2/src/tools/terminal_tool.rs index f41b909d0b..9bd2a9b58a 100644 --- a/crates/agent2/src/tools/terminal_tool.rs +++ b/crates/agent2/src/tools/terminal_tool.rs @@ -2,7 +2,7 @@ use agent_client_protocol as acp; use anyhow::Result; use futures::{FutureExt as _, future::Shared}; use gpui::{App, AppContext, Entity, SharedString, Task}; -use project::{Project, terminals::TerminalKind}; +use project::Project; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::{ @@ -144,15 +144,22 @@ impl AgentTool for TerminalTool { let terminal = self .project .update(cx, |project, cx| { - project.create_terminal( - TerminalKind::Task(task::SpawnInTerminal { + project.create_terminal_task( + task::SpawnInTerminal { command: Some(program), args, cwd: working_dir.clone(), env, ..Default::default() - }), + }, cx, + project + .active_entry() + .and_then(|entry_id| project.worktree_id_for_entry(entry_id, cx)) + .map(|worktree_id| project::ProjectPath { + worktree_id, + path: Arc::from(Path::new("")), + }), ) })? .await?; diff --git a/crates/assistant_tools/src/terminal_tool.rs b/crates/assistant_tools/src/terminal_tool.rs index b28e55e78a..49584d3c9c 100644 --- a/crates/assistant_tools/src/terminal_tool.rs +++ b/crates/assistant_tools/src/terminal_tool.rs @@ -15,7 +15,7 @@ use language::LineEnding; use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat}; use markdown::{Markdown, MarkdownElement, MarkdownStyle}; use portable_pty::{CommandBuilder, PtySize, native_pty_system}; -use project::{Project, terminals::TerminalKind}; +use project::Project; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use settings::Settings; @@ -213,18 +213,24 @@ impl Tool for TerminalTool { async move |cx| { let program = program.await; let env = env.await; - project .update(cx, |project, cx| { - project.create_terminal( - TerminalKind::Task(task::SpawnInTerminal { + project.create_terminal_task( + task::SpawnInTerminal { command: Some(program), args, cwd, env, ..Default::default() - }), + }, cx, + project + .active_entry() + .and_then(|entry_id| project.worktree_id_for_entry(entry_id, cx)) + .map(|worktree_id| project::ProjectPath { + worktree_id, + path: Arc::from(Path::new("")), + }), ) })? .await diff --git a/crates/debugger_ui/src/session/running.rs b/crates/debugger_ui/src/session/running.rs index 9991395f35..0881cdcb22 100644 --- a/crates/debugger_ui/src/session/running.rs +++ b/crates/debugger_ui/src/session/running.rs @@ -36,7 +36,6 @@ use module_list::ModuleList; use project::{ DebugScenarioContext, Project, WorktreeId, debugger::session::{self, Session, SessionEvent, SessionStateEvent, ThreadId, ThreadStatus}, - terminals::TerminalKind, }; use rpc::proto::ViewId; use serde_json::Value; @@ -1016,12 +1015,18 @@ impl RunningState { }; let terminal = project .update(cx, |project, cx| { - project.create_terminal( - TerminalKind::Task(task_with_shell.clone()), + project.create_terminal_task( + task_with_shell.clone(), cx, + project + .active_entry() + .and_then(|entry_id| project.worktree_id_for_entry(entry_id, cx)) + .map(|worktree_id| project::ProjectPath { + worktree_id, + path: Arc::from(std::path::Path::new("")), + }), ) - })? - .await?; + })?.await?; let terminal_view = cx.new_window_entity(|window, cx| { TerminalView::new( @@ -1165,7 +1170,7 @@ impl RunningState { .filter(|title| !title.is_empty()) .or_else(|| command.clone()) .unwrap_or_else(|| "Debug terminal".to_string()); - let kind = TerminalKind::Task(task::SpawnInTerminal { + let kind = task::SpawnInTerminal { id: task::TaskId("debug".to_string()), full_label: title.clone(), label: title.clone(), @@ -1183,12 +1188,24 @@ impl RunningState { show_summary: false, show_command: false, show_rerun: false, - }); + }; let workspace = self.workspace.clone(); let weak_project = project.downgrade(); - let terminal_task = project.update(cx, |project, cx| project.create_terminal(kind, cx)); + let terminal_task = project.update(cx, |project, cx| { + project.create_terminal_task( + kind, + cx, + project + .active_entry() + .and_then(|entry_id| project.worktree_id_for_entry(entry_id, cx)) + .map(|worktree_id| project::ProjectPath { + worktree_id, + path: Arc::from(std::path::Path::new("")), + }), + ) + }); let terminal_task = cx.spawn_in(window, async move |_, cx| { let terminal = terminal_task.await?; diff --git a/crates/language/src/toolchain.rs b/crates/language/src/toolchain.rs index 73c142c8ca..5798863a00 100644 --- a/crates/language/src/toolchain.rs +++ b/crates/language/src/toolchain.rs @@ -10,14 +10,15 @@ use std::{ }; use async_trait::async_trait; -use collections::HashMap; +use collections::{FxHashMap, HashMap}; use gpui::{AsyncApp, SharedString}; use settings::WorktreeId; +use task::ShellKind; use crate::{LanguageName, ManifestName}; /// Represents a single toolchain. -#[derive(Clone, Debug, Eq)] +#[derive(Clone, Eq, Debug)] pub struct Toolchain { /// User-facing label pub name: SharedString, @@ -25,24 +26,44 @@ pub struct Toolchain { pub language_name: LanguageName, /// Full toolchain data (including language-specific details) pub as_json: serde_json::Value, + /// shell -> script + pub activation_script: FxHashMap, + // Option + // sh activate -c "user shell -l" + // check if this work with powershell } impl std::hash::Hash for Toolchain { fn hash(&self, state: &mut H) { - self.name.hash(state); - self.path.hash(state); - self.language_name.hash(state); + let Self { + name, + path, + language_name, + as_json: _, + activation_script: _, + } = self; + name.hash(state); + path.hash(state); + language_name.hash(state); } } impl PartialEq for Toolchain { fn eq(&self, other: &Self) -> bool { + let Self { + name, + path, + language_name, + as_json: _, + activation_script: startup_script, + } = self; // Do not use as_json for comparisons; it shouldn't impact equality, as it's not user-surfaced. // Thus, there could be multiple entries that look the same in the UI. - (&self.name, &self.path, &self.language_name).eq(&( + (name, path, language_name, startup_script).eq(&( &other.name, &other.path, &other.language_name, + &other.activation_script, )) } } @@ -82,7 +103,7 @@ pub trait LocalLanguageToolchainStore: Send + Sync + 'static { ) -> Option; } -#[async_trait(?Send )] +#[async_trait(?Send)] impl LanguageToolchainStore for T { async fn active_toolchain( self: Arc, diff --git a/crates/languages/Cargo.toml b/crates/languages/Cargo.toml index 8e25818070..e431573088 100644 --- a/crates/languages/Cargo.toml +++ b/crates/languages/Cargo.toml @@ -71,6 +71,7 @@ settings.workspace = true smol.workspace = true snippet_provider.workspace = true task.workspace = true +terminal.workspace = true tempfile.workspace = true toml.workspace = true tree-sitter = { workspace = true, optional = true } diff --git a/crates/languages/src/python.rs b/crates/languages/src/python.rs index d21b5dabd3..ab36f47704 100644 --- a/crates/languages/src/python.rs +++ b/crates/languages/src/python.rs @@ -2,6 +2,7 @@ use anyhow::{Context as _, ensure}; use anyhow::{Result, anyhow}; use async_trait::async_trait; use collections::HashMap; +use futures::AsyncBufReadExt; use gpui::{App, Task}; use gpui::{AsyncApp, SharedString}; use language::Toolchain; @@ -30,12 +31,10 @@ use std::{ borrow::Cow, ffi::OsString, fmt::Write, - fs, - io::{self, BufRead}, path::{Path, PathBuf}, sync::Arc, }; -use task::{TaskTemplate, TaskTemplates, VariableName}; +use task::{ShellKind, TaskTemplate, TaskTemplates, VariableName}; use util::ResultExt; pub(crate) struct PyprojectTomlManifestProvider; @@ -741,14 +740,16 @@ fn env_priority(kind: Option) -> usize { /// Return the name of environment declared in Option { - fs::File::open(worktree_root.join(".venv")) - .and_then(|file| { - let mut venv_name = String::new(); - io::BufReader::new(file).read_line(&mut venv_name)?; - Ok(venv_name.trim().to_string()) - }) - .ok() +async fn get_worktree_venv_declaration(worktree_root: &Path) -> Option { + let file = async_fs::File::open(worktree_root.join(".venv")) + .await + .ok()?; + let mut venv_name = String::new(); + smol::io::BufReader::new(file) + .read_line(&mut venv_name) + .await + .ok()?; + Some(venv_name.trim().to_string()) } #[async_trait] @@ -791,7 +792,7 @@ impl ToolchainLister for PythonToolchainProvider { .map_or(Vec::new(), |mut guard| std::mem::take(&mut guard)); let wr = worktree_root; - let wr_venv = get_worktree_venv_declaration(&wr); + let wr_venv = get_worktree_venv_declaration(&wr).await; // Sort detected environments by: // environment name matching activation file (/.venv) // environment project dir matching worktree_root @@ -856,7 +857,7 @@ impl ToolchainLister for PythonToolchainProvider { .into_iter() .filter_map(|toolchain| { let mut name = String::from("Python"); - if let Some(ref version) = toolchain.version { + if let Some(version) = &toolchain.version { _ = write!(name, " {version}"); } @@ -872,12 +873,50 @@ impl ToolchainLister for PythonToolchainProvider { if let Some(nk) = name_and_kind { _ = write!(name, " {nk}"); } - Some(Toolchain { name: name.into(), path: toolchain.executable.as_ref()?.to_str()?.to_owned().into(), language_name: LanguageName::new("Python"), - as_json: serde_json::to_value(toolchain).ok()?, + as_json: serde_json::to_value(toolchain.clone()).ok()?, + activation_script: match (toolchain.kind, toolchain.prefix) { + (Some(PythonEnvironmentKind::Venv), Some(prefix)) => [ + ( + ShellKind::Fish, + "source", + prefix.join(BINARY_DIR).join("activate.fish"), + ), + ( + ShellKind::Powershell, + ".", + prefix.join(BINARY_DIR).join("activate.ps1"), + ), + ( + ShellKind::Nushell, + "overlay use", + prefix.join(BINARY_DIR).join("activate.nu"), + ), + ( + ShellKind::Posix, + ".", + prefix.join(BINARY_DIR).join("activate"), + ), + ( + ShellKind::Cmd, + ".", + prefix.join(BINARY_DIR).join("activate.bat"), + ), + ( + ShellKind::Csh, + ".", + prefix.join(BINARY_DIR).join("activate.csh"), + ), + ] + .into_iter() + .filter(|(_, _, path)| path.exists() && path.is_file()) + .map(|(kind, cmd, path)| (kind, format!("{cmd} {}", path.display()))) + .collect(), + _ => Default::default(), + }, }) }) .collect(); diff --git a/crates/project/src/debugger/dap_store.rs b/crates/project/src/debugger/dap_store.rs index 2906c32ff4..12be1d17c6 100644 --- a/crates/project/src/debugger/dap_store.rs +++ b/crates/project/src/debugger/dap_store.rs @@ -292,8 +292,8 @@ impl DapStore { .map(|command| (command, &binary.arguments)), binary.cwd.as_deref(), binary.envs, - None, path_style, + None, ); Ok(DebugAdapterBinary { diff --git a/crates/project/src/project_tests.rs b/crates/project/src/project_tests.rs index 6dcd07482e..6846df00bd 100644 --- a/crates/project/src/project_tests.rs +++ b/crates/project/src/project_tests.rs @@ -9204,6 +9204,7 @@ fn python_lang(fs: Arc) -> Arc { path: venv_path.to_string_lossy().into_owned().into(), language_name: LanguageName(SharedString::new_static("Python")), as_json: serde_json::Value::Null, + activation_script: Default::default(), }) } } diff --git a/crates/project/src/terminals.rs b/crates/project/src/terminals.rs index b009b357fe..149db2c51c 100644 --- a/crates/project/src/terminals.rs +++ b/crates/project/src/terminals.rs @@ -1,49 +1,31 @@ -use crate::{Project, ProjectPath}; -use anyhow::{Context as _, Result}; +use anyhow::Result; use collections::HashMap; use gpui::{App, AppContext as _, Context, Entity, Task, WeakEntity}; -use itertools::Itertools; +use itertools::Itertools as _; use language::LanguageName; use remote::{SshInfo, ssh_session::SshArgs}; use settings::{Settings, SettingsLocation}; use smol::channel::bounded; use std::{ borrow::Cow, - env::{self}, path::{Path, PathBuf}, sync::Arc, }; -use task::{Shell, ShellBuilder, SpawnInTerminal}; +use task::{Shell, ShellBuilder, ShellKind, SpawnInTerminal}; use terminal::{ - TaskState, TaskStatus, Terminal, TerminalBuilder, - terminal_settings::{self, ActivateScript, TerminalSettings, VenvSettings}, + TaskState, TaskStatus, Terminal, TerminalBuilder, terminal_settings::TerminalSettings, }; use util::{ - ResultExt, + get_system_shell, maybe, paths::{PathStyle, RemotePathBuf}, }; -/// The directory inside a Python virtual environment that contains executables -const PYTHON_VENV_BIN_DIR: &str = if cfg!(target_os = "windows") { - "Scripts" -} else { - "bin" -}; +use crate::{Project, ProjectPath}; pub struct Terminals { pub(crate) local_handles: Vec>, } -/// Terminals are opened either for the users shell, or to run a task. - -#[derive(Debug)] -pub enum TerminalKind { - /// Run a shell at the given path (or $HOME if None) - Shell(Option), - /// Run a task. - Task(SpawnInTerminal), -} - /// SshCommand describes how to connect to a remote server #[derive(Debug, Clone, PartialEq, Eq)] pub struct SshCommand { @@ -108,49 +90,383 @@ impl Project { None } - pub fn create_terminal( + pub fn create_terminal_task( &mut self, - kind: TerminalKind, + spawn_task: SpawnInTerminal, cx: &mut Context, + project_path_context: Option, ) -> Task>> { - let path: Option> = match &kind { - TerminalKind::Shell(path) => path.as_ref().map(|path| Arc::from(path.as_ref())), - TerminalKind::Task(spawn_task) => { - if let Some(cwd) = &spawn_task.cwd { - Some(Arc::from(cwd.as_ref())) - } else { - self.active_project_directory(cx) - } + let this = &mut *self; + let ssh_details = this.ssh_details(cx); + let path: Option> = if let Some(cwd) = &spawn_task.cwd { + if ssh_details.is_some() { + Some(Arc::from(cwd.as_ref())) + } else { + let cwd = cwd.to_string_lossy(); + let tilde_substituted = shellexpand::tilde(&cwd); + Some(Arc::from(Path::new(tilde_substituted.as_ref()))) } + } else { + this.active_project_directory(cx) }; + let is_ssh_terminal = ssh_details.is_some(); + let mut settings_location = None; if let Some(path) = path.as_ref() - && let Some((worktree, _)) = self.find_worktree(path, cx) + && let Some((worktree, _)) = this.find_worktree(path, cx) { settings_location = Some(SettingsLocation { worktree_id: worktree.read(cx).id(), path, }); } - let venv = TerminalSettings::get(settings_location, cx) - .detect_venv - .clone(); + let settings = TerminalSettings::get(settings_location, cx).clone(); + let (completion_tx, completion_rx) = bounded(1); + + // Start with the environment that we might have inherited from the Zed CLI. + let mut env = this + .environment + .read(cx) + .get_cli_environment() + .unwrap_or_default(); + // Then extend it with the explicit env variables from the settings, so they take + // precedence. + env.extend(settings.env); + + let local_path = if is_ssh_terminal { None } else { path.clone() }; + let toolchain = + project_path_context.map(|p| self.active_toolchain(p, LanguageName::new("Python"), cx)); cx.spawn(async move |project, cx| { - let python_venv_directory = if let Some(path) = path { - project - .update(cx, |this, cx| this.python_venv_directory(path, venv, cx))? - .await - } else { - None + let scripts = maybe!(async { + let toolchain = toolchain?.await?; + Some(toolchain.activation_script) + }) + .await; + let task_state = Some(TaskState { + id: spawn_task.id, + full_label: spawn_task.full_label, + label: spawn_task.label, + command_label: spawn_task.command_label, + hide: spawn_task.hide, + status: TaskStatus::Running, + show_summary: spawn_task.show_summary, + show_command: spawn_task.show_command, + show_rerun: spawn_task.show_rerun, + completion_rx, + }); + + let shell = { + env.extend(spawn_task.env); + // todo(lw): Use shell builder + let shell = match &ssh_details { + Some(ssh) => ssh.shell.clone(), + None => match &settings.shell { + Shell::Program(program) => program.clone(), + Shell::WithArguments { + program, + args: _, + title_override: _, + } => program.clone(), + Shell::System => get_system_shell(), + }, + }; + let shell_kind = ShellKind::new(&shell); + let activation_script = scripts.as_ref().and_then(|it| it.get(&shell_kind)); + + match ssh_details { + Some(SshDetails { + host, + ssh_command, + envs, + path_style, + shell, + }) => { + log::debug!("Connecting to a remote server: {ssh_command:?}"); + env.entry("TERM".to_string()) + .or_insert_with(|| "xterm-256color".to_string()); + + let (program, args) = wrap_for_ssh( + &shell, + &ssh_command, + spawn_task + .command + .as_ref() + .map(|command| (command, &spawn_task.args)), + path.as_deref(), + env, + path_style, + activation_script.map(String::as_str), + ); + env = HashMap::default(); + if let Some(envs) = envs { + env.extend(envs); + } + Shell::WithArguments { + program, + args, + title_override: Some(format!("{} — Terminal", host).into()), + } + } + None => match activation_script { + Some(activation_script) => { + let to_run = if let Some(command) = spawn_task.command { + let command: Option> = shlex::try_quote(&command).ok(); + let args = spawn_task + .args + .iter() + .filter_map(|arg| shlex::try_quote(arg).ok()); + command.into_iter().chain(args).join(" ") + } else { + format!("exec {shell} -l") + }; + Shell::WithArguments { + program: shell, + args: vec![ + "-c".to_owned(), + format!("{activation_script}; {to_run}",), + ], + title_override: None, + } + } + None => { + if let Some(program) = spawn_task.command { + Shell::WithArguments { + program, + args: spawn_task.args, + title_override: None, + } + } else { + Shell::System + } + } + }, + } }; - project.update(cx, |project, cx| { - project.create_terminal_with_venv(kind, python_venv_directory, cx) + project.update(cx, move |this, cx| { + TerminalBuilder::new( + local_path.map(|path| path.to_path_buf()), + task_state, + shell, + env, + settings.cursor_shape.unwrap_or_default(), + settings.alternate_scroll, + settings.max_scroll_history_lines, + is_ssh_terminal, + cx.entity_id().as_u64(), + Some(completion_tx), + cx, + None, + ) + .map(|builder| { + let terminal_handle = cx.new(|cx| builder.subscribe(cx)); + + this.terminals + .local_handles + .push(terminal_handle.downgrade()); + + let id = terminal_handle.entity_id(); + cx.observe_release(&terminal_handle, move |project, _terminal, cx| { + let handles = &mut project.terminals.local_handles; + + if let Some(index) = handles + .iter() + .position(|terminal| terminal.entity_id() == id) + { + handles.remove(index); + cx.notify(); + } + }) + .detach(); + + terminal_handle + }) })? }) } + pub fn create_terminal_shell( + &mut self, + cwd: Option, + cx: &mut Context, + project_path_context: Option, + ) -> Task>> { + let path = cwd.map(|p| Arc::from(&*p)); + let this = &mut *self; + let ssh_details = this.ssh_details(cx); + + let is_ssh_terminal = ssh_details.is_some(); + + let mut settings_location = None; + if let Some(path) = path.as_ref() + && let Some((worktree, _)) = this.find_worktree(path, cx) + { + settings_location = Some(SettingsLocation { + worktree_id: worktree.read(cx).id(), + path, + }); + } + let settings = TerminalSettings::get(settings_location, cx).clone(); + + // Start with the environment that we might have inherited from the Zed CLI. + let mut env = this + .environment + .read(cx) + .get_cli_environment() + .unwrap_or_default(); + // Then extend it with the explicit env variables from the settings, so they take + // precedence. + env.extend(settings.env); + + let local_path = if is_ssh_terminal { None } else { path.clone() }; + let toolchain = + project_path_context.map(|p| self.active_toolchain(p, LanguageName::new("Python"), cx)); + cx.spawn(async move |project, cx| { + // todo(lw): Use shell builder + let shell = match &ssh_details { + Some(ssh) => ssh.shell.clone(), + None => match &settings.shell { + Shell::Program(program) => program.clone(), + Shell::WithArguments { + program, + args: _, + title_override: _, + } => program.clone(), + Shell::System => get_system_shell(), + }, + }; + let shell_kind = ShellKind::new(&shell); + + let scripts = maybe!(async { + let toolchain = toolchain?.await?; + Some(toolchain.activation_script) + }) + .await; + let activation_script = scripts.as_ref().and_then(|it| it.get(&shell_kind)); + let shell = { + match ssh_details { + Some(SshDetails { + host, + ssh_command, + envs, + path_style, + shell: _, + }) => { + log::debug!("Connecting to a remote server: {ssh_command:?}"); + + // Alacritty sets its terminfo to `alacritty`, this requiring hosts to have it installed + // to properly display colors. + // We do not have the luxury of assuming the host has it installed, + // so we set it to a default that does not break the highlighting via ssh. + env.entry("TERM".to_string()) + .or_insert_with(|| "xterm-256color".to_string()); + + let (program, args) = wrap_for_ssh( + &shell, + &ssh_command, + None, + path.as_deref(), + env, + path_style, + activation_script.map(String::as_str), + ); + env = HashMap::default(); + if let Some(envs) = envs { + env.extend(envs); + } + Shell::WithArguments { + program, + args, + title_override: Some(format!("{} — Terminal", host).into()), + } + } + None => match activation_script { + Some(activation_script) => Shell::WithArguments { + program: shell.clone(), + args: vec![ + "-c".to_owned(), + format!("{activation_script}; exec {shell} -l",), + ], + title_override: None, + }, + None => settings.shell, + }, + } + }; + project.update(cx, move |this, cx| { + TerminalBuilder::new( + local_path.map(|path| path.to_path_buf()), + None, + shell, + env, + settings.cursor_shape.unwrap_or_default(), + settings.alternate_scroll, + settings.max_scroll_history_lines, + is_ssh_terminal, + cx.entity_id().as_u64(), + None, + cx, + None, + ) + .map(|builder| { + let terminal_handle = cx.new(|cx| builder.subscribe(cx)); + + this.terminals + .local_handles + .push(terminal_handle.downgrade()); + + let id = terminal_handle.entity_id(); + cx.observe_release(&terminal_handle, move |project, _terminal, cx| { + let handles = &mut project.terminals.local_handles; + + if let Some(index) = handles + .iter() + .position(|terminal| terminal.entity_id() == id) + { + handles.remove(index); + cx.notify(); + } + }) + .detach(); + + terminal_handle + }) + })? + }) + } + + pub fn clone_terminal( + &mut self, + terminal: &Entity, + cx: &mut Context<'_, Project>, + cwd: impl FnOnce() -> Option, + ) -> Result> { + terminal.read(cx).clone_builder(cx, cwd).map(|builder| { + let terminal_handle = cx.new(|cx| builder.subscribe(cx)); + + self.terminals + .local_handles + .push(terminal_handle.downgrade()); + + let id = terminal_handle.entity_id(); + cx.observe_release(&terminal_handle, move |project, _terminal, cx| { + let handles = &mut project.terminals.local_handles; + + if let Some(index) = handles + .iter() + .position(|terminal| terminal.entity_id() == id) + { + handles.remove(index); + cx.notify(); + } + }) + .detach(); + + terminal_handle + }) + } + pub fn terminal_settings<'a>( &'a self, path: &'a Option, @@ -199,8 +515,8 @@ impl Project { Some((&command, &args)), path.as_deref(), env, - None, path_style, + None, ); let mut command = std::process::Command::new(command); command.args(args); @@ -221,432 +537,6 @@ impl Project { } } - pub fn create_terminal_with_venv( - &mut self, - kind: TerminalKind, - python_venv_directory: Option, - cx: &mut Context, - ) -> Result> { - let this = &mut *self; - let ssh_details = this.ssh_details(cx); - let path: Option> = match &kind { - TerminalKind::Shell(path) => path.as_ref().map(|path| Arc::from(path.as_ref())), - TerminalKind::Task(spawn_task) => { - if let Some(cwd) = &spawn_task.cwd { - if ssh_details.is_some() { - Some(Arc::from(cwd.as_ref())) - } else { - let cwd = cwd.to_string_lossy(); - let tilde_substituted = shellexpand::tilde(&cwd); - Some(Arc::from(Path::new(tilde_substituted.as_ref()))) - } - } else { - this.active_project_directory(cx) - } - } - }; - - let is_ssh_terminal = ssh_details.is_some(); - - let mut settings_location = None; - if let Some(path) = path.as_ref() - && let Some((worktree, _)) = this.find_worktree(path, cx) - { - settings_location = Some(SettingsLocation { - worktree_id: worktree.read(cx).id(), - path, - }); - } - let settings = TerminalSettings::get(settings_location, cx).clone(); - - let (completion_tx, completion_rx) = bounded(1); - - // Start with the environment that we might have inherited from the Zed CLI. - let mut env = this - .environment - .read(cx) - .get_cli_environment() - .unwrap_or_default(); - // Then extend it with the explicit env variables from the settings, so they take - // precedence. - env.extend(settings.env); - - let local_path = if is_ssh_terminal { None } else { path.clone() }; - - let mut python_venv_activate_command = Task::ready(None); - - let (spawn_task, shell) = match kind { - TerminalKind::Shell(_) => { - if let Some(python_venv_directory) = &python_venv_directory { - python_venv_activate_command = this.python_activate_command( - python_venv_directory, - &settings.detect_venv, - &settings.shell, - cx, - ); - } - - match ssh_details { - Some(SshDetails { - host, - ssh_command, - envs, - path_style, - shell, - }) => { - log::debug!("Connecting to a remote server: {ssh_command:?}"); - - // Alacritty sets its terminfo to `alacritty`, this requiring hosts to have it installed - // to properly display colors. - // We do not have the luxury of assuming the host has it installed, - // so we set it to a default that does not break the highlighting via ssh. - env.entry("TERM".to_string()) - .or_insert_with(|| "xterm-256color".to_string()); - - let (program, args) = wrap_for_ssh( - &shell, - &ssh_command, - None, - path.as_deref(), - env, - None, - path_style, - ); - env = HashMap::default(); - if let Some(envs) = envs { - env.extend(envs); - } - ( - Option::::None, - Shell::WithArguments { - program, - args, - title_override: Some(format!("{} — Terminal", host).into()), - }, - ) - } - None => (None, settings.shell), - } - } - TerminalKind::Task(spawn_task) => { - let task_state = Some(TaskState { - id: spawn_task.id, - full_label: spawn_task.full_label, - label: spawn_task.label, - command_label: spawn_task.command_label, - hide: spawn_task.hide, - status: TaskStatus::Running, - show_summary: spawn_task.show_summary, - show_command: spawn_task.show_command, - show_rerun: spawn_task.show_rerun, - completion_rx, - }); - - env.extend(spawn_task.env); - - if let Some(venv_path) = &python_venv_directory { - env.insert( - "VIRTUAL_ENV".to_string(), - venv_path.to_string_lossy().to_string(), - ); - } - - match ssh_details { - Some(SshDetails { - host, - ssh_command, - envs, - path_style, - shell, - }) => { - log::debug!("Connecting to a remote server: {ssh_command:?}"); - env.entry("TERM".to_string()) - .or_insert_with(|| "xterm-256color".to_string()); - let (program, args) = wrap_for_ssh( - &shell, - &ssh_command, - spawn_task - .command - .as_ref() - .map(|command| (command, &spawn_task.args)), - path.as_deref(), - env, - python_venv_directory.as_deref(), - path_style, - ); - env = HashMap::default(); - if let Some(envs) = envs { - env.extend(envs); - } - ( - task_state, - Shell::WithArguments { - program, - args, - title_override: Some(format!("{} — Terminal", host).into()), - }, - ) - } - None => { - if let Some(venv_path) = &python_venv_directory { - add_environment_path(&mut env, &venv_path.join(PYTHON_VENV_BIN_DIR)) - .log_err(); - } - - let shell = if let Some(program) = spawn_task.command { - Shell::WithArguments { - program, - args: spawn_task.args, - title_override: None, - } - } else { - Shell::System - }; - (task_state, shell) - } - } - } - }; - TerminalBuilder::new( - local_path.map(|path| path.to_path_buf()), - python_venv_directory, - spawn_task, - shell, - env, - settings.cursor_shape.unwrap_or_default(), - settings.alternate_scroll, - settings.max_scroll_history_lines, - is_ssh_terminal, - cx.entity_id().as_u64(), - completion_tx, - cx, - ) - .map(|builder| { - let terminal_handle = cx.new(|cx| builder.subscribe(cx)); - - this.terminals - .local_handles - .push(terminal_handle.downgrade()); - - let id = terminal_handle.entity_id(); - cx.observe_release(&terminal_handle, move |project, _terminal, cx| { - let handles = &mut project.terminals.local_handles; - - if let Some(index) = handles - .iter() - .position(|terminal| terminal.entity_id() == id) - { - handles.remove(index); - cx.notify(); - } - }) - .detach(); - - this.activate_python_virtual_environment( - python_venv_activate_command, - &terminal_handle, - cx, - ); - - terminal_handle - }) - } - - fn python_venv_directory( - &self, - abs_path: Arc, - venv_settings: VenvSettings, - cx: &Context, - ) -> Task> { - cx.spawn(async move |this, cx| { - if let Some((worktree, relative_path)) = this - .update(cx, |this, cx| this.find_worktree(&abs_path, cx)) - .ok()? - { - let toolchain = this - .update(cx, |this, cx| { - this.active_toolchain( - ProjectPath { - worktree_id: worktree.read(cx).id(), - path: relative_path.into(), - }, - LanguageName::new("Python"), - cx, - ) - }) - .ok()? - .await; - - if let Some(toolchain) = toolchain { - let toolchain_path = Path::new(toolchain.path.as_ref()); - return Some(toolchain_path.parent()?.parent()?.to_path_buf()); - } - } - let venv_settings = venv_settings.as_option()?; - this.update(cx, move |this, cx| { - if let Some(path) = this.find_venv_in_worktree(&abs_path, &venv_settings, cx) { - return Some(path); - } - this.find_venv_on_filesystem(&abs_path, &venv_settings, cx) - }) - .ok() - .flatten() - }) - } - - fn find_venv_in_worktree( - &self, - abs_path: &Path, - venv_settings: &terminal_settings::VenvSettingsContent, - cx: &App, - ) -> Option { - venv_settings - .directories - .iter() - .map(|name| abs_path.join(name)) - .find(|venv_path| { - let bin_path = venv_path.join(PYTHON_VENV_BIN_DIR); - self.find_worktree(&bin_path, cx) - .and_then(|(worktree, relative_path)| { - worktree.read(cx).entry_for_path(&relative_path) - }) - .is_some_and(|entry| entry.is_dir()) - }) - } - - fn find_venv_on_filesystem( - &self, - abs_path: &Path, - venv_settings: &terminal_settings::VenvSettingsContent, - cx: &App, - ) -> Option { - let (worktree, _) = self.find_worktree(abs_path, cx)?; - let fs = worktree.read(cx).as_local()?.fs(); - venv_settings - .directories - .iter() - .map(|name| abs_path.join(name)) - .find(|venv_path| { - let bin_path = venv_path.join(PYTHON_VENV_BIN_DIR); - // One-time synchronous check is acceptable for terminal/task initialization - smol::block_on(fs.metadata(&bin_path)) - .ok() - .flatten() - .is_some_and(|meta| meta.is_dir) - }) - } - - fn activate_script_kind(shell: Option<&str>) -> ActivateScript { - let shell_env = std::env::var("SHELL").ok(); - let shell_path = shell.or_else(|| shell_env.as_deref()); - let shell = std::path::Path::new(shell_path.unwrap_or("")) - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or(""); - match shell { - "fish" => ActivateScript::Fish, - "tcsh" => ActivateScript::Csh, - "nu" => ActivateScript::Nushell, - "powershell" | "pwsh" => ActivateScript::PowerShell, - _ => ActivateScript::Default, - } - } - - fn python_activate_command( - &self, - venv_base_directory: &Path, - venv_settings: &VenvSettings, - shell: &Shell, - cx: &mut App, - ) -> Task> { - let Some(venv_settings) = venv_settings.as_option() else { - return Task::ready(None); - }; - let activate_keyword = match venv_settings.activate_script { - terminal_settings::ActivateScript::Default => match std::env::consts::OS { - "windows" => ".", - _ => ".", - }, - terminal_settings::ActivateScript::Nushell => "overlay use", - terminal_settings::ActivateScript::PowerShell => ".", - terminal_settings::ActivateScript::Pyenv => "pyenv", - _ => "source", - }; - let script_kind = - if venv_settings.activate_script == terminal_settings::ActivateScript::Default { - match shell { - Shell::Program(program) => Self::activate_script_kind(Some(program)), - Shell::WithArguments { - program, - args: _, - title_override: _, - } => Self::activate_script_kind(Some(program)), - Shell::System => Self::activate_script_kind(None), - } - } else { - venv_settings.activate_script - }; - - let activate_script_name = match script_kind { - terminal_settings::ActivateScript::Default - | terminal_settings::ActivateScript::Pyenv => "activate", - terminal_settings::ActivateScript::Csh => "activate.csh", - terminal_settings::ActivateScript::Fish => "activate.fish", - terminal_settings::ActivateScript::Nushell => "activate.nu", - terminal_settings::ActivateScript::PowerShell => "activate.ps1", - }; - - let line_ending = match std::env::consts::OS { - "windows" => "\r", - _ => "\n", - }; - - if venv_settings.venv_name.is_empty() { - let path = venv_base_directory - .join(PYTHON_VENV_BIN_DIR) - .join(activate_script_name) - .to_string_lossy() - .to_string(); - - let is_valid_path = self.resolve_abs_path(path.as_ref(), cx); - cx.background_spawn(async move { - let quoted = shlex::try_quote(&path).ok()?; - if is_valid_path.await.is_some_and(|meta| meta.is_file()) { - Some(format!( - "{} {} ; clear{}", - activate_keyword, quoted, line_ending - )) - } else { - None - } - }) - } else { - Task::ready(Some(format!( - "{activate_keyword} {activate_script_name} {name}; clear{line_ending}", - name = venv_settings.venv_name - ))) - } - } - - fn activate_python_virtual_environment( - &self, - command: Task>, - terminal_handle: &Entity, - cx: &mut App, - ) { - terminal_handle.update(cx, |_, cx| { - cx.spawn(async move |this, cx| { - if let Some(command) = command.await { - this.update(cx, |this, _| { - this.input(command.into_bytes()); - }) - .ok(); - } - }) - .detach() - }); - } - pub fn local_terminal_handles(&self) -> &Vec> { &self.terminals.local_handles } @@ -658,9 +548,10 @@ pub fn wrap_for_ssh( command: Option<(&String, &Vec)>, path: Option<&Path>, env: HashMap, - venv_directory: Option<&Path>, path_style: PathStyle, + activation_script: Option<&str>, ) -> (String, Vec) { + // todo make this shell aware let to_run = if let Some((command, args)) = command { let command: Option> = shlex::try_quote(command).ok(); let args = args.iter().filter_map(|arg| shlex::try_quote(arg).ok()); @@ -675,13 +566,10 @@ pub fn wrap_for_ssh( env_changes.push_str(&format!("{}={} ", k, v)); } } - if let Some(venv_directory) = venv_directory - && let Ok(str) = shlex::try_quote(venv_directory.to_string_lossy().as_ref()) - { - let path = RemotePathBuf::new(PathBuf::from(str.to_string()), path_style).to_string(); - env_changes.push_str(&format!("PATH={}:$PATH ", path)); - } + let activation_script = activation_script + .map(|s| format!(" {s};")) + .unwrap_or_default(); let commands = if let Some(path) = path { let path = RemotePathBuf::new(path.to_path_buf(), path_style).to_string(); // shlex will wrap the command in single quotes (''), disabling ~ expansion, @@ -693,12 +581,12 @@ pub fn wrap_for_ssh( .trim_start_matches("~") .trim_start_matches("/"); - format!("cd \"$HOME/{trimmed_path}\"; {env_changes} {to_run}") + format!("cd \"$HOME/{trimmed_path}\";{activation_script} {env_changes} {to_run}") } else { - format!("cd \"{path}\"; {env_changes} {to_run}") + format!("cd \"{path}\";{activation_script} {env_changes} {to_run}") } } else { - format!("cd; {env_changes} {to_run}") + format!("cd;{activation_script} {env_changes} {to_run}") }; let shell_invocation = format!("{shell} -c {}", shlex::try_quote(&commands).unwrap()); @@ -709,57 +597,3 @@ pub fn wrap_for_ssh( args.push(shell_invocation); (program, args) } - -fn add_environment_path(env: &mut HashMap, new_path: &Path) -> Result<()> { - let mut env_paths = vec![new_path.to_path_buf()]; - if let Some(path) = env.get("PATH").or(env::var("PATH").ok().as_ref()) { - let mut paths = std::env::split_paths(&path).collect::>(); - env_paths.append(&mut paths); - } - - let paths = std::env::join_paths(env_paths).context("failed to create PATH env variable")?; - env.insert("PATH".to_string(), paths.to_string_lossy().to_string()); - - Ok(()) -} - -#[cfg(test)] -mod tests { - use collections::HashMap; - - #[test] - fn test_add_environment_path_with_existing_path() { - let tmp_path = std::path::PathBuf::from("/tmp/new"); - let mut env = HashMap::default(); - let old_path = if cfg!(windows) { - "/usr/bin;/usr/local/bin" - } else { - "/usr/bin:/usr/local/bin" - }; - env.insert("PATH".to_string(), old_path.to_string()); - env.insert("OTHER".to_string(), "aaa".to_string()); - - super::add_environment_path(&mut env, &tmp_path).unwrap(); - if cfg!(windows) { - assert_eq!(env.get("PATH").unwrap(), &format!("/tmp/new;{}", old_path)); - } else { - assert_eq!(env.get("PATH").unwrap(), &format!("/tmp/new:{}", old_path)); - } - assert_eq!(env.get("OTHER").unwrap(), "aaa"); - } - - #[test] - fn test_add_environment_path_with_empty_path() { - let tmp_path = std::path::PathBuf::from("/tmp/new"); - let mut env = HashMap::default(); - env.insert("OTHER".to_string(), "aaa".to_string()); - let os_path = std::env::var("PATH").unwrap(); - super::add_environment_path(&mut env, &tmp_path).unwrap(); - if cfg!(windows) { - assert_eq!(env.get("PATH").unwrap(), &format!("/tmp/new;{}", os_path)); - } else { - assert_eq!(env.get("PATH").unwrap(), &format!("/tmp/new:{}", os_path)); - } - assert_eq!(env.get("OTHER").unwrap(), "aaa"); - } -} diff --git a/crates/project/src/toolchain_store.rs b/crates/project/src/toolchain_store.rs index ac87e64248..36d030b07e 100644 --- a/crates/project/src/toolchain_store.rs +++ b/crates/project/src/toolchain_store.rs @@ -20,6 +20,7 @@ use rpc::{ proto::{self, FromProto, ToProto}, }; use settings::WorktreeId; +use task::ShellKind; use util::ResultExt as _; use crate::{ @@ -138,6 +139,11 @@ impl ToolchainStore { // Do we need to convert path to native string? path: PathBuf::from(toolchain.path).to_proto().into(), as_json: serde_json::Value::from_str(&toolchain.raw_json)?, + activation_script: toolchain + .activation_script + .into_iter() + .map(|(k, v)| (ShellKind::new(&k), v)) + .collect(), language_name, }; let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id); @@ -178,6 +184,11 @@ impl ToolchainStore { name: toolchain.name.into(), path: path.to_proto(), raw_json: toolchain.as_json.to_string(), + activation_script: toolchain + .activation_script + .into_iter() + .map(|(k, v)| (k.to_string(), v)) + .collect(), } }), }) @@ -221,6 +232,11 @@ impl ToolchainStore { name: toolchain.name.to_string(), path: path.to_proto(), raw_json: toolchain.as_json.to_string(), + activation_script: toolchain + .activation_script + .into_iter() + .map(|(k, v)| (k.to_string(), v)) + .collect(), } }) .collect::>(); @@ -449,6 +465,11 @@ impl RemoteToolchainStore { name: toolchain.name.into(), path: path.to_proto(), raw_json: toolchain.as_json.to_string(), + activation_script: toolchain + .activation_script + .into_iter() + .map(|(k, v)| (k.to_string(), v)) + .collect(), }), path: Some(project_path.path.to_string_lossy().into_owned()), }) @@ -501,6 +522,11 @@ impl RemoteToolchainStore { .to_string() .into(), as_json: serde_json::Value::from_str(&toolchain.raw_json).ok()?, + activation_script: toolchain + .activation_script + .into_iter() + .map(|(k, v)| (ShellKind::new(&k), v)) + .collect(), }) }) .collect(); @@ -557,6 +583,11 @@ impl RemoteToolchainStore { .to_string() .into(), as_json: serde_json::Value::from_str(&toolchain.raw_json).ok()?, + activation_script: toolchain + .activation_script + .into_iter() + .map(|(k, v)| (ShellKind::new(&k), v)) + .collect(), }) }) }) diff --git a/crates/proto/proto/toolchain.proto b/crates/proto/proto/toolchain.proto index 08844a307a..004afc7823 100644 --- a/crates/proto/proto/toolchain.proto +++ b/crates/proto/proto/toolchain.proto @@ -12,6 +12,7 @@ message Toolchain { string name = 1; string path = 2; string raw_json = 3; + map activation_script = 4; } message ToolchainGroup { diff --git a/crates/task/src/shell_builder.rs b/crates/task/src/shell_builder.rs index de4ddc00f4..2d30b3f769 100644 --- a/crates/task/src/shell_builder.rs +++ b/crates/task/src/shell_builder.rs @@ -1,3 +1,7 @@ +use std::fmt; + +use util::get_system_shell; + use crate::Shell; #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)] @@ -11,9 +15,22 @@ pub enum ShellKind { Cmd, } +impl fmt::Display for ShellKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ShellKind::Posix => write!(f, "sh"), + ShellKind::Csh => write!(f, "csh"), + ShellKind::Fish => write!(f, "fish"), + ShellKind::Powershell => write!(f, "powershell"), + ShellKind::Nushell => write!(f, "nu"), + ShellKind::Cmd => write!(f, "cmd"), + } + } +} + impl ShellKind { pub fn system() -> Self { - Self::new(&system_shell()) + Self::new(&get_system_shell()) } pub fn new(program: &str) -> Self { @@ -22,12 +39,12 @@ impl ShellKind { #[cfg(not(windows))] let (_, program) = program.rsplit_once('/').unwrap_or(("", program)); if program == "powershell" - || program == "powershell.exe" + || program.ends_with("powershell.exe") || program == "pwsh" - || program == "pwsh.exe" + || program.ends_with("pwsh.exe") { ShellKind::Powershell - } else if program == "cmd" || program == "cmd.exe" { + } else if program == "cmd" || program.ends_with("cmd.exe") { ShellKind::Cmd } else if program == "nu" { ShellKind::Nushell @@ -178,18 +195,6 @@ impl ShellKind { } } -fn system_shell() -> String { - if cfg!(target_os = "windows") { - // `alacritty_terminal` uses this as default on Windows. See: - // https://github.com/alacritty/alacritty/blob/0d4ab7bca43213d96ddfe40048fc0f922543c6f8/alacritty_terminal/src/tty/windows/mod.rs#L130 - // We could use `util::get_windows_system_shell()` here, but we are running tasks here, so leave it to `powershell.exe` - // should be okay. - "powershell.exe".to_string() - } else { - std::env::var("SHELL").unwrap_or("/bin/sh".to_string()) - } -} - /// ShellBuilder is used to turn a user-requested task into a /// program that can be executed by the shell. pub struct ShellBuilder { @@ -206,7 +211,7 @@ impl ShellBuilder { let (program, args) = match shell { Shell::System => match remote_system_shell { Some(remote_shell) => (remote_shell.to_string(), Vec::new()), - None => (system_shell(), Vec::new()), + None => (get_system_shell(), Vec::new()), }, Shell::Program(shell) => (shell.clone(), Vec::new()), Shell::WithArguments { program, args, .. } => (program.clone(), args.clone()), diff --git a/crates/terminal/src/terminal.rs b/crates/terminal/src/terminal.rs index b38a69f095..1936eaa2d1 100644 --- a/crates/terminal/src/terminal.rs +++ b/crates/terminal/src/terminal.rs @@ -344,7 +344,6 @@ pub struct TerminalBuilder { impl TerminalBuilder { pub fn new( working_directory: Option, - python_venv_directory: Option, task: Option, shell: Shell, mut env: HashMap, @@ -353,8 +352,9 @@ impl TerminalBuilder { max_scroll_history_lines: Option, is_ssh_terminal: bool, window_id: u64, - completion_tx: Sender>, + completion_tx: Option>>, cx: &App, + startup_script: Option, ) -> Result { // If the parent environment doesn't have a locale set // (As is the case when launched from a .app on MacOS), @@ -428,13 +428,10 @@ impl TerminalBuilder { .clone() .or_else(|| Some(home_dir().to_path_buf())), drain_on_exit: true, - env: env.into_iter().collect(), + env: env.clone().into_iter().collect(), } }; - // Setup Alacritty's env, which modifies the current process's environment - alacritty_terminal::tty::setup_env(); - let default_cursor_style = AlacCursorStyle::from(cursor_shape); let scrolling_history = if task.is_some() { // Tasks like `cargo build --all` may produce a lot of output, ergo allow maximum scrolling. @@ -517,11 +514,19 @@ impl TerminalBuilder { hyperlink_regex_searches: RegexSearches::new(), vi_mode_enabled: false, is_ssh_terminal, - python_venv_directory, last_mouse_move_time: Instant::now(), last_hyperlink_search_position: None, #[cfg(windows)] shell_program, + startup_script, + template: CopyTemplate { + shell, + env, + cursor_shape, + alternate_scroll, + max_scroll_history_lines, + window_id, + }, }; Ok(TerminalBuilder { @@ -683,7 +688,7 @@ pub enum SelectionPhase { pub struct Terminal { pty_tx: Notifier, - completion_tx: Sender>, + completion_tx: Option>>, term: Arc>>, term_config: Config, events: VecDeque, @@ -695,7 +700,6 @@ pub struct Terminal { pub breadcrumb_text: String, pub pty_info: PtyProcessInfo, title_override: Option, - pub python_venv_directory: Option, scroll_px: Pixels, next_link_id: usize, selection_phase: SelectionPhase, @@ -707,6 +711,17 @@ pub struct Terminal { last_hyperlink_search_position: Option>, #[cfg(windows)] shell_program: Option, + template: CopyTemplate, + startup_script: Option, +} + +struct CopyTemplate { + shell: Shell, + env: HashMap, + cursor_shape: CursorShape, + alternate_scroll: AlternateScroll, + max_scroll_history_lines: Option, + window_id: u64, } pub struct TaskState { @@ -1895,7 +1910,9 @@ impl Terminal { } }); - self.completion_tx.try_send(e).ok(); + if let Some(tx) = &self.completion_tx { + tx.try_send(e).ok(); + } let task = match &mut self.task { Some(task) => task, None => { @@ -1950,6 +1967,28 @@ impl Terminal { pub fn vi_mode_enabled(&self) -> bool { self.vi_mode_enabled } + + pub fn clone_builder( + &self, + cx: &App, + cwd: impl FnOnce() -> Option, + ) -> Result { + let working_directory = self.working_directory().or_else(cwd); + TerminalBuilder::new( + working_directory, + None, + self.template.shell.clone(), + self.template.env.clone(), + self.template.cursor_shape, + self.template.alternate_scroll, + self.template.max_scroll_history_lines, + self.is_ssh_terminal, + self.template.window_id, + None, + cx, + self.startup_script.clone(), + ) + } } // Helper function to convert a grid row to a string @@ -2164,7 +2203,6 @@ mod tests { let (completion_tx, completion_rx) = smol::channel::unbounded(); let terminal = cx.new(|cx| { TerminalBuilder::new( - None, None, None, task::Shell::WithArguments { @@ -2178,8 +2216,9 @@ mod tests { None, false, 0, - completion_tx, + Some(completion_tx), cx, + None, ) .unwrap() .subscribe(cx) diff --git a/crates/terminal/src/terminal_settings.rs b/crates/terminal/src/terminal_settings.rs index 635e3e2ca5..133be9e623 100644 --- a/crates/terminal/src/terminal_settings.rs +++ b/crates/terminal/src/terminal_settings.rs @@ -95,6 +95,7 @@ pub enum VenvSettings { /// to the current working directory. We recommend overriding this /// in your project's settings, rather than globally. activate_script: Option, + // deprecate but use venv_name: Option, directories: Option>, }, diff --git a/crates/terminal_view/src/persistence.rs b/crates/terminal_view/src/persistence.rs index b93b267f58..b90f0fc7d3 100644 --- a/crates/terminal_view/src/persistence.rs +++ b/crates/terminal_view/src/persistence.rs @@ -3,9 +3,12 @@ use async_recursion::async_recursion; use collections::HashSet; use futures::{StreamExt as _, stream::FuturesUnordered}; use gpui::{AppContext as _, AsyncWindowContext, Axis, Entity, Task, WeakEntity}; -use project::{Project, terminals::TerminalKind}; +use project::{Project, ProjectPath}; use serde::{Deserialize, Serialize}; -use std::path::{Path, PathBuf}; +use std::{ + path::{Path, PathBuf}, + sync::Arc, +}; use ui::{App, Context, Pixels, Window}; use util::ResultExt as _; @@ -242,11 +245,20 @@ async fn deserialize_pane_group( .update(cx, |workspace, cx| default_working_directory(workspace, cx)) .ok() .flatten(); - let kind = TerminalKind::Shell( - working_directory.as_deref().map(Path::to_path_buf), - ); - let terminal = - project.update(cx, |project, cx| project.create_terminal(kind, cx)); + let p = workspace + .update(cx, |workspace, cx| { + let worktree = workspace.worktrees(cx).next()?.read(cx); + worktree.root_dir()?; + Some(ProjectPath { + worktree_id: worktree.id(), + path: Arc::from(Path::new("")), + }) + }) + .ok() + .flatten(); + let terminal = project.update(cx, |project, cx| { + project.create_terminal_shell(working_directory, cx, p) + }); Some(Some(terminal)) } else { Some(None) diff --git a/crates/terminal_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs index 6b17911487..7d8377579a 100644 --- a/crates/terminal_view/src/terminal_panel.rs +++ b/crates/terminal_view/src/terminal_panel.rs @@ -1,4 +1,11 @@ -use std::{cmp, ops::ControlFlow, path::PathBuf, process::ExitStatus, sync::Arc, time::Duration}; +use std::{ + cmp, + ops::ControlFlow, + path::{Path, PathBuf}, + process::ExitStatus, + sync::Arc, + time::Duration, +}; use crate::{ TerminalView, default_working_directory, @@ -16,7 +23,7 @@ use gpui::{ Task, WeakEntity, Window, actions, }; use itertools::Itertools; -use project::{Fs, Project, ProjectEntryId, terminals::TerminalKind}; +use project::{Fs, Project, ProjectEntryId}; use search::{BufferSearchBar, buffer_search::DivRegistrar}; use settings::Settings; use task::{RevealStrategy, RevealTarget, ShellBuilder, SpawnInTerminal, TaskId}; @@ -376,14 +383,19 @@ impl TerminalPanel { } self.serialize(cx); } - pane::Event::Split(direction) => { - let Some(new_pane) = self.new_pane_with_cloned_active_terminal(window, cx) else { - return; - }; + &pane::Event::Split(direction) => { + let fut = self.new_pane_with_cloned_active_terminal(window, cx); let pane = pane.clone(); - let direction = *direction; - self.center.split(&pane, &new_pane, direction).log_err(); - window.focus(&new_pane.focus_handle(cx)); + cx.spawn_in(window, async move |panel, cx| { + let Some(new_pane) = fut.await else { + return; + }; + _ = panel.update_in(cx, |panel, window, cx| { + panel.center.split(&pane, &new_pane, direction).log_err(); + window.focus(&new_pane.focus_handle(cx)); + }); + }) + .detach(); } pane::Event::Focus => { self.active_pane = pane.clone(); @@ -400,57 +412,72 @@ impl TerminalPanel { &mut self, window: &mut Window, cx: &mut Context, - ) -> Option> { - let workspace = self.workspace.upgrade()?; + ) -> Task>> { + let Some(workspace) = self.workspace.upgrade() else { + return Task::ready(None); + }; let workspace = workspace.read(cx); let database_id = workspace.database_id(); let weak_workspace = self.workspace.clone(); let project = workspace.project().clone(); - let (working_directory, python_venv_directory) = self - .active_pane + let active_pane = &self.active_pane; + let terminal_view = active_pane .read(cx) .active_item() - .and_then(|item| item.downcast::()) - .map(|terminal_view| { - let terminal = terminal_view.read(cx).terminal().read(cx); - ( - terminal - .working_directory() - .or_else(|| default_working_directory(workspace, cx)), - terminal.python_venv_directory.clone(), - ) - }) - .unwrap_or((None, None)); - let kind = TerminalKind::Shell(working_directory); - let terminal = project - .update(cx, |project, cx| { - project.create_terminal_with_venv(kind, python_venv_directory, cx) - }) - .ok()?; - - let terminal_view = Box::new(cx.new(|cx| { - TerminalView::new( - terminal.clone(), - weak_workspace.clone(), - database_id, - project.downgrade(), - window, - cx, - ) - })); - let pane = new_terminal_pane( - weak_workspace, - project, - self.active_pane.read(cx).is_zoomed(), - window, - cx, - ); - self.apply_tab_bar_buttons(&pane, cx); - pane.update(cx, |pane, cx| { - pane.add_item(terminal_view, true, true, None, window, cx); + .and_then(|item| item.downcast::()); + let working_directory = terminal_view.as_ref().and_then(|terminal_view| { + let terminal = terminal_view.read(cx).terminal().read(cx); + terminal + .working_directory() + .or_else(|| default_working_directory(workspace, cx)) }); + let is_zoomed = active_pane.read(cx).is_zoomed(); + cx.spawn_in(window, async move |panel, cx| { + let terminal = project + .update(cx, |project, cx| match terminal_view { + Some(view) => Task::ready(project.clone_terminal( + &view.read(cx).terminal.clone(), + cx, + || working_directory, + )), + None => project.create_terminal_shell( + working_directory, + cx, + project + .active_entry() + .and_then(|entry_id| project.worktree_id_for_entry(entry_id, cx)) + .map(|worktree_id| project::ProjectPath { + worktree_id, + path: Arc::from(Path::new("")), + }), + ), + }) + .ok()? + .await + .ok()?; - Some(pane) + panel + .update_in(cx, move |terminal_panel, window, cx| { + let terminal_view = Box::new(cx.new(|cx| { + TerminalView::new( + terminal.clone(), + weak_workspace.clone(), + database_id, + project.downgrade(), + window, + cx, + ) + })); + let pane = new_terminal_pane(weak_workspace, project, is_zoomed, window, cx); + terminal_panel.apply_tab_bar_buttons(&pane, cx); + pane.update(cx, |pane, cx| { + pane.add_item(terminal_view, true, true, None, window, cx); + }); + Some(pane) + }) + .ok() + .flatten() + }) } pub fn open_terminal( @@ -465,8 +492,8 @@ impl TerminalPanel { terminal_panel .update(cx, |panel, cx| { - panel.add_terminal( - TerminalKind::Shell(Some(action.working_directory.clone())), + panel.add_terminal_shell( + Some(action.working_directory.clone()), RevealStrategy::Always, window, cx, @@ -560,15 +587,26 @@ impl TerminalPanel { ) -> Task>> { let reveal = spawn_task.reveal; let reveal_target = spawn_task.reveal_target; - let kind = TerminalKind::Task(spawn_task); match reveal_target { RevealTarget::Center => self .workspace .update(cx, |workspace, cx| { - Self::add_center_terminal(workspace, kind, window, cx) + Self::add_center_terminal(workspace, window, cx, |project, cx| { + project.create_terminal_task( + spawn_task, + cx, + project + .active_entry() + .and_then(|entry_id| project.worktree_id_for_entry(entry_id, cx)) + .map(|worktree_id| project::ProjectPath { + worktree_id, + path: Arc::from(Path::new("")), + }), + ) + }) }) .unwrap_or_else(|e| Task::ready(Err(e))), - RevealTarget::Dock => self.add_terminal(kind, reveal, window, cx), + RevealTarget::Dock => self.add_terminal_task(spawn_task, reveal, window, cx), } } @@ -583,11 +621,14 @@ impl TerminalPanel { return; }; - let kind = TerminalKind::Shell(default_working_directory(workspace, cx)); - terminal_panel .update(cx, |this, cx| { - this.add_terminal(kind, RevealStrategy::Always, window, cx) + this.add_terminal_shell( + default_working_directory(workspace, cx), + RevealStrategy::Always, + window, + cx, + ) }) .detach_and_log_err(cx); } @@ -649,9 +690,13 @@ impl TerminalPanel { pub fn add_center_terminal( workspace: &mut Workspace, - kind: TerminalKind, window: &mut Window, cx: &mut Context, + create_terminal: impl FnOnce( + &mut Project, + &mut Context, + ) -> Task>> + + 'static, ) -> Task>> { if !is_enabled_in_workspace(workspace, cx) { return Task::ready(Err(anyhow!( @@ -660,9 +705,7 @@ impl TerminalPanel { } let project = workspace.project().downgrade(); cx.spawn_in(window, async move |workspace, cx| { - let terminal = project - .update(cx, |project, cx| project.create_terminal(kind, cx))? - .await?; + let terminal = project.update(cx, create_terminal)?.await?; workspace.update_in(cx, |workspace, window, cx| { let terminal_view = cx.new(|cx| { @@ -681,9 +724,9 @@ impl TerminalPanel { }) } - pub fn add_terminal( + pub fn add_terminal_task( &mut self, - kind: TerminalKind, + task: SpawnInTerminal, reveal_strategy: RevealStrategy, window: &mut Window, cx: &mut Context, @@ -699,7 +742,90 @@ impl TerminalPanel { })?; let project = workspace.read_with(cx, |workspace, _| workspace.project().clone())?; let terminal = project - .update(cx, |project, cx| project.create_terminal(kind, cx))? + .update(cx, |project, cx| { + project.create_terminal_task( + task, + cx, + project + .active_entry() + .and_then(|entry_id| project.worktree_id_for_entry(entry_id, cx)) + .map(|worktree_id| project::ProjectPath { + worktree_id, + path: Arc::from(Path::new("")), + }), + ) + })? + .await?; + let result = workspace.update_in(cx, |workspace, window, cx| { + let terminal_view = Box::new(cx.new(|cx| { + TerminalView::new( + terminal.clone(), + workspace.weak_handle(), + workspace.database_id(), + workspace.project().downgrade(), + window, + cx, + ) + })); + + match reveal_strategy { + RevealStrategy::Always => { + workspace.focus_panel::(window, cx); + } + RevealStrategy::NoFocus => { + workspace.open_panel::(window, cx); + } + RevealStrategy::Never => {} + } + + pane.update(cx, |pane, cx| { + let focus = pane.has_focus(window, cx) + || matches!(reveal_strategy, RevealStrategy::Always); + pane.add_item(terminal_view, true, focus, None, window, cx); + }); + + Ok(terminal.downgrade()) + })?; + terminal_panel.update(cx, |terminal_panel, cx| { + terminal_panel.pending_terminals_to_add = + terminal_panel.pending_terminals_to_add.saturating_sub(1); + terminal_panel.serialize(cx) + })?; + result + }) + } + + pub fn add_terminal_shell( + &mut self, + cwd: Option, + reveal_strategy: RevealStrategy, + window: &mut Window, + cx: &mut Context, + ) -> Task>> { + let workspace = self.workspace.clone(); + cx.spawn_in(window, async move |terminal_panel, cx| { + if workspace.update(cx, |workspace, cx| !is_enabled_in_workspace(workspace, cx))? { + anyhow::bail!("terminal not yet supported for remote projects"); + } + let pane = terminal_panel.update(cx, |terminal_panel, _| { + terminal_panel.pending_terminals_to_add += 1; + terminal_panel.active_pane.clone() + })?; + let project = workspace.read_with(cx, |workspace, _| workspace.project().clone())?; + let terminal = project + .update(cx, |project, cx| { + project.create_terminal_shell( + cwd, + cx, + project + .active_entry() + .and_then(|entry_id| project.worktree_id_for_entry(entry_id, cx)) + .map(|worktree_id| project::ProjectPath { + worktree_id, + path: Arc::from(Path::new("")), + }), + ) + })? .await?; let result = workspace.update_in(cx, |workspace, window, cx| { let terminal_view = Box::new(cx.new(|cx| { @@ -808,7 +934,17 @@ impl TerminalPanel { })??; let new_terminal = project .update(cx, |project, cx| { - project.create_terminal(TerminalKind::Task(spawn_task), cx) + project.create_terminal_task( + spawn_task, + cx, + project + .active_entry() + .and_then(|entry_id| project.worktree_id_for_entry(entry_id, cx)) + .map(|worktree_id| project::ProjectPath { + worktree_id, + path: Arc::from(Path::new("")), + }), + ) })? .await?; terminal_to_replace.update_in(cx, |terminal_to_replace, window, cx| { @@ -1237,18 +1373,29 @@ impl Render for TerminalPanel { let panes = terminal_panel.center.panes(); if let Some(&pane) = panes.get(action.0) { window.focus(&pane.read(cx).focus_handle(cx)); - } else if let Some(new_pane) = - terminal_panel.new_pane_with_cloned_active_terminal(window, cx) - { - terminal_panel - .center - .split( - &terminal_panel.active_pane, - &new_pane, - SplitDirection::Right, - ) - .log_err(); - window.focus(&new_pane.focus_handle(cx)); + } else { + let future = + terminal_panel.new_pane_with_cloned_active_terminal(window, cx); + cx.spawn_in(window, async move |terminal_panel, cx| { + if let Some(new_pane) = future.await { + _ = terminal_panel.update_in( + cx, + |terminal_panel, window, cx| { + terminal_panel + .center + .split( + &terminal_panel.active_pane, + &new_pane, + SplitDirection::Right, + ) + .log_err(); + let new_pane = new_pane.read(cx); + window.focus(&new_pane.focus_handle(cx)); + }, + ); + } + }) + .detach(); } }), ) @@ -1384,13 +1531,14 @@ impl Panel for TerminalPanel { return; } cx.defer_in(window, |this, window, cx| { - let Ok(kind) = this.workspace.update(cx, |workspace, cx| { - TerminalKind::Shell(default_working_directory(workspace, cx)) - }) else { + let Ok(kind) = this + .workspace + .update(cx, |workspace, cx| default_working_directory(workspace, cx)) + else { return; }; - this.add_terminal(kind, RevealStrategy::Always, window, cx) + this.add_terminal_shell(kind, RevealStrategy::Always, window, cx) .detach_and_log_err(cx) }) } diff --git a/crates/terminal_view/src/terminal_path_like_target.rs b/crates/terminal_view/src/terminal_path_like_target.rs index e20df7f001..f65cb482f7 100644 --- a/crates/terminal_view/src/terminal_path_like_target.rs +++ b/crates/terminal_view/src/terminal_path_like_target.rs @@ -364,7 +364,7 @@ fn possibly_open_target( mod tests { use super::*; use gpui::TestAppContext; - use project::{Project, terminals::TerminalKind}; + use project::Project; use serde_json::json; use std::path::{Path, PathBuf}; use terminal::{HoveredWord, alacritty_terminal::index::Point as AlacPoint}; @@ -405,8 +405,8 @@ mod tests { app_cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); let terminal = project - .update(cx, |project, cx| { - project.create_terminal(TerminalKind::Shell(None), cx) + .update(cx, |project: &mut Project, cx| { + project.create_terminal_shell(None, cx, None) }) .await .expect("Failed to create a terminal"); diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index 9aa855acb7..09daab1bc1 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -15,7 +15,7 @@ use gpui::{ deferred, div, }; use persistence::TERMINAL_DB; -use project::{Project, search::SearchQuery, terminals::TerminalKind}; +use project::{Project, ProjectPath, search::SearchQuery}; use schemars::JsonSchema; use task::TaskId; use terminal::{ @@ -204,12 +204,19 @@ impl TerminalView { cx: &mut Context, ) { let working_directory = default_working_directory(workspace, cx); - TerminalPanel::add_center_terminal( - workspace, - TerminalKind::Shell(working_directory), - window, - cx, - ) + TerminalPanel::add_center_terminal(workspace, window, cx, |project, cx| { + project.create_terminal_shell( + working_directory, + cx, + project + .active_entry() + .and_then(|entry_id| project.worktree_id_for_entry(entry_id, cx)) + .map(|worktree_id| project::ProjectPath { + worktree_id, + path: Arc::from(Path::new("")), + }), + ) + }) .detach_and_log_err(cx); } @@ -1333,16 +1340,10 @@ impl Item for TerminalView { let terminal = self .project .update(cx, |project, cx| { - let terminal = self.terminal().read(cx); - let working_directory = terminal - .working_directory() - .or_else(|| Some(project.active_project_directory(cx)?.to_path_buf())); - let python_venv_directory = terminal.python_venv_directory.clone(); - project.create_terminal_with_venv( - TerminalKind::Shell(working_directory), - python_venv_directory, - cx, - ) + let cwd = project + .active_project_directory(cx) + .map(|it| it.to_path_buf()); + project.clone_terminal(self.terminal(), cx, || cwd) }) .ok()? .log_err()?; @@ -1497,10 +1498,19 @@ impl SerializableItem for TerminalView { .ok() .flatten(); + let p = workspace + .update(cx, |workspace, cx| { + let worktree = workspace.worktrees(cx).next()?.read(cx); + worktree.root_dir()?; + Some(ProjectPath { + worktree_id: worktree.id(), + path: Arc::from(Path::new("")), + }) + }) + .ok() + .flatten(); let terminal = project - .update(cx, |project, cx| { - project.create_terminal(TerminalKind::Shell(cwd), cx) - })? + .update(cx, |project, cx| project.create_terminal_shell(cwd, cx, p))? .await?; cx.update(|window, cx| { cx.new(|cx| { diff --git a/crates/workspace/src/persistence.rs b/crates/workspace/src/persistence.rs index 39a1e08c93..c293bd0e49 100644 --- a/crates/workspace/src/persistence.rs +++ b/crates/workspace/src/persistence.rs @@ -1403,7 +1403,8 @@ impl WorkspaceDb { name: name.into(), path: path.into(), language_name, - as_json: serde_json::Value::from_str(&raw_json).ok()? + as_json: serde_json::Value::from_str(&raw_json).ok()?, + activation_script: Default::default(), }))) }) .await @@ -1423,11 +1424,13 @@ impl WorkspaceDb { let toolchain: Vec<(String, String, u64, String, String, String)> = select(workspace_id)?; + // todo look into re-serializing these if we fix up Ok(toolchain.into_iter().filter_map(|(name, path, worktree_id, relative_worktree_path, language_name, raw_json)| Some((Toolchain { name: name.into(), path: path.into(), language_name: LanguageName::new(&language_name), - as_json: serde_json::Value::from_str(&raw_json).ok()? + as_json: serde_json::Value::from_str(&raw_json).ok()?, + activation_script: Default::default(), }, WorktreeId::from_proto(worktree_id), Arc::from(relative_worktree_path.as_ref())))).collect()) }) .await