Allow ssh connection for setting up zed (#12063)

Co-Authored-By: Mikayla <mikayla@zed.dev>



Release Notes:

- Magic `ssh` login feature for remote development

---------

Co-authored-by: Mikayla <mikayla@zed.dev>
Co-authored-by: Nate Butler <iamnbutler@gmail.com>
This commit is contained in:
Conrad Irwin 2024-05-21 22:39:16 -06:00 committed by GitHub
parent 3382e79ef9
commit e5b9e2044e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 1242 additions and 785 deletions

4
Cargo.lock generated
View File

@ -7683,10 +7683,12 @@ dependencies = [
"serde_json", "serde_json",
"settings", "settings",
"sha2 0.10.7", "sha2 0.10.7",
"shlex",
"similar", "similar",
"smol", "smol",
"snippet", "snippet",
"task", "task",
"tempfile",
"terminal", "terminal",
"text", "text",
"unindent", "unindent",
@ -8056,6 +8058,8 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"smol", "smol",
"task",
"terminal_view",
"ui", "ui",
"ui_text_field", "ui_text_field",
"util", "util",

View File

@ -330,6 +330,7 @@ serde_json_lenient = { version = "0.1", features = [
serde_repr = "0.1" serde_repr = "0.1"
sha2 = "0.10" sha2 = "0.10"
shellexpand = "2.1.0" shellexpand = "2.1.0"
shlex = "1.3.0"
smallvec = { version = "1.6", features = ["union"] } smallvec = { version = "1.6", features = ["union"] }
smol = "1.2" smol = "1.2"
strum = { version = "0.25.0", features = ["derive"] } strum = { version = "0.25.0", features = ["derive"] }

View File

@ -251,8 +251,8 @@ impl Render for PromptManager {
.h(rems(40.)) .h(rems(40.))
.overflow_hidden() .overflow_hidden()
.child( .child(
ModalHeader::new("prompt-manager-header") ModalHeader::new()
.child(Headline::new("Prompt Library").size(HeadlineSize::Small)) .headline("Prompt Library")
.show_dismiss_button(true), .show_dismiss_button(true),
) )
.child( .child(

View File

@ -137,6 +137,7 @@ impl Database {
&self, &self,
id: DevServerId, id: DevServerId,
name: &str, name: &str,
ssh_connection_string: &Option<String>,
user_id: UserId, user_id: UserId,
) -> crate::Result<proto::DevServerProjectsUpdate> { ) -> crate::Result<proto::DevServerProjectsUpdate> {
self.transaction(|tx| async move { self.transaction(|tx| async move {
@ -149,6 +150,7 @@ impl Database {
dev_server::Entity::update(dev_server::ActiveModel { dev_server::Entity::update(dev_server::ActiveModel {
name: ActiveValue::Set(name.trim().to_string()), name: ActiveValue::Set(name.trim().to_string()),
ssh_connection_string: ActiveValue::Set(ssh_connection_string.clone()),
..dev_server.clone().into_active_model() ..dev_server.clone().into_active_model()
}) })
.exec(&*tx) .exec(&*tx)

View File

@ -2439,7 +2439,12 @@ async fn rename_dev_server(
let status = session let status = session
.db() .db()
.await .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?; .await?;
send_dev_server_projects_update(session.user_id(), status, &session).await; send_dev_server_projects_update(session.user_id(), status, &session).await;

View File

@ -352,6 +352,7 @@ async fn test_dev_server_rename(
store.rename_dev_server( store.rename_dev_server(
store.dev_servers().first().unwrap().id, store.dev_servers().first().unwrap().id,
"name-edited".to_string(), "name-edited".to_string(),
None,
cx, cx,
) )
}) })

View File

@ -185,6 +185,7 @@ impl Store {
&mut self, &mut self,
dev_server_id: DevServerId, dev_server_id: DevServerId,
name: String, name: String,
ssh_connection_string: Option<String>,
cx: &mut ModelContext<Self>, cx: &mut ModelContext<Self>,
) -> Task<Result<()>> { ) -> Task<Result<()>> {
let client = self.client.clone(); let client = self.client.clone();
@ -193,6 +194,7 @@ impl Store {
.request(proto::RenameDevServer { .request(proto::RenameDevServer {
dev_server_id: dev_server_id.0, dev_server_id: dev_server_id.0,
name, name,
ssh_connection_string,
}) })
.await?; .await?;
Ok(()) Ok(())

View File

@ -73,6 +73,9 @@ impl Markdown {
} }
pub fn reset(&mut self, source: String, cx: &mut ViewContext<Self>) { pub fn reset(&mut self, source: String, cx: &mut ViewContext<Self>) {
if source == self.source() {
return;
}
self.source = source; self.source = source;
self.selection = Selection::default(); self.selection = Selection::default();
self.autoscroll_request = None; self.autoscroll_request = None;
@ -544,8 +547,10 @@ impl Element for MarkdownElement {
}) })
} }
MarkdownTag::Link { dest_url, .. } => { MarkdownTag::Link { dest_url, .. } => {
builder.push_link(dest_url.clone(), range.clone()); if builder.code_block_stack.is_empty() {
builder.push_text_style(self.style.link.clone()) builder.push_link(dest_url.clone(), range.clone());
builder.push_text_style(self.style.link.clone())
}
} }
_ => log::error!("unsupported markdown tag {:?}", tag), _ => log::error!("unsupported markdown tag {:?}", tag),
} }
@ -577,7 +582,11 @@ impl Element for MarkdownElement {
MarkdownTagEnd::Emphasis => builder.pop_text_style(), MarkdownTagEnd::Emphasis => builder.pop_text_style(),
MarkdownTagEnd::Strong => builder.pop_text_style(), MarkdownTagEnd::Strong => builder.pop_text_style(),
MarkdownTagEnd::Strikethrough => 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), _ => log::error!("unsupported markdown tag end: {:?}", tag),
}, },
MarkdownEvent::Text => { MarkdownEvent::Text => {

View File

@ -52,10 +52,12 @@ regex.workspace = true
rpc.workspace = true rpc.workspace = true
schemars.workspace = true schemars.workspace = true
task.workspace = true task.workspace = true
tempfile.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
settings.workspace = true settings.workspace = true
sha2.workspace = true sha2.workspace = true
shlex.workspace = true
similar = "1.3" similar = "1.3"
smol.workspace = true smol.workspace = true
snippet.workspace = true snippet.workspace = true

View File

@ -5,8 +5,13 @@ use gpui::{
}; };
use settings::{Settings, SettingsLocation}; use settings::{Settings, SettingsLocation};
use smol::channel::bounded; use smol::channel::bounded;
use std::path::{Path, PathBuf}; use std::{
use task::SpawnInTerminal; env,
fs::File,
io::Write,
path::{Path, PathBuf},
};
use task::{SpawnInTerminal, TerminalWorkDir};
use terminal::{ use terminal::{
terminal_settings::{self, Shell, TerminalSettings, VenvSettingsContent}, terminal_settings::{self, Shell, TerminalSettings, VenvSettingsContent},
TaskState, TaskStatus, Terminal, TerminalBuilder, TaskState, TaskStatus, Terminal, TerminalBuilder,
@ -27,58 +32,57 @@ pub struct ConnectRemoteTerminal {
} }
impl Project { impl Project {
pub fn remote_terminal_connection_data( pub fn terminal_work_dir_for(
&self, &self,
pathbuf: Option<&PathBuf>,
cx: &AppContext, cx: &AppContext,
) -> Option<ConnectRemoteTerminal> { ) -> Option<TerminalWorkDir> {
self.dev_server_project_id() if self.is_local() {
.and_then(|dev_server_project_id| { return Some(TerminalWorkDir::Local(pathbuf?.clone()));
let projects_store = dev_server_projects::Store::global(cx).read(cx); }
let project_path = projects_store let dev_server_project_id = self.dev_server_project_id()?;
.dev_server_project(dev_server_project_id)? let projects_store = dev_server_projects::Store::global(cx).read(cx);
.path let ssh_command = projects_store
.clone(); .dev_server_for_project(dev_server_project_id)?
let ssh_connection_string = projects_store .ssh_connection_string
.dev_server_for_project(dev_server_project_id)? .clone()?
.ssh_connection_string .to_string();
.clone();
Some(project_path).zip(ssh_connection_string) let path = if let Some(pathbuf) = pathbuf {
}) pathbuf.to_string_lossy().to_string()
.map( } else {
|(project_path, ssh_connection_string)| ConnectRemoteTerminal { projects_store
ssh_connection_string, .dev_server_project(dev_server_project_id)?
project_path, .path
}, .to_string()
) };
Some(TerminalWorkDir::Ssh {
ssh_command,
path: Some(path),
})
} }
pub fn create_terminal( pub fn create_terminal(
&mut self, &mut self,
working_directory: Option<PathBuf>, working_directory: Option<TerminalWorkDir>,
spawn_task: Option<SpawnInTerminal>, spawn_task: Option<SpawnInTerminal>,
window: AnyWindowHandle, window: AnyWindowHandle,
cx: &mut ModelContext<Self>, cx: &mut ModelContext<Self>,
) -> anyhow::Result<Model<Terminal>> { ) -> anyhow::Result<Model<Terminal>> {
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 // used only for TerminalSettings::get
let worktree = { 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 let task_cwd = spawn_task
.as_ref() .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 terminal_cwd
.and_then(|terminal_cwd| self.find_local_worktree(terminal_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))) .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 { let settings_location = worktree.as_ref().map(|(worktree, path)| SettingsLocation {
@ -86,7 +90,8 @@ impl Project {
path, 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 settings = TerminalSettings::get(settings_location, cx);
let python_settings = settings.detect_venv.clone(); let python_settings = settings.detect_venv.clone();
let (completion_tx, completion_rx) = bounded(1); 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 // 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 // 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 let venv_base_directory = working_directory
.as_deref() .as_ref()
.unwrap_or_else(|| Path::new("")); .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 { let (spawn_task, shell) = match working_directory.as_ref() {
log::debug!("Connecting to a remote server: {remote_connection_data:?}"); Some(TerminalWorkDir::Ssh { ssh_command, path }) => {
// Alacritty sets its terminfo to `alacritty`, this requiring hosts to have it installed log::debug!("Connecting to a remote server: {ssh_command:?}");
// to properly display colors. // Alacritty sets its terminfo to `alacritty`, this requiring hosts to have it installed
// We do not have the luxury of assuming the host has it installed, // to properly display colors.
// so we set it to a default that does not break the highlighting via ssh. // We do not have the luxury of assuming the host has it installed,
env.entry("TERM".to_string()) // so we set it to a default that does not break the highlighting via ssh.
.or_insert_with(|| "xterm-256color".to_string()); env.entry("TERM".to_string())
.or_insert_with(|| "xterm-256color".to_string());
( let tmp_dir = tempfile::tempdir()?;
None, let real_ssh = which::which("ssh")?;
Shell::WithArguments { let ssh_path = tmp_dir.path().join("ssh");
program: "ssh".to_string(), let mut ssh_file = File::create(ssh_path.clone())?;
args: vec![
remote_connection_data.ssh_connection_string.to_string(), let to_run = if let Some(spawn_task) = spawn_task.as_ref() {
"-t".to_string(), Some(shlex::try_quote(&spawn_task.command)?.to_string())
format!( .into_iter()
"cd {} && exec $SHELL -l", .chain(spawn_task.args.iter().filter_map(|arg| {
escape_path_for_shell(remote_connection_data.project_path.as_ref()) shlex::try_quote(arg).ok().map(|arg| arg.to_string())
), }))
], .collect::<Vec<String>>()
}, .join(" ")
) } else {
} else if let Some(spawn_task) = spawn_task { "exec $SHELL -l".to_string()
log::debug!("Spawning task: {spawn_task:?}"); };
env.extend(spawn_task.env);
// Activate minimal Python virtual environment let (port_forward, local_dev_env) =
if let Some(python_settings) = &python_settings.as_option() { if env::var("ZED_RPC_URL") == Ok("http://localhost:8080/rpc".to_string()) {
self.set_python_venv_path_for_tasks(python_settings, venv_base_directory, &mut env); (
"-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( let terminal = TerminalBuilder::new(
working_directory.clone(), working_directory.and_then(|cwd| cwd.local_path()).clone(),
spawn_task, spawn_task,
shell, shell,
env, env,
@ -167,6 +250,7 @@ impl Project {
let id = terminal_handle.entity_id(); let id = terminal_handle.entity_id();
cx.observe_release(&terminal_handle, move |project, _terminal, cx| { cx.observe_release(&terminal_handle, move |project, _terminal, cx| {
drop(retained_script);
let handles = &mut project.terminals.local_handles; let handles = &mut project.terminals.local_handles;
if let Some(index) = handles if let Some(index) = handles
@ -183,7 +267,7 @@ impl Project {
if is_terminal { if is_terminal {
if let Some(python_settings) = &python_settings.as_option() { if let Some(python_settings) = &python_settings.as_option() {
if let Some(activate_script_path) = 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( self.activate_python_virtual_environment(
Project::get_activate_command(python_settings), Project::get_activate_command(python_settings),
@ -291,39 +375,3 @@ impl Project {
&self.terminals.local_handles &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

View File

@ -26,6 +26,8 @@ dev_server_projects.workspace = true
rpc.workspace = true rpc.workspace = true
serde.workspace = true serde.workspace = true
smol.workspace = true smol.workspace = true
task.workspace = true
terminal_view.workspace = true
ui.workspace = true ui.workspace = true
ui_text_field.workspace = true ui_text_field.workspace = true
util.workspace = true util.workspace = true

File diff suppressed because it is too large Load Diff

View File

@ -328,7 +328,8 @@ impl PickerDelegate for RecentProjectsDelegate {
).await?; ).await?;
if response == 1 { if response == 1 {
workspace.update(&mut cx, |workspace, cx| { 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 { } else {
workspace.update(&mut cx, |workspace, cx| { workspace.update(&mut cx, |workspace, cx| {

View File

@ -515,6 +515,7 @@ message ShutdownDevServer {
message RenameDevServer { message RenameDevServer {
uint64 dev_server_id = 1; uint64 dev_server_id = 1;
string name = 2; string name = 2;
optional string ssh_connection_string = 3;
} }
message DeleteDevServer { message DeleteDevServer {

View File

@ -19,6 +19,38 @@ pub use vscode_format::VsCodeTaskFile;
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct TaskId(pub String); 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<String>,
},
}
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<PathBuf> {
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. /// Contains all information needed by Zed to spawn a new terminal tab for the given task.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct SpawnInTerminal { pub struct SpawnInTerminal {
@ -36,7 +68,7 @@ pub struct SpawnInTerminal {
/// A human-readable label, containing command and all of its arguments, joined and substituted. /// A human-readable label, containing command and all of its arguments, joined and substituted.
pub command_label: String, pub command_label: String,
/// Current working directory to spawn the command into. /// Current working directory to spawn the command into.
pub cwd: Option<PathBuf>, pub cwd: Option<TerminalWorkDir>,
/// Env overrides for the command, will be appended to the terminal's environment from the settings. /// Env overrides for the command, will be appended to the terminal's environment from the settings.
pub env: HashMap<String, String>, pub env: HashMap<String, String>,
/// Whether to use a new terminal tab or reuse the existing one to spawn the process. /// Whether to use a new terminal tab or reuse the existing one to spawn the process.

View File

@ -8,7 +8,8 @@ use sha2::{Digest, Sha256};
use util::{truncate_and_remove_front, ResultExt}; use util::{truncate_and_remove_front, ResultExt};
use crate::{ 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. /// A template definition of a Zed task to run.
@ -112,12 +113,14 @@ impl TaskTemplate {
&variable_names, &variable_names,
&mut substituted_variables, &mut substituted_variables,
)?; )?;
Some(substitured_cwd) Some(TerminalWorkDir::Local(PathBuf::from(substitured_cwd)))
} }
None => None, None => None,
} }
.map(PathBuf::from) .or(cx
.or(cx.cwd.clone()); .cwd
.as_ref()
.map(|cwd| TerminalWorkDir::Local(cwd.clone())));
let human_readable_label = substitute_all_template_variables_in_str( let human_readable_label = substitute_all_template_variables_in_str(
&self.label, &self.label,
&truncated_variables, &truncated_variables,
@ -379,8 +382,10 @@ mod tests {
task_variables: TaskVariables::default(), task_variables: TaskVariables::default(),
}; };
assert_eq!( assert_eq!(
resolved_task(&task_without_cwd, &cx).cwd.as_deref(), resolved_task(&task_without_cwd, &cx)
Some(context_cwd.as_path()), .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" "TaskContext's cwd should be taken on resolve if task's cwd is None"
); );
@ -394,8 +399,10 @@ mod tests {
task_variables: TaskVariables::default(), task_variables: TaskVariables::default(),
}; };
assert_eq!( assert_eq!(
resolved_task(&task_with_cwd, &cx).cwd.as_deref(), resolved_task(&task_with_cwd, &cx)
Some(task_cwd.as_path()), .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" "TaskTemplate's cwd should be taken on resolve if TaskContext's cwd is None"
); );
@ -404,8 +411,10 @@ mod tests {
task_variables: TaskVariables::default(), task_variables: TaskVariables::default(),
}; };
assert_eq!( assert_eq!(
resolved_task(&task_with_cwd, &cx).cwd.as_deref(), resolved_task(&task_with_cwd, &cx)
Some(task_cwd.as_path()), .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" "TaskTemplate's cwd should be taken on resolve if TaskContext's cwd is not None"
); );
} }

View File

@ -6,16 +6,19 @@ use db::kvp::KEY_VALUE_STORE;
use futures::future::join_all; use futures::future::join_all;
use gpui::{ use gpui::{
actions, Action, AppContext, AsyncWindowContext, DismissEvent, Entity, EventEmitter, actions, Action, AppContext, AsyncWindowContext, DismissEvent, Entity, EventEmitter,
ExternalPaths, FocusHandle, FocusableView, IntoElement, ParentElement, Pixels, Render, Styled, ExternalPaths, FocusHandle, FocusableView, IntoElement, Model, ParentElement, Pixels, Render,
Subscription, Task, View, ViewContext, VisualContext, WeakView, WindowContext, Styled, Subscription, Task, View, ViewContext, VisualContext, WeakView, WindowContext,
}; };
use itertools::Itertools; use itertools::Itertools;
use project::{Fs, ProjectEntryId}; use project::{Fs, ProjectEntryId};
use search::{buffer_search::DivRegistrar, BufferSearchBar}; use search::{buffer_search::DivRegistrar, BufferSearchBar};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use settings::Settings; use settings::Settings;
use task::{RevealStrategy, SpawnInTerminal, TaskId}; use task::{RevealStrategy, SpawnInTerminal, TaskId, TerminalWorkDir};
use terminal::terminal_settings::{Shell, TerminalDockPosition, TerminalSettings}; use terminal::{
terminal_settings::{Shell, TerminalDockPosition, TerminalSettings},
Terminal,
};
use ui::{ use ui::{
h_flex, ButtonCommon, Clickable, ContextMenu, FluentBuilder, IconButton, IconSize, Selectable, h_flex, ButtonCommon, Clickable, ContextMenu, FluentBuilder, IconButton, IconSize, Selectable,
Tooltip, Tooltip,
@ -319,14 +322,16 @@ impl TerminalPanel {
return; return;
}; };
terminal_panel.update(cx, |panel, cx| { let terminal_work_dir = workspace
panel.add_terminal( .project()
Some(action.working_directory.clone()), .read(cx)
None, .terminal_work_dir_for(Some(&action.working_directory), cx);
RevealStrategy::Always,
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<Self>) { fn spawn_task(&mut self, spawn_in_terminal: &SpawnInTerminal, cx: &mut ViewContext<Self>) {
@ -355,18 +360,19 @@ impl TerminalPanel {
let spawn_task = spawn_task; let spawn_task = spawn_task;
let reveal = spawn_task.reveal; let reveal = spawn_task.reveal;
let working_directory = spawn_in_terminal.cwd.clone();
let allow_concurrent_runs = spawn_in_terminal.allow_concurrent_runs; let allow_concurrent_runs = spawn_in_terminal.allow_concurrent_runs;
let use_new_terminal = spawn_in_terminal.use_new_terminal; let use_new_terminal = spawn_in_terminal.use_new_terminal;
if allow_concurrent_runs && 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; return;
} }
let terminals_for_task = self.terminals_for_task(&spawn_in_terminal.full_label, cx); let terminals_for_task = self.terminals_for_task(&spawn_in_terminal.full_label, cx);
if terminals_for_task.is_empty() { 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; return;
} }
let (existing_item_index, existing_terminal) = terminals_for_task let (existing_item_index, existing_terminal) = terminals_for_task
@ -378,13 +384,7 @@ impl TerminalPanel {
!use_new_terminal, !use_new_terminal,
"Should have handled 'allow_concurrent_runs && use_new_terminal' case above" "Should have handled 'allow_concurrent_runs && use_new_terminal' case above"
); );
self.replace_terminal( self.replace_terminal(spawn_task, existing_item_index, existing_terminal, cx);
working_directory,
spawn_task,
existing_item_index,
existing_terminal,
cx,
);
} else { } else {
self.deferred_tasks.insert( self.deferred_tasks.insert(
spawn_in_terminal.id.clone(), spawn_in_terminal.id.clone(),
@ -393,14 +393,11 @@ impl TerminalPanel {
terminal_panel terminal_panel
.update(&mut cx, |terminal_panel, cx| { .update(&mut cx, |terminal_panel, cx| {
if use_new_terminal { if use_new_terminal {
terminal_panel.spawn_in_new_terminal( terminal_panel
spawn_task, .spawn_in_new_terminal(spawn_task, cx)
working_directory, .detach_and_log_err(cx);
cx,
);
} else { } else {
terminal_panel.replace_terminal( terminal_panel.replace_terminal(
working_directory,
spawn_task, spawn_task,
existing_item_index, existing_item_index,
existing_terminal, existing_terminal,
@ -428,14 +425,13 @@ impl TerminalPanel {
} }
} }
fn spawn_in_new_terminal( pub fn spawn_in_new_terminal(
&mut self, &mut self,
spawn_task: SpawnInTerminal, spawn_task: SpawnInTerminal,
working_directory: Option<PathBuf>,
cx: &mut ViewContext<Self>, cx: &mut ViewContext<Self>,
) { ) -> Task<Result<Model<Terminal>>> {
let reveal = spawn_task.reveal; 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 /// Create a new Terminal in the current working directory or the user's home directory
@ -448,9 +444,11 @@ impl TerminalPanel {
return; return;
}; };
terminal_panel.update(cx, |this, cx| { terminal_panel
this.add_terminal(None, None, RevealStrategy::Always, cx) .update(cx, |this, cx| {
}); this.add_terminal(None, None, RevealStrategy::Always, cx)
})
.detach_and_log_err(cx);
} }
fn terminals_for_task( fn terminals_for_task(
@ -482,17 +480,17 @@ impl TerminalPanel {
fn add_terminal( fn add_terminal(
&mut self, &mut self,
working_directory: Option<PathBuf>, working_directory: Option<TerminalWorkDir>,
spawn_task: Option<SpawnInTerminal>, spawn_task: Option<SpawnInTerminal>,
reveal_strategy: RevealStrategy, reveal_strategy: RevealStrategy,
cx: &mut ViewContext<Self>, cx: &mut ViewContext<Self>,
) { ) -> Task<Result<Model<Terminal>>> {
let workspace = self.workspace.clone(); let workspace = self.workspace.clone();
self.pending_terminals_to_add += 1; self.pending_terminals_to_add += 1;
cx.spawn(|terminal_panel, mut cx| async move { cx.spawn(|terminal_panel, mut cx| async move {
let pane = terminal_panel.update(&mut cx, |this, _| this.pane.clone())?; 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 { let working_directory = if let Some(working_directory) = working_directory {
Some(working_directory) Some(working_directory)
} else { } else {
@ -502,35 +500,33 @@ impl TerminalPanel {
}; };
let window = cx.window_handle(); let window = cx.window_handle();
if let Some(terminal) = workspace.project().update(cx, |project, cx| { let terminal = workspace.project().update(cx, |project, cx| {
project project.create_terminal(working_directory, spawn_task, window, cx)
.create_terminal(working_directory, spawn_task, window, cx) })?;
.log_err() let terminal_view = Box::new(cx.new_view(|cx| {
}) { TerminalView::new(
let terminal = Box::new(cx.new_view(|cx| { terminal.clone(),
TerminalView::new( workspace.weak_handle(),
terminal, workspace.database_id(),
workspace.weak_handle(), cx,
workspace.database_id(), )
cx, }));
) pane.update(cx, |pane, cx| {
})); let focus = pane.has_focus(cx);
pane.update(cx, |pane, cx| { pane.add_item(terminal_view, true, focus, None, cx);
let focus = pane.has_focus(cx); });
pane.add_item(terminal, true, focus, None, cx);
});
}
if reveal_strategy == RevealStrategy::Always { if reveal_strategy == RevealStrategy::Always {
workspace.focus_panel::<Self>(cx); workspace.focus_panel::<Self>(cx);
} }
Ok(terminal)
})?; })?;
terminal_panel.update(&mut cx, |this, cx| { terminal_panel.update(&mut cx, |this, cx| {
this.pending_terminals_to_add = this.pending_terminals_to_add.saturating_sub(1); this.pending_terminals_to_add = this.pending_terminals_to_add.saturating_sub(1);
this.serialize(cx) this.serialize(cx)
})?; })?;
anyhow::Ok(()) result
}) })
.detach_and_log_err(cx);
} }
fn serialize(&mut self, cx: &mut ViewContext<Self>) { fn serialize(&mut self, cx: &mut ViewContext<Self>) {
@ -579,7 +575,6 @@ impl TerminalPanel {
fn replace_terminal( fn replace_terminal(
&self, &self,
working_directory: Option<PathBuf>,
spawn_task: SpawnInTerminal, spawn_task: SpawnInTerminal,
terminal_item_index: usize, terminal_item_index: usize,
terminal_to_replace: View<TerminalView>, terminal_to_replace: View<TerminalView>,
@ -594,7 +589,7 @@ impl TerminalPanel {
let window = cx.window_handle(); let window = cx.window_handle();
let new_terminal = project.update(cx, |project, cx| { let new_terminal = project.update(cx, |project, cx| {
project project
.create_terminal(working_directory, Some(spawn_task), window, cx) .create_terminal(spawn_task.cwd.clone(), Some(spawn_task), window, cx)
.log_err() .log_err()
})?; })?;
terminal_to_replace.update(cx, |terminal_to_replace, cx| { 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<Self>) { fn set_active(&mut self, active: bool, cx: &mut ViewContext<Self>) {
if active && self.has_no_terminals(cx) { 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)
} }
} }

View File

@ -14,6 +14,7 @@ use language::Bias;
use persistence::TERMINAL_DB; use persistence::TERMINAL_DB;
use project::{search::SearchQuery, Fs, LocalWorktree, Metadata, Project}; use project::{search::SearchQuery, Fs, LocalWorktree, Metadata, Project};
use settings::SettingsStore; use settings::SettingsStore;
use task::TerminalWorkDir;
use terminal::{ use terminal::{
alacritty_terminal::{ alacritty_terminal::{
index::Point, index::Point,
@ -878,21 +879,26 @@ impl Item for TerminalView {
) -> Task<anyhow::Result<View<Self>>> { ) -> Task<anyhow::Result<View<Self>>> {
let window = cx.window_handle(); let window = cx.window_handle();
cx.spawn(|pane, mut cx| async move { cx.spawn(|pane, mut cx| async move {
let cwd = TERMINAL_DB let cwd = cx
.get_working_directory(item_id, workspace_id) .update(|cx| {
.log_err() let from_db = TERMINAL_DB
.flatten() .get_working_directory(item_id, workspace_id)
.or_else(|| { .log_err()
cx.update(|cx| { .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(); let strategy = TerminalSettings::get_global(cx).working_directory.clone();
workspace.upgrade().and_then(|workspace| { workspace.upgrade().and_then(|workspace| {
get_working_directory(workspace.read(cx), cx, strategy) 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| { let terminal = project.update(&mut cx, |project, cx| {
project.create_terminal(cwd, None, window, cx) project.create_terminal(cwd, None, window, cx)
@ -1043,20 +1049,24 @@ pub fn get_working_directory(
workspace: &Workspace, workspace: &Workspace,
cx: &AppContext, cx: &AppContext,
strategy: WorkingDirectory, strategy: WorkingDirectory,
) -> Option<PathBuf> { ) -> Option<TerminalWorkDir> {
let res = match strategy { if workspace.project().read(cx).is_local() {
WorkingDirectory::CurrentProjectDirectory => current_project_directory(workspace, cx) let res = match strategy {
.or_else(|| first_project_directory(workspace, cx)), WorkingDirectory::CurrentProjectDirectory => current_project_directory(workspace, cx)
WorkingDirectory::FirstProjectDirectory => first_project_directory(workspace, cx), .or_else(|| first_project_directory(workspace, cx)),
WorkingDirectory::AlwaysHome => None, WorkingDirectory::FirstProjectDirectory => first_project_directory(workspace, cx),
WorkingDirectory::Always { directory } => { WorkingDirectory::AlwaysHome => None,
shellexpand::full(&directory) //TODO handle this better WorkingDirectory::Always { directory } => {
.ok() shellexpand::full(&directory) //TODO handle this better
.map(|dir| Path::new(&dir.to_string()).to_path_buf()) .ok()
.filter(|dir| dir.is_dir()) .map(|dir| Path::new(&dir.to_string()).to_path_buf())
} .filter(|dir| dir.is_dir())
}; }
res.or_else(home_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 ///Gets the first project's home directory, or the home directory

View File

@ -13,6 +13,7 @@ mod list;
mod modal; mod modal;
mod popover; mod popover;
mod popover_menu; mod popover_menu;
mod radio;
mod right_click_menu; mod right_click_menu;
mod stack; mod stack;
mod tab; mod tab;
@ -39,6 +40,7 @@ pub use list::*;
pub use modal::*; pub use modal::*;
pub use popover::*; pub use popover::*;
pub use popover_menu::*; pub use popover_menu::*;
pub use radio::*;
pub use right_click_menu::*; pub use right_click_menu::*;
pub use stack::*; pub use stack::*;
pub use tab::*; pub use tab::*;

View File

@ -1,6 +1,6 @@
use gpui::{AnyView, DefiniteLength}; use gpui::{AnyView, DefiniteLength};
use crate::{prelude::*, IconPosition, KeyBinding, Spacing}; use crate::{prelude::*, ElevationIndex, IconPosition, KeyBinding, Spacing};
use crate::{ use crate::{
ButtonCommon, ButtonLike, ButtonSize, ButtonStyle, IconName, IconSize, Label, LineHeightStyle, ButtonCommon, ButtonLike, ButtonSize, ButtonStyle, IconName, IconSize, Label, LineHeightStyle,
}; };
@ -340,6 +340,11 @@ impl ButtonCommon for Button {
self.base = self.base.tooltip(tooltip); self.base = self.base.tooltip(tooltip);
self self
} }
fn layer(mut self, elevation: ElevationIndex) -> Self {
self.base = self.base.layer(elevation);
self
}
} }
impl RenderOnce for Button { impl RenderOnce for Button {

View File

@ -2,7 +2,7 @@ use gpui::{relative, DefiniteLength, MouseButton};
use gpui::{transparent_black, AnyElement, AnyView, ClickEvent, Hsla, Rems}; use gpui::{transparent_black, AnyElement, AnyView, ClickEvent, Hsla, Rems};
use smallvec::SmallVec; 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. /// A trait for buttons that can be Selected. Enables setting the [`ButtonStyle`] of a button when it is selected.
pub trait SelectableButton: Selectable { pub trait SelectableButton: Selectable {
@ -33,6 +33,8 @@ pub trait ButtonCommon: Clickable + Disableable {
/// Nearly all interactable elements should have a tooltip. Some example /// Nearly all interactable elements should have a tooltip. Some example
/// exceptions might a scroll bar, or a slider. /// exceptions might a scroll bar, or a slider.
fn tooltip(self, tooltip: impl Fn(&mut WindowContext) -> AnyView + 'static) -> Self; 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)] #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, Default)]
@ -135,11 +137,35 @@ pub(crate) struct ButtonLikeStyles {
pub icon_color: Hsla, pub icon_color: Hsla,
} }
fn element_bg_from_elevation(elevation: Option<Elevation>, 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 { impl ButtonStyle {
pub(crate) fn enabled(self, cx: &mut WindowContext) -> ButtonLikeStyles { pub(crate) fn enabled(
self,
elevation: Option<Elevation>,
cx: &mut WindowContext,
) -> ButtonLikeStyles {
let filled_background = element_bg_from_elevation(elevation, cx);
match self { match self {
ButtonStyle::Filled => ButtonLikeStyles { ButtonStyle::Filled => ButtonLikeStyles {
background: cx.theme().colors().element_background, background: filled_background,
border_color: transparent_black(), border_color: transparent_black(),
label_color: Color::Default.color(cx), label_color: Color::Default.color(cx),
icon_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<Elevation>,
cx: &mut WindowContext,
) -> ButtonLikeStyles {
let mut filled_background = element_bg_from_elevation(elevation, cx);
filled_background.fade_out(0.92);
match self { match self {
ButtonStyle::Filled => ButtonLikeStyles { ButtonStyle::Filled => ButtonLikeStyles {
background: cx.theme().colors().element_hover, background: filled_background,
border_color: transparent_black(), border_color: transparent_black(),
label_color: Color::Default.color(cx), label_color: Color::Default.color(cx),
icon_color: Color::Default.color(cx), icon_color: Color::Default.color(cx),
@ -238,7 +271,13 @@ impl ButtonStyle {
} }
#[allow(unused)] #[allow(unused)]
pub(crate) fn disabled(self, cx: &mut WindowContext) -> ButtonLikeStyles { pub(crate) fn disabled(
self,
elevation: Option<Elevation>,
cx: &mut WindowContext,
) -> ButtonLikeStyles {
let filled_background = element_bg_from_elevation(elevation, cx).fade_out(0.82);
match self { match self {
ButtonStyle::Filled => ButtonLikeStyles { ButtonStyle::Filled => ButtonLikeStyles {
background: cx.theme().colors().element_disabled, background: cx.theme().colors().element_disabled,
@ -301,6 +340,7 @@ pub struct ButtonLike {
pub(super) selected_style: Option<ButtonStyle>, pub(super) selected_style: Option<ButtonStyle>,
pub(super) width: Option<DefiniteLength>, pub(super) width: Option<DefiniteLength>,
pub(super) height: Option<DefiniteLength>, pub(super) height: Option<DefiniteLength>,
pub(super) layer: Option<Elevation>,
size: ButtonSize, size: ButtonSize,
rounding: Option<ButtonLikeRounding>, rounding: Option<ButtonLikeRounding>,
tooltip: Option<Box<dyn Fn(&mut WindowContext) -> AnyView>>, tooltip: Option<Box<dyn Fn(&mut WindowContext) -> AnyView>>,
@ -324,6 +364,7 @@ impl ButtonLike {
tooltip: None, tooltip: None,
children: SmallVec::new(), children: SmallVec::new(),
on_click: None, on_click: None,
layer: None,
} }
} }
@ -397,6 +438,11 @@ impl ButtonCommon for ButtonLike {
self.tooltip = Some(Box::new(tooltip)); self.tooltip = Some(Box::new(tooltip));
self self
} }
fn layer(mut self, elevation: ElevationIndex) -> Self {
self.layer = Some(elevation.into());
self
}
} }
impl VisibleOnHover for ButtonLike { impl VisibleOnHover for ButtonLike {
@ -437,11 +483,11 @@ impl RenderOnce for ButtonLike {
ButtonSize::Default | ButtonSize::Compact => this.px(Spacing::Small.rems(cx)), ButtonSize::Default | ButtonSize::Compact => this.px(Spacing::Small.rems(cx)),
ButtonSize::None => this, 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_not_allowed())
.when(!self.disabled, |this| { .when(!self.disabled, |this| {
this.cursor_pointer() 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)) .active(|active| active.bg(style.active(cx).background))
}) })
.when_some( .when_some(

View File

@ -1,6 +1,6 @@
use gpui::{AnyView, DefiniteLength}; use gpui::{AnyView, DefiniteLength};
use crate::{prelude::*, SelectableButton, Spacing}; use crate::{prelude::*, ElevationIndex, SelectableButton, Spacing};
use crate::{ButtonCommon, ButtonLike, ButtonSize, ButtonStyle, IconName, IconSize}; use crate::{ButtonCommon, ButtonLike, ButtonSize, ButtonStyle, IconName, IconSize};
use super::button_icon::ButtonIcon; use super::button_icon::ButtonIcon;
@ -119,6 +119,11 @@ impl ButtonCommon for IconButton {
self.base = self.base.tooltip(tooltip); self.base = self.base.tooltip(tooltip);
self self
} }
fn layer(mut self, elevation: ElevationIndex) -> Self {
self.base = self.base.layer(elevation);
self
}
} }
impl VisibleOnHover for IconButton { impl VisibleOnHover for IconButton {

View File

@ -1,6 +1,6 @@
use gpui::{AnyView, ClickEvent}; 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. /// The position of a [`ToggleButton`] within a group of buttons.
#[derive(Debug, PartialEq, Eq, Clone, Copy)] #[derive(Debug, PartialEq, Eq, Clone, Copy)]
@ -103,6 +103,11 @@ impl ButtonCommon for ToggleButton {
self.base = self.base.tooltip(tooltip); self.base = self.base.tooltip(tooltip);
self self
} }
fn layer(mut self, elevation: ElevationIndex) -> Self {
self.base = self.base.layer(elevation);
self
}
} }
impl RenderOnce for ToggleButton { impl RenderOnce for ToggleButton {

View File

@ -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 gpui::{prelude::FluentBuilder, *};
use smallvec::SmallVec; use smallvec::SmallVec;
use theme::ActiveTheme;
use crate::{ #[derive(IntoElement)]
h_flex, Clickable, IconButton, IconButtonShape, IconName, Label, LabelCommon, LabelSize, pub struct Modal {
Spacing, id: ElementId,
}; header: ModalHeader,
children: SmallVec<[AnyElement; 2]>,
footer: Option<ModalFooter>,
container_id: ElementId,
container_scroll_handler: Option<ScrollHandle>,
}
impl Modal {
pub fn new(id: impl Into<SharedString>, scroll_handle: Option<ScrollHandle>) -> 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<Item = AnyElement>) {
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)] #[derive(IntoElement)]
pub struct ModalHeader { pub struct ModalHeader {
id: ElementId, headline: Option<SharedString>,
children: SmallVec<[AnyElement; 2]>, children: SmallVec<[AnyElement; 2]>,
show_dismiss_button: bool, show_dismiss_button: bool,
show_back_button: bool, show_back_button: bool,
} }
impl ModalHeader { impl ModalHeader {
pub fn new(id: impl Into<ElementId>) -> Self { pub fn new() -> Self {
Self { Self {
id: id.into(), headline: None,
children: SmallVec::new(), children: SmallVec::new(),
show_dismiss_button: false, show_dismiss_button: false,
show_back_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<SharedString>) -> Self {
self.headline = Some(headline.into());
self
}
pub fn show_dismiss_button(mut self, show: bool) -> Self { pub fn show_dismiss_button(mut self, show: bool) -> Self {
self.show_dismiss_button = show; self.show_dismiss_button = show;
self self
@ -43,24 +135,36 @@ impl ParentElement for ModalHeader {
impl RenderOnce for ModalHeader { impl RenderOnce for ModalHeader {
fn render(self, cx: &mut WindowContext) -> impl IntoElement { 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() h_flex()
.id(self.id) .flex_none()
.justify_between()
.w_full() .w_full()
.px(Spacing::Large.rems(cx)) .px(Spacing::XLarge.rems(cx))
.py_1p5() .pt(Spacing::Large.rems(cx))
.pb(Spacing::Small.rems(cx))
.gap(Spacing::Large.rems(cx))
.when(self.show_back_button, |this| { .when(self.show_back_button, |this| {
this.child( this.child(
div().pr_1().child( IconButton::new("back", IconName::ArrowLeft)
IconButton::new("back", IconName::ArrowLeft) .shape(IconButtonShape::Square)
.shape(IconButtonShape::Square) .on_click(|_, cx| {
.on_click(|_, cx| { cx.dispatch_action(menu::Cancel.boxed_clone());
cx.dispatch_action(menu::Cancel.boxed_clone()); }),
}),
),
) )
}) })
.child(div().flex_1().children(self.children)) .child(div().flex_1().children(children))
.justify_between()
.when(self.show_dismiss_button, |this| { .when(self.show_dismiss_button, |this| {
this.child( this.child(
IconButton::new("dismiss", IconName::Close) 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<Item = AnyElement>) {
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)] #[derive(IntoElement)]
pub struct ModalRow { pub struct ModalRow {
children: SmallVec<[AnyElement; 2]>, children: SmallVec<[AnyElement; 2]>,
@ -123,6 +202,136 @@ impl RenderOnce for ModalRow {
} }
} }
#[derive(IntoElement)]
pub struct ModalFooter {
start_slot: Option<AnyElement>,
end_slot: Option<AnyElement>,
}
impl ModalFooter {
pub fn new() -> Self {
Self {
start_slot: None,
end_slot: None,
}
}
pub fn start_slot<E: IntoElement>(mut self, start_slot: impl Into<Option<E>>) -> Self {
self.start_slot = start_slot.into().map(IntoElement::into_any_element);
self
}
pub fn end_slot<E: IntoElement>(mut self, end_slot: impl Into<Option<E>>) -> 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<SectionHeader>,
meta: Option<SharedString>,
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<SharedString>) -> Self {
self.meta = Some(meta.into());
self
}
}
impl ParentElement for Section {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
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)] #[derive(IntoElement)]
pub struct SectionHeader { pub struct SectionHeader {
/// The label of the header. /// The label of the header.
@ -147,23 +356,40 @@ impl SectionHeader {
} }
impl RenderOnce for SectionHeader { impl RenderOnce for SectionHeader {
fn render(self, _cx: &mut WindowContext) -> impl IntoElement { fn render(self, cx: &mut WindowContext) -> impl IntoElement {
h_flex().id(self.label.clone()).w_full().child( h_flex()
div() .id(self.label.clone())
.h_7() .w_full()
.flex() .px(Spacing::Large.rems(cx))
.items_center() .child(
.justify_between() div()
.w_full() .h_7()
.gap_1() .flex()
.child( .items_center()
div().flex_1().child( .justify_between()
Label::new(self.label.clone()) .w_full()
.size(LabelSize::Large) .gap(Spacing::Small.rems(cx))
.into_element(), .child(
), div().flex_1().child(
) Label::new(self.label.clone())
.child(h_flex().children(self.end_slot)), .size(LabelSize::Small)
) .into_element(),
),
)
.child(h_flex().children(self.end_slot)),
)
}
}
impl Into<SectionHeader> for SharedString {
fn into(self) -> SectionHeader {
SectionHeader::new(self)
}
}
impl Into<SectionHeader> for &'static str {
fn into(self) -> SectionHeader {
let label: SharedString = self.into();
SectionHeader::new(label)
} }
} }

View File

@ -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<dyn Fn(&bool, &mut WindowContext) + 'static>,
}
impl RadioWithLabel {
pub fn new(
id: impl Into<ElementId>,
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);
})
}
}

View File

@ -9,6 +9,12 @@ pub enum Elevation {
ElementIndex(ElementIndex), ElementIndex(ElementIndex),
} }
impl Into<Elevation> for ElevationIndex {
fn into(self) -> Elevation {
Elevation::ElevationIndex(self)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ElevationIndex { pub enum ElevationIndex {
Background, Background,

View File

@ -4,7 +4,7 @@ use gpui::{
use settings::Settings; use settings::Settings;
use theme::{ActiveTheme, ThemeSettings}; use theme::{ActiveTheme, ThemeSettings};
use crate::rems_from_px; use crate::{rems_from_px, Color};
/// Extends [`gpui::Styled`] with typography-related styling methods. /// Extends [`gpui::Styled`] with typography-related styling methods.
pub trait StyledTypography: Styled + Sized { pub trait StyledTypography: Styled + Sized {
@ -164,6 +164,7 @@ impl HeadlineSize {
pub struct Headline { pub struct Headline {
size: HeadlineSize, size: HeadlineSize,
text: SharedString, text: SharedString,
color: Color,
} }
impl RenderOnce for Headline { impl RenderOnce for Headline {
@ -184,6 +185,7 @@ impl Headline {
Self { Self {
size: HeadlineSize::default(), size: HeadlineSize::default(),
text: text.into(), text: text.into(),
color: Color::default(),
} }
} }
@ -191,4 +193,9 @@ impl Headline {
self.size = size; self.size = size;
self self
} }
pub fn color(mut self, color: Color) -> Self {
self.color = color;
self
}
} }

View File

@ -6,7 +6,7 @@ Remote Development allows you to code at the speed of thought, even when your co
## Overview ## 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. 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. > 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" 2. Click "Add Server"
3. Give it a name, and copy the instructions given. 3. Choose whether to setup via SSH, or to follow the manual setup.
4. On the remote machine, install Zed > 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.
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.
6. On your laptop you can now open folders 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. > 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 ## 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: 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 ## 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. - You cannot spawn Tasks remotely.
- Extensions aren't yet supported in headless Zed. - 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. - You can not run `zed` in headless mode and in GUI mode at the same time on the same machine.
## Feedback ## 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).

View File

@ -112,7 +112,19 @@ macos() {
ditto "$temp/mount/$app" "/Applications/$app" ditto "$temp/mount/$app" "/Applications/$app"
hdiutil detach -quiet "$temp/mount" 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 "$@" main "$@"