Compare commits
11 Commits
v0.217.0-p
...
revert-pre
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee53b805f0 | ||
|
|
9e628505f3 | ||
|
|
3a84ec38ac | ||
|
|
a61bf33fb0 | ||
|
|
d83201256d | ||
|
|
8ee85eab3c | ||
|
|
5b309ef986 | ||
|
|
326ebb5230 | ||
|
|
f5babf96e1 | ||
|
|
f48aa252f8 | ||
|
|
4106c8a188 |
6
Cargo.lock
generated
6
Cargo.lock
generated
@@ -3595,6 +3595,7 @@ dependencies = [
|
||||
"settings",
|
||||
"smol",
|
||||
"tempfile",
|
||||
"terminal",
|
||||
"url",
|
||||
"util",
|
||||
]
|
||||
@@ -13156,6 +13157,7 @@ dependencies = [
|
||||
"askpass",
|
||||
"auto_update",
|
||||
"dap",
|
||||
"db",
|
||||
"editor",
|
||||
"extension_host",
|
||||
"file_finder",
|
||||
@@ -13167,6 +13169,7 @@ dependencies = [
|
||||
"log",
|
||||
"markdown",
|
||||
"menu",
|
||||
"node_runtime",
|
||||
"ordered-float 2.10.1",
|
||||
"paths",
|
||||
"picker",
|
||||
@@ -13185,6 +13188,7 @@ dependencies = [
|
||||
"util",
|
||||
"windows-registry 0.6.1",
|
||||
"workspace",
|
||||
"worktree",
|
||||
"zed_actions",
|
||||
]
|
||||
|
||||
@@ -20469,7 +20473,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zed"
|
||||
version = "0.217.0"
|
||||
version = "0.218.0"
|
||||
dependencies = [
|
||||
"acp_tools",
|
||||
"activity_indicator",
|
||||
|
||||
5
assets/icons/box.svg
Normal file
5
assets/icons/box.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13.3996 5.59852C13.3994 5.3881 13.3439 5.18144 13.2386 4.99926C13.1333 4.81709 12.9819 4.66581 12.7997 4.56059L8.59996 2.16076C8.41755 2.05544 8.21063 2 8 2C7.78937 2 7.58246 2.05544 7.40004 2.16076L3.20033 4.56059C3.0181 4.66581 2.86674 4.81709 2.76144 4.99926C2.65613 5.18144 2.60059 5.3881 2.60037 5.59852V10.3982C2.60059 10.6086 2.65613 10.8153 2.76144 10.9975C2.86674 11.1796 3.0181 11.3309 3.20033 11.4361L7.40004 13.836C7.58246 13.9413 7.78937 13.9967 8 13.9967C8.21063 13.9967 8.41755 13.9413 8.59996 13.836L12.7997 11.4361C12.9819 11.3309 13.1333 11.1796 13.2386 10.9975C13.3439 10.8153 13.3994 10.6086 13.3996 10.3982V5.59852Z" stroke="white" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M2.78033 4.99857L7.99998 7.99836L13.2196 4.99857" stroke="white" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8 13.9979V7.99829" stroke="white" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -180,7 +180,6 @@
|
||||
"ctrl-w g shift-d": "editor::GoToTypeDefinitionSplit",
|
||||
"ctrl-w space": "editor::OpenExcerptsSplit",
|
||||
"ctrl-w g space": "editor::OpenExcerptsSplit",
|
||||
"ctrl-6": "pane::AlternateFile",
|
||||
"ctrl-^": "pane::AlternateFile",
|
||||
".": "vim::Repeat"
|
||||
}
|
||||
|
||||
@@ -870,6 +870,10 @@
|
||||
//
|
||||
// Default: false
|
||||
"collapse_untracked_diff": false,
|
||||
/// Whether to show entries with tree or flat view in the panel
|
||||
///
|
||||
/// Default: false
|
||||
"tree_view": false,
|
||||
"scrollbar": {
|
||||
// When to show the scrollbar in the git panel.
|
||||
//
|
||||
|
||||
@@ -1372,7 +1372,7 @@ impl AcpThread {
|
||||
let path_style = self.project.read(cx).path_style(cx);
|
||||
let id = update.tool_call_id.clone();
|
||||
|
||||
let agent = self.connection().telemetry_id();
|
||||
let agent_telemetry_id = self.connection().telemetry_id();
|
||||
let session = self.session_id();
|
||||
if let ToolCallStatus::Completed | ToolCallStatus::Failed = status {
|
||||
let status = if matches!(status, ToolCallStatus::Completed) {
|
||||
@@ -1380,7 +1380,12 @@ impl AcpThread {
|
||||
} else {
|
||||
"failed"
|
||||
};
|
||||
telemetry::event!("Agent Tool Call Completed", agent, session, status);
|
||||
telemetry::event!(
|
||||
"Agent Tool Call Completed",
|
||||
agent_telemetry_id,
|
||||
session,
|
||||
status
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(ix) = self.index_for_tool_call(&id) {
|
||||
@@ -3556,8 +3561,8 @@ mod tests {
|
||||
}
|
||||
|
||||
impl AgentConnection for FakeAgentConnection {
|
||||
fn telemetry_id(&self) -> &'static str {
|
||||
"fake"
|
||||
fn telemetry_id(&self) -> SharedString {
|
||||
"fake".into()
|
||||
}
|
||||
|
||||
fn auth_methods(&self) -> &[acp::AuthMethod] {
|
||||
|
||||
@@ -20,7 +20,7 @@ impl UserMessageId {
|
||||
}
|
||||
|
||||
pub trait AgentConnection {
|
||||
fn telemetry_id(&self) -> &'static str;
|
||||
fn telemetry_id(&self) -> SharedString;
|
||||
|
||||
fn new_thread(
|
||||
self: Rc<Self>,
|
||||
@@ -322,8 +322,8 @@ mod test_support {
|
||||
}
|
||||
|
||||
impl AgentConnection for StubAgentConnection {
|
||||
fn telemetry_id(&self) -> &'static str {
|
||||
"stub"
|
||||
fn telemetry_id(&self) -> SharedString {
|
||||
"stub".into()
|
||||
}
|
||||
|
||||
fn auth_methods(&self) -> &[acp::AuthMethod] {
|
||||
|
||||
@@ -777,7 +777,7 @@ impl ActionLog {
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ActionLogTelemetry {
|
||||
pub agent_telemetry_id: &'static str,
|
||||
pub agent_telemetry_id: SharedString,
|
||||
pub session_id: Arc<str>,
|
||||
}
|
||||
|
||||
|
||||
@@ -947,8 +947,8 @@ impl acp_thread::AgentModelSelector for NativeAgentModelSelector {
|
||||
}
|
||||
|
||||
impl acp_thread::AgentConnection for NativeAgentConnection {
|
||||
fn telemetry_id(&self) -> &'static str {
|
||||
"zed"
|
||||
fn telemetry_id(&self) -> SharedString {
|
||||
"zed".into()
|
||||
}
|
||||
|
||||
fn new_thread(
|
||||
|
||||
@@ -21,10 +21,6 @@ impl NativeAgentServer {
|
||||
}
|
||||
|
||||
impl AgentServer for NativeAgentServer {
|
||||
fn telemetry_id(&self) -> &'static str {
|
||||
"zed"
|
||||
}
|
||||
|
||||
fn name(&self) -> SharedString {
|
||||
"Zed Agent".into()
|
||||
}
|
||||
|
||||
@@ -9,6 +9,10 @@ use futures::io::BufReader;
|
||||
use project::Project;
|
||||
use project::agent_server_store::AgentServerCommand;
|
||||
use serde::Deserialize;
|
||||
use settings::Settings as _;
|
||||
use task::ShellBuilder;
|
||||
#[cfg(windows)]
|
||||
use task::ShellKind;
|
||||
use util::ResultExt as _;
|
||||
|
||||
use std::path::PathBuf;
|
||||
@@ -21,7 +25,7 @@ use gpui::{App, AppContext as _, AsyncApp, Entity, SharedString, Task, WeakEntit
|
||||
|
||||
use acp_thread::{AcpThread, AuthRequired, LoadError, TerminalProviderEvent};
|
||||
use terminal::TerminalBuilder;
|
||||
use terminal::terminal_settings::{AlternateScroll, CursorShape};
|
||||
use terminal::terminal_settings::{AlternateScroll, CursorShape, TerminalSettings};
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
#[error("Unsupported version")]
|
||||
@@ -29,7 +33,7 @@ pub struct UnsupportedVersion;
|
||||
|
||||
pub struct AcpConnection {
|
||||
server_name: SharedString,
|
||||
telemetry_id: &'static str,
|
||||
telemetry_id: SharedString,
|
||||
connection: Rc<acp::ClientSideConnection>,
|
||||
sessions: Rc<RefCell<HashMap<acp::SessionId, AcpSession>>>,
|
||||
auth_methods: Vec<acp::AuthMethod>,
|
||||
@@ -54,7 +58,6 @@ pub struct AcpSession {
|
||||
|
||||
pub async fn connect(
|
||||
server_name: SharedString,
|
||||
telemetry_id: &'static str,
|
||||
command: AgentServerCommand,
|
||||
root_dir: &Path,
|
||||
default_mode: Option<acp::SessionModeId>,
|
||||
@@ -64,7 +67,6 @@ pub async fn connect(
|
||||
) -> Result<Rc<dyn AgentConnection>> {
|
||||
let conn = AcpConnection::stdio(
|
||||
server_name,
|
||||
telemetry_id,
|
||||
command.clone(),
|
||||
root_dir,
|
||||
default_mode,
|
||||
@@ -81,7 +83,6 @@ const MINIMUM_SUPPORTED_VERSION: acp::ProtocolVersion = acp::ProtocolVersion::V1
|
||||
impl AcpConnection {
|
||||
pub async fn stdio(
|
||||
server_name: SharedString,
|
||||
telemetry_id: &'static str,
|
||||
command: AgentServerCommand,
|
||||
root_dir: &Path,
|
||||
default_mode: Option<acp::SessionModeId>,
|
||||
@@ -89,9 +90,26 @@ impl AcpConnection {
|
||||
is_remote: bool,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Result<Self> {
|
||||
let mut child = util::command::new_smol_command(&command.path);
|
||||
let shell = cx.update(|cx| TerminalSettings::get(None, cx).shell.clone())?;
|
||||
let builder = ShellBuilder::new(&shell, cfg!(windows));
|
||||
#[cfg(windows)]
|
||||
let kind = builder.kind();
|
||||
let (cmd, args) = builder.build(Some(command.path.display().to_string()), &command.args);
|
||||
|
||||
let mut child = util::command::new_smol_command(cmd);
|
||||
#[cfg(windows)]
|
||||
if kind == ShellKind::Cmd {
|
||||
use smol::process::windows::CommandExt;
|
||||
for arg in args {
|
||||
child.raw_arg(arg);
|
||||
}
|
||||
} else {
|
||||
child.args(args);
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
child.args(args);
|
||||
|
||||
child
|
||||
.args(command.args.iter().map(|arg| arg.as_str()))
|
||||
.envs(command.env.iter().flatten())
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
@@ -199,6 +217,13 @@ impl AcpConnection {
|
||||
return Err(UnsupportedVersion.into());
|
||||
}
|
||||
|
||||
let telemetry_id = response
|
||||
.agent_info
|
||||
// Use the one the agent provides if we have one
|
||||
.map(|info| info.name.into())
|
||||
// Otherwise, just use the name
|
||||
.unwrap_or_else(|| server_name.clone());
|
||||
|
||||
Ok(Self {
|
||||
auth_methods: response.auth_methods,
|
||||
root_dir: root_dir.to_owned(),
|
||||
@@ -233,8 +258,8 @@ impl Drop for AcpConnection {
|
||||
}
|
||||
|
||||
impl AgentConnection for AcpConnection {
|
||||
fn telemetry_id(&self) -> &'static str {
|
||||
self.telemetry_id
|
||||
fn telemetry_id(&self) -> SharedString {
|
||||
self.telemetry_id.clone()
|
||||
}
|
||||
|
||||
fn new_thread(
|
||||
|
||||
@@ -56,7 +56,6 @@ impl AgentServerDelegate {
|
||||
pub trait AgentServer: Send {
|
||||
fn logo(&self) -> ui::IconName;
|
||||
fn name(&self) -> SharedString;
|
||||
fn telemetry_id(&self) -> &'static str;
|
||||
fn default_mode(&self, _cx: &mut App) -> Option<agent_client_protocol::SessionModeId> {
|
||||
None
|
||||
}
|
||||
|
||||
@@ -22,10 +22,6 @@ pub struct AgentServerLoginCommand {
|
||||
}
|
||||
|
||||
impl AgentServer for ClaudeCode {
|
||||
fn telemetry_id(&self) -> &'static str {
|
||||
"claude-code"
|
||||
}
|
||||
|
||||
fn name(&self) -> SharedString {
|
||||
"Claude Code".into()
|
||||
}
|
||||
@@ -83,7 +79,6 @@ impl AgentServer for ClaudeCode {
|
||||
cx: &mut App,
|
||||
) -> Task<Result<(Rc<dyn AgentConnection>, Option<task::SpawnInTerminal>)>> {
|
||||
let name = self.name();
|
||||
let telemetry_id = self.telemetry_id();
|
||||
let root_dir = root_dir.map(|root_dir| root_dir.to_string_lossy().into_owned());
|
||||
let is_remote = delegate.project.read(cx).is_via_remote_server();
|
||||
let store = delegate.store.downgrade();
|
||||
@@ -108,7 +103,6 @@ impl AgentServer for ClaudeCode {
|
||||
.await?;
|
||||
let connection = crate::acp::connect(
|
||||
name,
|
||||
telemetry_id,
|
||||
command,
|
||||
root_dir.as_ref(),
|
||||
default_mode,
|
||||
|
||||
@@ -23,10 +23,6 @@ pub(crate) mod tests {
|
||||
}
|
||||
|
||||
impl AgentServer for Codex {
|
||||
fn telemetry_id(&self) -> &'static str {
|
||||
"codex"
|
||||
}
|
||||
|
||||
fn name(&self) -> SharedString {
|
||||
"Codex".into()
|
||||
}
|
||||
@@ -84,7 +80,6 @@ impl AgentServer for Codex {
|
||||
cx: &mut App,
|
||||
) -> Task<Result<(Rc<dyn AgentConnection>, Option<task::SpawnInTerminal>)>> {
|
||||
let name = self.name();
|
||||
let telemetry_id = self.telemetry_id();
|
||||
let root_dir = root_dir.map(|root_dir| root_dir.to_string_lossy().into_owned());
|
||||
let is_remote = delegate.project.read(cx).is_via_remote_server();
|
||||
let store = delegate.store.downgrade();
|
||||
@@ -110,7 +105,6 @@ impl AgentServer for Codex {
|
||||
|
||||
let connection = crate::acp::connect(
|
||||
name,
|
||||
telemetry_id,
|
||||
command,
|
||||
root_dir.as_ref(),
|
||||
default_mode,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::{AgentServerDelegate, load_proxy_env};
|
||||
use crate::{AgentServer, AgentServerDelegate, load_proxy_env};
|
||||
use acp_thread::AgentConnection;
|
||||
use agent_client_protocol as acp;
|
||||
use anyhow::{Context as _, Result};
|
||||
@@ -20,11 +20,7 @@ impl CustomAgentServer {
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::AgentServer for CustomAgentServer {
|
||||
fn telemetry_id(&self) -> &'static str {
|
||||
"custom"
|
||||
}
|
||||
|
||||
impl AgentServer for CustomAgentServer {
|
||||
fn name(&self) -> SharedString {
|
||||
self.name.clone()
|
||||
}
|
||||
@@ -112,14 +108,12 @@ impl crate::AgentServer for CustomAgentServer {
|
||||
cx: &mut App,
|
||||
) -> Task<Result<(Rc<dyn AgentConnection>, Option<task::SpawnInTerminal>)>> {
|
||||
let name = self.name();
|
||||
let telemetry_id = self.telemetry_id();
|
||||
let root_dir = root_dir.map(|root_dir| root_dir.to_string_lossy().into_owned());
|
||||
let is_remote = delegate.project.read(cx).is_via_remote_server();
|
||||
let default_mode = self.default_mode(cx);
|
||||
let default_model = self.default_model(cx);
|
||||
let store = delegate.store.downgrade();
|
||||
let extra_env = load_proxy_env(cx);
|
||||
|
||||
cx.spawn(async move |cx| {
|
||||
let (command, root_dir, login) = store
|
||||
.update(cx, |store, cx| {
|
||||
@@ -139,7 +133,6 @@ impl crate::AgentServer for CustomAgentServer {
|
||||
.await?;
|
||||
let connection = crate::acp::connect(
|
||||
name,
|
||||
telemetry_id,
|
||||
command,
|
||||
root_dir.as_ref(),
|
||||
default_mode,
|
||||
|
||||
@@ -12,10 +12,6 @@ use project::agent_server_store::GEMINI_NAME;
|
||||
pub struct Gemini;
|
||||
|
||||
impl AgentServer for Gemini {
|
||||
fn telemetry_id(&self) -> &'static str {
|
||||
"gemini-cli"
|
||||
}
|
||||
|
||||
fn name(&self) -> SharedString {
|
||||
"Gemini CLI".into()
|
||||
}
|
||||
@@ -31,7 +27,6 @@ impl AgentServer for Gemini {
|
||||
cx: &mut App,
|
||||
) -> Task<Result<(Rc<dyn AgentConnection>, Option<task::SpawnInTerminal>)>> {
|
||||
let name = self.name();
|
||||
let telemetry_id = self.telemetry_id();
|
||||
let root_dir = root_dir.map(|root_dir| root_dir.to_string_lossy().into_owned());
|
||||
let is_remote = delegate.project.read(cx).is_via_remote_server();
|
||||
let store = delegate.store.downgrade();
|
||||
@@ -66,7 +61,6 @@ impl AgentServer for Gemini {
|
||||
|
||||
let connection = crate::acp::connect(
|
||||
name,
|
||||
telemetry_id,
|
||||
command,
|
||||
root_dir.as_ref(),
|
||||
default_mode,
|
||||
|
||||
@@ -565,8 +565,26 @@ impl MessageEditor {
|
||||
if let Some((workspace, selections)) =
|
||||
self.workspace.upgrade().zip(editor_clipboard_selections)
|
||||
{
|
||||
cx.stop_propagation();
|
||||
let Some(first_selection) = selections.first() else {
|
||||
return;
|
||||
};
|
||||
if let Some(file_path) = &first_selection.file_path {
|
||||
// In case someone pastes selections from another window
|
||||
// with a different project, we don't want to insert the
|
||||
// crease (containing the absolute path) since the agent
|
||||
// cannot access files outside the project.
|
||||
let is_in_project = workspace
|
||||
.read(cx)
|
||||
.project()
|
||||
.read(cx)
|
||||
.project_path_for_absolute_path(file_path, cx)
|
||||
.is_some();
|
||||
if !is_in_project {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
cx.stop_propagation();
|
||||
let insertion_target = self
|
||||
.editor
|
||||
.read(cx)
|
||||
|
||||
@@ -170,7 +170,7 @@ impl ThreadFeedbackState {
|
||||
}
|
||||
}
|
||||
let session_id = thread.read(cx).session_id().clone();
|
||||
let agent = thread.read(cx).connection().telemetry_id();
|
||||
let agent_telemetry_id = thread.read(cx).connection().telemetry_id();
|
||||
let task = telemetry.thread_data(&session_id, cx);
|
||||
let rating = match feedback {
|
||||
ThreadFeedback::Positive => "positive",
|
||||
@@ -180,7 +180,7 @@ impl ThreadFeedbackState {
|
||||
let thread = task.await?;
|
||||
telemetry::event!(
|
||||
"Agent Thread Rated",
|
||||
agent = agent,
|
||||
agent = agent_telemetry_id,
|
||||
session_id = session_id,
|
||||
rating = rating,
|
||||
thread = thread
|
||||
@@ -207,13 +207,13 @@ impl ThreadFeedbackState {
|
||||
self.comments_editor.take();
|
||||
|
||||
let session_id = thread.read(cx).session_id().clone();
|
||||
let agent = thread.read(cx).connection().telemetry_id();
|
||||
let agent_telemetry_id = thread.read(cx).connection().telemetry_id();
|
||||
let task = telemetry.thread_data(&session_id, cx);
|
||||
cx.background_spawn(async move {
|
||||
let thread = task.await?;
|
||||
telemetry::event!(
|
||||
"Agent Thread Feedback Comments",
|
||||
agent = agent,
|
||||
agent = agent_telemetry_id,
|
||||
session_id = session_id,
|
||||
comments = comments,
|
||||
thread = thread
|
||||
@@ -333,6 +333,7 @@ impl AcpThreadView {
|
||||
project: Entity<Project>,
|
||||
history_store: Entity<HistoryStore>,
|
||||
prompt_store: Option<Entity<PromptStore>>,
|
||||
track_load_event: bool,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
@@ -391,8 +392,9 @@ impl AcpThreadView {
|
||||
),
|
||||
];
|
||||
|
||||
let show_codex_windows_warning = crate::ExternalAgent::parse_built_in(agent.as_ref())
|
||||
== Some(crate::ExternalAgent::Codex);
|
||||
let show_codex_windows_warning = cfg!(windows)
|
||||
&& project.read(cx).is_local()
|
||||
&& agent.clone().downcast::<agent_servers::Codex>().is_some();
|
||||
|
||||
Self {
|
||||
agent: agent.clone(),
|
||||
@@ -404,6 +406,7 @@ impl AcpThreadView {
|
||||
resume_thread.clone(),
|
||||
workspace.clone(),
|
||||
project.clone(),
|
||||
track_load_event,
|
||||
window,
|
||||
cx,
|
||||
),
|
||||
@@ -448,6 +451,7 @@ impl AcpThreadView {
|
||||
self.resume_thread_metadata.clone(),
|
||||
self.workspace.clone(),
|
||||
self.project.clone(),
|
||||
true,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
@@ -461,6 +465,7 @@ impl AcpThreadView {
|
||||
resume_thread: Option<DbThreadMetadata>,
|
||||
workspace: WeakEntity<Workspace>,
|
||||
project: Entity<Project>,
|
||||
track_load_event: bool,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> ThreadState {
|
||||
@@ -519,6 +524,10 @@ impl AcpThreadView {
|
||||
}
|
||||
};
|
||||
|
||||
if track_load_event {
|
||||
telemetry::event!("Agent Thread Started", agent = connection.telemetry_id());
|
||||
}
|
||||
|
||||
let result = if let Some(native_agent) = connection
|
||||
.clone()
|
||||
.downcast::<agent::NativeAgentConnection>()
|
||||
@@ -1133,8 +1142,8 @@ impl AcpThreadView {
|
||||
let Some(thread) = self.thread() else {
|
||||
return;
|
||||
};
|
||||
let agent_telemetry_id = self.agent.telemetry_id();
|
||||
let session_id = thread.read(cx).session_id().clone();
|
||||
let agent_telemetry_id = thread.read(cx).connection().telemetry_id();
|
||||
let thread = thread.downgrade();
|
||||
if self.should_be_following {
|
||||
self.workspace
|
||||
@@ -1512,6 +1521,7 @@ impl AcpThreadView {
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let agent_telemetry_id = connection.telemetry_id();
|
||||
|
||||
// Check for the experimental "terminal-auth" _meta field
|
||||
let auth_method = connection.auth_methods().iter().find(|m| m.id == method);
|
||||
@@ -1579,19 +1589,18 @@ impl AcpThreadView {
|
||||
);
|
||||
cx.notify();
|
||||
self.auth_task = Some(cx.spawn_in(window, {
|
||||
let agent = self.agent.clone();
|
||||
async move |this, cx| {
|
||||
let result = authenticate.await;
|
||||
|
||||
match &result {
|
||||
Ok(_) => telemetry::event!(
|
||||
"Authenticate Agent Succeeded",
|
||||
agent = agent.telemetry_id()
|
||||
agent = agent_telemetry_id
|
||||
),
|
||||
Err(_) => {
|
||||
telemetry::event!(
|
||||
"Authenticate Agent Failed",
|
||||
agent = agent.telemetry_id(),
|
||||
agent = agent_telemetry_id,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1675,6 +1684,7 @@ impl AcpThreadView {
|
||||
None,
|
||||
this.workspace.clone(),
|
||||
this.project.clone(),
|
||||
true,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
@@ -1730,43 +1740,38 @@ impl AcpThreadView {
|
||||
connection.authenticate(method, cx)
|
||||
};
|
||||
cx.notify();
|
||||
self.auth_task =
|
||||
Some(cx.spawn_in(window, {
|
||||
let agent = self.agent.clone();
|
||||
async move |this, cx| {
|
||||
let result = authenticate.await;
|
||||
self.auth_task = Some(cx.spawn_in(window, {
|
||||
async move |this, cx| {
|
||||
let result = authenticate.await;
|
||||
|
||||
match &result {
|
||||
Ok(_) => telemetry::event!(
|
||||
"Authenticate Agent Succeeded",
|
||||
agent = agent.telemetry_id()
|
||||
),
|
||||
Err(_) => {
|
||||
telemetry::event!(
|
||||
"Authenticate Agent Failed",
|
||||
agent = agent.telemetry_id(),
|
||||
)
|
||||
}
|
||||
match &result {
|
||||
Ok(_) => telemetry::event!(
|
||||
"Authenticate Agent Succeeded",
|
||||
agent = agent_telemetry_id
|
||||
),
|
||||
Err(_) => {
|
||||
telemetry::event!("Authenticate Agent Failed", agent = agent_telemetry_id,)
|
||||
}
|
||||
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
if let Err(err) = result {
|
||||
if let ThreadState::Unauthenticated {
|
||||
pending_auth_method,
|
||||
..
|
||||
} = &mut this.thread_state
|
||||
{
|
||||
pending_auth_method.take();
|
||||
}
|
||||
this.handle_thread_error(err, cx);
|
||||
} else {
|
||||
this.reset(window, cx);
|
||||
}
|
||||
this.auth_task.take()
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}));
|
||||
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
if let Err(err) = result {
|
||||
if let ThreadState::Unauthenticated {
|
||||
pending_auth_method,
|
||||
..
|
||||
} = &mut this.thread_state
|
||||
{
|
||||
pending_auth_method.take();
|
||||
}
|
||||
this.handle_thread_error(err, cx);
|
||||
} else {
|
||||
this.reset(window, cx);
|
||||
}
|
||||
this.auth_task.take()
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
fn spawn_external_agent_login(
|
||||
@@ -1896,10 +1901,11 @@ impl AcpThreadView {
|
||||
let Some(thread) = self.thread() else {
|
||||
return;
|
||||
};
|
||||
let agent_telemetry_id = thread.read(cx).connection().telemetry_id();
|
||||
|
||||
telemetry::event!(
|
||||
"Agent Tool Call Authorized",
|
||||
agent = self.agent.telemetry_id(),
|
||||
agent = agent_telemetry_id,
|
||||
session = thread.read(cx).session_id(),
|
||||
option = option_kind
|
||||
);
|
||||
@@ -3509,6 +3515,8 @@ impl AcpThreadView {
|
||||
(method.id.0.clone(), method.name.clone())
|
||||
};
|
||||
|
||||
let agent_telemetry_id = connection.telemetry_id();
|
||||
|
||||
Button::new(method_id.clone(), name)
|
||||
.label_size(LabelSize::Small)
|
||||
.map(|this| {
|
||||
@@ -3528,7 +3536,7 @@ impl AcpThreadView {
|
||||
cx.listener(move |this, _, window, cx| {
|
||||
telemetry::event!(
|
||||
"Authenticate Agent Started",
|
||||
agent = this.agent.telemetry_id(),
|
||||
agent = agent_telemetry_id,
|
||||
method = method_id
|
||||
);
|
||||
|
||||
@@ -5376,47 +5384,39 @@ impl AcpThreadView {
|
||||
)
|
||||
}
|
||||
|
||||
fn render_codex_windows_warning(&self, cx: &mut Context<Self>) -> Option<Callout> {
|
||||
if self.show_codex_windows_warning {
|
||||
Some(
|
||||
Callout::new()
|
||||
.icon(IconName::Warning)
|
||||
.severity(Severity::Warning)
|
||||
.title("Codex on Windows")
|
||||
.description(
|
||||
"For best performance, run Codex in Windows Subsystem for Linux (WSL2)",
|
||||
)
|
||||
.actions_slot(
|
||||
Button::new("open-wsl-modal", "Open in WSL")
|
||||
.icon_size(IconSize::Small)
|
||||
.icon_color(Color::Muted)
|
||||
.on_click(cx.listener({
|
||||
move |_, _, _window, cx| {
|
||||
#[cfg(windows)]
|
||||
_window.dispatch_action(
|
||||
zed_actions::wsl_actions::OpenWsl::default().boxed_clone(),
|
||||
cx,
|
||||
);
|
||||
cx.notify();
|
||||
}
|
||||
})),
|
||||
)
|
||||
.dismiss_action(
|
||||
IconButton::new("dismiss", IconName::Close)
|
||||
.icon_size(IconSize::Small)
|
||||
.icon_color(Color::Muted)
|
||||
.tooltip(Tooltip::text("Dismiss Warning"))
|
||||
.on_click(cx.listener({
|
||||
move |this, _, _, cx| {
|
||||
this.show_codex_windows_warning = false;
|
||||
cx.notify();
|
||||
}
|
||||
})),
|
||||
),
|
||||
fn render_codex_windows_warning(&self, cx: &mut Context<Self>) -> Callout {
|
||||
Callout::new()
|
||||
.icon(IconName::Warning)
|
||||
.severity(Severity::Warning)
|
||||
.title("Codex on Windows")
|
||||
.description("For best performance, run Codex in Windows Subsystem for Linux (WSL2)")
|
||||
.actions_slot(
|
||||
Button::new("open-wsl-modal", "Open in WSL")
|
||||
.icon_size(IconSize::Small)
|
||||
.icon_color(Color::Muted)
|
||||
.on_click(cx.listener({
|
||||
move |_, _, _window, cx| {
|
||||
#[cfg(windows)]
|
||||
_window.dispatch_action(
|
||||
zed_actions::wsl_actions::OpenWsl::default().boxed_clone(),
|
||||
cx,
|
||||
);
|
||||
cx.notify();
|
||||
}
|
||||
})),
|
||||
)
|
||||
.dismiss_action(
|
||||
IconButton::new("dismiss", IconName::Close)
|
||||
.icon_size(IconSize::Small)
|
||||
.icon_color(Color::Muted)
|
||||
.tooltip(Tooltip::text("Dismiss Warning"))
|
||||
.on_click(cx.listener({
|
||||
move |this, _, _, cx| {
|
||||
this.show_codex_windows_warning = false;
|
||||
cx.notify();
|
||||
}
|
||||
})),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn render_thread_error(&mut self, window: &mut Window, cx: &mut Context<Self>) -> Option<Div> {
|
||||
@@ -5936,12 +5936,8 @@ impl Render for AcpThreadView {
|
||||
_ => this,
|
||||
})
|
||||
.children(self.render_thread_retry_status_callout(window, cx))
|
||||
.children({
|
||||
if cfg!(windows) && self.project.read(cx).is_local() {
|
||||
self.render_codex_windows_warning(cx)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
.when(self.show_codex_windows_warning, |this| {
|
||||
this.child(self.render_codex_windows_warning(cx))
|
||||
})
|
||||
.children(self.render_thread_error(window, cx))
|
||||
.when_some(
|
||||
@@ -6398,6 +6394,7 @@ pub(crate) mod tests {
|
||||
project,
|
||||
history_store,
|
||||
None,
|
||||
false,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
@@ -6475,10 +6472,6 @@ pub(crate) mod tests {
|
||||
where
|
||||
C: 'static + AgentConnection + Send + Clone,
|
||||
{
|
||||
fn telemetry_id(&self) -> &'static str {
|
||||
"test"
|
||||
}
|
||||
|
||||
fn logo(&self) -> ui::IconName {
|
||||
ui::IconName::Ai
|
||||
}
|
||||
@@ -6505,8 +6498,8 @@ pub(crate) mod tests {
|
||||
struct SaboteurAgentConnection;
|
||||
|
||||
impl AgentConnection for SaboteurAgentConnection {
|
||||
fn telemetry_id(&self) -> &'static str {
|
||||
"saboteur"
|
||||
fn telemetry_id(&self) -> SharedString {
|
||||
"saboteur".into()
|
||||
}
|
||||
|
||||
fn new_thread(
|
||||
@@ -6569,8 +6562,8 @@ pub(crate) mod tests {
|
||||
struct RefusalAgentConnection;
|
||||
|
||||
impl AgentConnection for RefusalAgentConnection {
|
||||
fn telemetry_id(&self) -> &'static str {
|
||||
"refusal"
|
||||
fn telemetry_id(&self) -> SharedString {
|
||||
"refusal".into()
|
||||
}
|
||||
|
||||
fn new_thread(
|
||||
@@ -6671,6 +6664,7 @@ pub(crate) mod tests {
|
||||
project.clone(),
|
||||
history_store.clone(),
|
||||
None,
|
||||
false,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
|
||||
@@ -305,6 +305,7 @@ impl ActiveView {
|
||||
project,
|
||||
history_store,
|
||||
prompt_store,
|
||||
false,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
@@ -885,10 +886,6 @@ impl AgentPanel {
|
||||
|
||||
let server = ext_agent.server(fs, history);
|
||||
|
||||
if !loading {
|
||||
telemetry::event!("Agent Thread Started", agent = server.telemetry_id());
|
||||
}
|
||||
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
let selected_agent = ext_agent.into();
|
||||
if this.selected_agent != selected_agent {
|
||||
@@ -905,6 +902,7 @@ impl AgentPanel {
|
||||
project,
|
||||
this.history_store.clone(),
|
||||
this.prompt_store.clone(),
|
||||
!loading,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
|
||||
@@ -160,16 +160,6 @@ pub enum ExternalAgent {
|
||||
}
|
||||
|
||||
impl ExternalAgent {
|
||||
pub fn parse_built_in(server: &dyn agent_servers::AgentServer) -> Option<Self> {
|
||||
match server.telemetry_id() {
|
||||
"gemini-cli" => Some(Self::Gemini),
|
||||
"claude-code" => Some(Self::ClaudeCode),
|
||||
"codex" => Some(Self::Codex),
|
||||
"zed" => Some(Self::NativeAgent),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn server(
|
||||
&self,
|
||||
fs: Arc<dyn fs::Fs>,
|
||||
|
||||
@@ -33,6 +33,7 @@ smol.workspace = true
|
||||
tempfile.workspace = true
|
||||
url = { workspace = true, features = ["serde"] }
|
||||
util.workspace = true
|
||||
terminal.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
gpui = { workspace = true, features = ["test-support"] }
|
||||
|
||||
@@ -8,9 +8,12 @@ use futures::{
|
||||
AsyncBufReadExt as _, AsyncRead, AsyncWrite, AsyncWriteExt as _, Stream, StreamExt as _,
|
||||
};
|
||||
use gpui::AsyncApp;
|
||||
use settings::Settings as _;
|
||||
use smol::channel;
|
||||
use smol::process::Child;
|
||||
use terminal::terminal_settings::TerminalSettings;
|
||||
use util::TryFutureExt as _;
|
||||
use util::shell_builder::ShellBuilder;
|
||||
|
||||
use crate::client::ModelContextServerBinary;
|
||||
use crate::transport::Transport;
|
||||
@@ -28,9 +31,14 @@ impl StdioTransport {
|
||||
working_directory: &Option<PathBuf>,
|
||||
cx: &AsyncApp,
|
||||
) -> Result<Self> {
|
||||
let mut command = util::command::new_smol_command(&binary.executable);
|
||||
let shell = cx.update(|cx| TerminalSettings::get(None, cx).shell.clone())?;
|
||||
let builder = ShellBuilder::new(&shell, cfg!(windows));
|
||||
let (command, args) =
|
||||
builder.build(Some(binary.executable.display().to_string()), &binary.args);
|
||||
|
||||
let mut command = util::command::new_smol_command(command);
|
||||
command
|
||||
.args(&binary.args)
|
||||
.args(args)
|
||||
.envs(binary.env.unwrap_or_default())
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
|
||||
@@ -280,7 +280,11 @@ pub fn deploy_context_menu(
|
||||
"Copy Permalink",
|
||||
Box::new(CopyPermalinkToLine),
|
||||
)
|
||||
.action_disabled_when(!has_git_repo, "File History", Box::new(git::FileHistory));
|
||||
.action_disabled_when(
|
||||
!has_git_repo,
|
||||
"View File History",
|
||||
Box::new(git::FileHistory),
|
||||
);
|
||||
match focus {
|
||||
Some(focus) => builder.context(focus),
|
||||
None => builder,
|
||||
|
||||
@@ -1819,7 +1819,6 @@ impl GitRepository for RealGitRepository {
|
||||
.args(["commit", "--quiet", "-m"])
|
||||
.arg(&message.to_string())
|
||||
.arg("--cleanup=strip")
|
||||
.arg("--no-verify")
|
||||
.stdout(smol::process::Stdio::piped())
|
||||
.stderr(smol::process::Stdio::piped());
|
||||
|
||||
@@ -2295,8 +2294,38 @@ impl GitRepository for RealGitRepository {
|
||||
self.executor
|
||||
.spawn(async move {
|
||||
let working_directory = working_directory?;
|
||||
let git = GitBinary::new(git_binary_path, working_directory, executor)
|
||||
let git = GitBinary::new(git_binary_path, working_directory.clone(), executor)
|
||||
.envs(HashMap::clone(&env));
|
||||
|
||||
let output = git.run(&["help", "-a"]).await?;
|
||||
if !output.lines().any(|line| line.trim().starts_with("hook ")) {
|
||||
log::warn!(
|
||||
"git hook command not available, running the {} hook manually",
|
||||
hook.as_str()
|
||||
);
|
||||
|
||||
let hook_abs_path = working_directory
|
||||
.join(".git")
|
||||
.join("hooks")
|
||||
.join(hook.as_str());
|
||||
if hook_abs_path.is_file() {
|
||||
let output = new_smol_command(&hook_abs_path)
|
||||
.envs(env.iter())
|
||||
.current_dir(&working_directory)
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
anyhow::ensure!(
|
||||
output.status.success(),
|
||||
"{} hook failed:\n{}",
|
||||
hook.as_str(),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
git.run(&["hook", "run", "--ignore-missing", hook.as_str()])
|
||||
.await?;
|
||||
Ok(())
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,7 @@ pub struct GitPanelSettings {
|
||||
pub fallback_branch_name: String,
|
||||
pub sort_by_path: bool,
|
||||
pub collapse_untracked_diff: bool,
|
||||
pub tree_view: bool,
|
||||
}
|
||||
|
||||
impl ScrollbarVisibility for GitPanelSettings {
|
||||
@@ -56,6 +57,7 @@ impl Settings for GitPanelSettings {
|
||||
fallback_branch_name: git_panel.fallback_branch_name.unwrap(),
|
||||
sort_by_path: git_panel.sort_by_path.unwrap(),
|
||||
collapse_untracked_diff: git_panel.collapse_untracked_diff.unwrap(),
|
||||
tree_view: git_panel.tree_view.unwrap(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -644,7 +644,10 @@ impl ProjectDiff {
|
||||
}
|
||||
|
||||
fn sort_prefix(repo: &Repository, repo_path: &RepoPath, status: FileStatus, cx: &App) -> u64 {
|
||||
if GitPanelSettings::get_global(cx).sort_by_path {
|
||||
let settings = GitPanelSettings::get_global(cx);
|
||||
|
||||
// Tree view can only sort by path
|
||||
if settings.sort_by_path || settings.tree_view {
|
||||
TRACKED_SORT_PREFIX
|
||||
} else if repo.had_conflict_on_last_merge_head_change(repo_path) {
|
||||
CONFLICT_SORT_PREFIX
|
||||
|
||||
@@ -49,6 +49,7 @@ pub enum IconName {
|
||||
BoltOutlined,
|
||||
Book,
|
||||
BookCopy,
|
||||
Box,
|
||||
CaseSensitive,
|
||||
Chat,
|
||||
Check,
|
||||
|
||||
@@ -126,11 +126,11 @@ impl LspInstaller for EsLintLspAdapter {
|
||||
}
|
||||
|
||||
self.node
|
||||
.run_npm_subcommand(&repo_root, "install", &[])
|
||||
.run_npm_subcommand(Some(&repo_root), "install", &[])
|
||||
.await?;
|
||||
|
||||
self.node
|
||||
.run_npm_subcommand(&repo_root, "run-script", &["compile"])
|
||||
.run_npm_subcommand(Some(&repo_root), "run-script", &["compile"])
|
||||
.await?;
|
||||
}
|
||||
|
||||
|
||||
@@ -1344,7 +1344,7 @@ impl ToolchainLister for PythonToolchainProvider {
|
||||
ShellKind::Fish => Some(format!("\"{pyenv}\" shell - fish {version}")),
|
||||
ShellKind::Posix => Some(format!("\"{pyenv}\" shell - sh {version}")),
|
||||
ShellKind::Nushell => Some(format!("^\"{pyenv}\" shell - nu {version}")),
|
||||
ShellKind::PowerShell => None,
|
||||
ShellKind::PowerShell | ShellKind::Pwsh => None,
|
||||
ShellKind::Csh => None,
|
||||
ShellKind::Tcsh => None,
|
||||
ShellKind::Cmd => None,
|
||||
|
||||
@@ -206,14 +206,14 @@ impl NodeRuntime {
|
||||
|
||||
pub async fn run_npm_subcommand(
|
||||
&self,
|
||||
directory: &Path,
|
||||
directory: Option<&Path>,
|
||||
subcommand: &str,
|
||||
args: &[&str],
|
||||
) -> Result<Output> {
|
||||
let http = self.0.lock().await.http.clone();
|
||||
self.instance()
|
||||
.await
|
||||
.run_npm_subcommand(Some(directory), http.proxy(), subcommand, args)
|
||||
.run_npm_subcommand(directory, http.proxy(), subcommand, args)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -283,7 +283,7 @@ impl NodeRuntime {
|
||||
]);
|
||||
|
||||
// This is also wrong because the directory is wrong.
|
||||
self.run_npm_subcommand(directory, "install", &arguments)
|
||||
self.run_npm_subcommand(Some(directory), "install", &arguments)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -559,7 +559,10 @@ impl NodeRuntimeTrait for ManagedNodeRuntime {
|
||||
command.env("PATH", env_path);
|
||||
command.env(NODE_CA_CERTS_ENV_VAR, node_ca_certs);
|
||||
command.arg(npm_file).arg(subcommand);
|
||||
command.args(["--cache".into(), self.installation_path.join("cache")]);
|
||||
command.arg(format!(
|
||||
"--cache={}",
|
||||
self.installation_path.join("cache").display()
|
||||
));
|
||||
command.args([
|
||||
"--userconfig".into(),
|
||||
self.installation_path.join("blank_user_npmrc"),
|
||||
@@ -703,7 +706,10 @@ impl NodeRuntimeTrait for SystemNodeRuntime {
|
||||
.env("PATH", path)
|
||||
.env(NODE_CA_CERTS_ENV_VAR, node_ca_certs)
|
||||
.arg(subcommand)
|
||||
.args(["--cache".into(), self.scratch_dir.join("cache")])
|
||||
.arg(format!(
|
||||
"--cache={}",
|
||||
self.scratch_dir.join("cache").display()
|
||||
))
|
||||
.args(args);
|
||||
configure_npm_command(&mut command, directory, proxy);
|
||||
let output = command.output().await?;
|
||||
|
||||
@@ -408,6 +408,12 @@ pub fn remote_servers_dir() -> &'static PathBuf {
|
||||
REMOTE_SERVERS_DIR.get_or_init(|| data_dir().join("remote_servers"))
|
||||
}
|
||||
|
||||
/// Returns the path to the directory where the devcontainer CLI is installed.
|
||||
pub fn devcontainer_dir() -> &'static PathBuf {
|
||||
static DEVCONTAINER_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
DEVCONTAINER_DIR.get_or_init(|| data_dir().join("devcontainer"))
|
||||
}
|
||||
|
||||
/// Returns the relative path to a `.zed` folder within a project.
|
||||
pub fn local_settings_folder_name() -> &'static str {
|
||||
".zed"
|
||||
|
||||
@@ -411,11 +411,11 @@ impl ContextServerStore {
|
||||
) {
|
||||
self.stop_server(&id, cx).log_err();
|
||||
}
|
||||
|
||||
let task = cx.spawn({
|
||||
let id = server.id();
|
||||
let server = server.clone();
|
||||
let configuration = configuration.clone();
|
||||
|
||||
async move |this, cx| {
|
||||
match server.clone().start(cx).await {
|
||||
Ok(_) => {
|
||||
|
||||
@@ -4570,17 +4570,13 @@ impl Repository {
|
||||
name_and_email: Option<(SharedString, SharedString)>,
|
||||
options: CommitOptions,
|
||||
askpass: AskPassDelegate,
|
||||
cx: &mut App,
|
||||
_cx: &mut App,
|
||||
) -> oneshot::Receiver<Result<()>> {
|
||||
let id = self.id;
|
||||
let askpass_delegates = self.askpass_delegates.clone();
|
||||
let askpass_id = util::post_inc(&mut self.latest_askpass_id);
|
||||
|
||||
let rx = self.run_hook(RunHook::PreCommit, cx);
|
||||
|
||||
self.send_job(Some("git commit".into()), move |git_repo, _cx| async move {
|
||||
rx.await??;
|
||||
|
||||
match git_repo {
|
||||
RepositoryState::Local(LocalRepositoryState {
|
||||
backend,
|
||||
|
||||
@@ -1142,7 +1142,7 @@ impl ProjectPanel {
|
||||
)
|
||||
.when(has_git_repo, |menu| {
|
||||
menu.separator()
|
||||
.action("File History", Box::new(git::FileHistory))
|
||||
.action("View File History", Box::new(git::FileHistory))
|
||||
})
|
||||
.when(!should_hide_rename, |menu| {
|
||||
menu.separator().action("Rename", Box::new(Rename))
|
||||
|
||||
@@ -16,6 +16,7 @@ doctest = false
|
||||
anyhow.workspace = true
|
||||
askpass.workspace = true
|
||||
auto_update.workspace = true
|
||||
db.workspace = true
|
||||
editor.workspace = true
|
||||
extension_host.workspace = true
|
||||
file_finder.workspace = true
|
||||
@@ -26,6 +27,7 @@ language.workspace = true
|
||||
log.workspace = true
|
||||
markdown.workspace = true
|
||||
menu.workspace = true
|
||||
node_runtime.workspace = true
|
||||
ordered-float.workspace = true
|
||||
paths.workspace = true
|
||||
picker.workspace = true
|
||||
@@ -34,6 +36,7 @@ release_channel.workspace = true
|
||||
remote.workspace = true
|
||||
semver.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
settings.workspace = true
|
||||
smol.workspace = true
|
||||
task.workspace = true
|
||||
@@ -42,6 +45,7 @@ theme.workspace = true
|
||||
ui.workspace = true
|
||||
util.workspace = true
|
||||
workspace.workspace = true
|
||||
worktree.workspace = true
|
||||
zed_actions.workspace = true
|
||||
indoc.workspace = true
|
||||
|
||||
|
||||
295
crates/recent_projects/src/dev_container.rs
Normal file
295
crates/recent_projects/src/dev_container.rs
Normal file
@@ -0,0 +1,295 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use gpui::AsyncWindowContext;
|
||||
use node_runtime::NodeRuntime;
|
||||
use serde::Deserialize;
|
||||
use settings::DevContainerConnection;
|
||||
use smol::fs;
|
||||
use workspace::Workspace;
|
||||
|
||||
use crate::remote_connections::Connection;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DevContainerUp {
|
||||
_outcome: String,
|
||||
container_id: String,
|
||||
_remote_user: String,
|
||||
remote_workspace_folder: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DevContainerConfiguration {
|
||||
name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DevContainerConfigurationOutput {
|
||||
configuration: DevContainerConfiguration,
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn dev_container_cli() -> String {
|
||||
"devcontainer".to_string()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn dev_container_cli() -> String {
|
||||
"devcontainer.cmd".to_string()
|
||||
}
|
||||
|
||||
async fn check_for_docker() -> Result<(), DevContainerError> {
|
||||
let mut command = util::command::new_smol_command("docker");
|
||||
command.arg("--version");
|
||||
|
||||
match command.output().await {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => {
|
||||
log::error!("Unable to find docker in $PATH: {:?}", e);
|
||||
Err(DevContainerError::DockerNotAvailable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn ensure_devcontainer_cli(node_runtime: NodeRuntime) -> Result<PathBuf, DevContainerError> {
|
||||
let mut command = util::command::new_smol_command(&dev_container_cli());
|
||||
command.arg("--version");
|
||||
|
||||
if let Err(e) = command.output().await {
|
||||
log::error!(
|
||||
"Unable to find devcontainer CLI in $PATH. Checking for a zed installed version. Error: {:?}",
|
||||
e
|
||||
);
|
||||
|
||||
let datadir_cli_path = paths::devcontainer_dir()
|
||||
.join("node_modules")
|
||||
.join(".bin")
|
||||
.join(&dev_container_cli());
|
||||
|
||||
let mut command =
|
||||
util::command::new_smol_command(&datadir_cli_path.as_os_str().display().to_string());
|
||||
command.arg("--version");
|
||||
|
||||
if let Err(e) = command.output().await {
|
||||
log::error!(
|
||||
"Unable to find devcontainer CLI in Data dir. Will try to install. Error: {:?}",
|
||||
e
|
||||
);
|
||||
} else {
|
||||
log::info!("Found devcontainer CLI in Data dir");
|
||||
return Ok(datadir_cli_path.clone());
|
||||
}
|
||||
|
||||
if let Err(e) = fs::create_dir_all(paths::devcontainer_dir()).await {
|
||||
log::error!("Unable to create devcontainer directory. Error: {:?}", e);
|
||||
return Err(DevContainerError::DevContainerCliNotAvailable);
|
||||
}
|
||||
|
||||
if let Err(e) = node_runtime
|
||||
.npm_install_packages(
|
||||
&paths::devcontainer_dir(),
|
||||
&[("@devcontainers/cli", "latest")],
|
||||
)
|
||||
.await
|
||||
{
|
||||
log::error!(
|
||||
"Unable to install devcontainer CLI to data directory. Error: {:?}",
|
||||
e
|
||||
);
|
||||
return Err(DevContainerError::DevContainerCliNotAvailable);
|
||||
};
|
||||
|
||||
let mut command = util::command::new_smol_command(&datadir_cli_path.display().to_string());
|
||||
command.arg("--version");
|
||||
if let Err(e) = command.output().await {
|
||||
log::error!(
|
||||
"Unable to find devcontainer cli after NPM install. Error: {:?}",
|
||||
e
|
||||
);
|
||||
Err(DevContainerError::DevContainerCliNotAvailable)
|
||||
} else {
|
||||
Ok(datadir_cli_path)
|
||||
}
|
||||
} else {
|
||||
log::info!("Found devcontainer cli on $PATH, using it");
|
||||
Ok(PathBuf::from(&dev_container_cli()))
|
||||
}
|
||||
}
|
||||
|
||||
async fn devcontainer_up(
|
||||
path_to_cli: &PathBuf,
|
||||
path: Arc<Path>,
|
||||
) -> Result<DevContainerUp, DevContainerError> {
|
||||
let mut command = util::command::new_smol_command(path_to_cli.display().to_string());
|
||||
command.arg("up");
|
||||
command.arg("--workspace-folder");
|
||||
command.arg(path.display().to_string());
|
||||
|
||||
match command.output().await {
|
||||
Ok(output) => {
|
||||
if output.status.success() {
|
||||
let raw = String::from_utf8_lossy(&output.stdout);
|
||||
serde_json::from_str::<DevContainerUp>(&raw).map_err(|e| {
|
||||
log::error!(
|
||||
"Unable to parse response from 'devcontainer up' command, error: {:?}",
|
||||
e
|
||||
);
|
||||
DevContainerError::DevContainerParseFailed
|
||||
})
|
||||
} else {
|
||||
log::error!(
|
||||
"Non-success status running devcontainer up for workspace: out: {:?}, err: {:?}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
Err(DevContainerError::DevContainerUpFailed)
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Error running devcontainer up: {:?}", e);
|
||||
Err(DevContainerError::DevContainerUpFailed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn devcontainer_read_configuration(
|
||||
path_to_cli: &PathBuf,
|
||||
path: Arc<Path>,
|
||||
) -> Result<DevContainerConfigurationOutput, DevContainerError> {
|
||||
let mut command = util::command::new_smol_command(path_to_cli.display().to_string());
|
||||
command.arg("read-configuration");
|
||||
command.arg("--workspace-folder");
|
||||
command.arg(path.display().to_string());
|
||||
match command.output().await {
|
||||
Ok(output) => {
|
||||
if output.status.success() {
|
||||
let raw = String::from_utf8_lossy(&output.stdout);
|
||||
serde_json::from_str::<DevContainerConfigurationOutput>(&raw).map_err(|e| {
|
||||
log::error!(
|
||||
"Unable to parse response from 'devcontainer read-configuration' command, error: {:?}",
|
||||
e
|
||||
);
|
||||
DevContainerError::DevContainerParseFailed
|
||||
})
|
||||
} else {
|
||||
log::error!(
|
||||
"Non-success status running devcontainer read-configuration for workspace: out: {:?}, err: {:?}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
Err(DevContainerError::DevContainerUpFailed)
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Error running devcontainer read-configuration: {:?}", e);
|
||||
Err(DevContainerError::DevContainerUpFailed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Name the project with two fallbacks
|
||||
async fn get_project_name(
|
||||
path_to_cli: &PathBuf,
|
||||
path: Arc<Path>,
|
||||
remote_workspace_folder: String,
|
||||
container_id: String,
|
||||
) -> Result<String, DevContainerError> {
|
||||
if let Ok(dev_container_configuration) =
|
||||
devcontainer_read_configuration(path_to_cli, path).await
|
||||
&& let Some(name) = dev_container_configuration.configuration.name
|
||||
{
|
||||
// Ideally, name the project after the name defined in devcontainer.json
|
||||
Ok(name)
|
||||
} else {
|
||||
// Otherwise, name the project after the remote workspace folder name
|
||||
Ok(Path::new(&remote_workspace_folder)
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.map(|string| string.into())
|
||||
// Finally, name the project after the container ID as a last resort
|
||||
.unwrap_or_else(|| container_id.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
fn project_directory(cx: &mut AsyncWindowContext) -> Option<Arc<Path>> {
|
||||
let Some(workspace) = cx.window_handle().downcast::<Workspace>() else {
|
||||
return None;
|
||||
};
|
||||
|
||||
match workspace.update(cx, |workspace, _, cx| {
|
||||
workspace.project().read(cx).active_project_directory(cx)
|
||||
}) {
|
||||
Ok(dir) => dir,
|
||||
Err(e) => {
|
||||
log::error!("Error getting project directory from workspace: {:?}", e);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn start_dev_container(
|
||||
cx: &mut AsyncWindowContext,
|
||||
node_runtime: NodeRuntime,
|
||||
) -> Result<(Connection, String), DevContainerError> {
|
||||
check_for_docker().await?;
|
||||
|
||||
let path_to_devcontainer_cli = ensure_devcontainer_cli(node_runtime).await?;
|
||||
|
||||
let Some(directory) = project_directory(cx) else {
|
||||
return Err(DevContainerError::DevContainerNotFound);
|
||||
};
|
||||
|
||||
if let Ok(DevContainerUp {
|
||||
container_id,
|
||||
remote_workspace_folder,
|
||||
..
|
||||
}) = devcontainer_up(&path_to_devcontainer_cli, directory.clone()).await
|
||||
{
|
||||
let project_name = get_project_name(
|
||||
&path_to_devcontainer_cli,
|
||||
directory,
|
||||
remote_workspace_folder.clone(),
|
||||
container_id.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let connection = Connection::DevContainer(DevContainerConnection {
|
||||
name: project_name.into(),
|
||||
container_id: container_id.into(),
|
||||
});
|
||||
|
||||
Ok((connection, remote_workspace_folder))
|
||||
} else {
|
||||
Err(DevContainerError::DevContainerUpFailed)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum DevContainerError {
|
||||
DockerNotAvailable,
|
||||
DevContainerCliNotAvailable,
|
||||
DevContainerUpFailed,
|
||||
DevContainerNotFound,
|
||||
DevContainerParseFailed,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
|
||||
use crate::dev_container::DevContainerUp;
|
||||
|
||||
#[test]
|
||||
fn should_parse_from_devcontainer_json() {
|
||||
let json = r#"{"outcome":"success","containerId":"826abcac45afd412abff083ab30793daff2f3c8ce2c831df728baf39933cb37a","remoteUser":"vscode","remoteWorkspaceFolder":"/workspaces/zed"}"#;
|
||||
let up: DevContainerUp = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(up._outcome, "success");
|
||||
assert_eq!(
|
||||
up.container_id,
|
||||
"826abcac45afd412abff083ab30793daff2f3c8ce2c831df728baf39933cb37a"
|
||||
);
|
||||
assert_eq!(up._remote_user, "vscode");
|
||||
assert_eq!(up.remote_workspace_folder, "/workspaces/zed");
|
||||
}
|
||||
}
|
||||
106
crates/recent_projects/src/dev_container_suggest.rs
Normal file
106
crates/recent_projects/src/dev_container_suggest.rs
Normal file
@@ -0,0 +1,106 @@
|
||||
use db::kvp::KEY_VALUE_STORE;
|
||||
use gpui::{SharedString, Window};
|
||||
use project::{Project, WorktreeId};
|
||||
use std::sync::LazyLock;
|
||||
use ui::prelude::*;
|
||||
use util::rel_path::RelPath;
|
||||
use workspace::Workspace;
|
||||
use workspace::notifications::NotificationId;
|
||||
use workspace::notifications::simple_message_notification::MessageNotification;
|
||||
use worktree::UpdatedEntriesSet;
|
||||
|
||||
const DEV_CONTAINER_SUGGEST_KEY: &str = "dev_container_suggest_dismissed";
|
||||
|
||||
fn devcontainer_path() -> &'static RelPath {
|
||||
static PATH: LazyLock<&'static RelPath> =
|
||||
LazyLock::new(|| RelPath::unix(".devcontainer").expect("valid path"));
|
||||
*PATH
|
||||
}
|
||||
|
||||
fn project_devcontainer_key(project_path: &str) -> String {
|
||||
format!("{}_{}", DEV_CONTAINER_SUGGEST_KEY, project_path)
|
||||
}
|
||||
|
||||
pub fn suggest_on_worktree_updated(
|
||||
worktree_id: WorktreeId,
|
||||
updated_entries: &UpdatedEntriesSet,
|
||||
project: &gpui::Entity<Project>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Workspace>,
|
||||
) {
|
||||
let devcontainer_updated = updated_entries
|
||||
.iter()
|
||||
.any(|(path, _, _)| path.as_ref() == devcontainer_path());
|
||||
|
||||
if !devcontainer_updated {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(worktree) = project.read(cx).worktree_for_id(worktree_id, cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let worktree = worktree.read(cx);
|
||||
|
||||
if !worktree.is_local() {
|
||||
return;
|
||||
}
|
||||
|
||||
let has_devcontainer = worktree
|
||||
.entry_for_path(devcontainer_path())
|
||||
.is_some_and(|entry| entry.is_dir());
|
||||
|
||||
if !has_devcontainer {
|
||||
return;
|
||||
}
|
||||
|
||||
let abs_path = worktree.abs_path();
|
||||
let project_path = abs_path.to_string_lossy().to_string();
|
||||
let key_for_dismiss = project_devcontainer_key(&project_path);
|
||||
|
||||
let already_dismissed = KEY_VALUE_STORE
|
||||
.read_kvp(&key_for_dismiss)
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some();
|
||||
|
||||
if already_dismissed {
|
||||
return;
|
||||
}
|
||||
|
||||
cx.on_next_frame(window, move |workspace, _window, cx| {
|
||||
struct DevContainerSuggestionNotification;
|
||||
|
||||
let notification_id = NotificationId::composite::<DevContainerSuggestionNotification>(
|
||||
SharedString::from(project_path.clone()),
|
||||
);
|
||||
|
||||
workspace.show_notification(notification_id, cx, |cx| {
|
||||
cx.new(move |cx| {
|
||||
MessageNotification::new(
|
||||
"This project contains a Dev Container configuration file. Would you like to re-open it in a container?",
|
||||
cx,
|
||||
)
|
||||
.primary_message("Yes, Open in Container")
|
||||
.primary_icon(IconName::Check)
|
||||
.primary_icon_color(Color::Success)
|
||||
.primary_on_click({
|
||||
move |window, cx| {
|
||||
window.dispatch_action(Box::new(zed_actions::OpenDevContainer), cx);
|
||||
}
|
||||
})
|
||||
.secondary_message("Don't Show Again")
|
||||
.secondary_icon(IconName::Close)
|
||||
.secondary_icon_color(Color::Error)
|
||||
.secondary_on_click({
|
||||
move |_window, cx| {
|
||||
let key = key_for_dismiss.clone();
|
||||
db::write_and_log(cx, move || {
|
||||
KEY_VALUE_STORE.write_kvp(key, "dismissed".to_string())
|
||||
});
|
||||
}
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
mod dev_container;
|
||||
mod dev_container_suggest;
|
||||
pub mod disconnected_overlay;
|
||||
mod remote_connections;
|
||||
mod remote_servers;
|
||||
mod ssh_config;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod wsl_picker;
|
||||
|
||||
@@ -31,7 +35,7 @@ use workspace::{
|
||||
WORKSPACE_DB, Workspace, WorkspaceId, notifications::DetachAndPromptErr,
|
||||
with_active_or_new_workspace,
|
||||
};
|
||||
use zed_actions::{OpenRecent, OpenRemote};
|
||||
use zed_actions::{OpenDevContainer, OpenRecent, OpenRemote};
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -161,6 +165,95 @@ pub fn init(cx: &mut App) {
|
||||
});
|
||||
|
||||
cx.observe_new(DisconnectedOverlay::register).detach();
|
||||
|
||||
cx.on_action(|_: &OpenDevContainer, cx| {
|
||||
with_active_or_new_workspace(cx, move |workspace, window, cx| {
|
||||
let app_state = workspace.app_state().clone();
|
||||
let replace_window = window.window_handle().downcast::<Workspace>();
|
||||
|
||||
cx.spawn_in(window, async move |_, mut cx| {
|
||||
let (connection, starting_dir) = match dev_container::start_dev_container(
|
||||
&mut cx,
|
||||
app_state.node_runtime.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok((c, s)) => (c, s),
|
||||
Err(e) => {
|
||||
log::error!("Failed to start Dev Container: {:?}", e);
|
||||
cx.prompt(
|
||||
gpui::PromptLevel::Critical,
|
||||
"Failed to start Dev Container",
|
||||
Some(&format!("{:?}", e)),
|
||||
&["Ok"],
|
||||
)
|
||||
.await
|
||||
.ok();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let result = open_remote_project(
|
||||
connection.into(),
|
||||
vec![starting_dir].into_iter().map(PathBuf::from).collect(),
|
||||
app_state,
|
||||
OpenOptions {
|
||||
replace_window,
|
||||
..OpenOptions::default()
|
||||
},
|
||||
&mut cx,
|
||||
)
|
||||
.await;
|
||||
|
||||
if let Err(e) = result {
|
||||
log::error!("Failed to connect: {e:#}");
|
||||
cx.prompt(
|
||||
gpui::PromptLevel::Critical,
|
||||
"Failed to connect",
|
||||
Some(&e.to_string()),
|
||||
&["Ok"],
|
||||
)
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
let fs = workspace.project().read(cx).fs().clone();
|
||||
let handle = cx.entity().downgrade();
|
||||
workspace.toggle_modal(window, cx, |window, cx| {
|
||||
RemoteServerProjects::new_dev_container(fs, window, handle, cx)
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Subscribe to worktree additions to suggest opening the project in a dev container
|
||||
cx.observe_new(
|
||||
|workspace: &mut Workspace, window: Option<&mut Window>, cx: &mut Context<Workspace>| {
|
||||
let Some(window) = window else {
|
||||
return;
|
||||
};
|
||||
cx.subscribe_in(
|
||||
workspace.project(),
|
||||
window,
|
||||
move |_, project, event, window, cx| {
|
||||
if let project::Event::WorktreeUpdatedEntries(worktree_id, updated_entries) =
|
||||
event
|
||||
{
|
||||
dev_container_suggest::suggest_on_worktree_updated(
|
||||
*worktree_id,
|
||||
updated_entries,
|
||||
project,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
},
|
||||
)
|
||||
.detach();
|
||||
},
|
||||
)
|
||||
.detach();
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -609,6 +702,7 @@ impl PickerDelegate for RecentProjectsDelegate {
|
||||
Icon::new(match options {
|
||||
RemoteConnectionOptions::Ssh { .. } => IconName::Server,
|
||||
RemoteConnectionOptions::Wsl { .. } => IconName::Linux,
|
||||
RemoteConnectionOptions::Docker(_) => IconName::Box,
|
||||
})
|
||||
.color(Color::Muted)
|
||||
.into_any_element()
|
||||
|
||||
@@ -18,16 +18,16 @@ use language::{CursorShape, Point};
|
||||
use markdown::{Markdown, MarkdownElement, MarkdownStyle};
|
||||
use release_channel::ReleaseChannel;
|
||||
use remote::{
|
||||
ConnectionIdentifier, RemoteClient, RemoteConnection, RemoteConnectionOptions, RemotePlatform,
|
||||
SshConnectionOptions,
|
||||
ConnectionIdentifier, DockerConnectionOptions, RemoteClient, RemoteConnection,
|
||||
RemoteConnectionOptions, RemotePlatform, SshConnectionOptions,
|
||||
};
|
||||
use semver::Version;
|
||||
pub use settings::SshConnection;
|
||||
use settings::{ExtendingVec, RegisterSetting, Settings, WslConnection};
|
||||
use settings::{DevContainerConnection, ExtendingVec, RegisterSetting, Settings, WslConnection};
|
||||
use theme::ThemeSettings;
|
||||
use ui::{
|
||||
ActiveTheme, Color, CommonAnimationExt, Context, Icon, IconName, IconSize, InteractiveElement,
|
||||
IntoElement, Label, LabelCommon, Styled, Window, prelude::*,
|
||||
ActiveTheme, Color, CommonAnimationExt, Context, InteractiveElement, IntoElement, KeyBinding,
|
||||
LabelCommon, ListItem, Styled, Window, prelude::*,
|
||||
};
|
||||
use util::paths::PathWithPosition;
|
||||
use workspace::{AppState, ModalView, Workspace};
|
||||
@@ -85,6 +85,7 @@ impl SshSettings {
|
||||
pub enum Connection {
|
||||
Ssh(SshConnection),
|
||||
Wsl(WslConnection),
|
||||
DevContainer(DevContainerConnection),
|
||||
}
|
||||
|
||||
impl From<Connection> for RemoteConnectionOptions {
|
||||
@@ -92,6 +93,13 @@ impl From<Connection> for RemoteConnectionOptions {
|
||||
match val {
|
||||
Connection::Ssh(conn) => RemoteConnectionOptions::Ssh(conn.into()),
|
||||
Connection::Wsl(conn) => RemoteConnectionOptions::Wsl(conn.into()),
|
||||
Connection::DevContainer(conn) => {
|
||||
RemoteConnectionOptions::Docker(DockerConnectionOptions {
|
||||
name: conn.name.to_string(),
|
||||
container_id: conn.container_id.to_string(),
|
||||
upload_binary_over_docker_exec: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -123,6 +131,7 @@ pub struct RemoteConnectionPrompt {
|
||||
connection_string: SharedString,
|
||||
nickname: Option<SharedString>,
|
||||
is_wsl: bool,
|
||||
is_devcontainer: bool,
|
||||
status_message: Option<SharedString>,
|
||||
prompt: Option<(Entity<Markdown>, oneshot::Sender<EncryptedPassword>)>,
|
||||
cancellation: Option<oneshot::Sender<()>>,
|
||||
@@ -148,6 +157,7 @@ impl RemoteConnectionPrompt {
|
||||
connection_string: String,
|
||||
nickname: Option<String>,
|
||||
is_wsl: bool,
|
||||
is_devcontainer: bool,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
@@ -155,6 +165,7 @@ impl RemoteConnectionPrompt {
|
||||
connection_string: connection_string.into(),
|
||||
nickname: nickname.map(|nickname| nickname.into()),
|
||||
is_wsl,
|
||||
is_devcontainer,
|
||||
editor: cx.new(|cx| Editor::single_line(window, cx)),
|
||||
status_message: None,
|
||||
cancellation: None,
|
||||
@@ -244,17 +255,16 @@ impl Render for RemoteConnectionPrompt {
|
||||
|
||||
v_flex()
|
||||
.key_context("PasswordPrompt")
|
||||
.py_2()
|
||||
.px_3()
|
||||
.p_2()
|
||||
.size_full()
|
||||
.text_buffer(cx)
|
||||
.when_some(self.status_message.clone(), |el, status_message| {
|
||||
el.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.gap_2()
|
||||
.child(
|
||||
Icon::new(IconName::ArrowCircle)
|
||||
.size(IconSize::Medium)
|
||||
.color(Color::Muted)
|
||||
.with_rotate_animation(2),
|
||||
)
|
||||
.child(
|
||||
@@ -287,15 +297,28 @@ impl RemoteConnectionModal {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let (connection_string, nickname, is_wsl) = match connection_options {
|
||||
RemoteConnectionOptions::Ssh(options) => {
|
||||
(options.connection_string(), options.nickname.clone(), false)
|
||||
let (connection_string, nickname, is_wsl, is_devcontainer) = match connection_options {
|
||||
RemoteConnectionOptions::Ssh(options) => (
|
||||
options.connection_string(),
|
||||
options.nickname.clone(),
|
||||
false,
|
||||
false,
|
||||
),
|
||||
RemoteConnectionOptions::Wsl(options) => {
|
||||
(options.distro_name.clone(), None, true, false)
|
||||
}
|
||||
RemoteConnectionOptions::Wsl(options) => (options.distro_name.clone(), None, true),
|
||||
RemoteConnectionOptions::Docker(options) => (options.name.clone(), None, false, true),
|
||||
};
|
||||
Self {
|
||||
prompt: cx.new(|cx| {
|
||||
RemoteConnectionPrompt::new(connection_string, nickname, is_wsl, window, cx)
|
||||
RemoteConnectionPrompt::new(
|
||||
connection_string,
|
||||
nickname,
|
||||
is_wsl,
|
||||
is_devcontainer,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
}),
|
||||
finished: false,
|
||||
paths,
|
||||
@@ -328,6 +351,7 @@ pub(crate) struct SshConnectionHeader {
|
||||
pub(crate) paths: Vec<PathBuf>,
|
||||
pub(crate) nickname: Option<SharedString>,
|
||||
pub(crate) is_wsl: bool,
|
||||
pub(crate) is_devcontainer: bool,
|
||||
}
|
||||
|
||||
impl RenderOnce for SshConnectionHeader {
|
||||
@@ -343,9 +367,12 @@ impl RenderOnce for SshConnectionHeader {
|
||||
(self.connection_string, None)
|
||||
};
|
||||
|
||||
let icon = match self.is_wsl {
|
||||
true => IconName::Linux,
|
||||
false => IconName::Server,
|
||||
let icon = if self.is_wsl {
|
||||
IconName::Linux
|
||||
} else if self.is_devcontainer {
|
||||
IconName::Box
|
||||
} else {
|
||||
IconName::Server
|
||||
};
|
||||
|
||||
h_flex()
|
||||
@@ -388,6 +415,7 @@ impl Render for RemoteConnectionModal {
|
||||
let nickname = self.prompt.read(cx).nickname.clone();
|
||||
let connection_string = self.prompt.read(cx).connection_string.clone();
|
||||
let is_wsl = self.prompt.read(cx).is_wsl;
|
||||
let is_devcontainer = self.prompt.read(cx).is_devcontainer;
|
||||
|
||||
let theme = cx.theme().clone();
|
||||
let body_color = theme.colors().editor_background;
|
||||
@@ -407,18 +435,34 @@ impl Render for RemoteConnectionModal {
|
||||
connection_string,
|
||||
nickname,
|
||||
is_wsl,
|
||||
is_devcontainer,
|
||||
}
|
||||
.render(window, cx),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.w_full()
|
||||
.rounded_b_lg()
|
||||
.bg(body_color)
|
||||
.border_t_1()
|
||||
.border_y_1()
|
||||
.border_color(theme.colors().border_variant)
|
||||
.child(self.prompt.clone()),
|
||||
)
|
||||
.child(
|
||||
div().w_full().py_1().child(
|
||||
ListItem::new("li-devcontainer-go-back")
|
||||
.inset(true)
|
||||
.spacing(ui::ListItemSpacing::Sparse)
|
||||
.start_slot(Icon::new(IconName::Close).color(Color::Muted))
|
||||
.child(Label::new("Cancel"))
|
||||
.end_slot(
|
||||
KeyBinding::for_action_in(&menu::Cancel, &self.focus_handle(cx), cx)
|
||||
.size(rems_from_px(12.)),
|
||||
)
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.dismiss(&menu::Cancel, window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -671,6 +715,9 @@ pub async fn open_remote_project(
|
||||
match connection_options {
|
||||
RemoteConnectionOptions::Ssh(_) => "Failed to connect over SSH",
|
||||
RemoteConnectionOptions::Wsl(_) => "Failed to connect to WSL",
|
||||
RemoteConnectionOptions::Docker(_) => {
|
||||
"Failed to connect to Dev Container"
|
||||
}
|
||||
},
|
||||
Some(&format!("{e:#}")),
|
||||
&["Retry", "Cancel"],
|
||||
@@ -727,6 +774,9 @@ pub async fn open_remote_project(
|
||||
match connection_options {
|
||||
RemoteConnectionOptions::Ssh(_) => "Failed to connect over SSH",
|
||||
RemoteConnectionOptions::Wsl(_) => "Failed to connect to WSL",
|
||||
RemoteConnectionOptions::Docker(_) => {
|
||||
"Failed to connect to Dev Container"
|
||||
}
|
||||
},
|
||||
Some(&format!("{e:#}")),
|
||||
&["Retry", "Cancel"],
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::{
|
||||
dev_container::start_dev_container,
|
||||
remote_connections::{
|
||||
Connection, RemoteConnectionModal, RemoteConnectionPrompt, SshConnection,
|
||||
SshConnectionHeader, SshSettings, connect, determine_paths_with_positions,
|
||||
@@ -24,7 +25,7 @@ use remote::{
|
||||
remote_client::ConnectionIdentifier,
|
||||
};
|
||||
use settings::{
|
||||
RemoteSettingsContent, Settings as _, SettingsStore, SshProject, update_settings_file,
|
||||
RemoteProject, RemoteSettingsContent, Settings as _, SettingsStore, update_settings_file,
|
||||
watch_config_file,
|
||||
};
|
||||
use smol::stream::StreamExt as _;
|
||||
@@ -39,12 +40,13 @@ use std::{
|
||||
},
|
||||
};
|
||||
use ui::{
|
||||
IconButtonShape, List, ListItem, ListSeparator, Modal, ModalHeader, Navigable, NavigableEntry,
|
||||
Section, Tooltip, WithScrollbar, prelude::*,
|
||||
CommonAnimationExt, IconButtonShape, KeyBinding, List, ListItem, ListSeparator, Modal,
|
||||
ModalHeader, Navigable, NavigableEntry, Section, Tooltip, WithScrollbar, prelude::*,
|
||||
};
|
||||
use util::{
|
||||
ResultExt,
|
||||
paths::{PathStyle, RemotePathBuf},
|
||||
rel_path::RelPath,
|
||||
};
|
||||
use workspace::{
|
||||
ModalView, OpenOptions, Toast, Workspace,
|
||||
@@ -85,6 +87,39 @@ impl CreateRemoteServer {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
enum DevContainerCreationProgress {
|
||||
Initial,
|
||||
Creating,
|
||||
Error(String),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct CreateRemoteDevContainer {
|
||||
// 3 Navigable Options
|
||||
// - Create from devcontainer.json
|
||||
// - Edit devcontainer.json
|
||||
// - Go back
|
||||
entries: [NavigableEntry; 3],
|
||||
progress: DevContainerCreationProgress,
|
||||
}
|
||||
|
||||
impl CreateRemoteDevContainer {
|
||||
fn new(window: &mut Window, cx: &mut Context<RemoteServerProjects>) -> Self {
|
||||
let entries = std::array::from_fn(|_| NavigableEntry::focusable(cx));
|
||||
entries[0].focus_handle.focus(window);
|
||||
Self {
|
||||
entries,
|
||||
progress: DevContainerCreationProgress::Initial,
|
||||
}
|
||||
}
|
||||
|
||||
fn progress(&mut self, progress: DevContainerCreationProgress) -> Self {
|
||||
self.progress = progress;
|
||||
self.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
struct AddWslDistro {
|
||||
picker: Entity<Picker<crate::wsl_picker::WslPickerDelegate>>,
|
||||
@@ -207,6 +242,11 @@ impl ProjectPicker {
|
||||
RemoteConnectionOptions::Wsl(connection) => ProjectPickerData::Wsl {
|
||||
distro_name: connection.distro_name.clone().into(),
|
||||
},
|
||||
RemoteConnectionOptions::Docker(_) => ProjectPickerData::Ssh {
|
||||
// Not implemented as a project picker at this time
|
||||
connection_string: "".into(),
|
||||
nickname: None,
|
||||
},
|
||||
};
|
||||
let _path_task = cx
|
||||
.spawn_in(window, {
|
||||
@@ -259,7 +299,7 @@ impl ProjectPicker {
|
||||
.as_mut()
|
||||
.and_then(|connections| connections.get_mut(index.0))
|
||||
{
|
||||
server.projects.insert(SshProject { paths });
|
||||
server.projects.insert(RemoteProject { paths });
|
||||
};
|
||||
}
|
||||
ServerIndex::Wsl(index) => {
|
||||
@@ -269,7 +309,7 @@ impl ProjectPicker {
|
||||
.as_mut()
|
||||
.and_then(|connections| connections.get_mut(index.0))
|
||||
{
|
||||
server.projects.insert(SshProject { paths });
|
||||
server.projects.insert(RemoteProject { paths });
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -349,6 +389,7 @@ impl gpui::Render for ProjectPicker {
|
||||
paths: Default::default(),
|
||||
nickname: nickname.clone(),
|
||||
is_wsl: false,
|
||||
is_devcontainer: false,
|
||||
}
|
||||
.render(window, cx),
|
||||
ProjectPickerData::Wsl { distro_name } => SshConnectionHeader {
|
||||
@@ -356,6 +397,7 @@ impl gpui::Render for ProjectPicker {
|
||||
paths: Default::default(),
|
||||
nickname: None,
|
||||
is_wsl: true,
|
||||
is_devcontainer: false,
|
||||
}
|
||||
.render(window, cx),
|
||||
})
|
||||
@@ -406,7 +448,7 @@ impl From<WslServerIndex> for ServerIndex {
|
||||
enum RemoteEntry {
|
||||
Project {
|
||||
open_folder: NavigableEntry,
|
||||
projects: Vec<(NavigableEntry, SshProject)>,
|
||||
projects: Vec<(NavigableEntry, RemoteProject)>,
|
||||
configure: NavigableEntry,
|
||||
connection: Connection,
|
||||
index: ServerIndex,
|
||||
@@ -440,6 +482,7 @@ impl RemoteEntry {
|
||||
struct DefaultState {
|
||||
scroll_handle: ScrollHandle,
|
||||
add_new_server: NavigableEntry,
|
||||
add_new_devcontainer: NavigableEntry,
|
||||
add_new_wsl: NavigableEntry,
|
||||
servers: Vec<RemoteEntry>,
|
||||
}
|
||||
@@ -448,6 +491,7 @@ impl DefaultState {
|
||||
fn new(ssh_config_servers: &BTreeSet<SharedString>, cx: &mut App) -> Self {
|
||||
let handle = ScrollHandle::new();
|
||||
let add_new_server = NavigableEntry::new(&handle, cx);
|
||||
let add_new_devcontainer = NavigableEntry::new(&handle, cx);
|
||||
let add_new_wsl = NavigableEntry::new(&handle, cx);
|
||||
|
||||
let ssh_settings = SshSettings::get_global(cx);
|
||||
@@ -517,6 +561,7 @@ impl DefaultState {
|
||||
Self {
|
||||
scroll_handle: handle,
|
||||
add_new_server,
|
||||
add_new_devcontainer,
|
||||
add_new_wsl,
|
||||
servers,
|
||||
}
|
||||
@@ -552,6 +597,7 @@ enum Mode {
|
||||
EditNickname(EditNicknameState),
|
||||
ProjectPicker(Entity<ProjectPicker>),
|
||||
CreateRemoteServer(CreateRemoteServer),
|
||||
CreateRemoteDevContainer(CreateRemoteDevContainer),
|
||||
#[cfg(target_os = "windows")]
|
||||
AddWslDistro(AddWslDistro),
|
||||
}
|
||||
@@ -598,6 +644,27 @@ impl RemoteServerProjects {
|
||||
)
|
||||
}
|
||||
|
||||
/// Creates a new RemoteServerProjects modal that opens directly in dev container creation mode.
|
||||
/// Used when suggesting dev container connection from toast notification.
|
||||
pub fn new_dev_container(
|
||||
fs: Arc<dyn Fs>,
|
||||
window: &mut Window,
|
||||
workspace: WeakEntity<Workspace>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
Self::new_inner(
|
||||
Mode::CreateRemoteDevContainer(
|
||||
CreateRemoteDevContainer::new(window, cx)
|
||||
.progress(DevContainerCreationProgress::Creating),
|
||||
),
|
||||
false,
|
||||
fs,
|
||||
window,
|
||||
workspace,
|
||||
cx,
|
||||
)
|
||||
}
|
||||
|
||||
fn new_inner(
|
||||
mode: Mode,
|
||||
create_new_window: bool,
|
||||
@@ -703,6 +770,7 @@ impl RemoteServerProjects {
|
||||
connection_options.connection_string(),
|
||||
connection_options.nickname.clone(),
|
||||
false,
|
||||
false,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
@@ -778,6 +846,7 @@ impl RemoteServerProjects {
|
||||
connection_options.distro_name.clone(),
|
||||
None,
|
||||
true,
|
||||
false,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
@@ -862,6 +931,15 @@ impl RemoteServerProjects {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn view_in_progress_dev_container(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.mode = Mode::CreateRemoteDevContainer(
|
||||
CreateRemoteDevContainer::new(window, cx)
|
||||
.progress(DevContainerCreationProgress::Creating),
|
||||
);
|
||||
self.focus_handle(cx).focus(window);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn create_remote_project(
|
||||
&mut self,
|
||||
index: ServerIndex,
|
||||
@@ -981,6 +1059,7 @@ impl RemoteServerProjects {
|
||||
|
||||
self.create_ssh_server(state.address_editor.clone(), window, cx);
|
||||
}
|
||||
Mode::CreateRemoteDevContainer(_) => {}
|
||||
Mode::EditNickname(state) => {
|
||||
let text = Some(state.editor.read(cx).text(cx)).filter(|text| !text.is_empty());
|
||||
let index = state.index;
|
||||
@@ -1024,14 +1103,14 @@ impl RemoteServerProjects {
|
||||
}
|
||||
}
|
||||
|
||||
fn render_ssh_connection(
|
||||
fn render_remote_connection(
|
||||
&mut self,
|
||||
ix: usize,
|
||||
ssh_server: RemoteEntry,
|
||||
remote_server: RemoteEntry,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
let connection = ssh_server.connection().into_owned();
|
||||
let connection = remote_server.connection().into_owned();
|
||||
|
||||
let (main_label, aux_label, is_wsl) = match &connection {
|
||||
Connection::Ssh(connection) => {
|
||||
@@ -1045,6 +1124,9 @@ impl RemoteServerProjects {
|
||||
Connection::Wsl(wsl_connection_options) => {
|
||||
(wsl_connection_options.distro_name.clone(), None, true)
|
||||
}
|
||||
Connection::DevContainer(dev_container_options) => {
|
||||
(dev_container_options.name.clone(), None, false)
|
||||
}
|
||||
};
|
||||
v_flex()
|
||||
.w_full()
|
||||
@@ -1082,7 +1164,7 @@ impl RemoteServerProjects {
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(match &ssh_server {
|
||||
.child(match &remote_server {
|
||||
RemoteEntry::Project {
|
||||
open_folder,
|
||||
projects,
|
||||
@@ -1094,9 +1176,9 @@ impl RemoteServerProjects {
|
||||
List::new()
|
||||
.empty_message("No projects.")
|
||||
.children(projects.iter().enumerate().map(|(pix, p)| {
|
||||
v_flex().gap_0p5().child(self.render_ssh_project(
|
||||
v_flex().gap_0p5().child(self.render_remote_project(
|
||||
index,
|
||||
ssh_server.clone(),
|
||||
remote_server.clone(),
|
||||
pix,
|
||||
p,
|
||||
window,
|
||||
@@ -1222,12 +1304,12 @@ impl RemoteServerProjects {
|
||||
})
|
||||
}
|
||||
|
||||
fn render_ssh_project(
|
||||
fn render_remote_project(
|
||||
&mut self,
|
||||
server_ix: ServerIndex,
|
||||
server: RemoteEntry,
|
||||
ix: usize,
|
||||
(navigation, project): &(NavigableEntry, SshProject),
|
||||
(navigation, project): &(NavigableEntry, RemoteProject),
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
@@ -1372,7 +1454,7 @@ impl RemoteServerProjects {
|
||||
fn delete_remote_project(
|
||||
&mut self,
|
||||
server: ServerIndex,
|
||||
project: &SshProject,
|
||||
project: &RemoteProject,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
match server {
|
||||
@@ -1388,7 +1470,7 @@ impl RemoteServerProjects {
|
||||
fn delete_ssh_project(
|
||||
&mut self,
|
||||
server: SshServerIndex,
|
||||
project: &SshProject,
|
||||
project: &RemoteProject,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let project = project.clone();
|
||||
@@ -1406,7 +1488,7 @@ impl RemoteServerProjects {
|
||||
fn delete_wsl_project(
|
||||
&mut self,
|
||||
server: WslServerIndex,
|
||||
project: &SshProject,
|
||||
project: &RemoteProject,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let project = project.clone();
|
||||
@@ -1451,6 +1533,342 @@ impl RemoteServerProjects {
|
||||
});
|
||||
}
|
||||
|
||||
fn edit_in_dev_container_json(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(workspace) = self.workspace.upgrade() else {
|
||||
cx.emit(DismissEvent);
|
||||
cx.notify();
|
||||
return;
|
||||
};
|
||||
|
||||
workspace.update(cx, |workspace, cx| {
|
||||
let project = workspace.project().clone();
|
||||
|
||||
let worktree = project
|
||||
.read(cx)
|
||||
.visible_worktrees(cx)
|
||||
.find_map(|tree| tree.read(cx).root_entry()?.is_dir().then_some(tree));
|
||||
|
||||
if let Some(worktree) = worktree {
|
||||
let tree_id = worktree.read(cx).id();
|
||||
let devcontainer_path = RelPath::unix(".devcontainer/devcontainer.json").unwrap();
|
||||
cx.spawn_in(window, async move |workspace, cx| {
|
||||
workspace
|
||||
.update_in(cx, |workspace, window, cx| {
|
||||
workspace.open_path(
|
||||
(tree_id, devcontainer_path),
|
||||
None,
|
||||
true,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
})?
|
||||
.await
|
||||
})
|
||||
.detach();
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
});
|
||||
cx.emit(DismissEvent);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn open_dev_container(&self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(app_state) = self
|
||||
.workspace
|
||||
.read_with(cx, |workspace, _| workspace.app_state().clone())
|
||||
.log_err()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let replace_window = window.window_handle().downcast::<Workspace>();
|
||||
|
||||
cx.spawn_in(window, async move |entity, cx| {
|
||||
let (connection, starting_dir) =
|
||||
match start_dev_container(cx, app_state.node_runtime.clone()).await {
|
||||
Ok((c, s)) => (c, s),
|
||||
Err(e) => {
|
||||
log::error!("Failed to start dev container: {:?}", e);
|
||||
entity
|
||||
.update_in(cx, |remote_server_projects, window, cx| {
|
||||
remote_server_projects.mode = Mode::CreateRemoteDevContainer(
|
||||
CreateRemoteDevContainer::new(window, cx).progress(
|
||||
DevContainerCreationProgress::Error(format!("{:?}", e)),
|
||||
),
|
||||
);
|
||||
})
|
||||
.log_err();
|
||||
return;
|
||||
}
|
||||
};
|
||||
entity
|
||||
.update(cx, |_, cx| {
|
||||
cx.emit(DismissEvent);
|
||||
})
|
||||
.log_err();
|
||||
|
||||
let result = open_remote_project(
|
||||
connection.into(),
|
||||
vec![starting_dir].into_iter().map(PathBuf::from).collect(),
|
||||
app_state,
|
||||
OpenOptions {
|
||||
replace_window,
|
||||
..OpenOptions::default()
|
||||
},
|
||||
cx,
|
||||
)
|
||||
.await;
|
||||
if let Err(e) = result {
|
||||
log::error!("Failed to connect: {e:#}");
|
||||
cx.prompt(
|
||||
gpui::PromptLevel::Critical,
|
||||
"Failed to connect",
|
||||
Some(&e.to_string()),
|
||||
&["Ok"],
|
||||
)
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn render_create_dev_container(
|
||||
&self,
|
||||
state: &CreateRemoteDevContainer,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
match &state.progress {
|
||||
DevContainerCreationProgress::Error(message) => {
|
||||
self.focus_handle(cx).focus(window);
|
||||
return div()
|
||||
.track_focus(&self.focus_handle(cx))
|
||||
.size_full()
|
||||
.child(
|
||||
v_flex()
|
||||
.py_1()
|
||||
.child(
|
||||
ListItem::new("Error")
|
||||
.inset(true)
|
||||
.selectable(false)
|
||||
.spacing(ui::ListItemSpacing::Sparse)
|
||||
.start_slot(Icon::new(IconName::XCircle).color(Color::Error))
|
||||
.child(Label::new("Error Creating Dev Container:"))
|
||||
.child(Label::new(message).buffer_font(cx)),
|
||||
)
|
||||
.child(ListSeparator)
|
||||
.child(
|
||||
div()
|
||||
.id("devcontainer-go-back")
|
||||
.track_focus(&state.entries[0].focus_handle)
|
||||
.on_action(cx.listener(
|
||||
|this, _: &menu::Confirm, window, cx| {
|
||||
this.mode =
|
||||
Mode::default_mode(&this.ssh_config_servers, cx);
|
||||
cx.focus_self(window);
|
||||
cx.notify();
|
||||
},
|
||||
))
|
||||
.child(
|
||||
ListItem::new("li-devcontainer-go-back")
|
||||
.toggle_state(
|
||||
state.entries[0]
|
||||
.focus_handle
|
||||
.contains_focused(window, cx),
|
||||
)
|
||||
.inset(true)
|
||||
.spacing(ui::ListItemSpacing::Sparse)
|
||||
.start_slot(
|
||||
Icon::new(IconName::ArrowLeft).color(Color::Muted),
|
||||
)
|
||||
.child(Label::new("Go Back"))
|
||||
.end_slot(
|
||||
KeyBinding::for_action_in(
|
||||
&menu::Cancel,
|
||||
&self.focus_handle,
|
||||
cx,
|
||||
)
|
||||
.size(rems_from_px(12.)),
|
||||
)
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
let state =
|
||||
CreateRemoteDevContainer::new(window, cx);
|
||||
this.mode = Mode::CreateRemoteDevContainer(state);
|
||||
|
||||
cx.notify();
|
||||
})),
|
||||
),
|
||||
),
|
||||
)
|
||||
.into_any_element();
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
|
||||
let mut view = Navigable::new(
|
||||
div()
|
||||
.track_focus(&self.focus_handle(cx))
|
||||
.size_full()
|
||||
.child(
|
||||
v_flex()
|
||||
.pb_1()
|
||||
.child(
|
||||
ModalHeader::new()
|
||||
.child(Headline::new("Dev Containers").size(HeadlineSize::XSmall)),
|
||||
)
|
||||
.child(ListSeparator)
|
||||
.child(
|
||||
div()
|
||||
.id("confirm-create-from-devcontainer-json")
|
||||
.track_focus(&state.entries[0].focus_handle)
|
||||
.on_action(cx.listener({
|
||||
move |this, _: &menu::Confirm, window, cx| {
|
||||
this.open_dev_container(window, cx);
|
||||
this.view_in_progress_dev_container(window, cx);
|
||||
}
|
||||
}))
|
||||
.map(|this| {
|
||||
if state.progress == DevContainerCreationProgress::Creating {
|
||||
this.child(
|
||||
ListItem::new("creating")
|
||||
.inset(true)
|
||||
.spacing(ui::ListItemSpacing::Sparse)
|
||||
.disabled(true)
|
||||
.start_slot(
|
||||
Icon::new(IconName::ArrowCircle)
|
||||
.color(Color::Muted)
|
||||
.with_rotate_animation(2),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.opacity(0.6)
|
||||
.gap_1()
|
||||
.child(Label::new("Creating From"))
|
||||
.child(
|
||||
Label::new("devcontainer.json")
|
||||
.buffer_font(cx),
|
||||
)
|
||||
.child(LoadingLabel::new("")),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
this.child(
|
||||
ListItem::new(
|
||||
"li-confirm-create-from-devcontainer-json",
|
||||
)
|
||||
.toggle_state(
|
||||
state.entries[0]
|
||||
.focus_handle
|
||||
.contains_focused(window, cx),
|
||||
)
|
||||
.inset(true)
|
||||
.spacing(ui::ListItemSpacing::Sparse)
|
||||
.start_slot(
|
||||
Icon::new(IconName::Plus).color(Color::Muted),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(Label::new("Open or Create New From"))
|
||||
.child(
|
||||
Label::new("devcontainer.json")
|
||||
.buffer_font(cx),
|
||||
),
|
||||
)
|
||||
.on_click(
|
||||
cx.listener({
|
||||
move |this, _, window, cx| {
|
||||
this.open_dev_container(window, cx);
|
||||
this.view_in_progress_dev_container(
|
||||
window, cx,
|
||||
);
|
||||
cx.notify();
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.id("edit-devcontainer-json")
|
||||
.track_focus(&state.entries[1].focus_handle)
|
||||
.on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
|
||||
this.edit_in_dev_container_json(window, cx);
|
||||
}))
|
||||
.child(
|
||||
ListItem::new("li-edit-devcontainer-json")
|
||||
.toggle_state(
|
||||
state.entries[1]
|
||||
.focus_handle
|
||||
.contains_focused(window, cx),
|
||||
)
|
||||
.inset(true)
|
||||
.spacing(ui::ListItemSpacing::Sparse)
|
||||
.start_slot(Icon::new(IconName::Pencil).color(Color::Muted))
|
||||
.child(
|
||||
h_flex().gap_1().child(Label::new("Edit")).child(
|
||||
Label::new("devcontainer.json").buffer_font(cx),
|
||||
),
|
||||
)
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.edit_in_dev_container_json(window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
.child(ListSeparator)
|
||||
.child(
|
||||
div()
|
||||
.id("devcontainer-go-back")
|
||||
.track_focus(&state.entries[2].focus_handle)
|
||||
.on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
|
||||
this.mode = Mode::default_mode(&this.ssh_config_servers, cx);
|
||||
cx.focus_self(window);
|
||||
cx.notify();
|
||||
}))
|
||||
.child(
|
||||
ListItem::new("li-devcontainer-go-back")
|
||||
.toggle_state(
|
||||
state.entries[2]
|
||||
.focus_handle
|
||||
.contains_focused(window, cx),
|
||||
)
|
||||
.inset(true)
|
||||
.spacing(ui::ListItemSpacing::Sparse)
|
||||
.start_slot(
|
||||
Icon::new(IconName::ArrowLeft).color(Color::Muted),
|
||||
)
|
||||
.child(Label::new("Go Back"))
|
||||
.end_slot(
|
||||
KeyBinding::for_action_in(
|
||||
&menu::Cancel,
|
||||
&self.focus_handle,
|
||||
cx,
|
||||
)
|
||||
.size(rems_from_px(12.)),
|
||||
)
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.mode =
|
||||
Mode::default_mode(&this.ssh_config_servers, cx);
|
||||
cx.focus_self(window);
|
||||
cx.notify()
|
||||
})),
|
||||
),
|
||||
),
|
||||
)
|
||||
.into_any_element(),
|
||||
);
|
||||
|
||||
view = view.entry(state.entries[0].clone());
|
||||
view = view.entry(state.entries[1].clone());
|
||||
view = view.entry(state.entries[2].clone());
|
||||
|
||||
view.render(window, cx).into_any_element()
|
||||
}
|
||||
|
||||
fn render_create_remote_server(
|
||||
&self,
|
||||
state: &CreateRemoteServer,
|
||||
@@ -1571,6 +1989,7 @@ impl RemoteServerProjects {
|
||||
paths: Default::default(),
|
||||
nickname: connection.nickname.clone().map(|s| s.into()),
|
||||
is_wsl: false,
|
||||
is_devcontainer: false,
|
||||
}
|
||||
.render(window, cx)
|
||||
.into_any_element(),
|
||||
@@ -1579,6 +1998,7 @@ impl RemoteServerProjects {
|
||||
paths: Default::default(),
|
||||
nickname: None,
|
||||
is_wsl: true,
|
||||
is_devcontainer: false,
|
||||
}
|
||||
.render(window, cx)
|
||||
.into_any_element(),
|
||||
@@ -1917,6 +2337,7 @@ impl RemoteServerProjects {
|
||||
paths: Default::default(),
|
||||
nickname,
|
||||
is_wsl: false,
|
||||
is_devcontainer: false,
|
||||
}
|
||||
.render(window, cx),
|
||||
)
|
||||
@@ -1998,7 +2419,7 @@ impl RemoteServerProjects {
|
||||
.track_focus(&state.add_new_server.focus_handle)
|
||||
.anchor_scroll(state.add_new_server.scroll_anchor.clone())
|
||||
.child(
|
||||
ListItem::new("register-remove-server-button")
|
||||
ListItem::new("register-remote-server-button")
|
||||
.toggle_state(
|
||||
state
|
||||
.add_new_server
|
||||
@@ -2008,7 +2429,7 @@ impl RemoteServerProjects {
|
||||
.inset(true)
|
||||
.spacing(ui::ListItemSpacing::Sparse)
|
||||
.start_slot(Icon::new(IconName::Plus).color(Color::Muted))
|
||||
.child(Label::new("Connect New Server"))
|
||||
.child(Label::new("Connect SSH Server"))
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
let state = CreateRemoteServer::new(window, cx);
|
||||
this.mode = Mode::CreateRemoteServer(state);
|
||||
@@ -2023,6 +2444,36 @@ impl RemoteServerProjects {
|
||||
cx.notify();
|
||||
}));
|
||||
|
||||
let connect_dev_container_button = div()
|
||||
.id("connect-new-dev-container")
|
||||
.track_focus(&state.add_new_devcontainer.focus_handle)
|
||||
.anchor_scroll(state.add_new_devcontainer.scroll_anchor.clone())
|
||||
.child(
|
||||
ListItem::new("register-dev-container-button")
|
||||
.toggle_state(
|
||||
state
|
||||
.add_new_devcontainer
|
||||
.focus_handle
|
||||
.contains_focused(window, cx),
|
||||
)
|
||||
.inset(true)
|
||||
.spacing(ui::ListItemSpacing::Sparse)
|
||||
.start_slot(Icon::new(IconName::Plus).color(Color::Muted))
|
||||
.child(Label::new("Connect Dev Container"))
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
let state = CreateRemoteDevContainer::new(window, cx);
|
||||
this.mode = Mode::CreateRemoteDevContainer(state);
|
||||
|
||||
cx.notify();
|
||||
})),
|
||||
)
|
||||
.on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
|
||||
let state = CreateRemoteDevContainer::new(window, cx);
|
||||
this.mode = Mode::CreateRemoteDevContainer(state);
|
||||
|
||||
cx.notify();
|
||||
}));
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
let wsl_connect_button = div()
|
||||
.id("wsl-connect-new-server")
|
||||
@@ -2049,13 +2500,30 @@ impl RemoteServerProjects {
|
||||
cx.notify();
|
||||
}));
|
||||
|
||||
let has_open_project = self
|
||||
.workspace
|
||||
.upgrade()
|
||||
.map(|workspace| {
|
||||
workspace
|
||||
.read(cx)
|
||||
.project()
|
||||
.read(cx)
|
||||
.visible_worktrees(cx)
|
||||
.next()
|
||||
.is_some()
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
||||
let modal_section = v_flex()
|
||||
.track_focus(&self.focus_handle(cx))
|
||||
.id("ssh-server-list")
|
||||
.overflow_y_scroll()
|
||||
.track_scroll(&state.scroll_handle)
|
||||
.size_full()
|
||||
.child(connect_button);
|
||||
.child(connect_button)
|
||||
.when(has_open_project, |this| {
|
||||
this.child(connect_dev_container_button)
|
||||
});
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
let modal_section = modal_section.child(wsl_connect_button);
|
||||
@@ -2067,17 +2535,20 @@ impl RemoteServerProjects {
|
||||
.child(
|
||||
List::new()
|
||||
.empty_message(
|
||||
v_flex()
|
||||
h_flex()
|
||||
.size_full()
|
||||
.p_2()
|
||||
.justify_center()
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().colors().border_variant)
|
||||
.child(
|
||||
div().px_3().child(
|
||||
Label::new("No remote servers registered yet.")
|
||||
.color(Color::Muted),
|
||||
),
|
||||
Label::new("No remote servers registered yet.")
|
||||
.color(Color::Muted),
|
||||
)
|
||||
.into_any_element(),
|
||||
)
|
||||
.children(state.servers.iter().enumerate().map(|(ix, connection)| {
|
||||
self.render_ssh_connection(ix, connection.clone(), window, cx)
|
||||
self.render_remote_connection(ix, connection.clone(), window, cx)
|
||||
.into_any_element()
|
||||
})),
|
||||
)
|
||||
@@ -2085,6 +2556,10 @@ impl RemoteServerProjects {
|
||||
)
|
||||
.entry(state.add_new_server.clone());
|
||||
|
||||
if has_open_project {
|
||||
modal_section = modal_section.entry(state.add_new_devcontainer.clone());
|
||||
}
|
||||
|
||||
if cfg!(target_os = "windows") {
|
||||
modal_section = modal_section.entry(state.add_new_wsl.clone());
|
||||
}
|
||||
@@ -2297,6 +2772,9 @@ impl Render for RemoteServerProjects {
|
||||
Mode::CreateRemoteServer(state) => self
|
||||
.render_create_remote_server(state, window, cx)
|
||||
.into_any_element(),
|
||||
Mode::CreateRemoteDevContainer(state) => self
|
||||
.render_create_dev_container(state, window, cx)
|
||||
.into_any_element(),
|
||||
Mode::EditNickname(state) => self
|
||||
.render_edit_nickname(state, window, cx)
|
||||
.into_any_element(),
|
||||
|
||||
@@ -10,5 +10,6 @@ pub use remote_client::{
|
||||
ConnectionIdentifier, ConnectionState, RemoteClient, RemoteClientDelegate, RemoteClientEvent,
|
||||
RemoteConnection, RemoteConnectionOptions, RemotePlatform, connect,
|
||||
};
|
||||
pub use transport::docker::DockerConnectionOptions;
|
||||
pub use transport::ssh::{SshConnectionOptions, SshPortForwardOption};
|
||||
pub use transport::wsl::WslConnectionOptions;
|
||||
|
||||
@@ -3,6 +3,7 @@ use crate::{
|
||||
protocol::MessageId,
|
||||
proxy::ProxyLaunchError,
|
||||
transport::{
|
||||
docker::{DockerConnectionOptions, DockerExecConnection},
|
||||
ssh::SshRemoteConnection,
|
||||
wsl::{WslConnectionOptions, WslRemoteConnection},
|
||||
},
|
||||
@@ -1042,6 +1043,11 @@ impl ConnectionPool {
|
||||
.await
|
||||
.map(|connection| Arc::new(connection) as Arc<dyn RemoteConnection>)
|
||||
}
|
||||
RemoteConnectionOptions::Docker(opts) => {
|
||||
DockerExecConnection::new(opts, delegate, cx)
|
||||
.await
|
||||
.map(|connection| Arc::new(connection) as Arc<dyn RemoteConnection>)
|
||||
}
|
||||
};
|
||||
|
||||
cx.update_global(|pool: &mut Self, _| {
|
||||
@@ -1077,6 +1083,7 @@ impl ConnectionPool {
|
||||
pub enum RemoteConnectionOptions {
|
||||
Ssh(SshConnectionOptions),
|
||||
Wsl(WslConnectionOptions),
|
||||
Docker(DockerConnectionOptions),
|
||||
}
|
||||
|
||||
impl RemoteConnectionOptions {
|
||||
@@ -1084,6 +1091,7 @@ impl RemoteConnectionOptions {
|
||||
match self {
|
||||
RemoteConnectionOptions::Ssh(opts) => opts.host.clone(),
|
||||
RemoteConnectionOptions::Wsl(opts) => opts.distro_name.clone(),
|
||||
RemoteConnectionOptions::Docker(opts) => opts.name.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ use gpui::{AppContext as _, AsyncApp, Task};
|
||||
use rpc::proto::Envelope;
|
||||
use smol::process::Child;
|
||||
|
||||
pub mod docker;
|
||||
pub mod ssh;
|
||||
pub mod wsl;
|
||||
|
||||
@@ -64,15 +65,15 @@ fn parse_shell(output: &str, fallback_shell: &str) -> String {
|
||||
}
|
||||
|
||||
fn handle_rpc_messages_over_child_process_stdio(
|
||||
mut ssh_proxy_process: Child,
|
||||
mut remote_proxy_process: Child,
|
||||
incoming_tx: UnboundedSender<Envelope>,
|
||||
mut outgoing_rx: UnboundedReceiver<Envelope>,
|
||||
mut connection_activity_tx: Sender<()>,
|
||||
cx: &AsyncApp,
|
||||
) -> Task<Result<i32>> {
|
||||
let mut child_stderr = ssh_proxy_process.stderr.take().unwrap();
|
||||
let mut child_stdout = ssh_proxy_process.stdout.take().unwrap();
|
||||
let mut child_stdin = ssh_proxy_process.stdin.take().unwrap();
|
||||
let mut child_stderr = remote_proxy_process.stderr.take().unwrap();
|
||||
let mut child_stdout = remote_proxy_process.stdout.take().unwrap();
|
||||
let mut child_stdin = remote_proxy_process.stdin.take().unwrap();
|
||||
|
||||
let mut stdin_buffer = Vec::new();
|
||||
let mut stdout_buffer = Vec::new();
|
||||
@@ -156,7 +157,7 @@ fn handle_rpc_messages_over_child_process_stdio(
|
||||
result.context("stderr")
|
||||
}
|
||||
};
|
||||
let status = ssh_proxy_process.status().await?.code().unwrap_or(1);
|
||||
let status = remote_proxy_process.status().await?.code().unwrap_or(1);
|
||||
match result {
|
||||
Ok(_) => Ok(status),
|
||||
Err(error) => Err(error),
|
||||
|
||||
757
crates/remote/src/transport/docker.rs
Normal file
757
crates/remote/src/transport/docker.rs
Normal file
@@ -0,0 +1,757 @@
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
use async_trait::async_trait;
|
||||
use collections::HashMap;
|
||||
use parking_lot::Mutex;
|
||||
use release_channel::{AppCommitSha, AppVersion, ReleaseChannel};
|
||||
use semver::Version as SemanticVersion;
|
||||
use std::time::Instant;
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
process::Stdio,
|
||||
sync::Arc,
|
||||
};
|
||||
use util::ResultExt;
|
||||
use util::shell::ShellKind;
|
||||
use util::{
|
||||
paths::{PathStyle, RemotePathBuf},
|
||||
rel_path::RelPath,
|
||||
};
|
||||
|
||||
use futures::channel::mpsc::{Sender, UnboundedReceiver, UnboundedSender};
|
||||
use gpui::{App, AppContext, AsyncApp, Task};
|
||||
use rpc::proto::Envelope;
|
||||
|
||||
use crate::{
|
||||
RemoteClientDelegate, RemoteConnection, RemoteConnectionOptions, RemotePlatform,
|
||||
remote_client::CommandTemplate,
|
||||
};
|
||||
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct DockerConnectionOptions {
|
||||
pub name: String,
|
||||
pub container_id: String,
|
||||
pub upload_binary_over_docker_exec: bool,
|
||||
}
|
||||
|
||||
pub(crate) struct DockerExecConnection {
|
||||
proxy_process: Mutex<Option<u32>>,
|
||||
remote_dir_for_server: String,
|
||||
remote_binary_relpath: Option<Arc<RelPath>>,
|
||||
connection_options: DockerConnectionOptions,
|
||||
remote_platform: Option<RemotePlatform>,
|
||||
path_style: Option<PathStyle>,
|
||||
shell: Option<String>,
|
||||
}
|
||||
|
||||
impl DockerExecConnection {
|
||||
pub async fn new(
|
||||
connection_options: DockerConnectionOptions,
|
||||
delegate: Arc<dyn RemoteClientDelegate>,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Result<Self> {
|
||||
let mut this = Self {
|
||||
proxy_process: Mutex::new(None),
|
||||
remote_dir_for_server: "/".to_string(),
|
||||
remote_binary_relpath: None,
|
||||
connection_options,
|
||||
remote_platform: None,
|
||||
path_style: None,
|
||||
shell: None,
|
||||
};
|
||||
let (release_channel, version, commit) = cx.update(|cx| {
|
||||
(
|
||||
ReleaseChannel::global(cx),
|
||||
AppVersion::global(cx),
|
||||
AppCommitSha::try_global(cx),
|
||||
)
|
||||
})?;
|
||||
let remote_platform = this.check_remote_platform().await?;
|
||||
|
||||
this.path_style = match remote_platform.os {
|
||||
"windows" => Some(PathStyle::Windows),
|
||||
_ => Some(PathStyle::Posix),
|
||||
};
|
||||
|
||||
this.remote_platform = Some(remote_platform);
|
||||
|
||||
this.shell = Some(this.discover_shell().await);
|
||||
|
||||
this.remote_dir_for_server = this.docker_user_home_dir().await?.trim().to_string();
|
||||
|
||||
this.remote_binary_relpath = Some(
|
||||
this.ensure_server_binary(
|
||||
&delegate,
|
||||
release_channel,
|
||||
version,
|
||||
&this.remote_dir_for_server,
|
||||
commit,
|
||||
cx,
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
|
||||
Ok(this)
|
||||
}
|
||||
|
||||
async fn discover_shell(&self) -> String {
|
||||
let default_shell = "sh";
|
||||
match self
|
||||
.run_docker_exec("sh", None, &Default::default(), &["-c", "echo $SHELL"])
|
||||
.await
|
||||
{
|
||||
Ok(shell) => match shell.trim() {
|
||||
"" => {
|
||||
log::error!("$SHELL is not set, falling back to {default_shell}");
|
||||
default_shell.to_owned()
|
||||
}
|
||||
shell => shell.to_owned(),
|
||||
},
|
||||
Err(e) => {
|
||||
log::error!("Failed to get shell: {e}");
|
||||
default_shell.to_owned()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn check_remote_platform(&self) -> Result<RemotePlatform> {
|
||||
let uname = self
|
||||
.run_docker_exec("uname", None, &Default::default(), &["-sm"])
|
||||
.await?;
|
||||
let Some((os, arch)) = uname.split_once(" ") else {
|
||||
anyhow::bail!("unknown uname: {uname:?}")
|
||||
};
|
||||
|
||||
let os = match os.trim() {
|
||||
"Darwin" => "macos",
|
||||
"Linux" => "linux",
|
||||
_ => anyhow::bail!(
|
||||
"Prebuilt remote servers are not yet available for {os:?}. See https://zed.dev/docs/remote-development"
|
||||
),
|
||||
};
|
||||
// exclude armv5,6,7 as they are 32-bit.
|
||||
let arch = if arch.starts_with("armv8")
|
||||
|| arch.starts_with("armv9")
|
||||
|| arch.starts_with("arm64")
|
||||
|| arch.starts_with("aarch64")
|
||||
{
|
||||
"aarch64"
|
||||
} else if arch.starts_with("x86") {
|
||||
"x86_64"
|
||||
} else {
|
||||
anyhow::bail!(
|
||||
"Prebuilt remote servers are not yet available for {arch:?}. See https://zed.dev/docs/remote-development"
|
||||
)
|
||||
};
|
||||
|
||||
Ok(RemotePlatform { os, arch })
|
||||
}
|
||||
|
||||
async fn ensure_server_binary(
|
||||
&self,
|
||||
delegate: &Arc<dyn RemoteClientDelegate>,
|
||||
release_channel: ReleaseChannel,
|
||||
version: SemanticVersion,
|
||||
remote_dir_for_server: &str,
|
||||
commit: Option<AppCommitSha>,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Result<Arc<RelPath>> {
|
||||
let remote_platform = if self.remote_platform.is_some() {
|
||||
self.remote_platform.unwrap()
|
||||
} else {
|
||||
anyhow::bail!("No remote platform defined; cannot proceed.")
|
||||
};
|
||||
|
||||
let version_str = match release_channel {
|
||||
ReleaseChannel::Nightly => {
|
||||
let commit = commit.map(|s| s.full()).unwrap_or_default();
|
||||
format!("{}-{}", version, commit)
|
||||
}
|
||||
ReleaseChannel::Dev => "build".to_string(),
|
||||
_ => version.to_string(),
|
||||
};
|
||||
let binary_name = format!(
|
||||
"zed-remote-server-{}-{}",
|
||||
release_channel.dev_name(),
|
||||
version_str
|
||||
);
|
||||
let dst_path =
|
||||
paths::remote_server_dir_relative().join(RelPath::unix(&binary_name).unwrap());
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
if let Some(remote_server_path) =
|
||||
super::build_remote_server_from_source(&remote_platform, delegate.as_ref(), cx).await?
|
||||
{
|
||||
let tmp_path = paths::remote_server_dir_relative().join(
|
||||
RelPath::unix(&format!(
|
||||
"download-{}-{}",
|
||||
std::process::id(),
|
||||
remote_server_path.file_name().unwrap().to_string_lossy()
|
||||
))
|
||||
.unwrap(),
|
||||
);
|
||||
self.upload_local_server_binary(
|
||||
&remote_server_path,
|
||||
&tmp_path,
|
||||
&remote_dir_for_server,
|
||||
delegate,
|
||||
cx,
|
||||
)
|
||||
.await?;
|
||||
self.extract_server_binary(&dst_path, &tmp_path, &remote_dir_for_server, delegate, cx)
|
||||
.await?;
|
||||
return Ok(dst_path);
|
||||
}
|
||||
|
||||
if self
|
||||
.run_docker_exec(
|
||||
&dst_path.display(self.path_style()),
|
||||
Some(&remote_dir_for_server),
|
||||
&Default::default(),
|
||||
&["version"],
|
||||
)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
return Ok(dst_path);
|
||||
}
|
||||
|
||||
let wanted_version = cx.update(|cx| match release_channel {
|
||||
ReleaseChannel::Nightly => Ok(None),
|
||||
ReleaseChannel::Dev => {
|
||||
anyhow::bail!(
|
||||
"ZED_BUILD_REMOTE_SERVER is not set and no remote server exists at ({:?})",
|
||||
dst_path
|
||||
)
|
||||
}
|
||||
_ => Ok(Some(AppVersion::global(cx))),
|
||||
})??;
|
||||
|
||||
let tmp_path_gz = paths::remote_server_dir_relative().join(
|
||||
RelPath::unix(&format!(
|
||||
"{}-download-{}.gz",
|
||||
binary_name,
|
||||
std::process::id()
|
||||
))
|
||||
.unwrap(),
|
||||
);
|
||||
if !self.connection_options.upload_binary_over_docker_exec
|
||||
&& let Some(url) = delegate
|
||||
.get_download_url(remote_platform, release_channel, wanted_version.clone(), cx)
|
||||
.await?
|
||||
{
|
||||
match self
|
||||
.download_binary_on_server(&url, &tmp_path_gz, &remote_dir_for_server, delegate, cx)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
self.extract_server_binary(
|
||||
&dst_path,
|
||||
&tmp_path_gz,
|
||||
&remote_dir_for_server,
|
||||
delegate,
|
||||
cx,
|
||||
)
|
||||
.await
|
||||
.context("extracting server binary")?;
|
||||
return Ok(dst_path);
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!(
|
||||
"Failed to download binary on server, attempting to download locally and then upload it the server: {e:#}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let src_path = delegate
|
||||
.download_server_binary_locally(remote_platform, release_channel, wanted_version, cx)
|
||||
.await
|
||||
.context("downloading server binary locally")?;
|
||||
self.upload_local_server_binary(
|
||||
&src_path,
|
||||
&tmp_path_gz,
|
||||
&remote_dir_for_server,
|
||||
delegate,
|
||||
cx,
|
||||
)
|
||||
.await
|
||||
.context("uploading server binary")?;
|
||||
self.extract_server_binary(
|
||||
&dst_path,
|
||||
&tmp_path_gz,
|
||||
&remote_dir_for_server,
|
||||
delegate,
|
||||
cx,
|
||||
)
|
||||
.await
|
||||
.context("extracting server binary")?;
|
||||
Ok(dst_path)
|
||||
}
|
||||
|
||||
async fn docker_user_home_dir(&self) -> Result<String> {
|
||||
let inner_program = self.shell();
|
||||
self.run_docker_exec(
|
||||
&inner_program,
|
||||
None,
|
||||
&Default::default(),
|
||||
&["-c", "echo $HOME"],
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn extract_server_binary(
|
||||
&self,
|
||||
dst_path: &RelPath,
|
||||
tmp_path: &RelPath,
|
||||
remote_dir_for_server: &str,
|
||||
delegate: &Arc<dyn RemoteClientDelegate>,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Result<()> {
|
||||
delegate.set_status(Some("Extracting remote development server"), cx);
|
||||
let server_mode = 0o755;
|
||||
|
||||
let shell_kind = ShellKind::Posix;
|
||||
let orig_tmp_path = tmp_path.display(self.path_style());
|
||||
let server_mode = format!("{:o}", server_mode);
|
||||
let server_mode = shell_kind
|
||||
.try_quote(&server_mode)
|
||||
.context("shell quoting")?;
|
||||
let dst_path = dst_path.display(self.path_style());
|
||||
let dst_path = shell_kind.try_quote(&dst_path).context("shell quoting")?;
|
||||
let script = if let Some(tmp_path) = orig_tmp_path.strip_suffix(".gz") {
|
||||
let orig_tmp_path = shell_kind
|
||||
.try_quote(&orig_tmp_path)
|
||||
.context("shell quoting")?;
|
||||
let tmp_path = shell_kind.try_quote(&tmp_path).context("shell quoting")?;
|
||||
format!(
|
||||
"gunzip -f {orig_tmp_path} && chmod {server_mode} {tmp_path} && mv {tmp_path} {dst_path}",
|
||||
)
|
||||
} else {
|
||||
let orig_tmp_path = shell_kind
|
||||
.try_quote(&orig_tmp_path)
|
||||
.context("shell quoting")?;
|
||||
format!("chmod {server_mode} {orig_tmp_path} && mv {orig_tmp_path} {dst_path}",)
|
||||
};
|
||||
let args = shell_kind.args_for_shell(false, script.to_string());
|
||||
self.run_docker_exec(
|
||||
"sh",
|
||||
Some(&remote_dir_for_server),
|
||||
&Default::default(),
|
||||
&args,
|
||||
)
|
||||
.await
|
||||
.log_err();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn upload_local_server_binary(
|
||||
&self,
|
||||
src_path: &Path,
|
||||
tmp_path_gz: &RelPath,
|
||||
remote_dir_for_server: &str,
|
||||
delegate: &Arc<dyn RemoteClientDelegate>,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Result<()> {
|
||||
if let Some(parent) = tmp_path_gz.parent() {
|
||||
self.run_docker_exec(
|
||||
"mkdir",
|
||||
Some(remote_dir_for_server),
|
||||
&Default::default(),
|
||||
&["-p", parent.display(self.path_style()).as_ref()],
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let src_stat = smol::fs::metadata(&src_path).await?;
|
||||
let size = src_stat.len();
|
||||
|
||||
let t0 = Instant::now();
|
||||
delegate.set_status(Some("Uploading remote development server"), cx);
|
||||
log::info!(
|
||||
"uploading remote development server to {:?} ({}kb)",
|
||||
tmp_path_gz,
|
||||
size / 1024
|
||||
);
|
||||
self.upload_file(src_path, tmp_path_gz, remote_dir_for_server)
|
||||
.await
|
||||
.context("failed to upload server binary")?;
|
||||
log::info!("uploaded remote development server in {:?}", t0.elapsed());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn upload_file(
|
||||
&self,
|
||||
src_path: &Path,
|
||||
dest_path: &RelPath,
|
||||
remote_dir_for_server: &str,
|
||||
) -> Result<()> {
|
||||
log::debug!("uploading file {:?} to {:?}", src_path, dest_path);
|
||||
|
||||
let src_path_display = src_path.display().to_string();
|
||||
let dest_path_str = dest_path.display(self.path_style());
|
||||
|
||||
let mut command = util::command::new_smol_command("docker");
|
||||
command.arg("cp");
|
||||
command.arg("-a");
|
||||
command.arg(&src_path_display);
|
||||
command.arg(format!(
|
||||
"{}:{}/{}",
|
||||
&self.connection_options.container_id, remote_dir_for_server, dest_path_str
|
||||
));
|
||||
|
||||
let output = command.output().await?;
|
||||
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
log::debug!(
|
||||
"failed to upload file via docker cp {src_path_display} -> {dest_path_str}: {stderr}",
|
||||
);
|
||||
anyhow::bail!(
|
||||
"failed to upload file via docker cp {} -> {}: {}",
|
||||
src_path_display,
|
||||
dest_path_str,
|
||||
stderr,
|
||||
);
|
||||
}
|
||||
|
||||
async fn run_docker_command(
|
||||
&self,
|
||||
subcommand: &str,
|
||||
args: &[impl AsRef<str>],
|
||||
) -> Result<String> {
|
||||
let mut command = util::command::new_smol_command("docker");
|
||||
command.arg(subcommand);
|
||||
for arg in args {
|
||||
command.arg(arg.as_ref());
|
||||
}
|
||||
let output = command.output().await?;
|
||||
anyhow::ensure!(
|
||||
output.status.success(),
|
||||
"failed to run command {command:?}: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
}
|
||||
|
||||
async fn run_docker_exec(
|
||||
&self,
|
||||
inner_program: &str,
|
||||
working_directory: Option<&str>,
|
||||
env: &HashMap<String, String>,
|
||||
program_args: &[impl AsRef<str>],
|
||||
) -> Result<String> {
|
||||
let mut args = match working_directory {
|
||||
Some(dir) => vec!["-w".to_string(), dir.to_string()],
|
||||
None => vec![],
|
||||
};
|
||||
|
||||
for (k, v) in env.iter() {
|
||||
args.push("-e".to_string());
|
||||
let env_declaration = format!("{}={}", k, v);
|
||||
args.push(env_declaration);
|
||||
}
|
||||
|
||||
args.push(self.connection_options.container_id.clone());
|
||||
args.push(inner_program.to_string());
|
||||
|
||||
for arg in program_args {
|
||||
args.push(arg.as_ref().to_owned());
|
||||
}
|
||||
self.run_docker_command("exec", args.as_ref()).await
|
||||
}
|
||||
|
||||
async fn download_binary_on_server(
|
||||
&self,
|
||||
url: &str,
|
||||
tmp_path_gz: &RelPath,
|
||||
remote_dir_for_server: &str,
|
||||
delegate: &Arc<dyn RemoteClientDelegate>,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Result<()> {
|
||||
if let Some(parent) = tmp_path_gz.parent() {
|
||||
self.run_docker_exec(
|
||||
"mkdir",
|
||||
Some(remote_dir_for_server),
|
||||
&Default::default(),
|
||||
&["-p", parent.display(self.path_style()).as_ref()],
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
delegate.set_status(Some("Downloading remote development server on host"), cx);
|
||||
|
||||
match self
|
||||
.run_docker_exec(
|
||||
"curl",
|
||||
Some(remote_dir_for_server),
|
||||
&Default::default(),
|
||||
&[
|
||||
"-f",
|
||||
"-L",
|
||||
url,
|
||||
"-o",
|
||||
&tmp_path_gz.display(self.path_style()),
|
||||
],
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
if self
|
||||
.run_docker_exec("which", None, &Default::default(), &["curl"])
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
log::info!("curl is not available, trying wget");
|
||||
match self
|
||||
.run_docker_exec(
|
||||
"wget",
|
||||
Some(remote_dir_for_server),
|
||||
&Default::default(),
|
||||
&[url, "-O", &tmp_path_gz.display(self.path_style())],
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
if self
|
||||
.run_docker_exec("which", None, &Default::default(), &["wget"])
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
return Err(e);
|
||||
} else {
|
||||
anyhow::bail!("Neither curl nor wget is available");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn kill_inner(&self) -> Result<()> {
|
||||
if let Some(pid) = self.proxy_process.lock().take() {
|
||||
if let Ok(_) = util::command::new_smol_command("kill")
|
||||
.arg(pid.to_string())
|
||||
.spawn()
|
||||
{
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow::anyhow!("Failed to kill process"))
|
||||
}
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait(?Send)]
|
||||
impl RemoteConnection for DockerExecConnection {
|
||||
fn has_wsl_interop(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn start_proxy(
|
||||
&self,
|
||||
unique_identifier: String,
|
||||
reconnect: bool,
|
||||
incoming_tx: UnboundedSender<Envelope>,
|
||||
outgoing_rx: UnboundedReceiver<Envelope>,
|
||||
connection_activity_tx: Sender<()>,
|
||||
delegate: Arc<dyn RemoteClientDelegate>,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Task<Result<i32>> {
|
||||
// We'll try connecting anew every time we open a devcontainer, so proactively try to kill any old connections.
|
||||
if !self.has_been_killed() {
|
||||
if let Err(e) = self.kill_inner() {
|
||||
return Task::ready(Err(e));
|
||||
};
|
||||
}
|
||||
|
||||
delegate.set_status(Some("Starting proxy"), cx);
|
||||
|
||||
let Some(remote_binary_relpath) = self.remote_binary_relpath.clone() else {
|
||||
return Task::ready(Err(anyhow!("Remote binary path not set")));
|
||||
};
|
||||
|
||||
let mut docker_args = vec![
|
||||
"exec".to_string(),
|
||||
"-w".to_string(),
|
||||
self.remote_dir_for_server.clone(),
|
||||
"-i".to_string(),
|
||||
self.connection_options.container_id.to_string(),
|
||||
];
|
||||
for env_var in ["RUST_LOG", "RUST_BACKTRACE", "ZED_GENERATE_MINIDUMPS"] {
|
||||
if let Some(value) = std::env::var(env_var).ok() {
|
||||
docker_args.push("-e".to_string());
|
||||
docker_args.push(format!("{}='{}'", env_var, value));
|
||||
}
|
||||
}
|
||||
let val = remote_binary_relpath
|
||||
.display(self.path_style())
|
||||
.into_owned();
|
||||
docker_args.push(val);
|
||||
docker_args.push("proxy".to_string());
|
||||
docker_args.push("--identifier".to_string());
|
||||
docker_args.push(unique_identifier);
|
||||
if reconnect {
|
||||
docker_args.push("--reconnect".to_string());
|
||||
}
|
||||
let mut command = util::command::new_smol_command("docker");
|
||||
command
|
||||
.kill_on_drop(true)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.args(docker_args);
|
||||
|
||||
let Ok(child) = command.spawn() else {
|
||||
return Task::ready(Err(anyhow::anyhow!(
|
||||
"Failed to start remote server process"
|
||||
)));
|
||||
};
|
||||
|
||||
let mut proxy_process = self.proxy_process.lock();
|
||||
*proxy_process = Some(child.id());
|
||||
|
||||
super::handle_rpc_messages_over_child_process_stdio(
|
||||
child,
|
||||
incoming_tx,
|
||||
outgoing_rx,
|
||||
connection_activity_tx,
|
||||
cx,
|
||||
)
|
||||
}
|
||||
|
||||
fn upload_directory(
|
||||
&self,
|
||||
src_path: PathBuf,
|
||||
dest_path: RemotePathBuf,
|
||||
cx: &App,
|
||||
) -> Task<Result<()>> {
|
||||
let dest_path_str = dest_path.to_string();
|
||||
let src_path_display = src_path.display().to_string();
|
||||
|
||||
let mut command = util::command::new_smol_command("docker");
|
||||
command.arg("cp");
|
||||
command.arg("-a"); // Archive mode is required to assign the file ownership to the default docker exec user
|
||||
command.arg(src_path_display);
|
||||
command.arg(format!(
|
||||
"{}:{}",
|
||||
self.connection_options.container_id, dest_path_str
|
||||
));
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let output = command.output().await?;
|
||||
|
||||
if output.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow::anyhow!("Failed to upload directory"))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn kill(&self) -> Result<()> {
|
||||
self.kill_inner()
|
||||
}
|
||||
|
||||
fn has_been_killed(&self) -> bool {
|
||||
self.proxy_process.lock().is_none()
|
||||
}
|
||||
|
||||
fn build_command(
|
||||
&self,
|
||||
program: Option<String>,
|
||||
args: &[String],
|
||||
env: &HashMap<String, String>,
|
||||
working_dir: Option<String>,
|
||||
_port_forward: Option<(u16, String, u16)>,
|
||||
) -> Result<CommandTemplate> {
|
||||
let mut parsed_working_dir = None;
|
||||
|
||||
let path_style = self.path_style();
|
||||
|
||||
if let Some(working_dir) = working_dir {
|
||||
let working_dir = RemotePathBuf::new(working_dir, path_style).to_string();
|
||||
|
||||
const TILDE_PREFIX: &'static str = "~/";
|
||||
if working_dir.starts_with(TILDE_PREFIX) {
|
||||
let working_dir = working_dir.trim_start_matches("~").trim_start_matches("/");
|
||||
parsed_working_dir = Some(format!("$HOME/{working_dir}"));
|
||||
} else {
|
||||
parsed_working_dir = Some(working_dir);
|
||||
}
|
||||
}
|
||||
|
||||
let mut inner_program = Vec::new();
|
||||
|
||||
if let Some(program) = program {
|
||||
inner_program.push(program);
|
||||
for arg in args {
|
||||
inner_program.push(arg.clone());
|
||||
}
|
||||
} else {
|
||||
inner_program.push(self.shell());
|
||||
inner_program.push("-l".to_string());
|
||||
};
|
||||
|
||||
let mut docker_args = vec!["exec".to_string()];
|
||||
|
||||
if let Some(parsed_working_dir) = parsed_working_dir {
|
||||
docker_args.push("-w".to_string());
|
||||
docker_args.push(parsed_working_dir);
|
||||
}
|
||||
|
||||
for (k, v) in env.iter() {
|
||||
docker_args.push("-e".to_string());
|
||||
docker_args.push(format!("{}={}", k, v));
|
||||
}
|
||||
|
||||
docker_args.push("-it".to_string());
|
||||
docker_args.push(self.connection_options.container_id.to_string());
|
||||
|
||||
docker_args.append(&mut inner_program);
|
||||
|
||||
Ok(CommandTemplate {
|
||||
program: "docker".to_string(),
|
||||
args: docker_args,
|
||||
// Docker-exec pipes in environment via the "-e" argument
|
||||
env: Default::default(),
|
||||
})
|
||||
}
|
||||
|
||||
fn build_forward_ports_command(
|
||||
&self,
|
||||
_forwards: Vec<(u16, String, u16)>,
|
||||
) -> Result<CommandTemplate> {
|
||||
Err(anyhow::anyhow!("Not currently supported for docker_exec"))
|
||||
}
|
||||
|
||||
fn connection_options(&self) -> RemoteConnectionOptions {
|
||||
RemoteConnectionOptions::Docker(self.connection_options.clone())
|
||||
}
|
||||
|
||||
fn path_style(&self) -> PathStyle {
|
||||
self.path_style.unwrap_or(PathStyle::Posix)
|
||||
}
|
||||
|
||||
fn shell(&self) -> String {
|
||||
match &self.shell {
|
||||
Some(shell) => shell.clone(),
|
||||
None => self.default_system_shell(),
|
||||
}
|
||||
}
|
||||
|
||||
fn default_system_shell(&self) -> String {
|
||||
String::from("/bin/sh")
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,8 @@ use tempfile::TempDir;
|
||||
use util::{
|
||||
paths::{PathStyle, RemotePathBuf},
|
||||
rel_path::RelPath,
|
||||
shell::ShellKind,
|
||||
shell::{Shell, ShellKind},
|
||||
shell_builder::ShellBuilder,
|
||||
};
|
||||
|
||||
pub(crate) struct SshRemoteConnection {
|
||||
@@ -1362,6 +1363,8 @@ fn build_command(
|
||||
} else {
|
||||
write!(exec, "{ssh_shell} -l")?;
|
||||
};
|
||||
let (command, command_args) = ShellBuilder::new(&Shell::Program(ssh_shell.to_owned()), false)
|
||||
.build(Some(exec.clone()), &[]);
|
||||
|
||||
let mut args = Vec::new();
|
||||
args.extend(ssh_args);
|
||||
@@ -1372,7 +1375,9 @@ fn build_command(
|
||||
}
|
||||
|
||||
args.push("-t".into());
|
||||
args.push(exec);
|
||||
args.push(command);
|
||||
args.extend(command_args);
|
||||
|
||||
Ok(CommandTemplate {
|
||||
program: "ssh".into(),
|
||||
args,
|
||||
@@ -1411,6 +1416,9 @@ mod tests {
|
||||
"-p",
|
||||
"2222",
|
||||
"-t",
|
||||
"/bin/fish",
|
||||
"-i",
|
||||
"-c",
|
||||
"cd \"$HOME/work\" && exec env INPUT_VA=val remote_program arg1 arg2"
|
||||
]
|
||||
);
|
||||
@@ -1443,6 +1451,9 @@ mod tests {
|
||||
"-L",
|
||||
"1:foo:2",
|
||||
"-t",
|
||||
"/bin/fish",
|
||||
"-i",
|
||||
"-c",
|
||||
"cd && exec env INPUT_VA=val /bin/fish -l"
|
||||
]
|
||||
);
|
||||
|
||||
@@ -23,7 +23,8 @@ use std::{
|
||||
use util::{
|
||||
paths::{PathStyle, RemotePathBuf},
|
||||
rel_path::RelPath,
|
||||
shell::ShellKind,
|
||||
shell::{Shell, ShellKind},
|
||||
shell_builder::ShellBuilder,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Deserialize, schemars::JsonSchema)]
|
||||
@@ -453,8 +454,10 @@ impl RemoteConnection for WslRemoteConnection {
|
||||
} else {
|
||||
write!(&mut exec, "{} -l", self.shell)?;
|
||||
}
|
||||
let (command, args) =
|
||||
ShellBuilder::new(&Shell::Program(self.shell.clone()), false).build(Some(exec), &[]);
|
||||
|
||||
let wsl_args = if let Some(user) = &self.connection_options.user {
|
||||
let mut wsl_args = if let Some(user) = &self.connection_options.user {
|
||||
vec![
|
||||
"--distribution".to_string(),
|
||||
self.connection_options.distro_name.clone(),
|
||||
@@ -463,9 +466,7 @@ impl RemoteConnection for WslRemoteConnection {
|
||||
"--cd".to_string(),
|
||||
working_dir,
|
||||
"--".to_string(),
|
||||
self.shell.clone(),
|
||||
"-c".to_string(),
|
||||
exec,
|
||||
command,
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
@@ -474,11 +475,10 @@ impl RemoteConnection for WslRemoteConnection {
|
||||
"--cd".to_string(),
|
||||
working_dir,
|
||||
"--".to_string(),
|
||||
self.shell.clone(),
|
||||
"-c".to_string(),
|
||||
exec,
|
||||
command,
|
||||
]
|
||||
};
|
||||
wsl_args.extend(args);
|
||||
|
||||
Ok(CommandTemplate {
|
||||
program: "wsl.exe".to_string(),
|
||||
|
||||
@@ -511,6 +511,11 @@ pub struct GitPanelSettingsContent {
|
||||
///
|
||||
/// Default: false
|
||||
pub collapse_untracked_diff: Option<bool>,
|
||||
|
||||
/// Whether to show entries with tree or flat view in the panel
|
||||
///
|
||||
/// Default: false
|
||||
pub tree_view: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(
|
||||
@@ -889,9 +894,19 @@ pub enum ImageFileSizeUnit {
|
||||
pub struct RemoteSettingsContent {
|
||||
pub ssh_connections: Option<Vec<SshConnection>>,
|
||||
pub wsl_connections: Option<Vec<WslConnection>>,
|
||||
pub dev_container_connections: Option<Vec<DevContainerConnection>>,
|
||||
pub read_ssh_config: Option<bool>,
|
||||
}
|
||||
|
||||
#[with_fallible_options]
|
||||
#[derive(
|
||||
Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema, MergeFrom, Hash,
|
||||
)]
|
||||
pub struct DevContainerConnection {
|
||||
pub name: SharedString,
|
||||
pub container_id: SharedString,
|
||||
}
|
||||
|
||||
#[with_fallible_options]
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, JsonSchema, MergeFrom)]
|
||||
pub struct SshConnection {
|
||||
@@ -901,7 +916,7 @@ pub struct SshConnection {
|
||||
#[serde(default)]
|
||||
pub args: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub projects: collections::BTreeSet<SshProject>,
|
||||
pub projects: collections::BTreeSet<RemoteProject>,
|
||||
/// Name to use for this server in UI.
|
||||
pub nickname: Option<String>,
|
||||
// By default Zed will download the binary to the host directly.
|
||||
@@ -918,14 +933,14 @@ pub struct WslConnection {
|
||||
pub distro_name: SharedString,
|
||||
pub user: Option<String>,
|
||||
#[serde(default)]
|
||||
pub projects: BTreeSet<SshProject>,
|
||||
pub projects: BTreeSet<RemoteProject>,
|
||||
}
|
||||
|
||||
#[with_fallible_options]
|
||||
#[derive(
|
||||
Clone, Debug, Default, Serialize, PartialEq, Eq, PartialOrd, Ord, Deserialize, JsonSchema,
|
||||
)]
|
||||
pub struct SshProject {
|
||||
pub struct RemoteProject {
|
||||
pub paths: Vec<String>,
|
||||
}
|
||||
|
||||
|
||||
@@ -4314,6 +4314,24 @@ pub(crate) fn settings_data(cx: &App) -> Vec<SettingsPage> {
|
||||
metadata: None,
|
||||
files: USER,
|
||||
}),
|
||||
SettingsPageItem::SettingItem(SettingItem {
|
||||
title: "Tree View",
|
||||
description: "Enable to show entries in tree view list, disable to show in flat view list.",
|
||||
field: Box::new(SettingField {
|
||||
json_path: Some("git_panel.tree_view"),
|
||||
pick: |settings_content| {
|
||||
settings_content.git_panel.as_ref()?.tree_view.as_ref()
|
||||
},
|
||||
write: |settings_content, value| {
|
||||
settings_content
|
||||
.git_panel
|
||||
.get_or_insert_default()
|
||||
.tree_view = value;
|
||||
},
|
||||
}),
|
||||
metadata: None,
|
||||
files: USER,
|
||||
}),
|
||||
SettingsPageItem::SettingItem(SettingItem {
|
||||
title: "Scroll Bar",
|
||||
description: "How and when the scrollbar should be displayed.",
|
||||
|
||||
@@ -323,12 +323,18 @@ impl TitleBar {
|
||||
let options = self.project.read(cx).remote_connection_options(cx)?;
|
||||
let host: SharedString = options.display_name().into();
|
||||
|
||||
let (nickname, icon) = match options {
|
||||
RemoteConnectionOptions::Ssh(options) => {
|
||||
(options.nickname.map(|nick| nick.into()), IconName::Server)
|
||||
let (nickname, tooltip_title, icon) = match options {
|
||||
RemoteConnectionOptions::Ssh(options) => (
|
||||
options.nickname.map(|nick| nick.into()),
|
||||
"Remote Project",
|
||||
IconName::Server,
|
||||
),
|
||||
RemoteConnectionOptions::Wsl(_) => (None, "Remote Project", IconName::Linux),
|
||||
RemoteConnectionOptions::Docker(_dev_container_connection) => {
|
||||
(None, "Dev Container", IconName::Box)
|
||||
}
|
||||
RemoteConnectionOptions::Wsl(_) => (None, IconName::Linux),
|
||||
};
|
||||
|
||||
let nickname = nickname.unwrap_or_else(|| host.clone());
|
||||
|
||||
let (indicator_color, meta) = match self.project.read(cx).remote_connection_state(cx)? {
|
||||
@@ -375,7 +381,7 @@ impl TitleBar {
|
||||
)
|
||||
.tooltip(move |_window, cx| {
|
||||
Tooltip::with_meta(
|
||||
"Remote Project",
|
||||
tooltip_title,
|
||||
Some(&OpenRemote {
|
||||
from_existing_connection: false,
|
||||
create_new_window: false,
|
||||
|
||||
@@ -56,7 +56,10 @@ pub enum ShellKind {
|
||||
Tcsh,
|
||||
Rc,
|
||||
Fish,
|
||||
/// Pre-installed "legacy" powershell for windows
|
||||
PowerShell,
|
||||
/// PowerShell 7.x
|
||||
Pwsh,
|
||||
Nushell,
|
||||
Cmd,
|
||||
Xonsh,
|
||||
@@ -238,6 +241,7 @@ impl fmt::Display for ShellKind {
|
||||
ShellKind::Tcsh => write!(f, "tcsh"),
|
||||
ShellKind::Fish => write!(f, "fish"),
|
||||
ShellKind::PowerShell => write!(f, "powershell"),
|
||||
ShellKind::Pwsh => write!(f, "pwsh"),
|
||||
ShellKind::Nushell => write!(f, "nu"),
|
||||
ShellKind::Cmd => write!(f, "cmd"),
|
||||
ShellKind::Rc => write!(f, "rc"),
|
||||
@@ -260,7 +264,8 @@ impl ShellKind {
|
||||
.to_string_lossy();
|
||||
|
||||
match &*program {
|
||||
"powershell" | "pwsh" => ShellKind::PowerShell,
|
||||
"powershell" => ShellKind::PowerShell,
|
||||
"pwsh" => ShellKind::Pwsh,
|
||||
"cmd" => ShellKind::Cmd,
|
||||
"nu" => ShellKind::Nushell,
|
||||
"fish" => ShellKind::Fish,
|
||||
@@ -279,7 +284,7 @@ impl ShellKind {
|
||||
|
||||
pub fn to_shell_variable(self, input: &str) -> String {
|
||||
match self {
|
||||
Self::PowerShell => Self::to_powershell_variable(input),
|
||||
Self::PowerShell | Self::Pwsh => Self::to_powershell_variable(input),
|
||||
Self::Cmd => Self::to_cmd_variable(input),
|
||||
Self::Posix => input.to_owned(),
|
||||
Self::Fish => input.to_owned(),
|
||||
@@ -407,8 +412,12 @@ impl ShellKind {
|
||||
|
||||
pub fn args_for_shell(&self, interactive: bool, combined_command: String) -> Vec<String> {
|
||||
match self {
|
||||
ShellKind::PowerShell => vec!["-C".to_owned(), combined_command],
|
||||
ShellKind::Cmd => vec!["/C".to_owned(), combined_command],
|
||||
ShellKind::PowerShell | ShellKind::Pwsh => vec!["-C".to_owned(), combined_command],
|
||||
ShellKind::Cmd => vec![
|
||||
"/S".to_owned(),
|
||||
"/C".to_owned(),
|
||||
format!("\"{combined_command}\""),
|
||||
],
|
||||
ShellKind::Posix
|
||||
| ShellKind::Nushell
|
||||
| ShellKind::Fish
|
||||
@@ -426,7 +435,7 @@ impl ShellKind {
|
||||
|
||||
pub const fn command_prefix(&self) -> Option<char> {
|
||||
match self {
|
||||
ShellKind::PowerShell => Some('&'),
|
||||
ShellKind::PowerShell | ShellKind::Pwsh => Some('&'),
|
||||
ShellKind::Nushell => Some('^'),
|
||||
ShellKind::Posix
|
||||
| ShellKind::Csh
|
||||
@@ -457,6 +466,7 @@ impl ShellKind {
|
||||
| ShellKind::Rc
|
||||
| ShellKind::Fish
|
||||
| ShellKind::PowerShell
|
||||
| ShellKind::Pwsh
|
||||
| ShellKind::Nushell
|
||||
| ShellKind::Xonsh
|
||||
| ShellKind::Elvish => ';',
|
||||
@@ -471,6 +481,7 @@ impl ShellKind {
|
||||
| ShellKind::Tcsh
|
||||
| ShellKind::Rc
|
||||
| ShellKind::Fish
|
||||
| ShellKind::Pwsh
|
||||
| ShellKind::PowerShell
|
||||
| ShellKind::Xonsh => "&&",
|
||||
ShellKind::Nushell | ShellKind::Elvish => ";",
|
||||
@@ -478,11 +489,10 @@ impl ShellKind {
|
||||
}
|
||||
|
||||
pub fn try_quote<'a>(&self, arg: &'a str) -> Option<Cow<'a, str>> {
|
||||
shlex::try_quote(arg).ok().map(|arg| match self {
|
||||
// If we are running in PowerShell, we want to take extra care when escaping strings.
|
||||
// In particular, we want to escape strings with a backtick (`) rather than a backslash (\).
|
||||
ShellKind::PowerShell => Cow::Owned(arg.replace("\\\"", "`\"").replace("\\\\", "\\")),
|
||||
ShellKind::Cmd => Cow::Owned(arg.replace("\\\\", "\\")),
|
||||
match self {
|
||||
ShellKind::PowerShell => Some(Self::quote_powershell(arg)),
|
||||
ShellKind::Pwsh => Some(Self::quote_pwsh(arg)),
|
||||
ShellKind::Cmd => Some(Self::quote_cmd(arg)),
|
||||
ShellKind::Posix
|
||||
| ShellKind::Csh
|
||||
| ShellKind::Tcsh
|
||||
@@ -490,8 +500,173 @@ impl ShellKind {
|
||||
| ShellKind::Fish
|
||||
| ShellKind::Nushell
|
||||
| ShellKind::Xonsh
|
||||
| ShellKind::Elvish => arg,
|
||||
})
|
||||
| ShellKind::Elvish => shlex::try_quote(arg).ok(),
|
||||
}
|
||||
}
|
||||
|
||||
fn quote_windows(arg: &str, enclose: bool) -> Cow<'_, str> {
|
||||
if arg.is_empty() {
|
||||
return Cow::Borrowed("\"\"");
|
||||
}
|
||||
|
||||
let needs_quoting = arg.chars().any(|c| c == ' ' || c == '\t' || c == '"');
|
||||
if !needs_quoting {
|
||||
return Cow::Borrowed(arg);
|
||||
}
|
||||
|
||||
let mut result = String::with_capacity(arg.len() + 2);
|
||||
|
||||
if enclose {
|
||||
result.push('"');
|
||||
}
|
||||
|
||||
let chars: Vec<char> = arg.chars().collect();
|
||||
let mut i = 0;
|
||||
|
||||
while i < chars.len() {
|
||||
if chars[i] == '\\' {
|
||||
let mut num_backslashes = 0;
|
||||
while i < chars.len() && chars[i] == '\\' {
|
||||
num_backslashes += 1;
|
||||
i += 1;
|
||||
}
|
||||
|
||||
if i < chars.len() && chars[i] == '"' {
|
||||
// Backslashes followed by quote: double the backslashes and escape the quote
|
||||
for _ in 0..(num_backslashes * 2 + 1) {
|
||||
result.push('\\');
|
||||
}
|
||||
result.push('"');
|
||||
i += 1;
|
||||
} else if i >= chars.len() {
|
||||
// Trailing backslashes: double them (they precede the closing quote)
|
||||
for _ in 0..(num_backslashes * 2) {
|
||||
result.push('\\');
|
||||
}
|
||||
} else {
|
||||
// Backslashes not followed by quote: output as-is
|
||||
for _ in 0..num_backslashes {
|
||||
result.push('\\');
|
||||
}
|
||||
}
|
||||
} else if chars[i] == '"' {
|
||||
// Quote not preceded by backslash: escape it
|
||||
result.push('\\');
|
||||
result.push('"');
|
||||
i += 1;
|
||||
} else {
|
||||
result.push(chars[i]);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if enclose {
|
||||
result.push('"');
|
||||
}
|
||||
Cow::Owned(result)
|
||||
}
|
||||
|
||||
fn needs_quoting_powershell(s: &str) -> bool {
|
||||
s.is_empty()
|
||||
|| s.chars().any(|c| {
|
||||
c.is_whitespace()
|
||||
|| matches!(
|
||||
c,
|
||||
'"' | '`'
|
||||
| '$'
|
||||
| '&'
|
||||
| '|'
|
||||
| '<'
|
||||
| '>'
|
||||
| ';'
|
||||
| '('
|
||||
| ')'
|
||||
| '['
|
||||
| ']'
|
||||
| '{'
|
||||
| '}'
|
||||
| ','
|
||||
| '\''
|
||||
| '@'
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn need_quotes_powershell(arg: &str) -> bool {
|
||||
let mut quote_count = 0;
|
||||
for c in arg.chars() {
|
||||
if c == '"' {
|
||||
quote_count += 1;
|
||||
} else if c.is_whitespace() && (quote_count % 2 == 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn escape_powershell_quotes(s: &str) -> String {
|
||||
let mut result = String::with_capacity(s.len() + 4);
|
||||
result.push('\'');
|
||||
for c in s.chars() {
|
||||
if c == '\'' {
|
||||
result.push('\'');
|
||||
}
|
||||
result.push(c);
|
||||
}
|
||||
result.push('\'');
|
||||
result
|
||||
}
|
||||
|
||||
pub fn quote_powershell(arg: &str) -> Cow<'_, str> {
|
||||
let ps_will_quote = Self::need_quotes_powershell(arg);
|
||||
let crt_quoted = Self::quote_windows(arg, !ps_will_quote);
|
||||
|
||||
if !Self::needs_quoting_powershell(arg) {
|
||||
return crt_quoted;
|
||||
}
|
||||
|
||||
Cow::Owned(Self::escape_powershell_quotes(&crt_quoted))
|
||||
}
|
||||
|
||||
pub fn quote_pwsh(arg: &str) -> Cow<'_, str> {
|
||||
if arg.is_empty() {
|
||||
return Cow::Borrowed("''");
|
||||
}
|
||||
|
||||
if !Self::needs_quoting_powershell(arg) {
|
||||
return Cow::Borrowed(arg);
|
||||
}
|
||||
|
||||
Cow::Owned(Self::escape_powershell_quotes(arg))
|
||||
}
|
||||
|
||||
pub fn quote_cmd(arg: &str) -> Cow<'_, str> {
|
||||
let crt_quoted = Self::quote_windows(arg, true);
|
||||
|
||||
let needs_cmd_escaping = crt_quoted.contains('"')
|
||||
|| crt_quoted.contains('%')
|
||||
|| crt_quoted
|
||||
.chars()
|
||||
.any(|c| matches!(c, '^' | '<' | '>' | '&' | '|' | '(' | ')'));
|
||||
|
||||
if !needs_cmd_escaping {
|
||||
return crt_quoted;
|
||||
}
|
||||
|
||||
let mut result = String::with_capacity(crt_quoted.len() * 2);
|
||||
for c in crt_quoted.chars() {
|
||||
match c {
|
||||
'^' | '"' | '<' | '>' | '&' | '|' | '(' | ')' => {
|
||||
result.push('^');
|
||||
result.push(c);
|
||||
}
|
||||
'%' => {
|
||||
result.push_str("%%cd:~,%");
|
||||
}
|
||||
_ => result.push(c),
|
||||
}
|
||||
}
|
||||
Cow::Owned(result)
|
||||
}
|
||||
|
||||
/// Quotes the given argument if necessary, taking into account the command prefix.
|
||||
@@ -538,7 +713,7 @@ impl ShellKind {
|
||||
match self {
|
||||
ShellKind::Cmd => "",
|
||||
ShellKind::Nushell => "overlay use",
|
||||
ShellKind::PowerShell => ".",
|
||||
ShellKind::PowerShell | ShellKind::Pwsh => ".",
|
||||
ShellKind::Fish
|
||||
| ShellKind::Csh
|
||||
| ShellKind::Tcsh
|
||||
@@ -558,6 +733,7 @@ impl ShellKind {
|
||||
| ShellKind::Rc
|
||||
| ShellKind::Fish
|
||||
| ShellKind::PowerShell
|
||||
| ShellKind::Pwsh
|
||||
| ShellKind::Nushell
|
||||
| ShellKind::Xonsh
|
||||
| ShellKind::Elvish => "clear",
|
||||
@@ -576,6 +752,7 @@ impl ShellKind {
|
||||
| ShellKind::Rc
|
||||
| ShellKind::Fish
|
||||
| ShellKind::PowerShell
|
||||
| ShellKind::Pwsh
|
||||
| ShellKind::Nushell
|
||||
| ShellKind::Xonsh
|
||||
| ShellKind::Elvish => true,
|
||||
@@ -605,7 +782,7 @@ mod tests {
|
||||
.try_quote("C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \"test_foo.py::test_foo\"")
|
||||
.unwrap()
|
||||
.into_owned(),
|
||||
"\"C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest `\"test_foo.py::test_foo`\"\"".to_string()
|
||||
"'C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \\\"test_foo.py::test_foo\\\"'".to_string()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -617,7 +794,113 @@ mod tests {
|
||||
.try_quote("C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \"test_foo.py::test_foo\"")
|
||||
.unwrap()
|
||||
.into_owned(),
|
||||
"\"C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \\\"test_foo.py::test_foo\\\"\"".to_string()
|
||||
"^\"C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \\^\"test_foo.py::test_foo\\^\"^\"".to_string()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_try_quote_powershell_edge_cases() {
|
||||
let shell_kind = ShellKind::PowerShell;
|
||||
|
||||
// Empty string
|
||||
assert_eq!(
|
||||
shell_kind.try_quote("").unwrap().into_owned(),
|
||||
"'\"\"'".to_string()
|
||||
);
|
||||
|
||||
// String without special characters (no quoting needed)
|
||||
assert_eq!(shell_kind.try_quote("simple").unwrap(), "simple");
|
||||
|
||||
// String with spaces
|
||||
assert_eq!(
|
||||
shell_kind.try_quote("hello world").unwrap().into_owned(),
|
||||
"'hello world'".to_string()
|
||||
);
|
||||
|
||||
// String with dollar signs
|
||||
assert_eq!(
|
||||
shell_kind.try_quote("$variable").unwrap().into_owned(),
|
||||
"'$variable'".to_string()
|
||||
);
|
||||
|
||||
// String with backticks
|
||||
assert_eq!(
|
||||
shell_kind.try_quote("test`command").unwrap().into_owned(),
|
||||
"'test`command'".to_string()
|
||||
);
|
||||
|
||||
// String with multiple special characters
|
||||
assert_eq!(
|
||||
shell_kind
|
||||
.try_quote("test `\"$var`\" end")
|
||||
.unwrap()
|
||||
.into_owned(),
|
||||
"'test `\\\"$var`\\\" end'".to_string()
|
||||
);
|
||||
|
||||
// String with backslashes and colon (path without spaces doesn't need quoting)
|
||||
assert_eq!(
|
||||
shell_kind.try_quote("C:\\path\\to\\file").unwrap(),
|
||||
"C:\\path\\to\\file"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_try_quote_cmd_edge_cases() {
|
||||
let shell_kind = ShellKind::Cmd;
|
||||
|
||||
// Empty string
|
||||
assert_eq!(
|
||||
shell_kind.try_quote("").unwrap().into_owned(),
|
||||
"^\"^\"".to_string()
|
||||
);
|
||||
|
||||
// String without special characters (no quoting needed)
|
||||
assert_eq!(shell_kind.try_quote("simple").unwrap(), "simple");
|
||||
|
||||
// String with spaces
|
||||
assert_eq!(
|
||||
shell_kind.try_quote("hello world").unwrap().into_owned(),
|
||||
"^\"hello world^\"".to_string()
|
||||
);
|
||||
|
||||
// String with space and backslash (backslash not at end, so not doubled)
|
||||
assert_eq!(
|
||||
shell_kind.try_quote("path\\ test").unwrap().into_owned(),
|
||||
"^\"path\\ test^\"".to_string()
|
||||
);
|
||||
|
||||
// String ending with backslash (must be doubled before closing quote)
|
||||
assert_eq!(
|
||||
shell_kind.try_quote("test path\\").unwrap().into_owned(),
|
||||
"^\"test path\\\\^\"".to_string()
|
||||
);
|
||||
|
||||
// String ending with multiple backslashes (all doubled before closing quote)
|
||||
assert_eq!(
|
||||
shell_kind.try_quote("test path\\\\").unwrap().into_owned(),
|
||||
"^\"test path\\\\\\\\^\"".to_string()
|
||||
);
|
||||
|
||||
// String with embedded quote (quote is escaped, backslash before it is doubled)
|
||||
assert_eq!(
|
||||
shell_kind.try_quote("test\\\"quote").unwrap().into_owned(),
|
||||
"^\"test\\\\\\^\"quote^\"".to_string()
|
||||
);
|
||||
|
||||
// String with multiple backslashes before embedded quote (all doubled)
|
||||
assert_eq!(
|
||||
shell_kind
|
||||
.try_quote("test\\\\\"quote")
|
||||
.unwrap()
|
||||
.into_owned(),
|
||||
"^\"test\\\\\\\\\\^\"quote^\"".to_string()
|
||||
);
|
||||
|
||||
// String with backslashes not before quotes (path without spaces doesn't need quoting)
|
||||
assert_eq!(
|
||||
shell_kind.try_quote("C:\\path\\to\\file").unwrap(),
|
||||
"C:\\path\\to\\file"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::borrow::Cow;
|
||||
|
||||
use crate::shell::get_system_shell;
|
||||
use crate::shell::{Shell, ShellKind};
|
||||
|
||||
@@ -42,7 +44,7 @@ impl ShellBuilder {
|
||||
self.program.clone()
|
||||
} else {
|
||||
match self.kind {
|
||||
ShellKind::PowerShell => {
|
||||
ShellKind::PowerShell | ShellKind::Pwsh => {
|
||||
format!("{} -C '{}'", self.program, command_to_use_in_label)
|
||||
}
|
||||
ShellKind::Cmd => {
|
||||
@@ -78,11 +80,27 @@ impl ShellBuilder {
|
||||
task_args: &[String],
|
||||
) -> (String, Vec<String>) {
|
||||
if let Some(task_command) = task_command {
|
||||
let mut combined_command = task_args.iter().fold(task_command, |mut command, arg| {
|
||||
command.push(' ');
|
||||
command.push_str(&self.kind.to_shell_variable(arg));
|
||||
command
|
||||
});
|
||||
let task_command = self.kind.prepend_command_prefix(&task_command);
|
||||
let task_command = if !task_args.is_empty() {
|
||||
match self.kind.try_quote_prefix_aware(&task_command) {
|
||||
Some(task_command) => task_command,
|
||||
None => task_command,
|
||||
}
|
||||
} else {
|
||||
task_command
|
||||
};
|
||||
let mut combined_command =
|
||||
task_args
|
||||
.iter()
|
||||
.fold(task_command.into_owned(), |mut command, arg| {
|
||||
command.push(' ');
|
||||
let shell_variable = self.kind.to_shell_variable(arg);
|
||||
command.push_str(&match self.kind.try_quote(&shell_variable) {
|
||||
Some(shell_variable) => shell_variable,
|
||||
None => Cow::Owned(shell_variable),
|
||||
});
|
||||
command
|
||||
});
|
||||
if self.redirect_stdin {
|
||||
match self.kind {
|
||||
ShellKind::Fish => {
|
||||
@@ -99,7 +117,7 @@ impl ShellBuilder {
|
||||
combined_command.insert(0, '(');
|
||||
combined_command.push_str(") </dev/null");
|
||||
}
|
||||
ShellKind::PowerShell => {
|
||||
ShellKind::PowerShell | ShellKind::Pwsh => {
|
||||
combined_command.insert_str(0, "$null | & {");
|
||||
combined_command.push_str("}");
|
||||
}
|
||||
@@ -115,6 +133,10 @@ impl ShellBuilder {
|
||||
|
||||
(self.program, self.args)
|
||||
}
|
||||
|
||||
pub fn kind(&self) -> ShellKind {
|
||||
self.kind
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -144,7 +166,7 @@ mod test {
|
||||
vec![
|
||||
"-i",
|
||||
"-c",
|
||||
"echo $env.hello $env.world nothing --($env.something) $ ${test"
|
||||
"^echo '$env.hello' '$env.world' nothing '--($env.something)' '$' '${test'"
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -159,7 +181,7 @@ mod test {
|
||||
.build(Some("echo".into()), &["nothing".to_string()]);
|
||||
|
||||
assert_eq!(program, "nu");
|
||||
assert_eq!(args, vec!["-i", "-c", "(echo nothing) </dev/null"]);
|
||||
assert_eq!(args, vec!["-i", "-c", "(^echo nothing) </dev/null"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -159,7 +159,7 @@ async fn capture_windows(
|
||||
zed_path.display()
|
||||
),
|
||||
]),
|
||||
ShellKind::PowerShell => cmd.args([
|
||||
ShellKind::PowerShell | ShellKind::Pwsh => cmd.args([
|
||||
"-NonInteractive",
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
|
||||
@@ -20,7 +20,9 @@ use project::debugger::breakpoint_store::{BreakpointState, SourceBreakpoint};
|
||||
|
||||
use language::{LanguageName, Toolchain, ToolchainScope};
|
||||
use project::WorktreeId;
|
||||
use remote::{RemoteConnectionOptions, SshConnectionOptions, WslConnectionOptions};
|
||||
use remote::{
|
||||
DockerConnectionOptions, RemoteConnectionOptions, SshConnectionOptions, WslConnectionOptions,
|
||||
};
|
||||
use sqlez::{
|
||||
bindable::{Bind, Column, StaticColumnCount},
|
||||
statement::Statement,
|
||||
@@ -702,6 +704,10 @@ impl Domain for WorkspaceDb {
|
||||
sql!(
|
||||
DROP TABLE ssh_connections;
|
||||
),
|
||||
sql!(
|
||||
ALTER TABLE remote_connections ADD COLUMN name TEXT;
|
||||
ALTER TABLE remote_connections ADD COLUMN container_id TEXT;
|
||||
),
|
||||
];
|
||||
|
||||
// Allow recovering from bad migration that was initially shipped to nightly
|
||||
@@ -728,9 +734,9 @@ impl WorkspaceDb {
|
||||
pub(crate) fn remote_workspace_for_roots<P: AsRef<Path>>(
|
||||
&self,
|
||||
worktree_roots: &[P],
|
||||
ssh_project_id: RemoteConnectionId,
|
||||
remote_project_id: RemoteConnectionId,
|
||||
) -> Option<SerializedWorkspace> {
|
||||
self.workspace_for_roots_internal(worktree_roots, Some(ssh_project_id))
|
||||
self.workspace_for_roots_internal(worktree_roots, Some(remote_project_id))
|
||||
}
|
||||
|
||||
pub(crate) fn workspace_for_roots_internal<P: AsRef<Path>>(
|
||||
@@ -806,9 +812,20 @@ impl WorkspaceDb {
|
||||
order: paths_order,
|
||||
});
|
||||
|
||||
let remote_connection_options = if let Some(remote_connection_id) = remote_connection_id {
|
||||
self.remote_connection(remote_connection_id)
|
||||
.context("Get remote connection")
|
||||
.log_err()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Some(SerializedWorkspace {
|
||||
id: workspace_id,
|
||||
location: SerializedWorkspaceLocation::Local,
|
||||
location: match remote_connection_options {
|
||||
Some(options) => SerializedWorkspaceLocation::Remote(options),
|
||||
None => SerializedWorkspaceLocation::Local,
|
||||
},
|
||||
paths,
|
||||
center_group: self
|
||||
.get_center_pane_group(workspace_id)
|
||||
@@ -1110,10 +1127,12 @@ impl WorkspaceDb {
|
||||
options: RemoteConnectionOptions,
|
||||
) -> Result<RemoteConnectionId> {
|
||||
let kind;
|
||||
let user;
|
||||
let mut user = None;
|
||||
let mut host = None;
|
||||
let mut port = None;
|
||||
let mut distro = None;
|
||||
let mut name = None;
|
||||
let mut container_id = None;
|
||||
match options {
|
||||
RemoteConnectionOptions::Ssh(options) => {
|
||||
kind = RemoteConnectionKind::Ssh;
|
||||
@@ -1126,8 +1145,22 @@ impl WorkspaceDb {
|
||||
distro = Some(options.distro_name);
|
||||
user = options.user;
|
||||
}
|
||||
RemoteConnectionOptions::Docker(options) => {
|
||||
kind = RemoteConnectionKind::Docker;
|
||||
container_id = Some(options.container_id);
|
||||
name = Some(options.name);
|
||||
}
|
||||
}
|
||||
Self::get_or_create_remote_connection_query(this, kind, host, port, user, distro)
|
||||
Self::get_or_create_remote_connection_query(
|
||||
this,
|
||||
kind,
|
||||
host,
|
||||
port,
|
||||
user,
|
||||
distro,
|
||||
name,
|
||||
container_id,
|
||||
)
|
||||
}
|
||||
|
||||
fn get_or_create_remote_connection_query(
|
||||
@@ -1137,6 +1170,8 @@ impl WorkspaceDb {
|
||||
port: Option<u16>,
|
||||
user: Option<String>,
|
||||
distro: Option<String>,
|
||||
name: Option<String>,
|
||||
container_id: Option<String>,
|
||||
) -> Result<RemoteConnectionId> {
|
||||
if let Some(id) = this.select_row_bound(sql!(
|
||||
SELECT id
|
||||
@@ -1146,7 +1181,9 @@ impl WorkspaceDb {
|
||||
host IS ? AND
|
||||
port IS ? AND
|
||||
user IS ? AND
|
||||
distro IS ?
|
||||
distro IS ? AND
|
||||
name IS ? AND
|
||||
container_id IS ?
|
||||
LIMIT 1
|
||||
))?((
|
||||
kind.serialize(),
|
||||
@@ -1154,6 +1191,8 @@ impl WorkspaceDb {
|
||||
port,
|
||||
user.clone(),
|
||||
distro.clone(),
|
||||
name.clone(),
|
||||
container_id.clone(),
|
||||
))? {
|
||||
Ok(RemoteConnectionId(id))
|
||||
} else {
|
||||
@@ -1163,10 +1202,20 @@ impl WorkspaceDb {
|
||||
host,
|
||||
port,
|
||||
user,
|
||||
distro
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5)
|
||||
distro,
|
||||
name,
|
||||
container_id
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
||||
RETURNING id
|
||||
))?((kind.serialize(), host, port, user, distro))?
|
||||
))?((
|
||||
kind.serialize(),
|
||||
host,
|
||||
port,
|
||||
user,
|
||||
distro,
|
||||
name,
|
||||
container_id,
|
||||
))?
|
||||
.context("failed to insert remote project")?;
|
||||
Ok(RemoteConnectionId(id))
|
||||
}
|
||||
@@ -1249,15 +1298,23 @@ impl WorkspaceDb {
|
||||
fn remote_connections(&self) -> Result<HashMap<RemoteConnectionId, RemoteConnectionOptions>> {
|
||||
Ok(self.select(sql!(
|
||||
SELECT
|
||||
id, kind, host, port, user, distro
|
||||
id, kind, host, port, user, distro, container_id, name
|
||||
FROM
|
||||
remote_connections
|
||||
))?()?
|
||||
.into_iter()
|
||||
.filter_map(|(id, kind, host, port, user, distro)| {
|
||||
.filter_map(|(id, kind, host, port, user, distro, container_id, name)| {
|
||||
Some((
|
||||
RemoteConnectionId(id),
|
||||
Self::remote_connection_from_row(kind, host, port, user, distro)?,
|
||||
Self::remote_connection_from_row(
|
||||
kind,
|
||||
host,
|
||||
port,
|
||||
user,
|
||||
distro,
|
||||
container_id,
|
||||
name,
|
||||
)?,
|
||||
))
|
||||
})
|
||||
.collect())
|
||||
@@ -1267,13 +1324,13 @@ impl WorkspaceDb {
|
||||
&self,
|
||||
id: RemoteConnectionId,
|
||||
) -> Result<RemoteConnectionOptions> {
|
||||
let (kind, host, port, user, distro) = self.select_row_bound(sql!(
|
||||
SELECT kind, host, port, user, distro
|
||||
let (kind, host, port, user, distro, container_id, name) = self.select_row_bound(sql!(
|
||||
SELECT kind, host, port, user, distro, container_id, name
|
||||
FROM remote_connections
|
||||
WHERE id = ?
|
||||
))?(id.0)?
|
||||
.context("no such remote connection")?;
|
||||
Self::remote_connection_from_row(kind, host, port, user, distro)
|
||||
Self::remote_connection_from_row(kind, host, port, user, distro, container_id, name)
|
||||
.context("invalid remote_connection row")
|
||||
}
|
||||
|
||||
@@ -1283,6 +1340,8 @@ impl WorkspaceDb {
|
||||
port: Option<u16>,
|
||||
user: Option<String>,
|
||||
distro: Option<String>,
|
||||
container_id: Option<String>,
|
||||
name: Option<String>,
|
||||
) -> Option<RemoteConnectionOptions> {
|
||||
match RemoteConnectionKind::deserialize(&kind)? {
|
||||
RemoteConnectionKind::Wsl => Some(RemoteConnectionOptions::Wsl(WslConnectionOptions {
|
||||
@@ -1295,6 +1354,13 @@ impl WorkspaceDb {
|
||||
username: user,
|
||||
..Default::default()
|
||||
})),
|
||||
RemoteConnectionKind::Docker => {
|
||||
Some(RemoteConnectionOptions::Docker(DockerConnectionOptions {
|
||||
container_id: container_id?,
|
||||
name: name?,
|
||||
upload_binary_over_docker_exec: false,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ pub(crate) struct RemoteConnectionId(pub u64);
|
||||
pub(crate) enum RemoteConnectionKind {
|
||||
Ssh,
|
||||
Wsl,
|
||||
Docker,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Clone)]
|
||||
@@ -75,6 +76,7 @@ impl RemoteConnectionKind {
|
||||
match self {
|
||||
RemoteConnectionKind::Ssh => "ssh",
|
||||
RemoteConnectionKind::Wsl => "wsl",
|
||||
RemoteConnectionKind::Docker => "docker",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +84,7 @@ impl RemoteConnectionKind {
|
||||
match text {
|
||||
"ssh" => Some(Self::Ssh),
|
||||
"wsl" => Some(Self::Wsl),
|
||||
"docker" => Some(Self::Docker),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7780,7 +7780,7 @@ pub fn open_remote_project_with_new_connection(
|
||||
) -> Task<Result<Vec<Option<Box<dyn ItemHandle>>>>> {
|
||||
cx.spawn(async move |cx| {
|
||||
let (workspace_id, serialized_workspace) =
|
||||
serialize_remote_project(remote_connection.connection_options(), paths.clone(), cx)
|
||||
deserialize_remote_project(remote_connection.connection_options(), paths.clone(), cx)
|
||||
.await?;
|
||||
|
||||
let session = match cx
|
||||
@@ -7834,7 +7834,7 @@ pub fn open_remote_project_with_existing_connection(
|
||||
) -> Task<Result<Vec<Option<Box<dyn ItemHandle>>>>> {
|
||||
cx.spawn(async move |cx| {
|
||||
let (workspace_id, serialized_workspace) =
|
||||
serialize_remote_project(connection_options.clone(), paths.clone(), cx).await?;
|
||||
deserialize_remote_project(connection_options.clone(), paths.clone(), cx).await?;
|
||||
|
||||
open_remote_project_inner(
|
||||
project,
|
||||
@@ -7936,7 +7936,7 @@ async fn open_remote_project_inner(
|
||||
Ok(items.into_iter().map(|item| item?.ok()).collect())
|
||||
}
|
||||
|
||||
fn serialize_remote_project(
|
||||
fn deserialize_remote_project(
|
||||
connection_options: RemoteConnectionOptions,
|
||||
paths: Vec<PathBuf>,
|
||||
cx: &AsyncApp,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
description = "The fast, collaborative code editor."
|
||||
edition.workspace = true
|
||||
name = "zed"
|
||||
version = "0.217.0"
|
||||
version = "0.218.0"
|
||||
publish.workspace = true
|
||||
license = "GPL-3.0-or-later"
|
||||
authors = ["Zed Team <hi@zed.dev>"]
|
||||
|
||||
@@ -1 +1 @@
|
||||
preview
|
||||
dev
|
||||
|
||||
@@ -428,6 +428,12 @@ pub struct OpenRemote {
|
||||
pub create_new_window: bool,
|
||||
}
|
||||
|
||||
/// Opens the dev container connection modal.
|
||||
#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)]
|
||||
#[action(namespace = projects)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct OpenDevContainer;
|
||||
|
||||
/// Where to spawn the task in the UI.
|
||||
#[derive(Default, Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
|
||||
Reference in New Issue
Block a user