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",
"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",

View File

@ -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"] }

View File

@ -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(

View File

@ -137,6 +137,7 @@ impl Database {
&self,
id: DevServerId,
name: &str,
ssh_connection_string: &Option<String>,
user_id: UserId,
) -> crate::Result<proto::DevServerProjectsUpdate> {
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)

View File

@ -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;

View File

@ -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,
)
})

View File

@ -185,6 +185,7 @@ impl Store {
&mut self,
dev_server_id: DevServerId,
name: String,
ssh_connection_string: Option<String>,
cx: &mut ModelContext<Self>,
) -> Task<Result<()>> {
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(())

View File

@ -73,6 +73,9 @@ impl Markdown {
}
pub fn reset(&mut self, source: String, cx: &mut ViewContext<Self>) {
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 => {

View File

@ -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

View File

@ -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<ConnectRemoteTerminal> {
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<TerminalWorkDir> {
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<PathBuf>,
working_directory: Option<TerminalWorkDir>,
spawn_task: Option<SpawnInTerminal>,
window: AnyWindowHandle,
cx: &mut ModelContext<Self>,
) -> 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
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::<Vec<String>>()
.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

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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| {

View File

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

View File

@ -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<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.
#[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<PathBuf>,
pub cwd: Option<TerminalWorkDir>,
/// Env overrides for the command, will be appended to the terminal's environment from the settings.
pub env: HashMap<String, String>,
/// 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 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"
);
}

View File

@ -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<Self>) {
@ -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<PathBuf>,
cx: &mut ViewContext<Self>,
) {
) -> Task<Result<Model<Terminal>>> {
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<PathBuf>,
working_directory: Option<TerminalWorkDir>,
spawn_task: Option<SpawnInTerminal>,
reveal_strategy: RevealStrategy,
cx: &mut ViewContext<Self>,
) {
) -> Task<Result<Model<Terminal>>> {
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::<Self>(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<Self>) {
@ -579,7 +575,6 @@ impl TerminalPanel {
fn replace_terminal(
&self,
working_directory: Option<PathBuf>,
spawn_task: SpawnInTerminal,
terminal_item_index: usize,
terminal_to_replace: View<TerminalView>,
@ -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<Self>) {
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 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<anyhow::Result<View<Self>>> {
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<PathBuf> {
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<TerminalWorkDir> {
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

View File

@ -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::*;

View File

@ -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 {

View File

@ -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<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 {
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 {
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<Elevation>,
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<Elevation>,
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<ButtonStyle>,
pub(super) width: Option<DefiniteLength>,
pub(super) height: Option<DefiniteLength>,
pub(super) layer: Option<Elevation>,
size: ButtonSize,
rounding: Option<ButtonLikeRounding>,
tooltip: Option<Box<dyn Fn(&mut WindowContext) -> 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(

View File

@ -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 {

View File

@ -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 {

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 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<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)]
pub struct ModalHeader {
id: ElementId,
headline: Option<SharedString>,
children: SmallVec<[AnyElement; 2]>,
show_dismiss_button: bool,
show_back_button: bool,
}
impl ModalHeader {
pub fn new(id: impl Into<ElementId>) -> 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<SharedString>) -> 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<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)]
pub struct ModalRow {
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)]
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<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),
}
impl Into<Elevation> for ElevationIndex {
fn into(self) -> Elevation {
Elevation::ElevationIndex(self)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ElevationIndex {
Background,

View File

@ -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
}
}

View File

@ -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).

View File

@ -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 "$@"