1use crate::Project;
2use collections::HashMap;
3use gpui::{
4 AnyWindowHandle, AppContext, Context, Entity, Model, ModelContext, SharedString, WeakModel,
5};
6use itertools::Itertools;
7use settings::{Settings, SettingsLocation};
8use smol::channel::bounded;
9use std::{
10 env,
11 fs::File,
12 io::Write,
13 path::{Path, PathBuf},
14};
15use task::{SpawnInTerminal, TerminalWorkDir};
16use terminal::{
17 terminal_settings::{self, Shell, TerminalSettings, VenvSettingsContent},
18 TaskState, TaskStatus, Terminal, TerminalBuilder,
19};
20use util::ResultExt;
21
22// #[cfg(target_os = "macos")]
23// use std::os::unix::ffi::OsStrExt;
24
25pub struct Terminals {
26 pub(crate) local_handles: Vec<WeakModel<terminal::Terminal>>,
27}
28
29#[derive(Debug, Clone)]
30pub struct ConnectRemoteTerminal {
31 pub ssh_connection_string: SharedString,
32 pub project_path: SharedString,
33}
34
35impl Project {
36 pub fn terminal_work_dir_for(
37 &self,
38 pathbuf: Option<&Path>,
39 cx: &AppContext,
40 ) -> Option<TerminalWorkDir> {
41 if self.is_local() {
42 return Some(TerminalWorkDir::Local(pathbuf?.to_owned()));
43 }
44 let dev_server_project_id = self.dev_server_project_id()?;
45 let projects_store = dev_server_projects::Store::global(cx).read(cx);
46 let ssh_command = projects_store
47 .dev_server_for_project(dev_server_project_id)?
48 .ssh_connection_string
49 .as_ref()?
50 .to_string();
51
52 let path = if let Some(pathbuf) = pathbuf {
53 pathbuf.to_string_lossy().to_string()
54 } else {
55 projects_store
56 .dev_server_project(dev_server_project_id)?
57 .path
58 .to_string()
59 };
60
61 Some(TerminalWorkDir::Ssh {
62 ssh_command,
63 path: Some(path),
64 })
65 }
66
67 pub fn create_terminal(
68 &mut self,
69 working_directory: Option<TerminalWorkDir>,
70 spawn_task: Option<SpawnInTerminal>,
71 window: AnyWindowHandle,
72 cx: &mut ModelContext<Self>,
73 ) -> anyhow::Result<Model<Terminal>> {
74 // used only for TerminalSettings::get
75 let worktree = {
76 let terminal_cwd = working_directory.as_ref().and_then(|cwd| cwd.local_path());
77 let task_cwd = spawn_task
78 .as_ref()
79 .and_then(|spawn_task| spawn_task.cwd.as_ref())
80 .and_then(|cwd| cwd.local_path());
81
82 terminal_cwd
83 .and_then(|terminal_cwd| self.find_local_worktree(&terminal_cwd, cx))
84 .or_else(|| task_cwd.and_then(|spawn_cwd| self.find_local_worktree(&spawn_cwd, cx)))
85 };
86
87 let settings_location = worktree.as_ref().map(|(worktree, path)| SettingsLocation {
88 worktree_id: worktree.read(cx).id().to_usize(),
89 path,
90 });
91
92 let is_terminal = spawn_task.is_none()
93 && working_directory
94 .as_ref()
95 .map_or(true, |work_dir| work_dir.is_local());
96 let settings = TerminalSettings::get(settings_location, cx);
97 let python_settings = settings.detect_venv.clone();
98 let (completion_tx, completion_rx) = bounded(1);
99
100 let mut env = settings.env.clone();
101 // Alacritty uses parent project's working directory when no working directory is provided
102 // https://github.com/alacritty/alacritty/blob/fd1a3cc79192d1d03839f0fd8c72e1f8d0fce42e/extra/man/alacritty.5.scd?plain=1#L47-L52
103
104 let mut retained_script = None;
105
106 let venv_base_directory = working_directory
107 .as_ref()
108 .and_then(|cwd| cwd.local_path())
109 .unwrap_or_else(|| Path::new(""));
110
111 let (spawn_task, shell) = match working_directory.as_ref() {
112 Some(TerminalWorkDir::Ssh { ssh_command, path }) => {
113 log::debug!("Connecting to a remote server: {ssh_command:?}");
114 let tmp_dir = tempfile::tempdir()?;
115 let ssh_shell_result = prepare_ssh_shell(
116 &mut env,
117 tmp_dir.path(),
118 spawn_task.as_ref(),
119 ssh_command,
120 path.as_deref(),
121 );
122 retained_script = Some(tmp_dir);
123 let ssh_shell = ssh_shell_result?;
124
125 (
126 spawn_task.map(|spawn_task| TaskState {
127 id: spawn_task.id,
128 full_label: spawn_task.full_label,
129 label: spawn_task.label,
130 command_label: spawn_task.command_label,
131 status: TaskStatus::Running,
132 completion_rx,
133 }),
134 ssh_shell,
135 )
136 }
137 _ => {
138 if let Some(spawn_task) = spawn_task {
139 log::debug!("Spawning task: {spawn_task:?}");
140 env.extend(spawn_task.env);
141 // Activate minimal Python virtual environment
142 if let Some(python_settings) = &python_settings.as_option() {
143 self.set_python_venv_path_for_tasks(
144 python_settings,
145 &venv_base_directory,
146 &mut env,
147 );
148 }
149 (
150 Some(TaskState {
151 id: spawn_task.id,
152 full_label: spawn_task.full_label,
153 label: spawn_task.label,
154 command_label: spawn_task.command_label,
155 status: TaskStatus::Running,
156 completion_rx,
157 }),
158 Shell::WithArguments {
159 program: spawn_task.command,
160 args: spawn_task.args,
161 },
162 )
163 } else {
164 (None, settings.shell.clone())
165 }
166 }
167 };
168
169 let terminal = TerminalBuilder::new(
170 working_directory
171 .as_ref()
172 .and_then(|cwd| cwd.local_path())
173 .map(ToOwned::to_owned),
174 spawn_task,
175 shell,
176 env,
177 Some(settings.blinking),
178 settings.alternate_scroll,
179 settings.max_scroll_history_lines,
180 window,
181 completion_tx,
182 )
183 .map(|builder| {
184 let terminal_handle = cx.new_model(|cx| builder.subscribe(cx));
185
186 self.terminals
187 .local_handles
188 .push(terminal_handle.downgrade());
189
190 let id = terminal_handle.entity_id();
191 cx.observe_release(&terminal_handle, move |project, _terminal, cx| {
192 drop(retained_script);
193 let handles = &mut project.terminals.local_handles;
194
195 if let Some(index) = handles
196 .iter()
197 .position(|terminal| terminal.entity_id() == id)
198 {
199 handles.remove(index);
200 cx.notify();
201 }
202 })
203 .detach();
204
205 // if the terminal is not a task, activate full Python virtual environment
206 if is_terminal {
207 if let Some(python_settings) = &python_settings.as_option() {
208 if let Some(activate_script_path) =
209 self.find_activate_script_path(python_settings, &venv_base_directory)
210 {
211 self.activate_python_virtual_environment(
212 Project::get_activate_command(python_settings),
213 activate_script_path,
214 &terminal_handle,
215 cx,
216 );
217 }
218 }
219 }
220 terminal_handle
221 });
222
223 terminal
224 }
225
226 pub fn find_activate_script_path(
227 &mut self,
228 settings: &VenvSettingsContent,
229 venv_base_directory: &Path,
230 ) -> Option<PathBuf> {
231 let activate_script_name = match settings.activate_script {
232 terminal_settings::ActivateScript::Default => "activate",
233 terminal_settings::ActivateScript::Csh => "activate.csh",
234 terminal_settings::ActivateScript::Fish => "activate.fish",
235 terminal_settings::ActivateScript::Nushell => "activate.nu",
236 };
237
238 settings
239 .directories
240 .into_iter()
241 .find_map(|virtual_environment_name| {
242 let path = venv_base_directory
243 .join(virtual_environment_name)
244 .join("bin")
245 .join(activate_script_name);
246 path.exists().then_some(path)
247 })
248 }
249
250 pub fn set_python_venv_path_for_tasks(
251 &mut self,
252 settings: &VenvSettingsContent,
253 venv_base_directory: &Path,
254 env: &mut HashMap<String, String>,
255 ) {
256 let activate_path = settings
257 .directories
258 .into_iter()
259 .find_map(|virtual_environment_name| {
260 let path = venv_base_directory.join(virtual_environment_name);
261 path.exists().then_some(path)
262 });
263
264 if let Some(path) = activate_path {
265 // Some tools use VIRTUAL_ENV to detect the virtual environment
266 env.insert(
267 "VIRTUAL_ENV".to_string(),
268 path.to_string_lossy().to_string(),
269 );
270
271 let path_bin = path.join("bin");
272 // We need to set the PATH to include the virtual environment's bin directory
273 if let Some(paths) = std::env::var_os("PATH") {
274 let paths = std::iter::once(path_bin).chain(std::env::split_paths(&paths));
275 if let Some(new_path) = std::env::join_paths(paths).log_err() {
276 env.insert("PATH".to_string(), new_path.to_string_lossy().to_string());
277 }
278 } else {
279 env.insert(
280 "PATH".to_string(),
281 path.join("bin").to_string_lossy().to_string(),
282 );
283 }
284 }
285 }
286
287 fn get_activate_command(settings: &VenvSettingsContent) -> &'static str {
288 match settings.activate_script {
289 terminal_settings::ActivateScript::Nushell => "overlay use",
290 _ => "source",
291 }
292 }
293
294 fn activate_python_virtual_environment(
295 &mut self,
296 activate_command: &'static str,
297 activate_script: PathBuf,
298 terminal_handle: &Model<Terminal>,
299 cx: &mut ModelContext<Project>,
300 ) {
301 // Paths are not strings so we need to jump through some hoops to format the command without `format!`
302 let mut command = Vec::from(activate_command.as_bytes());
303 command.push(b' ');
304 // Wrapping path in double quotes to catch spaces in folder name
305 command.extend_from_slice(b"\"");
306 command.extend_from_slice(activate_script.as_os_str().as_encoded_bytes());
307 command.extend_from_slice(b"\"");
308 command.push(b'\n');
309
310 terminal_handle.update(cx, |this, _| this.input_bytes(command));
311 }
312
313 pub fn local_terminal_handles(&self) -> &Vec<WeakModel<terminal::Terminal>> {
314 &self.terminals.local_handles
315 }
316}
317
318fn prepare_ssh_shell(
319 env: &mut HashMap<String, String>,
320 tmp_dir: &Path,
321 spawn_task: Option<&SpawnInTerminal>,
322 ssh_command: &str,
323 path: Option<&str>,
324) -> anyhow::Result<Shell> {
325 // Alacritty sets its terminfo to `alacritty`, this requiring hosts to have it installed
326 // to properly display colors.
327 // We do not have the luxury of assuming the host has it installed,
328 // so we set it to a default that does not break the highlighting via ssh.
329 env.entry("TERM".to_string())
330 .or_insert_with(|| "xterm-256color".to_string());
331
332 let real_ssh = which::which("ssh")?;
333 let ssh_path = tmp_dir.join("ssh");
334 let mut ssh_file = File::create(&ssh_path)?;
335
336 let to_run = if let Some(spawn_task) = spawn_task {
337 Some(shlex::try_quote(&spawn_task.command)?)
338 .into_iter()
339 .chain(
340 spawn_task
341 .args
342 .iter()
343 .filter_map(|arg| shlex::try_quote(arg).ok()),
344 )
345 .join(" ")
346 } else {
347 "exec $SHELL -l".to_string()
348 };
349
350 let (port_forward, local_dev_env) =
351 if env::var("ZED_RPC_URL").as_deref() == Ok("http://localhost:8080/rpc") {
352 (
353 "-R 8080:localhost:8080",
354 "export ZED_RPC_URL=http://localhost:8080/rpc;",
355 )
356 } else {
357 ("", "")
358 };
359
360 let commands = if let Some(path) = path {
361 // I've found that `ssh -t dev sh -c 'cd; cd /tmp; pwd'` gives /tmp
362 // but `ssh -t dev sh -c 'cd /tmp; pwd'` gives /root
363 format!("cd {path}; {local_dev_env} {to_run}")
364 } else {
365 format!("cd; {local_dev_env} {to_run}")
366 };
367 let shell_invocation = &format!("sh -c {}", shlex::try_quote(&commands)?);
368
369 // To support things like `gh cs ssh`/`coder ssh`, we run whatever command
370 // you have configured, but place our custom script on the path so that it will
371 // be run instead.
372 write!(
373 &mut ssh_file,
374 "#!/bin/sh\nexec {} \"$@\" {} {} {}",
375 real_ssh.to_string_lossy(),
376 if spawn_task.is_none() { "-t" } else { "" },
377 port_forward,
378 shlex::try_quote(shell_invocation)?,
379 )?;
380
381 // todo(windows)
382 #[cfg(not(target_os = "windows"))]
383 std::fs::set_permissions(ssh_path, smol::fs::unix::PermissionsExt::from_mode(0o755))?;
384 let path = format!(
385 "{}:{}",
386 tmp_dir.to_string_lossy(),
387 env.get("PATH")
388 .cloned()
389 .or(env::var("PATH").ok())
390 .unwrap_or_default()
391 );
392 env.insert("PATH".to_string(), path);
393
394 let mut args = shlex::split(&ssh_command).unwrap_or_default();
395 let program = args.drain(0..1).next().unwrap_or("ssh".to_string());
396 Ok(Shell::WithArguments { program, args })
397}