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