Compare commits

..

11 Commits

Author SHA1 Message Date
Cole Miller
ee53b805f0 fix 2025-12-10 18:00:48 -05:00
Xipeng Jin
9e628505f3 git: Add tree view support to Git Panel (#44089)
Closes #35803

This PR adds tree view support to the git panel UI as an additional
setting and moves git entry checkboxes to the right. Tree view only
supports sorting by paths behavior since sorting by status can become
noisy, due to having to duplicate directories that have entries with
different statuses.

### Tree vs Flat View
<img width="358" height="250" alt="image"
src="https://github.com/user-attachments/assets/c6b95d57-12fc-4c5e-8537-ee129963e50c"
/>
<img width="362" height="152" alt="image"
src="https://github.com/user-attachments/assets/0a69e00f-3878-4807-ae45-65e2d54174fc"
/>


#### Architecture changes

Before this PR, `GitPanel::entries` represented all entries and all
visible entries because both sets were equal to one another. However,
this equality isn't true for tree view, because entries can be
collapsed. To fix this, `TreeState` was added as a logical indices field
that is used to filter out non-visible entries. A benefit of this field
is that it could be used in the future to implement searching in the
GitPanel.

Another significant thing this PR changed was adding a HashMap field
`entries_by_indices` on `GitPanel`. We did this because `entry_by_path`
used binary search, which becomes overly complicated to implement for
tree view. The performance of this function matters because it's a hot
code path, so a linear search wasn't ideal either. The solution was
using a hash map to improve time complexity from O(log n) to O(1), where
n is the count of entries.

#### Follow-ups
In the future, we could use `ui::ListItem` to render entries in the tree
view to improve UI consistency.
 
Release Notes:

- Added tree view for Git panel. Users are able to switch between Flat
and Tree view in Git panel.

---------

Co-authored-by: Anthony Eid <anthony@zed.dev>
Co-authored-by: Remco Smits <djsmits12@gmail.com>
2025-12-10 15:11:36 -05:00
KyleBarton
3a84ec38ac Introduce MVP Dev Containers support (#44442)
Partially addresses #11473 

MVP of dev containers with the following capabilities:

- If in a project with `.devcontainer/devcontainer.json`, a pop-up
notification will ask if you want to open the project in a dev
container. This can be dismissed:
<img width="1478" height="1191" alt="Screenshot 2025-12-08 at 3 15
23 PM"
src="https://github.com/user-attachments/assets/ec2e20d6-28ec-4495-8f23-4c1d48a9ce78"
/>
- Similarly, if a `devcontainer.json` file is in the project, you can
open a devcontainer (or go the devcontainer.json file for further
editing) via the `open remote` modal:


https://github.com/user-attachments/assets/61f2fdaa-2808-4efc-994c-7b444a92c0b1

*Limitations*

This is a first release, and comes with some limitations:
- Zed extensions are not managed in `devcontainer.json` yet. They will
need to be installed either on host or in the container. Host +
Container sync their extensions, so there is not currently a concept of
what is installed in the container vs what is installed on host: they
come from the same list of manifests
- This implementation uses the [devcontainer
CLI](https://github.com/devcontainers/cli) for its control plane. Hence,
it does not yet support the `forwardPorts` directive. A single port can
be opened with `appPort`. See reference in docs
[here](https://github.com/devcontainers/cli/tree/main/example-usage#how-the-tool-examples-work)
- Editing devcontainer.json does not automatically cause the dev
container to be rebuilt. So if you add features, change images, etc, you
will need to `docker kill` the existing dev container before proceeding.
- Currently takes a hard dependency on `docker` being available in the
user's `PATH`.


Release Notes:

- Added ability to Open a project in a DevContainer, provided a
`.devcontainer/devcontainer.json` is present

---------

Co-authored-by: Danilo Leal <daniloleal09@gmail.com>
Co-authored-by: Danilo Leal <67129314+danilo-leal@users.noreply.github.com>
2025-12-10 12:10:43 -08:00
Danilo Leal
a61bf33fb0 Fix label copy for file history menu items (#44569)
Buttons and menu items should preferably always start with an infinitive
verb that describes what will happen when you trigger them. Instead of
just "File History", we should say "_View_ File History".

Release Notes:

- N/A
2025-12-10 18:00:11 +00:00
John Tur
d83201256d Use shell to launch MCP and ACP servers (#42382)
`npx`, and any `npm install`-ed programs, exist as batch
scripts/PowerShell scripts on the PATH. We have to use a shell to launch
these programs.

Fixes https://github.com/zed-industries/zed/issues/41435
Closes https://github.com/zed-industries/zed/pull/42651


Release Notes:

- windows: Custom MCP and ACP servers installed through `npm` now launch
correctly.

---------

Co-authored-by: Lukas Wirth <me@lukaswirth.dev>
2025-12-10 12:08:37 -05:00
Ben Kunkle
8ee85eab3c vim: Remove ctrl-6 keybinding alias for pane::AlternateFile (#44560)
Closes #ISSUE

It seems that `ctrl-6` is used exclusively as an alias, as can be seen
in the [linked section of the vim
docs](https://vimhelp.org/editing.txt.html#CTRL-%5E) from the initial PR
that added it. This however conflicts with the `ctrl-{n}` bindings for
`pane::ActivateItem` on macOS, leading to confusing file selection when
`ctrl-6` is pressed.

Release Notes:

- vim(BREAKING): Removed a keybinding conflict between the default macOS
bindings for `pane::ActivateItem` and the `ctrl-6` alias
for`pane::AlternateFile` which is primarily bound to `ctrl-^`. `ctrl-6`
is no longer treated as an alias for `ctrl-^` in vim mode. If you'd like
to restore `ctrl-6` as a binding for `pane::AlternateFile`, paste the
following into your `keymap.json` file:
```
  {
    "context": "VimControl && !menu",
    "bindings": {
      "ctrl-6": "pane::AlternateFile"
    }
  }
```
2025-12-10 16:55:50 +00:00
Ben Brandt
5b309ef986 acp: Better telemetry IDs for ACP agents (#44544)
We were defining these in multiple places and also weren't leveraging
the ids the agents were already providing.

This should make sure we use them consistently and avoid issues in the
future.

Release Notes:

- N/A
2025-12-10 16:48:08 +00:00
Mayank Verma
326ebb5230 git: Fix failing commits when hook command is not available (#43993) 2025-12-10 16:34:49 +00:00
Bennet Bo Fenner
f5babf96e1 agent_ui: Fix project path not found error when pasting code from other project (#44555)
The problem with inserting the absolute paths is that the agent will try
to read them. However, we don't allow the agent to read files outside
the current project. For now, we will only insert the crease in case the
code that is getting pasted is from the same project

Release Notes:

- Fixed an issue where pasting code into the agent panel from another
window would show an error
2025-12-10 16:30:10 +00:00
Joseph T. Lyons
f48aa252f8 Bump Zed to v0.218 (#44551)
Release Notes:

- N/A
2025-12-10 15:28:39 +00:00
Finn Evers
4106c8a188 Disable OmniSharp by default for C# files (#44427)
In preparation for https://github.com/zed-extensions/csharp/pull/11. Do
not merge before that PR is published.

Release Notes:

- Added support for Roslyn in C# files. Roslyn will now be the default
language server for C#
2025-12-10 10:12:41 -05:00
58 changed files with 3530 additions and 520 deletions

6
Cargo.lock generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -21,10 +21,6 @@ impl NativeAgentServer {
}
impl AgentServer for NativeAgentServer {
fn telemetry_id(&self) -> &'static str {
"zed"
}
fn name(&self) -> SharedString {
"Zed Agent".into()
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -49,6 +49,7 @@ pub enum IconName {
BoltOutlined,
Book,
BookCopy,
Box,
CaseSensitive,
Chat,
Check,

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(_) => {

View File

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

View File

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

View File

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

View 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");
}
}

View 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())
});
}
})
})
});
});
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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")
}
}

View File

@@ -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"
]
);

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"
);
}

View 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]

View File

@@ -159,7 +159,7 @@ async fn capture_windows(
zed_path.display()
),
]),
ShellKind::PowerShell => cmd.args([
ShellKind::PowerShell | ShellKind::Pwsh => cmd.args([
"-NonInteractive",
"-NoProfile",
"-Command",

View File

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

View File

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

View File

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

View File

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

View File

@@ -1 +1 @@
preview
dev

View File

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