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:
parent
3382e79ef9
commit
e5b9e2044e
29 changed files with 1242 additions and 785 deletions
|
@ -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(
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
})
|
||||
|
|
|
@ -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(())
|
||||
|
|
|
@ -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 => {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
@ -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| {
|
||||
|
|
|
@ -515,6 +515,7 @@ message ShutdownDevServer {
|
|||
message RenameDevServer {
|
||||
uint64 dev_server_id = 1;
|
||||
string name = 2;
|
||||
optional string ssh_connection_string = 3;
|
||||
}
|
||||
|
||||
message DeleteDevServer {
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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::*;
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
61
crates/ui/src/components/radio.rs
Normal file
61
crates/ui/src/components/radio.rs
Normal 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);
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue