diff --git a/Cargo.lock b/Cargo.lock index 464824b939..9627e69acf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7683,10 +7683,12 @@ dependencies = [ "serde_json", "settings", "sha2 0.10.7", + "shlex", "similar", "smol", "snippet", "task", + "tempfile", "terminal", "text", "unindent", @@ -8056,6 +8058,8 @@ dependencies = [ "serde", "serde_json", "smol", + "task", + "terminal_view", "ui", "ui_text_field", "util", diff --git a/Cargo.toml b/Cargo.toml index 2346b63890..b6ea709e32 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -330,6 +330,7 @@ serde_json_lenient = { version = "0.1", features = [ serde_repr = "0.1" sha2 = "0.10" shellexpand = "2.1.0" +shlex = "1.3.0" smallvec = { version = "1.6", features = ["union"] } smol = "1.2" strum = { version = "0.25.0", features = ["derive"] } diff --git a/crates/assistant/src/prompt_library.rs b/crates/assistant/src/prompt_library.rs index 3490b13478..66bd548ff9 100644 --- a/crates/assistant/src/prompt_library.rs +++ b/crates/assistant/src/prompt_library.rs @@ -251,8 +251,8 @@ impl Render for PromptManager { .h(rems(40.)) .overflow_hidden() .child( - ModalHeader::new("prompt-manager-header") - .child(Headline::new("Prompt Library").size(HeadlineSize::Small)) + ModalHeader::new() + .headline("Prompt Library") .show_dismiss_button(true), ) .child( diff --git a/crates/collab/src/db/queries/dev_servers.rs b/crates/collab/src/db/queries/dev_servers.rs index 8eb3d43b9c..b61da6fd57 100644 --- a/crates/collab/src/db/queries/dev_servers.rs +++ b/crates/collab/src/db/queries/dev_servers.rs @@ -137,6 +137,7 @@ impl Database { &self, id: DevServerId, name: &str, + ssh_connection_string: &Option, user_id: UserId, ) -> crate::Result { self.transaction(|tx| async move { @@ -149,6 +150,7 @@ impl Database { dev_server::Entity::update(dev_server::ActiveModel { name: ActiveValue::Set(name.trim().to_string()), + ssh_connection_string: ActiveValue::Set(ssh_connection_string.clone()), ..dev_server.clone().into_active_model() }) .exec(&*tx) diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 18d6b00965..afd7e6fe28 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -2439,7 +2439,12 @@ async fn rename_dev_server( let status = session .db() .await - .rename_dev_server(dev_server_id, &request.name, session.user_id()) + .rename_dev_server( + dev_server_id, + &request.name, + &request.ssh_connection_string, + session.user_id(), + ) .await?; send_dev_server_projects_update(session.user_id(), status, &session).await; diff --git a/crates/collab/src/tests/dev_server_tests.rs b/crates/collab/src/tests/dev_server_tests.rs index c0b8f55852..c759cbc3db 100644 --- a/crates/collab/src/tests/dev_server_tests.rs +++ b/crates/collab/src/tests/dev_server_tests.rs @@ -352,6 +352,7 @@ async fn test_dev_server_rename( store.rename_dev_server( store.dev_servers().first().unwrap().id, "name-edited".to_string(), + None, cx, ) }) diff --git a/crates/dev_server_projects/src/dev_server_projects.rs b/crates/dev_server_projects/src/dev_server_projects.rs index 31a7d4ea2f..e69c905a14 100644 --- a/crates/dev_server_projects/src/dev_server_projects.rs +++ b/crates/dev_server_projects/src/dev_server_projects.rs @@ -185,6 +185,7 @@ impl Store { &mut self, dev_server_id: DevServerId, name: String, + ssh_connection_string: Option, cx: &mut ModelContext, ) -> Task> { let client = self.client.clone(); @@ -193,6 +194,7 @@ impl Store { .request(proto::RenameDevServer { dev_server_id: dev_server_id.0, name, + ssh_connection_string, }) .await?; Ok(()) diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs index 5f0621d60e..c2754a4f75 100644 --- a/crates/markdown/src/markdown.rs +++ b/crates/markdown/src/markdown.rs @@ -73,6 +73,9 @@ impl Markdown { } pub fn reset(&mut self, source: String, cx: &mut ViewContext) { + if source == self.source() { + return; + } self.source = source; self.selection = Selection::default(); self.autoscroll_request = None; @@ -544,8 +547,10 @@ impl Element for MarkdownElement { }) } MarkdownTag::Link { dest_url, .. } => { - builder.push_link(dest_url.clone(), range.clone()); - builder.push_text_style(self.style.link.clone()) + if builder.code_block_stack.is_empty() { + builder.push_link(dest_url.clone(), range.clone()); + builder.push_text_style(self.style.link.clone()) + } } _ => log::error!("unsupported markdown tag {:?}", tag), } @@ -577,7 +582,11 @@ impl Element for MarkdownElement { MarkdownTagEnd::Emphasis => builder.pop_text_style(), MarkdownTagEnd::Strong => builder.pop_text_style(), MarkdownTagEnd::Strikethrough => builder.pop_text_style(), - MarkdownTagEnd::Link => builder.pop_text_style(), + MarkdownTagEnd::Link => { + if builder.code_block_stack.is_empty() { + builder.pop_text_style() + } + } _ => log::error!("unsupported markdown tag end: {:?}", tag), }, MarkdownEvent::Text => { diff --git a/crates/project/Cargo.toml b/crates/project/Cargo.toml index 3e8b7c0c0b..ae5c510f35 100644 --- a/crates/project/Cargo.toml +++ b/crates/project/Cargo.toml @@ -52,10 +52,12 @@ regex.workspace = true rpc.workspace = true schemars.workspace = true task.workspace = true +tempfile.workspace = true serde.workspace = true serde_json.workspace = true settings.workspace = true sha2.workspace = true +shlex.workspace = true similar = "1.3" smol.workspace = true snippet.workspace = true diff --git a/crates/project/src/terminals.rs b/crates/project/src/terminals.rs index ae260d1d2a..49aabe973f 100644 --- a/crates/project/src/terminals.rs +++ b/crates/project/src/terminals.rs @@ -5,8 +5,13 @@ use gpui::{ }; use settings::{Settings, SettingsLocation}; use smol::channel::bounded; -use std::path::{Path, PathBuf}; -use task::SpawnInTerminal; +use std::{ + env, + fs::File, + io::Write, + path::{Path, PathBuf}, +}; +use task::{SpawnInTerminal, TerminalWorkDir}; use terminal::{ terminal_settings::{self, Shell, TerminalSettings, VenvSettingsContent}, TaskState, TaskStatus, Terminal, TerminalBuilder, @@ -27,58 +32,57 @@ pub struct ConnectRemoteTerminal { } impl Project { - pub fn remote_terminal_connection_data( + pub fn terminal_work_dir_for( &self, + pathbuf: Option<&PathBuf>, cx: &AppContext, - ) -> Option { - self.dev_server_project_id() - .and_then(|dev_server_project_id| { - let projects_store = dev_server_projects::Store::global(cx).read(cx); - let project_path = projects_store - .dev_server_project(dev_server_project_id)? - .path - .clone(); - let ssh_connection_string = projects_store - .dev_server_for_project(dev_server_project_id)? - .ssh_connection_string - .clone(); - Some(project_path).zip(ssh_connection_string) - }) - .map( - |(project_path, ssh_connection_string)| ConnectRemoteTerminal { - ssh_connection_string, - project_path, - }, - ) + ) -> Option { + if self.is_local() { + return Some(TerminalWorkDir::Local(pathbuf?.clone())); + } + let dev_server_project_id = self.dev_server_project_id()?; + let projects_store = dev_server_projects::Store::global(cx).read(cx); + let ssh_command = projects_store + .dev_server_for_project(dev_server_project_id)? + .ssh_connection_string + .clone()? + .to_string(); + + let path = if let Some(pathbuf) = pathbuf { + pathbuf.to_string_lossy().to_string() + } else { + projects_store + .dev_server_project(dev_server_project_id)? + .path + .to_string() + }; + + Some(TerminalWorkDir::Ssh { + ssh_command, + path: Some(path), + }) } pub fn create_terminal( &mut self, - working_directory: Option, + working_directory: Option, spawn_task: Option, window: AnyWindowHandle, cx: &mut ModelContext, ) -> anyhow::Result> { - let remote_connection_data = if self.is_remote() { - let remote_connection_data = self.remote_terminal_connection_data(cx); - if remote_connection_data.is_none() { - anyhow::bail!("Cannot create terminal for remote project without connection data") - } - remote_connection_data - } else { - None - }; - // used only for TerminalSettings::get let worktree = { - let terminal_cwd = working_directory.as_deref(); + let terminal_cwd = working_directory + .as_ref() + .and_then(|cwd| cwd.local_path().clone()); let task_cwd = spawn_task .as_ref() - .and_then(|spawn_task| spawn_task.cwd.as_deref()); + .and_then(|spawn_task| spawn_task.cwd.as_ref()) + .and_then(|cwd| cwd.local_path()); terminal_cwd - .and_then(|terminal_cwd| self.find_local_worktree(terminal_cwd, cx)) - .or_else(|| task_cwd.and_then(|spawn_cwd| self.find_local_worktree(spawn_cwd, cx))) + .and_then(|terminal_cwd| self.find_local_worktree(&terminal_cwd, cx)) + .or_else(|| task_cwd.and_then(|spawn_cwd| self.find_local_worktree(&spawn_cwd, cx))) }; let settings_location = worktree.as_ref().map(|(worktree, path)| SettingsLocation { @@ -86,7 +90,8 @@ impl Project { path, }); - let is_terminal = spawn_task.is_none() && remote_connection_data.is_none(); + let is_terminal = spawn_task.is_none() && (working_directory.as_ref().is_none()) + || (working_directory.as_ref().unwrap().is_local()); let settings = TerminalSettings::get(settings_location, cx); let python_settings = settings.detect_venv.clone(); let (completion_tx, completion_rx) = bounded(1); @@ -95,60 +100,138 @@ impl Project { // Alacritty uses parent project's working directory when no working directory is provided // https://github.com/alacritty/alacritty/blob/fd1a3cc79192d1d03839f0fd8c72e1f8d0fce42e/extra/man/alacritty.5.scd?plain=1#L47-L52 + let mut retained_script = None; + let venv_base_directory = working_directory - .as_deref() - .unwrap_or_else(|| Path::new("")); + .as_ref() + .and_then(|cwd| cwd.local_path().map(|path| path.clone())) + .unwrap_or_else(|| PathBuf::new()) + .clone(); - let (spawn_task, shell) = if let Some(remote_connection_data) = remote_connection_data { - log::debug!("Connecting to a remote server: {remote_connection_data:?}"); - // 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 (spawn_task, shell) = match working_directory.as_ref() { + Some(TerminalWorkDir::Ssh { ssh_command, path }) => { + 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()); - ( - None, - Shell::WithArguments { - program: "ssh".to_string(), - args: vec![ - remote_connection_data.ssh_connection_string.to_string(), - "-t".to_string(), - format!( - "cd {} && exec $SHELL -l", - escape_path_for_shell(remote_connection_data.project_path.as_ref()) - ), - ], - }, - ) - } else if let Some(spawn_task) = spawn_task { - log::debug!("Spawning task: {spawn_task:?}"); - env.extend(spawn_task.env); - // Activate minimal Python virtual environment - if let Some(python_settings) = &python_settings.as_option() { - self.set_python_venv_path_for_tasks(python_settings, venv_base_directory, &mut env); + let tmp_dir = tempfile::tempdir()?; + let real_ssh = which::which("ssh")?; + let ssh_path = tmp_dir.path().join("ssh"); + let mut ssh_file = File::create(ssh_path.clone())?; + + let to_run = if let Some(spawn_task) = spawn_task.as_ref() { + Some(shlex::try_quote(&spawn_task.command)?.to_string()) + .into_iter() + .chain(spawn_task.args.iter().filter_map(|arg| { + shlex::try_quote(arg).ok().map(|arg| arg.to_string()) + })) + .collect::>() + .join(" ") + } else { + "exec $SHELL -l".to_string() + }; + + let (port_forward, local_dev_env) = + if env::var("ZED_RPC_URL") == Ok("http://localhost:8080/rpc".to_string()) { + ( + "-R 8080:localhost:8080", + "export ZED_RPC_URL=http://localhost:8080/rpc;", + ) + } else { + ("", "") + }; + + let commands = if let Some(path) = path { + // I've found that `ssh -t dev sh -c 'cd; cd /tmp; pwd'` gives /tmp + // but `ssh -t dev sh -c 'cd /tmp; pwd'` gives /root + format!("cd {}; {} {}", path, local_dev_env, to_run) + } else { + format!("cd; {} {}", local_dev_env, to_run) + }; + + let shell_invocation = &format!("sh -c {}", shlex::try_quote(&commands)?); + + // To support things like `gh cs ssh`/`coder ssh`, we run whatever command + // you have configured, but place our custom script on the path so that it will + // be run instead. + write!( + &mut ssh_file, + "#!/bin/sh\nexec {} \"$@\" {} {} {}", + real_ssh.to_string_lossy(), + if spawn_task.is_none() { "-t" } else { "" }, + port_forward, + shlex::try_quote(shell_invocation)?, + )?; + // todo(windows) + #[cfg(not(target_os = "windows"))] + std::fs::set_permissions( + ssh_path, + smol::fs::unix::PermissionsExt::from_mode(0o755), + )?; + let path = format!( + "{}:{}", + tmp_dir.path().to_string_lossy(), + env.get("PATH") + .cloned() + .or(env::var("PATH").ok()) + .unwrap_or_default() + ); + env.insert("PATH".to_string(), path); + + let mut args = shlex::split(&ssh_command).unwrap_or_default(); + let program = args.drain(0..1).next().unwrap_or("ssh".to_string()); + + retained_script = Some(tmp_dir); + ( + spawn_task.map(|spawn_task| TaskState { + id: spawn_task.id, + full_label: spawn_task.full_label, + label: spawn_task.label, + command_label: spawn_task.command_label, + status: TaskStatus::Running, + completion_rx, + }), + Shell::WithArguments { program, args }, + ) + } + _ => { + if let Some(spawn_task) = spawn_task { + log::debug!("Spawning task: {spawn_task:?}"); + env.extend(spawn_task.env); + // Activate minimal Python virtual environment + if let Some(python_settings) = &python_settings.as_option() { + self.set_python_venv_path_for_tasks( + python_settings, + &venv_base_directory, + &mut env, + ); + } + ( + Some(TaskState { + id: spawn_task.id, + full_label: spawn_task.full_label, + label: spawn_task.label, + command_label: spawn_task.command_label, + status: TaskStatus::Running, + completion_rx, + }), + Shell::WithArguments { + program: spawn_task.command, + args: spawn_task.args, + }, + ) + } else { + (None, settings.shell.clone()) + } } - ( - Some(TaskState { - id: spawn_task.id, - full_label: spawn_task.full_label, - label: spawn_task.label, - command_label: spawn_task.command_label, - status: TaskStatus::Running, - completion_rx, - }), - Shell::WithArguments { - program: spawn_task.command, - args: spawn_task.args, - }, - ) - } else { - (None, settings.shell.clone()) }; let terminal = TerminalBuilder::new( - working_directory.clone(), + working_directory.and_then(|cwd| cwd.local_path()).clone(), spawn_task, shell, env, @@ -167,6 +250,7 @@ impl Project { let id = terminal_handle.entity_id(); cx.observe_release(&terminal_handle, move |project, _terminal, cx| { + drop(retained_script); let handles = &mut project.terminals.local_handles; if let Some(index) = handles @@ -183,7 +267,7 @@ impl Project { if is_terminal { if let Some(python_settings) = &python_settings.as_option() { if let Some(activate_script_path) = - self.find_activate_script_path(python_settings, venv_base_directory) + self.find_activate_script_path(python_settings, &venv_base_directory) { self.activate_python_virtual_environment( Project::get_activate_command(python_settings), @@ -291,39 +375,3 @@ impl Project { &self.terminals.local_handles } } - -#[cfg(unix)] -fn escape_path_for_shell(input: &str) -> String { - input - .chars() - .fold(String::with_capacity(input.len()), |mut s, c| { - match c { - ' ' | '"' | '\'' | '\\' | '(' | ')' | '{' | '}' | '[' | ']' | '|' | ';' | '&' - | '<' | '>' | '*' | '?' | '$' | '#' | '!' | '=' | '^' | '%' | ':' => { - s.push('\\'); - s.push('\\'); - s.push(c); - } - _ => s.push(c), - } - s - }) -} - -#[cfg(windows)] -fn escape_path_for_shell(input: &str) -> String { - input - .chars() - .fold(String::with_capacity(input.len()), |mut s, c| { - match c { - '^' | '&' | '|' | '<' | '>' | ' ' | '(' | ')' | '@' | '`' | '=' | ';' | '%' => { - s.push('^'); - s.push(c); - } - _ => s.push(c), - } - s - }) -} - -// TODO: Add a few tests for adding and removing terminal tabs diff --git a/crates/recent_projects/Cargo.toml b/crates/recent_projects/Cargo.toml index c3527329fe..a78c90d6b5 100644 --- a/crates/recent_projects/Cargo.toml +++ b/crates/recent_projects/Cargo.toml @@ -26,6 +26,8 @@ dev_server_projects.workspace = true rpc.workspace = true serde.workspace = true smol.workspace = true +task.workspace = true +terminal_view.workspace = true ui.workspace = true ui_text_field.workspace = true util.workspace = true diff --git a/crates/recent_projects/src/dev_servers.rs b/crates/recent_projects/src/dev_servers.rs index dc3f136d8f..e6eb0891fd 100644 --- a/crates/recent_projects/src/dev_servers.rs +++ b/crates/recent_projects/src/dev_servers.rs @@ -1,23 +1,34 @@ use std::time::Duration; +use anyhow::Context; use dev_server_projects::{DevServer, DevServerId, DevServerProject, DevServerProjectId}; use editor::Editor; use feature_flags::FeatureFlagAppExt; use feature_flags::FeatureFlagViewExt; use gpui::Subscription; +use gpui::Task; +use gpui::WeakView; use gpui::{ - percentage, Action, Animation, AnimationExt, AnyElement, AppContext, ClipboardItem, - DismissEvent, EventEmitter, FocusHandle, FocusableView, Model, ScrollHandle, Transformation, - View, ViewContext, + percentage, Animation, AnimationExt, AnyElement, AppContext, DismissEvent, EventEmitter, + FocusHandle, FocusableView, Model, ScrollHandle, Transformation, View, ViewContext, }; use markdown::Markdown; use markdown::MarkdownStyle; +use rpc::proto::RegenerateDevServerTokenResponse; use rpc::{ - proto::{CreateDevServerResponse, DevServerStatus, RegenerateDevServerTokenResponse}, + proto::{CreateDevServerResponse, DevServerStatus}, ErrorCode, ErrorExt, }; -use ui::CheckboxWithLabel; -use ui::{prelude::*, Indicator, List, ListHeader, ListItem, ModalContent, ModalHeader, Tooltip}; +use task::RevealStrategy; +use task::SpawnInTerminal; +use task::TerminalWorkDir; +use terminal_view::terminal_panel::TerminalPanel; +use ui::ElevationIndex; +use ui::Section; +use ui::{ + prelude::*, Indicator, List, ListHeader, ListItem, Modal, ModalFooter, ModalHeader, + RadioWithLabel, Tooltip, +}; use ui_text_field::{FieldLabelLayout, TextField}; use util::ResultExt; use workspace::{notifications::DetachAndPromptErr, AppState, ModalView, Workspace, WORKSPACE_DB}; @@ -29,10 +40,9 @@ pub struct DevServerProjects { focus_handle: FocusHandle, scroll_handle: ScrollHandle, dev_server_store: Model, + workspace: WeakView, project_path_input: View, dev_server_name_input: View, - use_server_name_in_ssh: Selection, - rename_dev_server_input: View, markdown: View, _dev_server_subscription: Subscription, } @@ -40,22 +50,9 @@ pub struct DevServerProjects { #[derive(Default, Clone)] struct CreateDevServer { creating: bool, - dev_server: Option, - // ssh_connection_string: Option, -} - -#[derive(Clone)] -struct EditDevServer { - dev_server_id: DevServerId, - state: EditDevServerState, -} - -#[derive(Clone, PartialEq)] -enum EditDevServerState { - Default, - RenamingDevServer, - RegeneratingToken, - RegeneratedToken(RegenerateDevServerTokenResponse), + dev_server_id: Option, + access_token: Option, + manual_setup: bool, } struct CreateDevServerProject { @@ -67,7 +64,6 @@ struct CreateDevServerProject { enum Mode { Default(Option), CreateDevServer(CreateDevServer), - EditDevServer(EditDevServer), } impl DevServerProjects { @@ -86,26 +82,27 @@ impl DevServerProjects { fn register_open_remote_action(workspace: &mut Workspace) { workspace.register_action(|workspace, _: &OpenRemote, cx| { - workspace.toggle_modal(cx, |cx| Self::new(cx)) + let handle = cx.view().downgrade(); + workspace.toggle_modal(cx, |cx| Self::new(cx, handle)) }); } pub fn open(workspace: View, cx: &mut WindowContext) { workspace.update(cx, |workspace, cx| { - workspace.toggle_modal(cx, |cx| Self::new(cx)) + let handle = cx.view().downgrade(); + workspace.toggle_modal(cx, |cx| Self::new(cx, handle)) }) } - pub fn new(cx: &mut ViewContext) -> Self { + pub fn new(cx: &mut ViewContext, workspace: WeakView) -> Self { let project_path_input = cx.new_view(|cx| { let mut editor = Editor::single_line(cx); editor.set_placeholder_text("Project path (~/work/zed, /workspace/zed, …)", cx); editor }); - let dev_server_name_input = - cx.new_view(|cx| TextField::new(cx, "Name", "").with_label(FieldLabelLayout::Stacked)); - let rename_dev_server_input = - cx.new_view(|cx| TextField::new(cx, "Name", "").with_label(FieldLabelLayout::Stacked)); + let dev_server_name_input = cx.new_view(|cx| { + TextField::new(cx, "Name", "192.168.0.1").with_label(FieldLabelLayout::Hidden) + }); let focus_handle = cx.focus_handle(); let dev_server_store = dev_server_projects::Store::global(cx); @@ -123,7 +120,10 @@ impl DevServerProjects { }, inline_code: Default::default(), block_quote: Default::default(), - link: Default::default(), + link: gpui::TextStyleRefinement { + color: Some(Color::Accent.color(cx)), + ..Default::default() + }, rule_color: Default::default(), block_quote_border_color: Default::default(), syntax: cx.theme().syntax().clone(), @@ -132,15 +132,19 @@ impl DevServerProjects { let markdown = cx.new_view(|cx| Markdown::new("".to_string(), markdown_style, None, cx)); Self { - mode: Mode::Default(None), + mode: Mode::CreateDevServer(CreateDevServer { + creating: false, + dev_server_id: None, + access_token: None, + manual_setup: false, + }), focus_handle, scroll_handle: ScrollHandle::new(), dev_server_store, project_path_input, dev_server_name_input, - rename_dev_server_input, markdown, - use_server_name_in_ssh: Selection::Unselected, + workspace, _dev_server_subscription: subscription, } } @@ -254,137 +258,179 @@ impl DevServerProjects { })); } - pub fn create_dev_server(&mut self, cx: &mut ViewContext) { + pub fn create_or_update_dev_server( + &mut self, + manual_setup: bool, + existing_id: Option, + access_token: Option, + cx: &mut ViewContext, + ) { let name = get_text(&self.dev_server_name_input, cx); if name.is_empty() { return; } - let ssh_connection_string = if self.use_server_name_in_ssh == Selection::Selected { + let ssh_connection_string = if manual_setup { + None + } else if name.contains(' ') { Some(name.clone()) } else { - None + Some(format!("ssh {}", name)) }; - let dev_server = self.dev_server_store.update(cx, |store, cx| { - store.create_dev_server(name, ssh_connection_string, cx) + let dev_server = self.dev_server_store.update(cx, { + let access_token = access_token.clone(); + |store, cx| { + let ssh_connection_string = ssh_connection_string.clone(); + if let Some(dev_server_id) = existing_id { + let rename = store.rename_dev_server( + dev_server_id, + name.clone(), + ssh_connection_string, + cx, + ); + let token = if let Some(access_token) = access_token { + Task::ready(Ok(RegenerateDevServerTokenResponse { + dev_server_id: dev_server_id.0, + access_token, + })) + } else { + store.regenerate_dev_server_token(dev_server_id, cx) + }; + cx.spawn(|_, _| async move { + rename.await?; + let response = token.await?; + Ok(CreateDevServerResponse { + dev_server_id: dev_server_id.0, + name, + access_token: response.access_token, + }) + }) + } else { + store.create_dev_server(name, ssh_connection_string.clone(), cx) + } + } }); - cx.spawn(|this, mut cx| async move { + let workspace = self.workspace.clone(); + + cx.spawn({ + let access_token = access_token.clone(); + |this, mut cx| async move { let result = dev_server.await; - this.update(&mut cx, |this, cx| match &result { + match result { Ok(dev_server) => { - this.focus_handle.focus(cx); - this.mode = Mode::CreateDevServer(CreateDevServer { - creating: false, - dev_server: Some(dev_server.clone()), - }); - } - Err(_) => { - this.mode = Mode::CreateDevServer(Default::default()); - } - }) - .log_err(); - result - }) + if let Some(ssh_connection_string) = ssh_connection_string { + + let access_token = access_token.clone(); + this.update(&mut cx, |this, cx| { + this.focus_handle.focus(cx); + this.mode = Mode::CreateDevServer(CreateDevServer { + creating: true, + dev_server_id: Some(DevServerId(dev_server.dev_server_id)), + access_token: Some(access_token.unwrap_or(dev_server.access_token.clone())), + manual_setup: false, + }); + cx.notify(); + })?; + let terminal_panel = workspace + .update(&mut cx, |workspace, cx| workspace.panel::(cx)) + .ok() + .flatten() + .with_context(|| anyhow::anyhow!("No terminal panel"))?; + + let command = "sh".to_string(); + let args = vec!["-x".to_string(),"-c".to_string(), + format!(r#"~/.local/bin/zed -v >/dev/stderr || (curl -sSL https://zed.dev/install.sh || wget -qO- https://zed.dev/install.sh) | bash && ~/.local/bin/zed --dev-server-token {}"#, dev_server.access_token)]; + + let terminal = terminal_panel.update(&mut cx, |terminal_panel, cx| { + terminal_panel.spawn_in_new_terminal( + SpawnInTerminal { + id: task::TaskId("ssh-remote".into()), + full_label: "Install zed over ssh".into(), + label: "Install zed over ssh".into(), + command, + args, + command_label: ssh_connection_string.clone(), + cwd: Some(TerminalWorkDir::Ssh { ssh_command: ssh_connection_string, path: None }), + env: Default::default(), + use_new_terminal: true, + allow_concurrent_runs: false, + reveal: RevealStrategy::Always, + }, + cx, + ) + })?.await?; + + terminal.update(&mut cx, |terminal, cx| { + terminal.wait_for_completed_task(cx) + })?.await; + + // There's a race-condition between the task completing successfully, and the server sending us the online status. Make it less likely we'll show the error state. + if this.update(&mut cx, |this, cx| { + this.dev_server_store.read(cx).dev_server_status(DevServerId(dev_server.dev_server_id)) + })? == DevServerStatus::Offline { + cx.background_executor().timer(Duration::from_millis(200)).await + } + } + + this.update(&mut cx, |this, cx| { + this.focus_handle.focus(cx); + this.mode = Mode::CreateDevServer(CreateDevServer { + creating: false, + dev_server_id: Some(DevServerId(dev_server.dev_server_id)), + access_token: Some(dev_server.access_token), + manual_setup: false, + }); + cx.notify(); + })?; + Ok(()) + } + Err(e) => { + this.update(&mut cx, |this, cx| { + this.mode = Mode::CreateDevServer(CreateDevServer { creating:false, dev_server_id: existing_id, access_token: None, manual_setup }); + cx.notify() + }) + .log_err(); + + return Err(e) + } + } + }}) .detach_and_prompt_err("Failed to create server", cx, |_, _| None); self.mode = Mode::CreateDevServer(CreateDevServer { creating: true, - dev_server: None, + dev_server_id: existing_id, + access_token, + manual_setup, }); cx.notify() } - fn rename_dev_server(&mut self, id: DevServerId, cx: &mut ViewContext) { - let name = get_text(&self.rename_dev_server_input, cx); - - let Some(dev_server) = self.dev_server_store.read(cx).dev_server(id) else { - return; + fn delete_dev_server(&mut self, id: DevServerId, cx: &mut ViewContext) { + let store = self.dev_server_store.read(cx); + let prompt = if store.projects_for_server(id).is_empty() + && store + .dev_server(id) + .is_some_and(|server| server.status == DevServerStatus::Offline) + { + None + } else { + Some(cx.prompt( + gpui::PromptLevel::Warning, + "Are you sure?", + Some("This will delete the dev server and all of its remote projects."), + &["Delete", "Cancel"], + )) }; - if name.is_empty() || dev_server.name == name { - return; - } - - let request = self - .dev_server_store - .update(cx, |store, cx| store.rename_dev_server(id, name, cx)); - - self.mode = Mode::EditDevServer(EditDevServer { - dev_server_id: id, - state: EditDevServerState::RenamingDevServer, - }); - cx.spawn(|this, mut cx| async move { - request.await?; - this.update(&mut cx, move |this, cx| { - this.mode = Mode::EditDevServer(EditDevServer { - dev_server_id: id, - state: EditDevServerState::Default, - }); - cx.notify(); - }) - }) - .detach_and_prompt_err("Failed to rename dev server", cx, |_, _| None); - } - - fn refresh_dev_server_token(&mut self, id: DevServerId, cx: &mut ViewContext) { - let answer = cx.prompt( - gpui::PromptLevel::Warning, - "Are you sure?", - Some("This will invalidate the existing dev server token."), - &["Generate", "Cancel"], - ); - cx.spawn(|this, mut cx| async move { - let answer = answer.await?; - - if answer != 0 { - return Ok(()); - } - - let response = this - .update(&mut cx, move |this, cx| { - let request = this - .dev_server_store - .update(cx, |store, cx| store.regenerate_dev_server_token(id, cx)); - this.mode = Mode::EditDevServer(EditDevServer { - dev_server_id: id, - state: EditDevServerState::RegeneratingToken, - }); - cx.notify(); - request - })? - .await?; - - this.update(&mut cx, move |this, cx| { - this.mode = Mode::EditDevServer(EditDevServer { - dev_server_id: id, - state: EditDevServerState::RegeneratedToken(response), - }); - cx.notify(); - }) - .log_err(); - - Ok(()) - }) - .detach_and_prompt_err("Failed to delete dev server", cx, |_, _| None); - } - - fn delete_dev_server(&mut self, id: DevServerId, cx: &mut ViewContext) { - let answer = cx.prompt( - gpui::PromptLevel::Warning, - "Are you sure?", - Some("This will delete the dev server and all of its remote projects."), - &["Delete", "Cancel"], - ); - - cx.spawn(|this, mut cx| async move { - let answer = answer.await?; - - if answer != 0 { - return Ok(()); + if let Some(prompt) = prompt { + if prompt.await? != 0 { + return Ok(()); + } } let project_ids: Vec = this.update(&mut cx, |this, cx| { @@ -457,19 +503,13 @@ impl DevServerProjects { self.create_dev_server_project(create_project.dev_server_id, cx); } Mode::CreateDevServer(state) => { - if !state.creating && state.dev_server.is_none() { - self.create_dev_server(cx); - } - } - Mode::EditDevServer(edit_dev_server) => { - if self - .rename_dev_server_input - .read(cx) - .editor() - .read(cx) - .is_focused(cx) - { - self.rename_dev_server(edit_dev_server.dev_server_id, cx); + if !state.creating { + self.create_or_update_dev_server( + state.manual_setup, + state.dev_server_id, + state.access_token.clone(), + cx, + ); } } } @@ -495,6 +535,7 @@ impl DevServerProjects { let dev_server_id = dev_server.id; let status = dev_server.status; let dev_server_name = dev_server.name.clone(); + let manual_setup = dev_server.ssh_connection_string.is_none(); v_flex() .w_full() @@ -523,7 +564,13 @@ impl DevServerProjects { ) }), ) - .child(dev_server_name.clone()) + .child( + div() + .max_w(rems(26.)) + .overflow_hidden() + .whitespace_nowrap() + .child(Label::new(dev_server_name.clone())), + ) .child( h_flex() .visible_on_hover("dev-server") @@ -531,12 +578,14 @@ impl DevServerProjects { .child( IconButton::new("edit-dev-server", IconName::Pencil) .on_click(cx.listener(move |this, _, cx| { - this.mode = Mode::EditDevServer(EditDevServer { - dev_server_id, - state: EditDevServerState::Default, + this.mode = Mode::CreateDevServer(CreateDevServer { + dev_server_id: Some(dev_server_id), + creating: false, + access_token: None, + manual_setup, }); let dev_server_name = dev_server_name.clone(); - this.rename_dev_server_input.update( + this.dev_server_name_input.update( cx, move |input, cx| { input.editor().update(cx, move |editor, cx| { @@ -561,7 +610,7 @@ impl DevServerProjects { .child( v_flex() .w_full() - .bg(cx.theme().colors().title_bar_background) // todo: this should be distinct + .bg(cx.theme().colors().background) .border_1() .border_color(cx.theme().colors().border_variant) .rounded_md() @@ -672,129 +721,151 @@ impl DevServerProjects { ) -> impl IntoElement { let CreateDevServer { creating, - dev_server, - } = state; + dev_server_id, + access_token, + manual_setup, + } = state.clone(); - self.dev_server_name_input.update(cx, |input, cx| { - input.set_disabled(creating || dev_server.is_some(), cx); + let status = dev_server_id + .map(|id| self.dev_server_store.read(cx).dev_server_status(id)) + .unwrap_or_default(); + + let name = self.dev_server_name_input.update(cx, |input, cx| { + input.editor().update(cx, |editor, cx| { + if editor.text(cx).is_empty() { + if manual_setup { + editor.set_placeholder_text("example-server", cx) + } else { + editor.set_placeholder_text("ssh host", cx) + } + } + editor.text(cx) + }) }); - v_flex() - .id("scroll-container") - .h_full() - .overflow_y_scroll() - .track_scroll(&self.scroll_handle) - .px_1() - .pt_0p5() - .gap_px() - .child( - ModalHeader::new("create-dev-server") - .show_back_button(true) - .child(Headline::new("New dev server").size(HeadlineSize::Small)), + Modal::new("create-dev-server", Some(self.scroll_handle.clone())) + .header( + ModalHeader::new() + .headline("Create Dev Server") + .show_back_button(true), ) - .child( - ModalContent::new().child( - v_flex() - .w_full() - .child( - v_flex() - .pb_2() - .w_full() - .px_2() - .child( - div() - .pl_2() - .max_w(rems(16.)) - .child(self.dev_server_name_input.clone()), - ) - ) - .child( - h_flex() - .pb_2() - .items_end() - .w_full() - .px_2() - .border_b_1() - .border_color(cx.theme().colors().border) - .child( - div() - .pl_1() - .pb(px(3.)) - .when(!creating && dev_server.is_none(), |div| { - div - .child( - CheckboxWithLabel::new( - "use-server-name-in-ssh", - Label::new("Use name as ssh connection string"), - self.use_server_name_in_ssh, - cx.listener(move |this, &new_selection, _| { - this.use_server_name_in_ssh = new_selection; - }) - ) - ) - .child( - Button::new("create-dev-server", "Create").on_click( - cx.listener(move |this, _, cx| { - this.create_dev_server(cx); - }) - ) - ) - }) - .when(creating && dev_server.is_none(), |div| { - div - .child( - CheckboxWithLabel::new( - "use-server-name-in-ssh", - Label::new("Use SSH for terminals"), - self.use_server_name_in_ssh, - |&_, _| {} - ) - ) - .child( - Button::new("create-dev-server", "Creating...") - .disabled(true), - ) - }), - ) - ) - .when(dev_server.is_none(), |div| { - let server_name = get_text(&self.dev_server_name_input, cx); - let server_name_trimmed = server_name.trim(); - let ssh_host_name = if server_name_trimmed.is_empty() { - "user@host" - } else { - server_name_trimmed - }; - div.px_2().child(Label::new(format!( - "Once you have created a dev server, you will be given a command to run on the server to register it.\n\n\ - If you enable SSH, then the terminal will automatically `ssh {ssh_host_name}` on open." + .section( + Section::new() + .header(if manual_setup { "Server Name".into()} else { "SSH arguments".into()}) + .child( + div() + .max_w(rems(16.)) + .child(self.dev_server_name_input.clone()) + ), + ) + .section( + Section::new_contained() + .header("Connection Method".into()) + .child( + v_flex() + .w_full() + .gap_y(Spacing::Large.rems(cx)) + .child(v_flex().child(RadioWithLabel::new( + "use-server-name-in-ssh", + Label::new("Connect via SSH (default)"), + !manual_setup, + cx.listener({ + let state = state.clone(); + move |this, _, cx| { + this.mode = Mode::CreateDevServer(CreateDevServer { + manual_setup: false, + ..state.clone() + }); + cx.notify() + } + }), + )) + .child(RadioWithLabel::new( + "use-server-name-in-ssh", + Label::new("Manual Setup"), + manual_setup, + cx.listener({ + let state = state.clone(); + move |this, _, cx| { + this.mode = Mode::CreateDevServer(CreateDevServer { + manual_setup: true, + ..state.clone() + }); + cx.notify() + }}), ))) - }) - .when_some(dev_server.clone(), |div, dev_server| { - let status = self - .dev_server_store - .read(cx) - .dev_server_status(DevServerId(dev_server.dev_server_id)); - - div.child( - self.render_dev_server_token_instructions(&dev_server.access_token, &dev_server.name, status, cx) - ) - }), - ) + .when(dev_server_id.is_none(), |el| { + el.child( + if manual_setup { + Label::new( + "Click create to generate a token for this server. The next step will provide instructions for setting zed up on that machine." + ) + } else { + Label::new( + "Enter the command you use to ssh into this server.\n\ + For example: `ssh me@my.server` or `gh cs ssh -c example`." + ) + }.size(LabelSize::Small).color(Color::Muted)) + }) + .when(dev_server_id.is_some() && access_token.is_none(),|el|{ + el.child( + if manual_setup { + Label::new( + "Note: updating the dev server generate a new token" + ) + } else { + Label::new( + "Enter the command you use to ssh into this server.\n\ + For example: `ssh me@my.server` or `gh cs ssh -c example`." + ) + }.size(LabelSize::Small).color(Color::Muted) + ) + }) + .when_some(access_token.clone(), { + |el, access_token| { + el.child( + self.render_dev_server_token_creating(access_token, name, manual_setup, status, creating, cx) + ) + }})) ) + .footer(ModalFooter::new().end_slot( + if status == DevServerStatus::Online { + Button::new("create-dev-server", "Done") + .style(ButtonStyle::Filled) + .layer(ElevationIndex::ModalSurface) + .on_click(cx.listener(move |this, _, cx| { + cx.focus(&this.focus_handle); + this.mode = Mode::Default(None); + cx.notify(); + })) + } else { + Button::new("create-dev-server", if manual_setup { "Create"} else { "Connect"}) + .style(ButtonStyle::Filled) + .layer(ElevationIndex::ModalSurface) + .disabled(creating) + .on_click(cx.listener({ + let access_token = access_token.clone(); + move |this, _, cx| { + this.create_or_update_dev_server(manual_setup, dev_server_id, access_token.clone(), cx); + }})) + } + )) } - fn render_dev_server_token_instructions( + fn render_dev_server_token_creating( &self, - access_token: &str, - dev_server_name: &str, + access_token: String, + dev_server_name: String, + manual_setup: bool, status: DevServerStatus, + creating: bool, cx: &mut ViewContext, ) -> Div { - let instructions = SharedString::from(format!("zed --dev-server-token {}", access_token)); self.markdown.update(cx, |markdown, cx| { - if !markdown.source().contains(access_token) { - markdown.reset(format!("```\n{}\n```", instructions), cx); + if manual_setup { + markdown.reset(format!("Please log into '{}'. If you don't yet have zed installed, run:\n```\ncurl https://zed.dev/install.sh | bash\n```\nThen to start zed in headless mode:\n```\nzed --dev-server-token {}\n```", dev_server_name, access_token), cx); + } else { + markdown.reset("Please wait while we connect over SSH.\n\nIf you run into problems, please [file a bug](https://github.com/zed-industries/zed), and in the meantime try using manual setup.".to_string(), cx); } }); @@ -802,40 +873,20 @@ impl DevServerProjects { .pl_2() .pt_2() .gap_2() - .child( - h_flex() - .justify_between() - .w_full() - .child(Label::new(format!( - "Please log into `{}` and run:", - dev_server_name - ))) - .child( - Button::new("copy-access-token", "Copy Instructions") - .icon(Some(IconName::Copy)) - .icon_size(IconSize::Small) - .on_click({ - let instructions = instructions.clone(); - cx.listener(move |_, _, cx| { - cx.write_to_clipboard(ClipboardItem::new( - instructions.to_string(), - )) - }) - }), - ), - ) - .child(v_flex().w_full().child(self.markdown.clone())) - .when(status == DevServerStatus::Offline, |this| { - this.child(Self::render_loading_spinner("Waiting for connection…")) - }) - .when(status == DevServerStatus::Online, |this| { - this.child(Label::new("🎊 Connection established!")).child( - h_flex() - .justify_end() - .child(Button::new("done", "Done").on_click( - cx.listener(|_, _, cx| cx.dispatch_action(menu::Cancel.boxed_clone())), - )), - ) + .child(v_flex().w_full().text_sm().child(self.markdown.clone())) + .map(|el| { + if status == DevServerStatus::Offline && !manual_setup && !creating { + el.child( + h_flex() + .gap_2() + .child(Icon::new(IconName::Disconnected).size(IconSize::Medium)) + .child(Label::new("Not connected")), + ) + } else if status == DevServerStatus::Offline { + el.child(Self::render_loading_spinner("Waiting for connection…")) + } else { + el.child(Label::new("🎊 Connection established!")) + } }) } @@ -854,127 +905,6 @@ impl DevServerProjects { .child(Label::new(label)) } - fn render_edit_dev_server( - &mut self, - edit_dev_server: EditDevServer, - cx: &mut ViewContext, - ) -> impl IntoElement { - let dev_server_id = edit_dev_server.dev_server_id; - let dev_server = self - .dev_server_store - .read(cx) - .dev_server(dev_server_id) - .cloned(); - - let dev_server_name = dev_server - .as_ref() - .map(|dev_server| dev_server.name.clone()) - .unwrap_or_default(); - - let dev_server_status = dev_server - .map(|dev_server| dev_server.status) - .unwrap_or(DevServerStatus::Offline); - - let disabled = matches!( - edit_dev_server.state, - EditDevServerState::RenamingDevServer | EditDevServerState::RegeneratingToken - ); - self.rename_dev_server_input.update(cx, |input, cx| { - input.set_disabled(disabled, cx); - }); - - let rename_dev_server_input_text = self - .rename_dev_server_input - .read(cx) - .editor() - .read(cx) - .text(cx); - - let content = v_flex().w_full().gap_2().child( - h_flex() - .pb_2() - .border_b_1() - .border_color(cx.theme().colors().border) - .items_end() - .w_full() - .px_2() - .child( - div() - .pl_2() - .max_w(rems(16.)) - .child(self.rename_dev_server_input.clone()), - ) - .child( - div() - .pl_1() - .pb(px(3.)) - .when( - edit_dev_server.state != EditDevServerState::RenamingDevServer, - |div| { - div.child( - Button::new("rename-dev-server", "Rename") - .disabled( - rename_dev_server_input_text.trim().is_empty() - || rename_dev_server_input_text == dev_server_name, - ) - .on_click(cx.listener(move |this, _, cx| { - this.rename_dev_server(dev_server_id, cx); - cx.notify(); - })), - ) - }, - ) - .when( - edit_dev_server.state == EditDevServerState::RenamingDevServer, - |div| { - div.child( - Button::new("rename-dev-server", "Renaming...").disabled(true), - ) - }, - ), - ), - ); - - let content = content.child(match edit_dev_server.state { - EditDevServerState::RegeneratingToken => { - Self::render_loading_spinner("Generating token...") - } - EditDevServerState::RegeneratedToken(response) => self - .render_dev_server_token_instructions( - &response.access_token, - &dev_server_name, - dev_server_status, - cx, - ), - _ => h_flex().items_end().w_full().child( - Button::new("regenerate-dev-server-token", "Generate new access token") - .icon(IconName::Update) - .on_click(cx.listener(move |this, _, cx| { - this.refresh_dev_server_token(dev_server_id, cx); - cx.notify(); - })), - ), - }); - - v_flex() - .id("scroll-container") - .h_full() - .overflow_y_scroll() - .track_scroll(&self.scroll_handle) - .px_1() - .pt_0p5() - .gap_px() - .child( - ModalHeader::new("edit-dev-server") - .show_back_button(true) - .child( - Headline::new(format!("Edit {}", &dev_server_name)) - .size(HeadlineSize::Small), - ), - ) - .child(ModalContent::new().child(v_flex().w_full().child(content))) - } - fn render_default(&mut self, cx: &mut ViewContext) -> impl IntoElement { let dev_servers = self.dev_server_store.read(cx).dev_servers(); @@ -994,51 +924,50 @@ impl DevServerProjects { creating_dev_server = Some(*dev_server_id); }; - v_flex() - .id("scroll-container") - .h_full() - .overflow_y_scroll() - .track_scroll(&self.scroll_handle) - .px_1() - .pt_0p5() - .gap_px() - .child( - ModalHeader::new("remote-projects") + Modal::new("remote-projects", Some(self.scroll_handle.clone())) + .header( + ModalHeader::new() .show_dismiss_button(true) .child(Headline::new("Remote Projects").size(HeadlineSize::Small)), ) - .child( - ModalContent::new().child( - List::new() - .empty_message("No dev servers registered.") - .header(Some( - ListHeader::new("Dev Servers").end_slot( - Button::new("register-dev-server-button", "New Server") - .icon(IconName::Plus) - .icon_position(IconPosition::Start) - .tooltip(|cx| Tooltip::text("Register a new dev server", cx)) - .on_click(cx.listener(|this, _, cx| { - this.mode = - Mode::CreateDevServer(CreateDevServer::default()); - this.dev_server_name_input.update(cx, |text_field, cx| { - text_field.editor().update(cx, |editor, cx| { - editor.set_text("", cx); - }); - }); - this.use_server_name_in_ssh = Selection::Unselected; - cx.notify(); - })), - ), - )) - .children(dev_servers.iter().map(|dev_server| { - let creating = if creating_dev_server == Some(dev_server.id) { - is_creating - } else { - None - }; - self.render_dev_server(dev_server, creating, cx) - .into_any_element() - })), + .section( + Section::new().child( + div().mb_4().child( + List::new() + .empty_message("No dev servers registered.") + .header(Some( + ListHeader::new("Dev Servers").end_slot( + Button::new("register-dev-server-button", "New Server") + .icon(IconName::Plus) + .icon_position(IconPosition::Start) + .tooltip(|cx| { + Tooltip::text("Register a new dev server", cx) + }) + .on_click(cx.listener(|this, _, cx| { + this.mode = + Mode::CreateDevServer(CreateDevServer::default()); + this.dev_server_name_input.update( + cx, + |text_field, cx| { + text_field.editor().update(cx, |editor, cx| { + editor.set_text("", cx); + }); + }, + ); + cx.notify(); + })), + ), + )) + .children(dev_servers.iter().map(|dev_server| { + let creating = if creating_dev_server == Some(dev_server.id) { + is_creating + } else { + None + }; + self.render_dev_server(dev_server, creating, cx) + .into_any_element() + })), + ), ), ) } @@ -1072,6 +1001,9 @@ impl Render for DevServerProjects { .key_context("DevServerModal") .on_action(cx.listener(Self::cancel)) .on_action(cx.listener(Self::confirm)) + .capture_any_mouse_down(cx.listener(|this, _, cx| { + this.focus_handle(cx).focus(cx); + })) .on_mouse_down_out(cx.listener(|this, _, cx| { if matches!(this.mode, Mode::Default(None)) { cx.emit(DismissEvent) @@ -1080,18 +1012,13 @@ impl Render for DevServerProjects { cx.stop_propagation() } })) - .pb_4() .w(rems(34.)) - .min_h(rems(20.)) .max_h(rems(40.)) .child(match &self.mode { Mode::Default(_) => self.render_default(cx).into_any_element(), Mode::CreateDevServer(state) => self .render_create_dev_server(state.clone(), cx) .into_any_element(), - Mode::EditDevServer(state) => self - .render_edit_dev_server(state.clone(), cx) - .into_any_element(), }) } } diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index 7b57e72cb7..edec370216 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/crates/recent_projects/src/recent_projects.rs @@ -328,7 +328,8 @@ impl PickerDelegate for RecentProjectsDelegate { ).await?; if response == 1 { workspace.update(&mut cx, |workspace, cx| { - workspace.toggle_modal(cx, |cx| DevServerProjects::new(cx)) + let handle = cx.view().downgrade(); + workspace.toggle_modal(cx, |cx| DevServerProjects::new(cx, handle)) })?; } else { workspace.update(&mut cx, |workspace, cx| { diff --git a/crates/rpc/proto/zed.proto b/crates/rpc/proto/zed.proto index c2b7685eab..1ddf43741d 100644 --- a/crates/rpc/proto/zed.proto +++ b/crates/rpc/proto/zed.proto @@ -515,6 +515,7 @@ message ShutdownDevServer { message RenameDevServer { uint64 dev_server_id = 1; string name = 2; + optional string ssh_connection_string = 3; } message DeleteDevServer { diff --git a/crates/task/src/lib.rs b/crates/task/src/lib.rs index b31b8db5df..36b67c30be 100644 --- a/crates/task/src/lib.rs +++ b/crates/task/src/lib.rs @@ -19,6 +19,38 @@ pub use vscode_format::VsCodeTaskFile; #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct TaskId(pub String); +/// TerminalWorkDir describes where a task should be run +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TerminalWorkDir { + /// Local is on this machine + Local(PathBuf), + /// SSH runs the terminal over ssh + Ssh { + /// The command to run to connect + ssh_command: String, + /// The path on the remote server + path: Option, + }, +} + +impl TerminalWorkDir { + /// is_local + pub fn is_local(&self) -> bool { + match self { + TerminalWorkDir::Local(_) => true, + TerminalWorkDir::Ssh { .. } => false, + } + } + + /// local_path + pub fn local_path(&self) -> Option { + match self { + TerminalWorkDir::Local(path) => Some(path.clone()), + TerminalWorkDir::Ssh { .. } => None, + } + } +} + /// Contains all information needed by Zed to spawn a new terminal tab for the given task. #[derive(Debug, Clone, PartialEq, Eq)] pub struct SpawnInTerminal { @@ -36,7 +68,7 @@ pub struct SpawnInTerminal { /// A human-readable label, containing command and all of its arguments, joined and substituted. pub command_label: String, /// Current working directory to spawn the command into. - pub cwd: Option, + pub cwd: Option, /// Env overrides for the command, will be appended to the terminal's environment from the settings. pub env: HashMap, /// Whether to use a new terminal tab or reuse the existing one to spawn the process. diff --git a/crates/task/src/task_template.rs b/crates/task/src/task_template.rs index efd9556366..a10cc47fa8 100644 --- a/crates/task/src/task_template.rs +++ b/crates/task/src/task_template.rs @@ -8,7 +8,8 @@ use sha2::{Digest, Sha256}; use util::{truncate_and_remove_front, ResultExt}; use crate::{ - ResolvedTask, SpawnInTerminal, TaskContext, TaskId, VariableName, ZED_VARIABLE_NAME_PREFIX, + ResolvedTask, SpawnInTerminal, TaskContext, TaskId, TerminalWorkDir, VariableName, + ZED_VARIABLE_NAME_PREFIX, }; /// A template definition of a Zed task to run. @@ -112,12 +113,14 @@ impl TaskTemplate { &variable_names, &mut substituted_variables, )?; - Some(substitured_cwd) + Some(TerminalWorkDir::Local(PathBuf::from(substitured_cwd))) } None => None, } - .map(PathBuf::from) - .or(cx.cwd.clone()); + .or(cx + .cwd + .as_ref() + .map(|cwd| TerminalWorkDir::Local(cwd.clone()))); let human_readable_label = substitute_all_template_variables_in_str( &self.label, &truncated_variables, @@ -379,8 +382,10 @@ mod tests { task_variables: TaskVariables::default(), }; assert_eq!( - resolved_task(&task_without_cwd, &cx).cwd.as_deref(), - Some(context_cwd.as_path()), + resolved_task(&task_without_cwd, &cx) + .cwd + .and_then(|cwd| cwd.local_path()), + Some(context_cwd.clone()), "TaskContext's cwd should be taken on resolve if task's cwd is None" ); @@ -394,8 +399,10 @@ mod tests { task_variables: TaskVariables::default(), }; assert_eq!( - resolved_task(&task_with_cwd, &cx).cwd.as_deref(), - Some(task_cwd.as_path()), + resolved_task(&task_with_cwd, &cx) + .cwd + .and_then(|cwd| cwd.local_path()), + Some(task_cwd.clone()), "TaskTemplate's cwd should be taken on resolve if TaskContext's cwd is None" ); @@ -404,8 +411,10 @@ mod tests { task_variables: TaskVariables::default(), }; assert_eq!( - resolved_task(&task_with_cwd, &cx).cwd.as_deref(), - Some(task_cwd.as_path()), + resolved_task(&task_with_cwd, &cx) + .cwd + .and_then(|cwd| cwd.local_path()), + Some(task_cwd.clone()), "TaskTemplate's cwd should be taken on resolve if TaskContext's cwd is not None" ); } diff --git a/crates/terminal_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs index 9d39d4e061..1084d54ab4 100644 --- a/crates/terminal_view/src/terminal_panel.rs +++ b/crates/terminal_view/src/terminal_panel.rs @@ -6,16 +6,19 @@ use db::kvp::KEY_VALUE_STORE; use futures::future::join_all; use gpui::{ actions, Action, AppContext, AsyncWindowContext, DismissEvent, Entity, EventEmitter, - ExternalPaths, FocusHandle, FocusableView, IntoElement, ParentElement, Pixels, Render, Styled, - Subscription, Task, View, ViewContext, VisualContext, WeakView, WindowContext, + ExternalPaths, FocusHandle, FocusableView, IntoElement, Model, ParentElement, Pixels, Render, + Styled, Subscription, Task, View, ViewContext, VisualContext, WeakView, WindowContext, }; use itertools::Itertools; use project::{Fs, ProjectEntryId}; use search::{buffer_search::DivRegistrar, BufferSearchBar}; use serde::{Deserialize, Serialize}; use settings::Settings; -use task::{RevealStrategy, SpawnInTerminal, TaskId}; -use terminal::terminal_settings::{Shell, TerminalDockPosition, TerminalSettings}; +use task::{RevealStrategy, SpawnInTerminal, TaskId, TerminalWorkDir}; +use terminal::{ + terminal_settings::{Shell, TerminalDockPosition, TerminalSettings}, + Terminal, +}; use ui::{ h_flex, ButtonCommon, Clickable, ContextMenu, FluentBuilder, IconButton, IconSize, Selectable, Tooltip, @@ -319,14 +322,16 @@ impl TerminalPanel { return; }; - terminal_panel.update(cx, |panel, cx| { - panel.add_terminal( - Some(action.working_directory.clone()), - None, - RevealStrategy::Always, - cx, - ) - }); + let terminal_work_dir = workspace + .project() + .read(cx) + .terminal_work_dir_for(Some(&action.working_directory), cx); + + terminal_panel + .update(cx, |panel, cx| { + panel.add_terminal(terminal_work_dir, None, RevealStrategy::Always, cx) + }) + .detach_and_log_err(cx); } fn spawn_task(&mut self, spawn_in_terminal: &SpawnInTerminal, cx: &mut ViewContext) { @@ -355,18 +360,19 @@ impl TerminalPanel { let spawn_task = spawn_task; let reveal = spawn_task.reveal; - let working_directory = spawn_in_terminal.cwd.clone(); let allow_concurrent_runs = spawn_in_terminal.allow_concurrent_runs; let use_new_terminal = spawn_in_terminal.use_new_terminal; if allow_concurrent_runs && use_new_terminal { - self.spawn_in_new_terminal(spawn_task, working_directory, cx); + self.spawn_in_new_terminal(spawn_task, cx) + .detach_and_log_err(cx); return; } let terminals_for_task = self.terminals_for_task(&spawn_in_terminal.full_label, cx); if terminals_for_task.is_empty() { - self.spawn_in_new_terminal(spawn_task, working_directory, cx); + self.spawn_in_new_terminal(spawn_task, cx) + .detach_and_log_err(cx); return; } let (existing_item_index, existing_terminal) = terminals_for_task @@ -378,13 +384,7 @@ impl TerminalPanel { !use_new_terminal, "Should have handled 'allow_concurrent_runs && use_new_terminal' case above" ); - self.replace_terminal( - working_directory, - spawn_task, - existing_item_index, - existing_terminal, - cx, - ); + self.replace_terminal(spawn_task, existing_item_index, existing_terminal, cx); } else { self.deferred_tasks.insert( spawn_in_terminal.id.clone(), @@ -393,14 +393,11 @@ impl TerminalPanel { terminal_panel .update(&mut cx, |terminal_panel, cx| { if use_new_terminal { - terminal_panel.spawn_in_new_terminal( - spawn_task, - working_directory, - cx, - ); + terminal_panel + .spawn_in_new_terminal(spawn_task, cx) + .detach_and_log_err(cx); } else { terminal_panel.replace_terminal( - working_directory, spawn_task, existing_item_index, existing_terminal, @@ -428,14 +425,13 @@ impl TerminalPanel { } } - fn spawn_in_new_terminal( + pub fn spawn_in_new_terminal( &mut self, spawn_task: SpawnInTerminal, - working_directory: Option, cx: &mut ViewContext, - ) { + ) -> Task>> { let reveal = spawn_task.reveal; - self.add_terminal(working_directory, Some(spawn_task), reveal, cx); + self.add_terminal(spawn_task.cwd.clone(), Some(spawn_task), reveal, cx) } /// Create a new Terminal in the current working directory or the user's home directory @@ -448,9 +444,11 @@ impl TerminalPanel { return; }; - terminal_panel.update(cx, |this, cx| { - this.add_terminal(None, None, RevealStrategy::Always, cx) - }); + terminal_panel + .update(cx, |this, cx| { + this.add_terminal(None, None, RevealStrategy::Always, cx) + }) + .detach_and_log_err(cx); } fn terminals_for_task( @@ -482,17 +480,17 @@ impl TerminalPanel { fn add_terminal( &mut self, - working_directory: Option, + working_directory: Option, spawn_task: Option, reveal_strategy: RevealStrategy, cx: &mut ViewContext, - ) { + ) -> Task>> { let workspace = self.workspace.clone(); self.pending_terminals_to_add += 1; cx.spawn(|terminal_panel, mut cx| async move { let pane = terminal_panel.update(&mut cx, |this, _| this.pane.clone())?; - workspace.update(&mut cx, |workspace, cx| { + let result = workspace.update(&mut cx, |workspace, cx| { let working_directory = if let Some(working_directory) = working_directory { Some(working_directory) } else { @@ -502,35 +500,33 @@ impl TerminalPanel { }; let window = cx.window_handle(); - if let Some(terminal) = workspace.project().update(cx, |project, cx| { - project - .create_terminal(working_directory, spawn_task, window, cx) - .log_err() - }) { - let terminal = Box::new(cx.new_view(|cx| { - TerminalView::new( - terminal, - workspace.weak_handle(), - workspace.database_id(), - cx, - ) - })); - pane.update(cx, |pane, cx| { - let focus = pane.has_focus(cx); - pane.add_item(terminal, true, focus, None, cx); - }); - } + let terminal = workspace.project().update(cx, |project, cx| { + project.create_terminal(working_directory, spawn_task, window, cx) + })?; + let terminal_view = Box::new(cx.new_view(|cx| { + TerminalView::new( + terminal.clone(), + workspace.weak_handle(), + workspace.database_id(), + cx, + ) + })); + pane.update(cx, |pane, cx| { + let focus = pane.has_focus(cx); + pane.add_item(terminal_view, true, focus, None, cx); + }); + if reveal_strategy == RevealStrategy::Always { workspace.focus_panel::(cx); } + Ok(terminal) })?; terminal_panel.update(&mut cx, |this, cx| { this.pending_terminals_to_add = this.pending_terminals_to_add.saturating_sub(1); this.serialize(cx) })?; - anyhow::Ok(()) + result }) - .detach_and_log_err(cx); } fn serialize(&mut self, cx: &mut ViewContext) { @@ -579,7 +575,6 @@ impl TerminalPanel { fn replace_terminal( &self, - working_directory: Option, spawn_task: SpawnInTerminal, terminal_item_index: usize, terminal_to_replace: View, @@ -594,7 +589,7 @@ impl TerminalPanel { let window = cx.window_handle(); let new_terminal = project.update(cx, |project, cx| { project - .create_terminal(working_directory, Some(spawn_task), window, cx) + .create_terminal(spawn_task.cwd.clone(), Some(spawn_task), window, cx) .log_err() })?; terminal_to_replace.update(cx, |terminal_to_replace, cx| { @@ -738,7 +733,8 @@ impl Panel for TerminalPanel { fn set_active(&mut self, active: bool, cx: &mut ViewContext) { if active && self.has_no_terminals(cx) { - self.add_terminal(None, None, RevealStrategy::Never, cx); + self.add_terminal(None, None, RevealStrategy::Never, cx) + .detach_and_log_err(cx) } } diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index e273a69b21..6974fe9eb9 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -14,6 +14,7 @@ use language::Bias; use persistence::TERMINAL_DB; use project::{search::SearchQuery, Fs, LocalWorktree, Metadata, Project}; use settings::SettingsStore; +use task::TerminalWorkDir; use terminal::{ alacritty_terminal::{ index::Point, @@ -878,21 +879,26 @@ impl Item for TerminalView { ) -> Task>> { let window = cx.window_handle(); cx.spawn(|pane, mut cx| async move { - let cwd = TERMINAL_DB - .get_working_directory(item_id, workspace_id) - .log_err() - .flatten() - .or_else(|| { - cx.update(|cx| { + let cwd = cx + .update(|cx| { + let from_db = TERMINAL_DB + .get_working_directory(item_id, workspace_id) + .log_err() + .flatten(); + if from_db + .as_ref() + .is_some_and(|from_db| !from_db.as_os_str().is_empty()) + { + project.read(cx).terminal_work_dir_for(from_db.as_ref(), cx) + } else { let strategy = TerminalSettings::get_global(cx).working_directory.clone(); workspace.upgrade().and_then(|workspace| { get_working_directory(workspace.read(cx), cx, strategy) }) - }) - .ok() - .flatten() + } }) - .filter(|cwd| !cwd.as_os_str().is_empty()); + .ok() + .flatten(); let terminal = project.update(&mut cx, |project, cx| { project.create_terminal(cwd, None, window, cx) @@ -1043,20 +1049,24 @@ pub fn get_working_directory( workspace: &Workspace, cx: &AppContext, strategy: WorkingDirectory, -) -> Option { - let res = match strategy { - WorkingDirectory::CurrentProjectDirectory => current_project_directory(workspace, cx) - .or_else(|| first_project_directory(workspace, cx)), - WorkingDirectory::FirstProjectDirectory => first_project_directory(workspace, cx), - WorkingDirectory::AlwaysHome => None, - WorkingDirectory::Always { directory } => { - shellexpand::full(&directory) //TODO handle this better - .ok() - .map(|dir| Path::new(&dir.to_string()).to_path_buf()) - .filter(|dir| dir.is_dir()) - } - }; - res.or_else(home_dir) +) -> Option { + if workspace.project().read(cx).is_local() { + let res = match strategy { + WorkingDirectory::CurrentProjectDirectory => current_project_directory(workspace, cx) + .or_else(|| first_project_directory(workspace, cx)), + WorkingDirectory::FirstProjectDirectory => first_project_directory(workspace, cx), + WorkingDirectory::AlwaysHome => None, + WorkingDirectory::Always { directory } => { + shellexpand::full(&directory) //TODO handle this better + .ok() + .map(|dir| Path::new(&dir.to_string()).to_path_buf()) + .filter(|dir| dir.is_dir()) + } + }; + res.or_else(home_dir).map(|cwd| TerminalWorkDir::Local(cwd)) + } else { + workspace.project().read(cx).terminal_work_dir_for(None, cx) + } } ///Gets the first project's home directory, or the home directory diff --git a/crates/ui/src/components.rs b/crates/ui/src/components.rs index 5a2b1e9cf1..3c564fc1eb 100644 --- a/crates/ui/src/components.rs +++ b/crates/ui/src/components.rs @@ -13,6 +13,7 @@ mod list; mod modal; mod popover; mod popover_menu; +mod radio; mod right_click_menu; mod stack; mod tab; @@ -39,6 +40,7 @@ pub use list::*; pub use modal::*; pub use popover::*; pub use popover_menu::*; +pub use radio::*; pub use right_click_menu::*; pub use stack::*; pub use tab::*; diff --git a/crates/ui/src/components/button/button.rs b/crates/ui/src/components/button/button.rs index cc1766dc0c..58d3264a4f 100644 --- a/crates/ui/src/components/button/button.rs +++ b/crates/ui/src/components/button/button.rs @@ -1,6 +1,6 @@ use gpui::{AnyView, DefiniteLength}; -use crate::{prelude::*, IconPosition, KeyBinding, Spacing}; +use crate::{prelude::*, ElevationIndex, IconPosition, KeyBinding, Spacing}; use crate::{ ButtonCommon, ButtonLike, ButtonSize, ButtonStyle, IconName, IconSize, Label, LineHeightStyle, }; @@ -340,6 +340,11 @@ impl ButtonCommon for Button { self.base = self.base.tooltip(tooltip); self } + + fn layer(mut self, elevation: ElevationIndex) -> Self { + self.base = self.base.layer(elevation); + self + } } impl RenderOnce for Button { diff --git a/crates/ui/src/components/button/button_like.rs b/crates/ui/src/components/button/button_like.rs index 3d79095614..d9ffc29e1f 100644 --- a/crates/ui/src/components/button/button_like.rs +++ b/crates/ui/src/components/button/button_like.rs @@ -2,7 +2,7 @@ use gpui::{relative, DefiniteLength, MouseButton}; use gpui::{transparent_black, AnyElement, AnyView, ClickEvent, Hsla, Rems}; use smallvec::SmallVec; -use crate::{prelude::*, Spacing}; +use crate::{prelude::*, Elevation, ElevationIndex, Spacing}; /// A trait for buttons that can be Selected. Enables setting the [`ButtonStyle`] of a button when it is selected. pub trait SelectableButton: Selectable { @@ -33,6 +33,8 @@ pub trait ButtonCommon: Clickable + Disableable { /// Nearly all interactable elements should have a tooltip. Some example /// exceptions might a scroll bar, or a slider. fn tooltip(self, tooltip: impl Fn(&mut WindowContext) -> AnyView + 'static) -> Self; + + fn layer(self, elevation: ElevationIndex) -> Self; } #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, Default)] @@ -135,11 +137,35 @@ pub(crate) struct ButtonLikeStyles { pub icon_color: Hsla, } +fn element_bg_from_elevation(elevation: Option, cx: &mut WindowContext) -> Hsla { + match elevation { + Some(Elevation::ElevationIndex(ElevationIndex::Background)) => { + cx.theme().colors().element_background + } + Some(Elevation::ElevationIndex(ElevationIndex::ElevatedSurface)) => { + cx.theme().colors().surface_background + } + Some(Elevation::ElevationIndex(ElevationIndex::Surface)) => { + cx.theme().colors().elevated_surface_background + } + Some(Elevation::ElevationIndex(ElevationIndex::ModalSurface)) => { + cx.theme().colors().background + } + _ => cx.theme().colors().element_background, + } +} + impl ButtonStyle { - pub(crate) fn enabled(self, cx: &mut WindowContext) -> ButtonLikeStyles { + pub(crate) fn enabled( + self, + elevation: Option, + cx: &mut WindowContext, + ) -> ButtonLikeStyles { + let filled_background = element_bg_from_elevation(elevation, cx); + match self { ButtonStyle::Filled => ButtonLikeStyles { - background: cx.theme().colors().element_background, + background: filled_background, border_color: transparent_black(), label_color: Color::Default.color(cx), icon_color: Color::Default.color(cx), @@ -160,10 +186,17 @@ impl ButtonStyle { } } - pub(crate) fn hovered(self, cx: &mut WindowContext) -> ButtonLikeStyles { + pub(crate) fn hovered( + self, + elevation: Option, + cx: &mut WindowContext, + ) -> ButtonLikeStyles { + let mut filled_background = element_bg_from_elevation(elevation, cx); + filled_background.fade_out(0.92); + match self { ButtonStyle::Filled => ButtonLikeStyles { - background: cx.theme().colors().element_hover, + background: filled_background, border_color: transparent_black(), label_color: Color::Default.color(cx), icon_color: Color::Default.color(cx), @@ -238,7 +271,13 @@ impl ButtonStyle { } #[allow(unused)] - pub(crate) fn disabled(self, cx: &mut WindowContext) -> ButtonLikeStyles { + pub(crate) fn disabled( + self, + elevation: Option, + cx: &mut WindowContext, + ) -> ButtonLikeStyles { + let filled_background = element_bg_from_elevation(elevation, cx).fade_out(0.82); + match self { ButtonStyle::Filled => ButtonLikeStyles { background: cx.theme().colors().element_disabled, @@ -301,6 +340,7 @@ pub struct ButtonLike { pub(super) selected_style: Option, pub(super) width: Option, pub(super) height: Option, + pub(super) layer: Option, size: ButtonSize, rounding: Option, tooltip: Option AnyView>>, @@ -324,6 +364,7 @@ impl ButtonLike { tooltip: None, children: SmallVec::new(), on_click: None, + layer: None, } } @@ -397,6 +438,11 @@ impl ButtonCommon for ButtonLike { self.tooltip = Some(Box::new(tooltip)); self } + + fn layer(mut self, elevation: ElevationIndex) -> Self { + self.layer = Some(elevation.into()); + self + } } impl VisibleOnHover for ButtonLike { @@ -437,11 +483,11 @@ impl RenderOnce for ButtonLike { ButtonSize::Default | ButtonSize::Compact => this.px(Spacing::Small.rems(cx)), ButtonSize::None => this, }) - .bg(style.enabled(cx).background) + .bg(style.enabled(self.layer, cx).background) .when(self.disabled, |this| this.cursor_not_allowed()) .when(!self.disabled, |this| { this.cursor_pointer() - .hover(|hover| hover.bg(style.hovered(cx).background)) + .hover(|hover| hover.bg(style.hovered(self.layer, cx).background)) .active(|active| active.bg(style.active(cx).background)) }) .when_some( diff --git a/crates/ui/src/components/button/icon_button.rs b/crates/ui/src/components/button/icon_button.rs index e7872cfe03..3973ebe8c6 100644 --- a/crates/ui/src/components/button/icon_button.rs +++ b/crates/ui/src/components/button/icon_button.rs @@ -1,6 +1,6 @@ use gpui::{AnyView, DefiniteLength}; -use crate::{prelude::*, SelectableButton, Spacing}; +use crate::{prelude::*, ElevationIndex, SelectableButton, Spacing}; use crate::{ButtonCommon, ButtonLike, ButtonSize, ButtonStyle, IconName, IconSize}; use super::button_icon::ButtonIcon; @@ -119,6 +119,11 @@ impl ButtonCommon for IconButton { self.base = self.base.tooltip(tooltip); self } + + fn layer(mut self, elevation: ElevationIndex) -> Self { + self.base = self.base.layer(elevation); + self + } } impl VisibleOnHover for IconButton { diff --git a/crates/ui/src/components/button/toggle_button.rs b/crates/ui/src/components/button/toggle_button.rs index e458c636ec..c74ffd6655 100644 --- a/crates/ui/src/components/button/toggle_button.rs +++ b/crates/ui/src/components/button/toggle_button.rs @@ -1,6 +1,6 @@ use gpui::{AnyView, ClickEvent}; -use crate::{prelude::*, ButtonLike, ButtonLikeRounding}; +use crate::{prelude::*, ButtonLike, ButtonLikeRounding, ElevationIndex}; /// The position of a [`ToggleButton`] within a group of buttons. #[derive(Debug, PartialEq, Eq, Clone, Copy)] @@ -103,6 +103,11 @@ impl ButtonCommon for ToggleButton { self.base = self.base.tooltip(tooltip); self } + + fn layer(mut self, elevation: ElevationIndex) -> Self { + self.base = self.base.layer(elevation); + self + } } impl RenderOnce for ToggleButton { diff --git a/crates/ui/src/components/modal.rs b/crates/ui/src/components/modal.rs index 55ba661055..fd135967e3 100644 --- a/crates/ui/src/components/modal.rs +++ b/crates/ui/src/components/modal.rs @@ -1,29 +1,121 @@ +use crate::{ + h_flex, rems_from_px, v_flex, Clickable, Color, Headline, HeadlineSize, IconButton, + IconButtonShape, IconName, Label, LabelCommon, LabelSize, Spacing, +}; use gpui::{prelude::FluentBuilder, *}; use smallvec::SmallVec; +use theme::ActiveTheme; -use crate::{ - h_flex, Clickable, IconButton, IconButtonShape, IconName, Label, LabelCommon, LabelSize, - Spacing, -}; +#[derive(IntoElement)] +pub struct Modal { + id: ElementId, + header: ModalHeader, + children: SmallVec<[AnyElement; 2]>, + footer: Option, + container_id: ElementId, + container_scroll_handler: Option, +} + +impl Modal { + pub fn new(id: impl Into, scroll_handle: Option) -> Self { + let id = id.into(); + + let container_id = ElementId::Name(format!("{}_container", id.clone()).into()); + Self { + id: ElementId::Name(id), + header: ModalHeader::new(), + children: SmallVec::new(), + footer: None, + container_id, + container_scroll_handler: scroll_handle, + } + } + + pub fn header(mut self, header: ModalHeader) -> Self { + self.header = header; + self + } + + pub fn section(mut self, section: Section) -> Self { + self.children.push(section.into_any_element()); + self + } + + pub fn footer(mut self, footer: ModalFooter) -> Self { + self.footer = Some(footer); + self + } + + pub fn show_dismiss(mut self, show: bool) -> Self { + self.header.show_dismiss_button = show; + self + } + + pub fn show_back(mut self, show: bool) -> Self { + self.header.show_back_button = show; + self + } +} + +impl ParentElement for Modal { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements) + } +} + +impl RenderOnce for Modal { + fn render(self, cx: &mut WindowContext) -> impl IntoElement { + v_flex() + .id(self.id.clone()) + .size_full() + .flex_1() + .overflow_hidden() + .child(self.header) + .child( + v_flex() + .id(self.container_id.clone()) + .w_full() + .gap(Spacing::Large.rems(cx)) + .when_some( + self.container_scroll_handler, + |this, container_scroll_handle| { + this.overflow_y_scroll() + .track_scroll(&container_scroll_handle) + }, + ) + .children(self.children), + ) + .children(self.footer) + } +} #[derive(IntoElement)] pub struct ModalHeader { - id: ElementId, + headline: Option, children: SmallVec<[AnyElement; 2]>, show_dismiss_button: bool, show_back_button: bool, } impl ModalHeader { - pub fn new(id: impl Into) -> Self { + pub fn new() -> Self { Self { - id: id.into(), + headline: None, children: SmallVec::new(), show_dismiss_button: false, show_back_button: false, } } + /// Set the headline of the modal. + /// + /// This will insert the headline as the first item + /// of `children` if it is not already present. + pub fn headline(mut self, headline: impl Into) -> Self { + self.headline = Some(headline.into()); + self + } + pub fn show_dismiss_button(mut self, show: bool) -> Self { self.show_dismiss_button = show; self @@ -43,24 +135,36 @@ impl ParentElement for ModalHeader { impl RenderOnce for ModalHeader { fn render(self, cx: &mut WindowContext) -> impl IntoElement { + let mut children = self.children; + + if self.headline.is_some() { + children.insert( + 0, + Headline::new(self.headline.unwrap()) + .size(HeadlineSize::XSmall) + .color(Color::Muted) + .into_any_element(), + ); + } + h_flex() - .id(self.id) + .flex_none() + .justify_between() .w_full() - .px(Spacing::Large.rems(cx)) - .py_1p5() + .px(Spacing::XLarge.rems(cx)) + .pt(Spacing::Large.rems(cx)) + .pb(Spacing::Small.rems(cx)) + .gap(Spacing::Large.rems(cx)) .when(self.show_back_button, |this| { this.child( - div().pr_1().child( - IconButton::new("back", IconName::ArrowLeft) - .shape(IconButtonShape::Square) - .on_click(|_, cx| { - cx.dispatch_action(menu::Cancel.boxed_clone()); - }), - ), + IconButton::new("back", IconName::ArrowLeft) + .shape(IconButtonShape::Square) + .on_click(|_, cx| { + cx.dispatch_action(menu::Cancel.boxed_clone()); + }), ) }) - .child(div().flex_1().children(self.children)) - .justify_between() + .child(div().flex_1().children(children)) .when(self.show_dismiss_button, |this| { this.child( IconButton::new("dismiss", IconName::Close) @@ -73,31 +177,6 @@ impl RenderOnce for ModalHeader { } } -#[derive(IntoElement)] -pub struct ModalContent { - children: SmallVec<[AnyElement; 2]>, -} - -impl ModalContent { - pub fn new() -> Self { - Self { - children: SmallVec::new(), - } - } -} - -impl ParentElement for ModalContent { - fn extend(&mut self, elements: impl IntoIterator) { - self.children.extend(elements) - } -} - -impl RenderOnce for ModalContent { - fn render(self, _cx: &mut WindowContext) -> impl IntoElement { - h_flex().w_full().px_2().py_1p5().children(self.children) - } -} - #[derive(IntoElement)] pub struct ModalRow { children: SmallVec<[AnyElement; 2]>, @@ -123,6 +202,136 @@ impl RenderOnce for ModalRow { } } +#[derive(IntoElement)] +pub struct ModalFooter { + start_slot: Option, + end_slot: Option, +} + +impl ModalFooter { + pub fn new() -> Self { + Self { + start_slot: None, + end_slot: None, + } + } + + pub fn start_slot(mut self, start_slot: impl Into>) -> Self { + self.start_slot = start_slot.into().map(IntoElement::into_any_element); + self + } + + pub fn end_slot(mut self, end_slot: impl Into>) -> Self { + self.end_slot = end_slot.into().map(IntoElement::into_any_element); + self + } +} + +impl RenderOnce for ModalFooter { + fn render(self, cx: &mut WindowContext) -> impl IntoElement { + h_flex() + .flex_none() + .w_full() + .p(Spacing::Large.rems(cx)) + .justify_between() + .child(div().when_some(self.start_slot, |this, start_slot| this.child(start_slot))) + .child(div().when_some(self.end_slot, |this, end_slot| this.child(end_slot))) + } +} + +#[derive(IntoElement)] +pub struct Section { + contained: bool, + header: Option, + meta: Option, + children: SmallVec<[AnyElement; 2]>, +} + +impl Section { + pub fn new() -> Self { + Self { + contained: false, + header: None, + meta: None, + children: SmallVec::new(), + } + } + + pub fn new_contained() -> Self { + Self { + contained: true, + header: None, + meta: None, + children: SmallVec::new(), + } + } + + pub fn contained(mut self, contained: bool) -> Self { + self.contained = contained; + self + } + + pub fn header(mut self, header: SectionHeader) -> Self { + self.header = Some(header); + self + } + + pub fn meta(mut self, meta: impl Into) -> Self { + self.meta = Some(meta.into()); + self + } +} + +impl ParentElement for Section { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements) + } +} + +impl RenderOnce for Section { + fn render(self, cx: &mut WindowContext) -> impl IntoElement { + let mut section_bg = cx.theme().colors().text; + section_bg.fade_out(0.96); + + let children = if self.contained { + v_flex().flex_1().px(Spacing::XLarge.rems(cx)).child( + v_flex() + .w_full() + .rounded_md() + .border_1() + .border_color(cx.theme().colors().border) + .bg(section_bg) + .py(Spacing::Medium.rems(cx)) + .px(Spacing::Large.rems(cx) - rems_from_px(1.0)) + .gap_y(Spacing::Small.rems(cx)) + .child(div().flex().flex_1().size_full().children(self.children)), + ) + } else { + v_flex() + .w_full() + .gap_y(Spacing::Small.rems(cx)) + .px(Spacing::Large.rems(cx) + Spacing::Large.rems(cx)) + .children(self.children) + }; + + v_flex() + .size_full() + .flex_1() + .child( + v_flex() + .flex_none() + .px(Spacing::XLarge.rems(cx)) + .children(self.header) + .when_some(self.meta, |this, meta| { + this.child(Label::new(meta).size(LabelSize::Small).color(Color::Muted)) + }), + ) + .child(children) + // fill any leftover space + .child(div().flex().flex_1()) + } +} + #[derive(IntoElement)] pub struct SectionHeader { /// The label of the header. @@ -147,23 +356,40 @@ impl SectionHeader { } impl RenderOnce for SectionHeader { - fn render(self, _cx: &mut WindowContext) -> impl IntoElement { - h_flex().id(self.label.clone()).w_full().child( - div() - .h_7() - .flex() - .items_center() - .justify_between() - .w_full() - .gap_1() - .child( - div().flex_1().child( - Label::new(self.label.clone()) - .size(LabelSize::Large) - .into_element(), - ), - ) - .child(h_flex().children(self.end_slot)), - ) + fn render(self, cx: &mut WindowContext) -> impl IntoElement { + h_flex() + .id(self.label.clone()) + .w_full() + .px(Spacing::Large.rems(cx)) + .child( + div() + .h_7() + .flex() + .items_center() + .justify_between() + .w_full() + .gap(Spacing::Small.rems(cx)) + .child( + div().flex_1().child( + Label::new(self.label.clone()) + .size(LabelSize::Small) + .into_element(), + ), + ) + .child(h_flex().children(self.end_slot)), + ) + } +} + +impl Into for SharedString { + fn into(self) -> SectionHeader { + SectionHeader::new(self) + } +} + +impl Into for &'static str { + fn into(self) -> SectionHeader { + let label: SharedString = self.into(); + SectionHeader::new(label) } } diff --git a/crates/ui/src/components/radio.rs b/crates/ui/src/components/radio.rs new file mode 100644 index 0000000000..f3eeb9dac0 --- /dev/null +++ b/crates/ui/src/components/radio.rs @@ -0,0 +1,61 @@ +use std::sync::Arc; + +use crate::prelude::*; + +/// A [`Checkbox`] that has a [`Label`]. +#[derive(IntoElement)] +pub struct RadioWithLabel { + id: ElementId, + label: Label, + selected: bool, + on_click: Arc, +} + +impl RadioWithLabel { + pub fn new( + id: impl Into, + label: Label, + selected: bool, + on_click: impl Fn(&bool, &mut WindowContext) + 'static, + ) -> Self { + Self { + id: id.into(), + label, + selected, + on_click: Arc::new(on_click), + } + } +} + +impl RenderOnce for RadioWithLabel { + fn render(self, cx: &mut WindowContext) -> impl IntoElement { + let inner_diameter = rems_from_px(6.); + let outer_diameter = rems_from_px(16.); + let border_width = rems_from_px(1.); + h_flex() + .id(self.id) + .gap(Spacing::Large.rems(cx)) + .group("") + .child( + div() + .size(outer_diameter) + .rounded(outer_diameter / 2.) + .border_color(cx.theme().colors().border) + .border(border_width) + .group_hover("", |el| el.bg(cx.theme().colors().element_hover)) + .when(self.selected, |el| { + el.child( + div() + .m((outer_diameter - inner_diameter) / 2. - border_width) + .size(inner_diameter) + .rounded(inner_diameter / 2.) + .bg(cx.theme().colors().icon_accent), + ) + }), + ) + .child(self.label) + .on_click(move |_event, cx| { + (self.on_click)(&true, cx); + }) + } +} diff --git a/crates/ui/src/styles/elevation.rs b/crates/ui/src/styles/elevation.rs index 3f568d8223..cc87d0a4f7 100644 --- a/crates/ui/src/styles/elevation.rs +++ b/crates/ui/src/styles/elevation.rs @@ -9,6 +9,12 @@ pub enum Elevation { ElementIndex(ElementIndex), } +impl Into for ElevationIndex { + fn into(self) -> Elevation { + Elevation::ElevationIndex(self) + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ElevationIndex { Background, diff --git a/crates/ui/src/styles/typography.rs b/crates/ui/src/styles/typography.rs index 8de54872cc..6b359512f4 100644 --- a/crates/ui/src/styles/typography.rs +++ b/crates/ui/src/styles/typography.rs @@ -4,7 +4,7 @@ use gpui::{ use settings::Settings; use theme::{ActiveTheme, ThemeSettings}; -use crate::rems_from_px; +use crate::{rems_from_px, Color}; /// Extends [`gpui::Styled`] with typography-related styling methods. pub trait StyledTypography: Styled + Sized { @@ -164,6 +164,7 @@ impl HeadlineSize { pub struct Headline { size: HeadlineSize, text: SharedString, + color: Color, } impl RenderOnce for Headline { @@ -184,6 +185,7 @@ impl Headline { Self { size: HeadlineSize::default(), text: text.into(), + color: Color::default(), } } @@ -191,4 +193,9 @@ impl Headline { self.size = size; self } + + pub fn color(mut self, color: Color) -> Self { + self.color = color; + self + } } diff --git a/docs/src/remote-development.md b/docs/src/remote-development.md index 76817645ab..7ebeaaac06 100644 --- a/docs/src/remote-development.md +++ b/docs/src/remote-development.md @@ -6,7 +6,7 @@ Remote Development allows you to code at the speed of thought, even when your co ## Overview -Remote development requires running two instances of Zed. A headless instance on the remote machine, and the editor interface on your local computer. All configuration is done on your local computer, except for starting the headless instance. +Remote development requires running two instances of Zed. A headless instance on the remote machine, and the editor interface on your local computer. All configuration is done on your local computer. Currently the two instances connect via Zed's servers, but we intend to build peer to peer communication in the future. @@ -14,18 +14,48 @@ Currently the two instances connect via Zed's servers, but we intend to build pe > NOTE: You must be in the alpha program to see this UI. The instructions will likely change as the feature gets closer to launch. -1. Open the projects dialog with `cmd-option-o` and then click "Connect…". +1. Download and install the latest [Zed Preview](https://zed.dev/releases/preview). +1. Open the remote projects dialogue with `cmd-shift-p remote` 2. Click "Add Server" -3. Give it a name, and copy the instructions given. -4. On the remote machine, install Zed - ``` - curl https://zed.dev/install.sh | bash - ``` -5. On the remote machine, paste the instructions from step 3. You should see `connected!`. - > NOTE: If this command runs but doesn't output anything, try running `zed --foreground --dev-server-token YY.XXX`. It is possible that the zed background process is crashing on startup. +3. Choose whether to setup via SSH, or to follow the manual setup. + > NOTE: With both options your laptop and the remote machine will communicate + via https://collab.zed.dev/, so you will need outbound internet access on the remote machine. 6. On your laptop you can now open folders on the remote machine. > NOTE: Zed does not currently handle opening very large directories (e.g. `/` or `~` that may have >100,000 files) very well. We are working on improving this, but suggest in the meantime opening only specific projects, or subfolders of very large mono-repos. +## Toubleshooting + +### UI is not showing up + +This can happen either if you were just added to the alpha, in which case you need to restart zed. Or, if you lost connection to the zed server, in which case you just need to click "Sign In" in the top right. + +### SSH connections + +If you chose to connect via SSH, the command you specify will be run in a zed terminal given you an opportunity to type any passwords/keyphrases etc. that you need. +Once a connection is established zed will be downloaded and installed to `~/.local/bin/zed` on the remote machine, and run. + +If you don't see any output from the zed command, it is likely that zed is crashing +on startup. You can troubleshoot this by switching to manual mode and passing the `--foreground` flag. Please [file a bug](https://github.com/zed-industries/zed) so we can debug it together. + +### SSH-like connections + +Zed intercepts `ssh` in a way that should make it possible to intercept connections made by most "ssh wrappers". For example you +can specify: + +* `user@host` will assume you meant `ssh user@host` +* `ssh -J jump target` to connect via a jump-host +* `gh cs ssh -c example-codespace` to connect to a github codespace +* `doctl compute ssh example-droplet` to connect to a digital ocean droplet +* `gcloud compute ssh` for a google cloud instance + +### zed --dev-server-token isn't connecting + +There are a few likely causes of failure: + +* `zed --dev-server-token` runs but outputs nothing. This is probably because the zed background process is crashing on startup. Try running `zed --dev-server-token XX --foreground` to see any output, and [file a bug](https://github.com/zed-industries/zed) so we can debug it together. +* `zed --dev-server-token` outputs something like "Connection refused" or "Unauthorized" and immediately exits. This is likely due to issues making outbound HTTP requests to https://collab.zed.dev from your host. You can try to debug this with `curl https://collab.zed.dev`, but we have seen cases where curl is whitelisted, but other binaries are not allowed network access. +* `zed --dev-server-token` outputs "Zed is already running". If you are editing an existing server, it is possible that clicking "Connect" a second time will work, but if not you will have to manually log into the server and kill the zed process. + ## Supported platforms The remote machine must be able to run Zed. The following platforms should work, though note that we have not exhaustively tested every linux distribution: @@ -36,11 +66,11 @@ The remote machine must be able to run Zed. The following platforms should work, ## Known Limitations -- The Terminal does not work remotely. +- The Terminal does not work remotely unless you configure the machine to use SSH. - You cannot spawn Tasks remotely. - Extensions aren't yet supported in headless Zed. - You can not run `zed` in headless mode and in GUI mode at the same time on the same machine. ## Feedback -- Please join the #remoting-feedback in the [Zed Discord](https://discord.gg/qSDQ8VWc7k). +- Please join the #remoting-feedback channel in the [Zed Discord](https://discord.gg/qSDQ8VWc7k). diff --git a/script/install.sh b/script/install.sh index b30d067492..07e020f948 100755 --- a/script/install.sh +++ b/script/install.sh @@ -112,7 +112,19 @@ macos() { ditto "$temp/mount/$app" "/Applications/$app" hdiutil detach -quiet "$temp/mount" - echo "Zed has been installed. Run with 'open /Applications/$app'" + mkdir -p "$HOME/.local/bin" + # Link the binary + ln -sf /Applications/$app/Contents/MacOS/cli "$HOME/.local/bin/zed" + + if which "zed" >/dev/null 2>&1; then + echo "Zed has been installed. Run with 'zed'" + else + echo "To run Zed from your terminal, you must add ~/.local/bin to your PATH" + echo "Run:" + echo " echo 'export PATH=\$HOME/.local/bin:\$PATH' >> ~/.bashrc" + echo " source ~/.bashrc" + echo "To run Zed now, '~/.local/bin/zed'" + fi } main "$@"