Compare commits

...

10 Commits

Author SHA1 Message Date
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
56 changed files with 3531 additions and 513 deletions

6
Cargo.lock generated
View File

@@ -3595,6 +3595,7 @@ dependencies = [
"settings", "settings",
"smol", "smol",
"tempfile", "tempfile",
"terminal",
"url", "url",
"util", "util",
] ]
@@ -13156,6 +13157,7 @@ dependencies = [
"askpass", "askpass",
"auto_update", "auto_update",
"dap", "dap",
"db",
"editor", "editor",
"extension_host", "extension_host",
"file_finder", "file_finder",
@@ -13167,6 +13169,7 @@ dependencies = [
"log", "log",
"markdown", "markdown",
"menu", "menu",
"node_runtime",
"ordered-float 2.10.1", "ordered-float 2.10.1",
"paths", "paths",
"picker", "picker",
@@ -13185,6 +13188,7 @@ dependencies = [
"util", "util",
"windows-registry 0.6.1", "windows-registry 0.6.1",
"workspace", "workspace",
"worktree",
"zed_actions", "zed_actions",
] ]
@@ -20469,7 +20473,7 @@ dependencies = [
[[package]] [[package]]
name = "zed" name = "zed"
version = "0.217.0" version = "0.218.0"
dependencies = [ dependencies = [
"acp_tools", "acp_tools",
"activity_indicator", "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 g shift-d": "editor::GoToTypeDefinitionSplit",
"ctrl-w space": "editor::OpenExcerptsSplit", "ctrl-w space": "editor::OpenExcerptsSplit",
"ctrl-w g space": "editor::OpenExcerptsSplit", "ctrl-w g space": "editor::OpenExcerptsSplit",
"ctrl-6": "pane::AlternateFile",
"ctrl-^": "pane::AlternateFile", "ctrl-^": "pane::AlternateFile",
".": "vim::Repeat" ".": "vim::Repeat"
} }

View File

@@ -870,6 +870,10 @@
// //
// Default: false // Default: false
"collapse_untracked_diff": false, "collapse_untracked_diff": false,
/// Whether to show entries with tree or flat view in the panel
///
/// Default: false
"tree_view": false,
"scrollbar": { "scrollbar": {
// When to show the scrollbar in the git panel. // When to show the scrollbar in the git panel.
// //
@@ -1810,6 +1814,9 @@
"allowed": false "allowed": false
} }
}, },
"CSharp": {
"language_servers": ["roslyn", "!omnisharp", "..."]
},
"CSS": { "CSS": {
"prettier": { "prettier": {
"allowed": true "allowed": true

View File

@@ -1372,7 +1372,7 @@ impl AcpThread {
let path_style = self.project.read(cx).path_style(cx); let path_style = self.project.read(cx).path_style(cx);
let id = update.tool_call_id.clone(); 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(); let session = self.session_id();
if let ToolCallStatus::Completed | ToolCallStatus::Failed = status { if let ToolCallStatus::Completed | ToolCallStatus::Failed = status {
let status = if matches!(status, ToolCallStatus::Completed) { let status = if matches!(status, ToolCallStatus::Completed) {
@@ -1380,7 +1380,12 @@ impl AcpThread {
} else { } else {
"failed" "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) { if let Some(ix) = self.index_for_tool_call(&id) {
@@ -3556,8 +3561,8 @@ mod tests {
} }
impl AgentConnection for FakeAgentConnection { impl AgentConnection for FakeAgentConnection {
fn telemetry_id(&self) -> &'static str { fn telemetry_id(&self) -> SharedString {
"fake" "fake".into()
} }
fn auth_methods(&self) -> &[acp::AuthMethod] { fn auth_methods(&self) -> &[acp::AuthMethod] {

View File

@@ -20,7 +20,7 @@ impl UserMessageId {
} }
pub trait AgentConnection { pub trait AgentConnection {
fn telemetry_id(&self) -> &'static str; fn telemetry_id(&self) -> SharedString;
fn new_thread( fn new_thread(
self: Rc<Self>, self: Rc<Self>,
@@ -322,8 +322,8 @@ mod test_support {
} }
impl AgentConnection for StubAgentConnection { impl AgentConnection for StubAgentConnection {
fn telemetry_id(&self) -> &'static str { fn telemetry_id(&self) -> SharedString {
"stub" "stub".into()
} }
fn auth_methods(&self) -> &[acp::AuthMethod] { fn auth_methods(&self) -> &[acp::AuthMethod] {

View File

@@ -777,7 +777,7 @@ impl ActionLog {
#[derive(Clone)] #[derive(Clone)]
pub struct ActionLogTelemetry { pub struct ActionLogTelemetry {
pub agent_telemetry_id: &'static str, pub agent_telemetry_id: SharedString,
pub session_id: Arc<str>, pub session_id: Arc<str>,
} }

View File

@@ -947,8 +947,8 @@ impl acp_thread::AgentModelSelector for NativeAgentModelSelector {
} }
impl acp_thread::AgentConnection for NativeAgentConnection { impl acp_thread::AgentConnection for NativeAgentConnection {
fn telemetry_id(&self) -> &'static str { fn telemetry_id(&self) -> SharedString {
"zed" "zed".into()
} }
fn new_thread( fn new_thread(

View File

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

View File

@@ -9,6 +9,10 @@ use futures::io::BufReader;
use project::Project; use project::Project;
use project::agent_server_store::AgentServerCommand; use project::agent_server_store::AgentServerCommand;
use serde::Deserialize; use serde::Deserialize;
use settings::Settings as _;
use task::ShellBuilder;
#[cfg(windows)]
use task::ShellKind;
use util::ResultExt as _; use util::ResultExt as _;
use std::path::PathBuf; 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 acp_thread::{AcpThread, AuthRequired, LoadError, TerminalProviderEvent};
use terminal::TerminalBuilder; use terminal::TerminalBuilder;
use terminal::terminal_settings::{AlternateScroll, CursorShape}; use terminal::terminal_settings::{AlternateScroll, CursorShape, TerminalSettings};
#[derive(Debug, Error)] #[derive(Debug, Error)]
#[error("Unsupported version")] #[error("Unsupported version")]
@@ -29,7 +33,7 @@ pub struct UnsupportedVersion;
pub struct AcpConnection { pub struct AcpConnection {
server_name: SharedString, server_name: SharedString,
telemetry_id: &'static str, telemetry_id: SharedString,
connection: Rc<acp::ClientSideConnection>, connection: Rc<acp::ClientSideConnection>,
sessions: Rc<RefCell<HashMap<acp::SessionId, AcpSession>>>, sessions: Rc<RefCell<HashMap<acp::SessionId, AcpSession>>>,
auth_methods: Vec<acp::AuthMethod>, auth_methods: Vec<acp::AuthMethod>,
@@ -54,7 +58,6 @@ pub struct AcpSession {
pub async fn connect( pub async fn connect(
server_name: SharedString, server_name: SharedString,
telemetry_id: &'static str,
command: AgentServerCommand, command: AgentServerCommand,
root_dir: &Path, root_dir: &Path,
default_mode: Option<acp::SessionModeId>, default_mode: Option<acp::SessionModeId>,
@@ -64,7 +67,6 @@ pub async fn connect(
) -> Result<Rc<dyn AgentConnection>> { ) -> Result<Rc<dyn AgentConnection>> {
let conn = AcpConnection::stdio( let conn = AcpConnection::stdio(
server_name, server_name,
telemetry_id,
command.clone(), command.clone(),
root_dir, root_dir,
default_mode, default_mode,
@@ -81,7 +83,6 @@ const MINIMUM_SUPPORTED_VERSION: acp::ProtocolVersion = acp::ProtocolVersion::V1
impl AcpConnection { impl AcpConnection {
pub async fn stdio( pub async fn stdio(
server_name: SharedString, server_name: SharedString,
telemetry_id: &'static str,
command: AgentServerCommand, command: AgentServerCommand,
root_dir: &Path, root_dir: &Path,
default_mode: Option<acp::SessionModeId>, default_mode: Option<acp::SessionModeId>,
@@ -89,9 +90,26 @@ impl AcpConnection {
is_remote: bool, is_remote: bool,
cx: &mut AsyncApp, cx: &mut AsyncApp,
) -> Result<Self> { ) -> 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 child
.args(command.args.iter().map(|arg| arg.as_str()))
.envs(command.env.iter().flatten()) .envs(command.env.iter().flatten())
.stdin(std::process::Stdio::piped()) .stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped())
@@ -199,6 +217,13 @@ impl AcpConnection {
return Err(UnsupportedVersion.into()); 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 { Ok(Self {
auth_methods: response.auth_methods, auth_methods: response.auth_methods,
root_dir: root_dir.to_owned(), root_dir: root_dir.to_owned(),
@@ -233,8 +258,8 @@ impl Drop for AcpConnection {
} }
impl AgentConnection for AcpConnection { impl AgentConnection for AcpConnection {
fn telemetry_id(&self) -> &'static str { fn telemetry_id(&self) -> SharedString {
self.telemetry_id self.telemetry_id.clone()
} }
fn new_thread( fn new_thread(

View File

@@ -56,7 +56,6 @@ impl AgentServerDelegate {
pub trait AgentServer: Send { pub trait AgentServer: Send {
fn logo(&self) -> ui::IconName; fn logo(&self) -> ui::IconName;
fn name(&self) -> SharedString; fn name(&self) -> SharedString;
fn telemetry_id(&self) -> &'static str;
fn default_mode(&self, _cx: &mut App) -> Option<agent_client_protocol::SessionModeId> { fn default_mode(&self, _cx: &mut App) -> Option<agent_client_protocol::SessionModeId> {
None None
} }

View File

@@ -22,10 +22,6 @@ pub struct AgentServerLoginCommand {
} }
impl AgentServer for ClaudeCode { impl AgentServer for ClaudeCode {
fn telemetry_id(&self) -> &'static str {
"claude-code"
}
fn name(&self) -> SharedString { fn name(&self) -> SharedString {
"Claude Code".into() "Claude Code".into()
} }
@@ -83,7 +79,6 @@ impl AgentServer for ClaudeCode {
cx: &mut App, cx: &mut App,
) -> Task<Result<(Rc<dyn AgentConnection>, Option<task::SpawnInTerminal>)>> { ) -> Task<Result<(Rc<dyn AgentConnection>, Option<task::SpawnInTerminal>)>> {
let name = self.name(); 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 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 is_remote = delegate.project.read(cx).is_via_remote_server();
let store = delegate.store.downgrade(); let store = delegate.store.downgrade();
@@ -108,7 +103,6 @@ impl AgentServer for ClaudeCode {
.await?; .await?;
let connection = crate::acp::connect( let connection = crate::acp::connect(
name, name,
telemetry_id,
command, command,
root_dir.as_ref(), root_dir.as_ref(),
default_mode, default_mode,

View File

@@ -23,10 +23,6 @@ pub(crate) mod tests {
} }
impl AgentServer for Codex { impl AgentServer for Codex {
fn telemetry_id(&self) -> &'static str {
"codex"
}
fn name(&self) -> SharedString { fn name(&self) -> SharedString {
"Codex".into() "Codex".into()
} }
@@ -84,7 +80,6 @@ impl AgentServer for Codex {
cx: &mut App, cx: &mut App,
) -> Task<Result<(Rc<dyn AgentConnection>, Option<task::SpawnInTerminal>)>> { ) -> Task<Result<(Rc<dyn AgentConnection>, Option<task::SpawnInTerminal>)>> {
let name = self.name(); 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 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 is_remote = delegate.project.read(cx).is_via_remote_server();
let store = delegate.store.downgrade(); let store = delegate.store.downgrade();
@@ -110,7 +105,6 @@ impl AgentServer for Codex {
let connection = crate::acp::connect( let connection = crate::acp::connect(
name, name,
telemetry_id,
command, command,
root_dir.as_ref(), root_dir.as_ref(),
default_mode, 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 acp_thread::AgentConnection;
use agent_client_protocol as acp; use agent_client_protocol as acp;
use anyhow::{Context as _, Result}; use anyhow::{Context as _, Result};
@@ -20,11 +20,7 @@ impl CustomAgentServer {
} }
} }
impl crate::AgentServer for CustomAgentServer { impl AgentServer for CustomAgentServer {
fn telemetry_id(&self) -> &'static str {
"custom"
}
fn name(&self) -> SharedString { fn name(&self) -> SharedString {
self.name.clone() self.name.clone()
} }
@@ -112,14 +108,12 @@ impl crate::AgentServer for CustomAgentServer {
cx: &mut App, cx: &mut App,
) -> Task<Result<(Rc<dyn AgentConnection>, Option<task::SpawnInTerminal>)>> { ) -> Task<Result<(Rc<dyn AgentConnection>, Option<task::SpawnInTerminal>)>> {
let name = self.name(); 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 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 is_remote = delegate.project.read(cx).is_via_remote_server();
let default_mode = self.default_mode(cx); let default_mode = self.default_mode(cx);
let default_model = self.default_model(cx); let default_model = self.default_model(cx);
let store = delegate.store.downgrade(); let store = delegate.store.downgrade();
let extra_env = load_proxy_env(cx); let extra_env = load_proxy_env(cx);
cx.spawn(async move |cx| { cx.spawn(async move |cx| {
let (command, root_dir, login) = store let (command, root_dir, login) = store
.update(cx, |store, cx| { .update(cx, |store, cx| {
@@ -139,7 +133,6 @@ impl crate::AgentServer for CustomAgentServer {
.await?; .await?;
let connection = crate::acp::connect( let connection = crate::acp::connect(
name, name,
telemetry_id,
command, command,
root_dir.as_ref(), root_dir.as_ref(),
default_mode, default_mode,

View File

@@ -12,10 +12,6 @@ use project::agent_server_store::GEMINI_NAME;
pub struct Gemini; pub struct Gemini;
impl AgentServer for Gemini { impl AgentServer for Gemini {
fn telemetry_id(&self) -> &'static str {
"gemini-cli"
}
fn name(&self) -> SharedString { fn name(&self) -> SharedString {
"Gemini CLI".into() "Gemini CLI".into()
} }
@@ -31,7 +27,6 @@ impl AgentServer for Gemini {
cx: &mut App, cx: &mut App,
) -> Task<Result<(Rc<dyn AgentConnection>, Option<task::SpawnInTerminal>)>> { ) -> Task<Result<(Rc<dyn AgentConnection>, Option<task::SpawnInTerminal>)>> {
let name = self.name(); 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 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 is_remote = delegate.project.read(cx).is_via_remote_server();
let store = delegate.store.downgrade(); let store = delegate.store.downgrade();
@@ -66,7 +61,6 @@ impl AgentServer for Gemini {
let connection = crate::acp::connect( let connection = crate::acp::connect(
name, name,
telemetry_id,
command, command,
root_dir.as_ref(), root_dir.as_ref(),
default_mode, default_mode,

View File

@@ -565,8 +565,26 @@ impl MessageEditor {
if let Some((workspace, selections)) = if let Some((workspace, selections)) =
self.workspace.upgrade().zip(editor_clipboard_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 let insertion_target = self
.editor .editor
.read(cx) .read(cx)

View File

@@ -170,7 +170,7 @@ impl ThreadFeedbackState {
} }
} }
let session_id = thread.read(cx).session_id().clone(); 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 task = telemetry.thread_data(&session_id, cx);
let rating = match feedback { let rating = match feedback {
ThreadFeedback::Positive => "positive", ThreadFeedback::Positive => "positive",
@@ -180,7 +180,7 @@ impl ThreadFeedbackState {
let thread = task.await?; let thread = task.await?;
telemetry::event!( telemetry::event!(
"Agent Thread Rated", "Agent Thread Rated",
agent = agent, agent = agent_telemetry_id,
session_id = session_id, session_id = session_id,
rating = rating, rating = rating,
thread = thread thread = thread
@@ -207,13 +207,13 @@ impl ThreadFeedbackState {
self.comments_editor.take(); self.comments_editor.take();
let session_id = thread.read(cx).session_id().clone(); 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 task = telemetry.thread_data(&session_id, cx);
cx.background_spawn(async move { cx.background_spawn(async move {
let thread = task.await?; let thread = task.await?;
telemetry::event!( telemetry::event!(
"Agent Thread Feedback Comments", "Agent Thread Feedback Comments",
agent = agent, agent = agent_telemetry_id,
session_id = session_id, session_id = session_id,
comments = comments, comments = comments,
thread = thread thread = thread
@@ -333,6 +333,7 @@ impl AcpThreadView {
project: Entity<Project>, project: Entity<Project>,
history_store: Entity<HistoryStore>, history_store: Entity<HistoryStore>,
prompt_store: Option<Entity<PromptStore>>, prompt_store: Option<Entity<PromptStore>>,
track_load_event: bool,
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> Self { ) -> Self {
@@ -391,8 +392,9 @@ impl AcpThreadView {
), ),
]; ];
let show_codex_windows_warning = crate::ExternalAgent::parse_built_in(agent.as_ref()) let show_codex_windows_warning = cfg!(windows)
== Some(crate::ExternalAgent::Codex); && project.read(cx).is_local()
&& agent.clone().downcast::<agent_servers::Codex>().is_some();
Self { Self {
agent: agent.clone(), agent: agent.clone(),
@@ -404,6 +406,7 @@ impl AcpThreadView {
resume_thread.clone(), resume_thread.clone(),
workspace.clone(), workspace.clone(),
project.clone(), project.clone(),
track_load_event,
window, window,
cx, cx,
), ),
@@ -448,6 +451,7 @@ impl AcpThreadView {
self.resume_thread_metadata.clone(), self.resume_thread_metadata.clone(),
self.workspace.clone(), self.workspace.clone(),
self.project.clone(), self.project.clone(),
true,
window, window,
cx, cx,
); );
@@ -461,6 +465,7 @@ impl AcpThreadView {
resume_thread: Option<DbThreadMetadata>, resume_thread: Option<DbThreadMetadata>,
workspace: WeakEntity<Workspace>, workspace: WeakEntity<Workspace>,
project: Entity<Project>, project: Entity<Project>,
track_load_event: bool,
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> ThreadState { ) -> 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 let result = if let Some(native_agent) = connection
.clone() .clone()
.downcast::<agent::NativeAgentConnection>() .downcast::<agent::NativeAgentConnection>()
@@ -1133,8 +1142,8 @@ impl AcpThreadView {
let Some(thread) = self.thread() else { let Some(thread) = self.thread() else {
return; return;
}; };
let agent_telemetry_id = self.agent.telemetry_id();
let session_id = thread.read(cx).session_id().clone(); let session_id = thread.read(cx).session_id().clone();
let agent_telemetry_id = thread.read(cx).connection().telemetry_id();
let thread = thread.downgrade(); let thread = thread.downgrade();
if self.should_be_following { if self.should_be_following {
self.workspace self.workspace
@@ -1512,6 +1521,7 @@ impl AcpThreadView {
else { else {
return; return;
}; };
let agent_telemetry_id = connection.telemetry_id();
// Check for the experimental "terminal-auth" _meta field // Check for the experimental "terminal-auth" _meta field
let auth_method = connection.auth_methods().iter().find(|m| m.id == method); let auth_method = connection.auth_methods().iter().find(|m| m.id == method);
@@ -1579,19 +1589,18 @@ impl AcpThreadView {
); );
cx.notify(); cx.notify();
self.auth_task = Some(cx.spawn_in(window, { self.auth_task = Some(cx.spawn_in(window, {
let agent = self.agent.clone();
async move |this, cx| { async move |this, cx| {
let result = authenticate.await; let result = authenticate.await;
match &result { match &result {
Ok(_) => telemetry::event!( Ok(_) => telemetry::event!(
"Authenticate Agent Succeeded", "Authenticate Agent Succeeded",
agent = agent.telemetry_id() agent = agent_telemetry_id
), ),
Err(_) => { Err(_) => {
telemetry::event!( telemetry::event!(
"Authenticate Agent Failed", "Authenticate Agent Failed",
agent = agent.telemetry_id(), agent = agent_telemetry_id,
) )
} }
} }
@@ -1675,6 +1684,7 @@ impl AcpThreadView {
None, None,
this.workspace.clone(), this.workspace.clone(),
this.project.clone(), this.project.clone(),
true,
window, window,
cx, cx,
) )
@@ -1730,43 +1740,38 @@ impl AcpThreadView {
connection.authenticate(method, cx) connection.authenticate(method, cx)
}; };
cx.notify(); cx.notify();
self.auth_task = self.auth_task = Some(cx.spawn_in(window, {
Some(cx.spawn_in(window, { async move |this, cx| {
let agent = self.agent.clone(); let result = authenticate.await;
async move |this, cx| {
let result = authenticate.await;
match &result { match &result {
Ok(_) => telemetry::event!( Ok(_) => telemetry::event!(
"Authenticate Agent Succeeded", "Authenticate Agent Succeeded",
agent = agent.telemetry_id() agent = agent_telemetry_id
), ),
Err(_) => { Err(_) => {
telemetry::event!( telemetry::event!("Authenticate Agent Failed", agent = agent_telemetry_id,)
"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( fn spawn_external_agent_login(
@@ -1896,10 +1901,11 @@ impl AcpThreadView {
let Some(thread) = self.thread() else { let Some(thread) = self.thread() else {
return; return;
}; };
let agent_telemetry_id = thread.read(cx).connection().telemetry_id();
telemetry::event!( telemetry::event!(
"Agent Tool Call Authorized", "Agent Tool Call Authorized",
agent = self.agent.telemetry_id(), agent = agent_telemetry_id,
session = thread.read(cx).session_id(), session = thread.read(cx).session_id(),
option = option_kind option = option_kind
); );
@@ -3509,6 +3515,8 @@ impl AcpThreadView {
(method.id.0.clone(), method.name.clone()) (method.id.0.clone(), method.name.clone())
}; };
let agent_telemetry_id = connection.telemetry_id();
Button::new(method_id.clone(), name) Button::new(method_id.clone(), name)
.label_size(LabelSize::Small) .label_size(LabelSize::Small)
.map(|this| { .map(|this| {
@@ -3528,7 +3536,7 @@ impl AcpThreadView {
cx.listener(move |this, _, window, cx| { cx.listener(move |this, _, window, cx| {
telemetry::event!( telemetry::event!(
"Authenticate Agent Started", "Authenticate Agent Started",
agent = this.agent.telemetry_id(), agent = agent_telemetry_id,
method = method_id method = method_id
); );
@@ -5376,47 +5384,39 @@ impl AcpThreadView {
) )
} }
fn render_codex_windows_warning(&self, cx: &mut Context<Self>) -> Option<Callout> { fn render_codex_windows_warning(&self, cx: &mut Context<Self>) -> Callout {
if self.show_codex_windows_warning { Callout::new()
Some( .icon(IconName::Warning)
Callout::new() .severity(Severity::Warning)
.icon(IconName::Warning) .title("Codex on Windows")
.severity(Severity::Warning) .description("For best performance, run Codex in Windows Subsystem for Linux (WSL2)")
.title("Codex on Windows") .actions_slot(
.description( Button::new("open-wsl-modal", "Open in WSL")
"For best performance, run Codex in Windows Subsystem for Linux (WSL2)", .icon_size(IconSize::Small)
) .icon_color(Color::Muted)
.actions_slot( .on_click(cx.listener({
Button::new("open-wsl-modal", "Open in WSL") move |_, _, _window, cx| {
.icon_size(IconSize::Small) #[cfg(windows)]
.icon_color(Color::Muted) _window.dispatch_action(
.on_click(cx.listener({ zed_actions::wsl_actions::OpenWsl::default().boxed_clone(),
move |_, _, _window, cx| { cx,
#[cfg(windows)] );
_window.dispatch_action( cx.notify();
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)
.dismiss_action( .tooltip(Tooltip::text("Dismiss Warning"))
IconButton::new("dismiss", IconName::Close) .on_click(cx.listener({
.icon_size(IconSize::Small) move |this, _, _, cx| {
.icon_color(Color::Muted) this.show_codex_windows_warning = false;
.tooltip(Tooltip::text("Dismiss Warning")) cx.notify();
.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> { fn render_thread_error(&mut self, window: &mut Window, cx: &mut Context<Self>) -> Option<Div> {
@@ -5936,12 +5936,8 @@ impl Render for AcpThreadView {
_ => this, _ => this,
}) })
.children(self.render_thread_retry_status_callout(window, cx)) .children(self.render_thread_retry_status_callout(window, cx))
.children({ .when(self.show_codex_windows_warning, |this| {
if cfg!(windows) && self.project.read(cx).is_local() { this.child(self.render_codex_windows_warning(cx))
self.render_codex_windows_warning(cx)
} else {
None
}
}) })
.children(self.render_thread_error(window, cx)) .children(self.render_thread_error(window, cx))
.when_some( .when_some(
@@ -6398,6 +6394,7 @@ pub(crate) mod tests {
project, project,
history_store, history_store,
None, None,
false,
window, window,
cx, cx,
) )
@@ -6475,10 +6472,6 @@ pub(crate) mod tests {
where where
C: 'static + AgentConnection + Send + Clone, C: 'static + AgentConnection + Send + Clone,
{ {
fn telemetry_id(&self) -> &'static str {
"test"
}
fn logo(&self) -> ui::IconName { fn logo(&self) -> ui::IconName {
ui::IconName::Ai ui::IconName::Ai
} }
@@ -6505,8 +6498,8 @@ pub(crate) mod tests {
struct SaboteurAgentConnection; struct SaboteurAgentConnection;
impl AgentConnection for SaboteurAgentConnection { impl AgentConnection for SaboteurAgentConnection {
fn telemetry_id(&self) -> &'static str { fn telemetry_id(&self) -> SharedString {
"saboteur" "saboteur".into()
} }
fn new_thread( fn new_thread(
@@ -6569,8 +6562,8 @@ pub(crate) mod tests {
struct RefusalAgentConnection; struct RefusalAgentConnection;
impl AgentConnection for RefusalAgentConnection { impl AgentConnection for RefusalAgentConnection {
fn telemetry_id(&self) -> &'static str { fn telemetry_id(&self) -> SharedString {
"refusal" "refusal".into()
} }
fn new_thread( fn new_thread(
@@ -6671,6 +6664,7 @@ pub(crate) mod tests {
project.clone(), project.clone(),
history_store.clone(), history_store.clone(),
None, None,
false,
window, window,
cx, cx,
) )

View File

@@ -305,6 +305,7 @@ impl ActiveView {
project, project,
history_store, history_store,
prompt_store, prompt_store,
false,
window, window,
cx, cx,
) )
@@ -885,10 +886,6 @@ impl AgentPanel {
let server = ext_agent.server(fs, history); 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| { this.update_in(cx, |this, window, cx| {
let selected_agent = ext_agent.into(); let selected_agent = ext_agent.into();
if this.selected_agent != selected_agent { if this.selected_agent != selected_agent {
@@ -905,6 +902,7 @@ impl AgentPanel {
project, project,
this.history_store.clone(), this.history_store.clone(),
this.prompt_store.clone(), this.prompt_store.clone(),
!loading,
window, window,
cx, cx,
) )

View File

@@ -160,16 +160,6 @@ pub enum ExternalAgent {
} }
impl 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( pub fn server(
&self, &self,
fs: Arc<dyn fs::Fs>, fs: Arc<dyn fs::Fs>,

View File

@@ -33,6 +33,7 @@ smol.workspace = true
tempfile.workspace = true tempfile.workspace = true
url = { workspace = true, features = ["serde"] } url = { workspace = true, features = ["serde"] }
util.workspace = true util.workspace = true
terminal.workspace = true
[dev-dependencies] [dev-dependencies]
gpui = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] }

View File

@@ -8,9 +8,12 @@ use futures::{
AsyncBufReadExt as _, AsyncRead, AsyncWrite, AsyncWriteExt as _, Stream, StreamExt as _, AsyncBufReadExt as _, AsyncRead, AsyncWrite, AsyncWriteExt as _, Stream, StreamExt as _,
}; };
use gpui::AsyncApp; use gpui::AsyncApp;
use settings::Settings as _;
use smol::channel; use smol::channel;
use smol::process::Child; use smol::process::Child;
use terminal::terminal_settings::TerminalSettings;
use util::TryFutureExt as _; use util::TryFutureExt as _;
use util::shell_builder::ShellBuilder;
use crate::client::ModelContextServerBinary; use crate::client::ModelContextServerBinary;
use crate::transport::Transport; use crate::transport::Transport;
@@ -28,9 +31,14 @@ impl StdioTransport {
working_directory: &Option<PathBuf>, working_directory: &Option<PathBuf>,
cx: &AsyncApp, cx: &AsyncApp,
) -> Result<Self> { ) -> 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 command
.args(&binary.args) .args(args)
.envs(binary.env.unwrap_or_default()) .envs(binary.env.unwrap_or_default())
.stdin(std::process::Stdio::piped()) .stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped())

View File

@@ -280,7 +280,11 @@ pub fn deploy_context_menu(
"Copy Permalink", "Copy Permalink",
Box::new(CopyPermalinkToLine), 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 { match focus {
Some(focus) => builder.context(focus), Some(focus) => builder.context(focus),
None => builder, None => builder,

View File

@@ -2295,8 +2295,38 @@ impl GitRepository for RealGitRepository {
self.executor self.executor
.spawn(async move { .spawn(async move {
let working_directory = working_directory?; 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)); .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()]) git.run(&["hook", "run", "--ignore-missing", hook.as_str()])
.await?; .await?;
Ok(()) 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 fallback_branch_name: String,
pub sort_by_path: bool, pub sort_by_path: bool,
pub collapse_untracked_diff: bool, pub collapse_untracked_diff: bool,
pub tree_view: bool,
} }
impl ScrollbarVisibility for GitPanelSettings { impl ScrollbarVisibility for GitPanelSettings {
@@ -56,6 +57,7 @@ impl Settings for GitPanelSettings {
fallback_branch_name: git_panel.fallback_branch_name.unwrap(), fallback_branch_name: git_panel.fallback_branch_name.unwrap(),
sort_by_path: git_panel.sort_by_path.unwrap(), sort_by_path: git_panel.sort_by_path.unwrap(),
collapse_untracked_diff: git_panel.collapse_untracked_diff.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 { 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 TRACKED_SORT_PREFIX
} else if repo.had_conflict_on_last_merge_head_change(repo_path) { } else if repo.had_conflict_on_last_merge_head_change(repo_path) {
CONFLICT_SORT_PREFIX CONFLICT_SORT_PREFIX

View File

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

View File

@@ -126,11 +126,11 @@ impl LspInstaller for EsLintLspAdapter {
} }
self.node self.node
.run_npm_subcommand(&repo_root, "install", &[]) .run_npm_subcommand(Some(&repo_root), "install", &[])
.await?; .await?;
self.node self.node
.run_npm_subcommand(&repo_root, "run-script", &["compile"]) .run_npm_subcommand(Some(&repo_root), "run-script", &["compile"])
.await?; .await?;
} }

View File

@@ -1344,7 +1344,7 @@ impl ToolchainLister for PythonToolchainProvider {
ShellKind::Fish => Some(format!("\"{pyenv}\" shell - fish {version}")), ShellKind::Fish => Some(format!("\"{pyenv}\" shell - fish {version}")),
ShellKind::Posix => Some(format!("\"{pyenv}\" shell - sh {version}")), ShellKind::Posix => Some(format!("\"{pyenv}\" shell - sh {version}")),
ShellKind::Nushell => Some(format!("^\"{pyenv}\" shell - nu {version}")), ShellKind::Nushell => Some(format!("^\"{pyenv}\" shell - nu {version}")),
ShellKind::PowerShell => None, ShellKind::PowerShell | ShellKind::Pwsh => None,
ShellKind::Csh => None, ShellKind::Csh => None,
ShellKind::Tcsh => None, ShellKind::Tcsh => None,
ShellKind::Cmd => None, ShellKind::Cmd => None,

View File

@@ -206,14 +206,14 @@ impl NodeRuntime {
pub async fn run_npm_subcommand( pub async fn run_npm_subcommand(
&self, &self,
directory: &Path, directory: Option<&Path>,
subcommand: &str, subcommand: &str,
args: &[&str], args: &[&str],
) -> Result<Output> { ) -> Result<Output> {
let http = self.0.lock().await.http.clone(); let http = self.0.lock().await.http.clone();
self.instance() self.instance()
.await .await
.run_npm_subcommand(Some(directory), http.proxy(), subcommand, args) .run_npm_subcommand(directory, http.proxy(), subcommand, args)
.await .await
} }
@@ -283,7 +283,7 @@ impl NodeRuntime {
]); ]);
// This is also wrong because the directory is wrong. // 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?; .await?;
Ok(()) Ok(())
} }
@@ -559,7 +559,10 @@ impl NodeRuntimeTrait for ManagedNodeRuntime {
command.env("PATH", env_path); command.env("PATH", env_path);
command.env(NODE_CA_CERTS_ENV_VAR, node_ca_certs); command.env(NODE_CA_CERTS_ENV_VAR, node_ca_certs);
command.arg(npm_file).arg(subcommand); 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([ command.args([
"--userconfig".into(), "--userconfig".into(),
self.installation_path.join("blank_user_npmrc"), self.installation_path.join("blank_user_npmrc"),
@@ -703,7 +706,10 @@ impl NodeRuntimeTrait for SystemNodeRuntime {
.env("PATH", path) .env("PATH", path)
.env(NODE_CA_CERTS_ENV_VAR, node_ca_certs) .env(NODE_CA_CERTS_ENV_VAR, node_ca_certs)
.arg(subcommand) .arg(subcommand)
.args(["--cache".into(), self.scratch_dir.join("cache")]) .arg(format!(
"--cache={}",
self.scratch_dir.join("cache").display()
))
.args(args); .args(args);
configure_npm_command(&mut command, directory, proxy); configure_npm_command(&mut command, directory, proxy);
let output = command.output().await?; 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")) 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. /// Returns the relative path to a `.zed` folder within a project.
pub fn local_settings_folder_name() -> &'static str { pub fn local_settings_folder_name() -> &'static str {
".zed" ".zed"

View File

@@ -411,11 +411,11 @@ impl ContextServerStore {
) { ) {
self.stop_server(&id, cx).log_err(); self.stop_server(&id, cx).log_err();
} }
let task = cx.spawn({ let task = cx.spawn({
let id = server.id(); let id = server.id();
let server = server.clone(); let server = server.clone();
let configuration = configuration.clone(); let configuration = configuration.clone();
async move |this, cx| { async move |this, cx| {
match server.clone().start(cx).await { match server.clone().start(cx).await {
Ok(_) => { Ok(_) => {

View File

@@ -1142,7 +1142,7 @@ impl ProjectPanel {
) )
.when(has_git_repo, |menu| { .when(has_git_repo, |menu| {
menu.separator() menu.separator()
.action("File History", Box::new(git::FileHistory)) .action("View File History", Box::new(git::FileHistory))
}) })
.when(!should_hide_rename, |menu| { .when(!should_hide_rename, |menu| {
menu.separator().action("Rename", Box::new(Rename)) menu.separator().action("Rename", Box::new(Rename))

View File

@@ -16,6 +16,7 @@ doctest = false
anyhow.workspace = true anyhow.workspace = true
askpass.workspace = true askpass.workspace = true
auto_update.workspace = true auto_update.workspace = true
db.workspace = true
editor.workspace = true editor.workspace = true
extension_host.workspace = true extension_host.workspace = true
file_finder.workspace = true file_finder.workspace = true
@@ -26,6 +27,7 @@ language.workspace = true
log.workspace = true log.workspace = true
markdown.workspace = true markdown.workspace = true
menu.workspace = true menu.workspace = true
node_runtime.workspace = true
ordered-float.workspace = true ordered-float.workspace = true
paths.workspace = true paths.workspace = true
picker.workspace = true picker.workspace = true
@@ -34,6 +36,7 @@ release_channel.workspace = true
remote.workspace = true remote.workspace = true
semver.workspace = true semver.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true
settings.workspace = true settings.workspace = true
smol.workspace = true smol.workspace = true
task.workspace = true task.workspace = true
@@ -42,6 +45,7 @@ theme.workspace = true
ui.workspace = true ui.workspace = true
util.workspace = true util.workspace = true
workspace.workspace = true workspace.workspace = true
worktree.workspace = true
zed_actions.workspace = true zed_actions.workspace = true
indoc.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; pub mod disconnected_overlay;
mod remote_connections; mod remote_connections;
mod remote_servers; mod remote_servers;
mod ssh_config; mod ssh_config;
use std::path::PathBuf;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
mod wsl_picker; mod wsl_picker;
@@ -31,7 +35,7 @@ use workspace::{
WORKSPACE_DB, Workspace, WorkspaceId, notifications::DetachAndPromptErr, WORKSPACE_DB, Workspace, WorkspaceId, notifications::DetachAndPromptErr,
with_active_or_new_workspace, with_active_or_new_workspace,
}; };
use zed_actions::{OpenRecent, OpenRemote}; use zed_actions::{OpenDevContainer, OpenRecent, OpenRemote};
pub fn init(cx: &mut App) { pub fn init(cx: &mut App) {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
@@ -161,6 +165,95 @@ pub fn init(cx: &mut App) {
}); });
cx.observe_new(DisconnectedOverlay::register).detach(); 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")] #[cfg(target_os = "windows")]
@@ -609,6 +702,7 @@ impl PickerDelegate for RecentProjectsDelegate {
Icon::new(match options { Icon::new(match options {
RemoteConnectionOptions::Ssh { .. } => IconName::Server, RemoteConnectionOptions::Ssh { .. } => IconName::Server,
RemoteConnectionOptions::Wsl { .. } => IconName::Linux, RemoteConnectionOptions::Wsl { .. } => IconName::Linux,
RemoteConnectionOptions::Docker(_) => IconName::Box,
}) })
.color(Color::Muted) .color(Color::Muted)
.into_any_element() .into_any_element()

View File

@@ -18,16 +18,16 @@ use language::{CursorShape, Point};
use markdown::{Markdown, MarkdownElement, MarkdownStyle}; use markdown::{Markdown, MarkdownElement, MarkdownStyle};
use release_channel::ReleaseChannel; use release_channel::ReleaseChannel;
use remote::{ use remote::{
ConnectionIdentifier, RemoteClient, RemoteConnection, RemoteConnectionOptions, RemotePlatform, ConnectionIdentifier, DockerConnectionOptions, RemoteClient, RemoteConnection,
SshConnectionOptions, RemoteConnectionOptions, RemotePlatform, SshConnectionOptions,
}; };
use semver::Version; use semver::Version;
pub use settings::SshConnection; pub use settings::SshConnection;
use settings::{ExtendingVec, RegisterSetting, Settings, WslConnection}; use settings::{DevContainerConnection, ExtendingVec, RegisterSetting, Settings, WslConnection};
use theme::ThemeSettings; use theme::ThemeSettings;
use ui::{ use ui::{
ActiveTheme, Color, CommonAnimationExt, Context, Icon, IconName, IconSize, InteractiveElement, ActiveTheme, Color, CommonAnimationExt, Context, InteractiveElement, IntoElement, KeyBinding,
IntoElement, Label, LabelCommon, Styled, Window, prelude::*, LabelCommon, ListItem, Styled, Window, prelude::*,
}; };
use util::paths::PathWithPosition; use util::paths::PathWithPosition;
use workspace::{AppState, ModalView, Workspace}; use workspace::{AppState, ModalView, Workspace};
@@ -85,6 +85,7 @@ impl SshSettings {
pub enum Connection { pub enum Connection {
Ssh(SshConnection), Ssh(SshConnection),
Wsl(WslConnection), Wsl(WslConnection),
DevContainer(DevContainerConnection),
} }
impl From<Connection> for RemoteConnectionOptions { impl From<Connection> for RemoteConnectionOptions {
@@ -92,6 +93,13 @@ impl From<Connection> for RemoteConnectionOptions {
match val { match val {
Connection::Ssh(conn) => RemoteConnectionOptions::Ssh(conn.into()), Connection::Ssh(conn) => RemoteConnectionOptions::Ssh(conn.into()),
Connection::Wsl(conn) => RemoteConnectionOptions::Wsl(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, connection_string: SharedString,
nickname: Option<SharedString>, nickname: Option<SharedString>,
is_wsl: bool, is_wsl: bool,
is_devcontainer: bool,
status_message: Option<SharedString>, status_message: Option<SharedString>,
prompt: Option<(Entity<Markdown>, oneshot::Sender<EncryptedPassword>)>, prompt: Option<(Entity<Markdown>, oneshot::Sender<EncryptedPassword>)>,
cancellation: Option<oneshot::Sender<()>>, cancellation: Option<oneshot::Sender<()>>,
@@ -148,6 +157,7 @@ impl RemoteConnectionPrompt {
connection_string: String, connection_string: String,
nickname: Option<String>, nickname: Option<String>,
is_wsl: bool, is_wsl: bool,
is_devcontainer: bool,
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> Self { ) -> Self {
@@ -155,6 +165,7 @@ impl RemoteConnectionPrompt {
connection_string: connection_string.into(), connection_string: connection_string.into(),
nickname: nickname.map(|nickname| nickname.into()), nickname: nickname.map(|nickname| nickname.into()),
is_wsl, is_wsl,
is_devcontainer,
editor: cx.new(|cx| Editor::single_line(window, cx)), editor: cx.new(|cx| Editor::single_line(window, cx)),
status_message: None, status_message: None,
cancellation: None, cancellation: None,
@@ -244,17 +255,16 @@ impl Render for RemoteConnectionPrompt {
v_flex() v_flex()
.key_context("PasswordPrompt") .key_context("PasswordPrompt")
.py_2() .p_2()
.px_3()
.size_full() .size_full()
.text_buffer(cx) .text_buffer(cx)
.when_some(self.status_message.clone(), |el, status_message| { .when_some(self.status_message.clone(), |el, status_message| {
el.child( el.child(
h_flex() h_flex()
.gap_1() .gap_2()
.child( .child(
Icon::new(IconName::ArrowCircle) Icon::new(IconName::ArrowCircle)
.size(IconSize::Medium) .color(Color::Muted)
.with_rotate_animation(2), .with_rotate_animation(2),
) )
.child( .child(
@@ -287,15 +297,28 @@ impl RemoteConnectionModal {
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> Self { ) -> Self {
let (connection_string, nickname, is_wsl) = match connection_options { let (connection_string, nickname, is_wsl, is_devcontainer) = match connection_options {
RemoteConnectionOptions::Ssh(options) => { RemoteConnectionOptions::Ssh(options) => (
(options.connection_string(), options.nickname.clone(), false) 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 { Self {
prompt: cx.new(|cx| { 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, finished: false,
paths, paths,
@@ -328,6 +351,7 @@ pub(crate) struct SshConnectionHeader {
pub(crate) paths: Vec<PathBuf>, pub(crate) paths: Vec<PathBuf>,
pub(crate) nickname: Option<SharedString>, pub(crate) nickname: Option<SharedString>,
pub(crate) is_wsl: bool, pub(crate) is_wsl: bool,
pub(crate) is_devcontainer: bool,
} }
impl RenderOnce for SshConnectionHeader { impl RenderOnce for SshConnectionHeader {
@@ -343,9 +367,12 @@ impl RenderOnce for SshConnectionHeader {
(self.connection_string, None) (self.connection_string, None)
}; };
let icon = match self.is_wsl { let icon = if self.is_wsl {
true => IconName::Linux, IconName::Linux
false => IconName::Server, } else if self.is_devcontainer {
IconName::Box
} else {
IconName::Server
}; };
h_flex() h_flex()
@@ -388,6 +415,7 @@ impl Render for RemoteConnectionModal {
let nickname = self.prompt.read(cx).nickname.clone(); let nickname = self.prompt.read(cx).nickname.clone();
let connection_string = self.prompt.read(cx).connection_string.clone(); let connection_string = self.prompt.read(cx).connection_string.clone();
let is_wsl = self.prompt.read(cx).is_wsl; let is_wsl = self.prompt.read(cx).is_wsl;
let is_devcontainer = self.prompt.read(cx).is_devcontainer;
let theme = cx.theme().clone(); let theme = cx.theme().clone();
let body_color = theme.colors().editor_background; let body_color = theme.colors().editor_background;
@@ -407,18 +435,34 @@ impl Render for RemoteConnectionModal {
connection_string, connection_string,
nickname, nickname,
is_wsl, is_wsl,
is_devcontainer,
} }
.render(window, cx), .render(window, cx),
) )
.child( .child(
div() div()
.w_full() .w_full()
.rounded_b_lg()
.bg(body_color) .bg(body_color)
.border_t_1() .border_y_1()
.border_color(theme.colors().border_variant) .border_color(theme.colors().border_variant)
.child(self.prompt.clone()), .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 { match connection_options {
RemoteConnectionOptions::Ssh(_) => "Failed to connect over SSH", RemoteConnectionOptions::Ssh(_) => "Failed to connect over SSH",
RemoteConnectionOptions::Wsl(_) => "Failed to connect to WSL", RemoteConnectionOptions::Wsl(_) => "Failed to connect to WSL",
RemoteConnectionOptions::Docker(_) => {
"Failed to connect to Dev Container"
}
}, },
Some(&format!("{e:#}")), Some(&format!("{e:#}")),
&["Retry", "Cancel"], &["Retry", "Cancel"],
@@ -727,6 +774,9 @@ pub async fn open_remote_project(
match connection_options { match connection_options {
RemoteConnectionOptions::Ssh(_) => "Failed to connect over SSH", RemoteConnectionOptions::Ssh(_) => "Failed to connect over SSH",
RemoteConnectionOptions::Wsl(_) => "Failed to connect to WSL", RemoteConnectionOptions::Wsl(_) => "Failed to connect to WSL",
RemoteConnectionOptions::Docker(_) => {
"Failed to connect to Dev Container"
}
}, },
Some(&format!("{e:#}")), Some(&format!("{e:#}")),
&["Retry", "Cancel"], &["Retry", "Cancel"],

View File

@@ -1,4 +1,5 @@
use crate::{ use crate::{
dev_container::start_dev_container,
remote_connections::{ remote_connections::{
Connection, RemoteConnectionModal, RemoteConnectionPrompt, SshConnection, Connection, RemoteConnectionModal, RemoteConnectionPrompt, SshConnection,
SshConnectionHeader, SshSettings, connect, determine_paths_with_positions, SshConnectionHeader, SshSettings, connect, determine_paths_with_positions,
@@ -24,7 +25,7 @@ use remote::{
remote_client::ConnectionIdentifier, remote_client::ConnectionIdentifier,
}; };
use settings::{ use settings::{
RemoteSettingsContent, Settings as _, SettingsStore, SshProject, update_settings_file, RemoteProject, RemoteSettingsContent, Settings as _, SettingsStore, update_settings_file,
watch_config_file, watch_config_file,
}; };
use smol::stream::StreamExt as _; use smol::stream::StreamExt as _;
@@ -39,12 +40,13 @@ use std::{
}, },
}; };
use ui::{ use ui::{
IconButtonShape, List, ListItem, ListSeparator, Modal, ModalHeader, Navigable, NavigableEntry, CommonAnimationExt, IconButtonShape, KeyBinding, List, ListItem, ListSeparator, Modal,
Section, Tooltip, WithScrollbar, prelude::*, ModalHeader, Navigable, NavigableEntry, Section, Tooltip, WithScrollbar, prelude::*,
}; };
use util::{ use util::{
ResultExt, ResultExt,
paths::{PathStyle, RemotePathBuf}, paths::{PathStyle, RemotePathBuf},
rel_path::RelPath,
}; };
use workspace::{ use workspace::{
ModalView, OpenOptions, Toast, 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")] #[cfg(target_os = "windows")]
struct AddWslDistro { struct AddWslDistro {
picker: Entity<Picker<crate::wsl_picker::WslPickerDelegate>>, picker: Entity<Picker<crate::wsl_picker::WslPickerDelegate>>,
@@ -207,6 +242,11 @@ impl ProjectPicker {
RemoteConnectionOptions::Wsl(connection) => ProjectPickerData::Wsl { RemoteConnectionOptions::Wsl(connection) => ProjectPickerData::Wsl {
distro_name: connection.distro_name.clone().into(), 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 let _path_task = cx
.spawn_in(window, { .spawn_in(window, {
@@ -259,7 +299,7 @@ impl ProjectPicker {
.as_mut() .as_mut()
.and_then(|connections| connections.get_mut(index.0)) .and_then(|connections| connections.get_mut(index.0))
{ {
server.projects.insert(SshProject { paths }); server.projects.insert(RemoteProject { paths });
}; };
} }
ServerIndex::Wsl(index) => { ServerIndex::Wsl(index) => {
@@ -269,7 +309,7 @@ impl ProjectPicker {
.as_mut() .as_mut()
.and_then(|connections| connections.get_mut(index.0)) .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(), paths: Default::default(),
nickname: nickname.clone(), nickname: nickname.clone(),
is_wsl: false, is_wsl: false,
is_devcontainer: false,
} }
.render(window, cx), .render(window, cx),
ProjectPickerData::Wsl { distro_name } => SshConnectionHeader { ProjectPickerData::Wsl { distro_name } => SshConnectionHeader {
@@ -356,6 +397,7 @@ impl gpui::Render for ProjectPicker {
paths: Default::default(), paths: Default::default(),
nickname: None, nickname: None,
is_wsl: true, is_wsl: true,
is_devcontainer: false,
} }
.render(window, cx), .render(window, cx),
}) })
@@ -406,7 +448,7 @@ impl From<WslServerIndex> for ServerIndex {
enum RemoteEntry { enum RemoteEntry {
Project { Project {
open_folder: NavigableEntry, open_folder: NavigableEntry,
projects: Vec<(NavigableEntry, SshProject)>, projects: Vec<(NavigableEntry, RemoteProject)>,
configure: NavigableEntry, configure: NavigableEntry,
connection: Connection, connection: Connection,
index: ServerIndex, index: ServerIndex,
@@ -440,6 +482,7 @@ impl RemoteEntry {
struct DefaultState { struct DefaultState {
scroll_handle: ScrollHandle, scroll_handle: ScrollHandle,
add_new_server: NavigableEntry, add_new_server: NavigableEntry,
add_new_devcontainer: NavigableEntry,
add_new_wsl: NavigableEntry, add_new_wsl: NavigableEntry,
servers: Vec<RemoteEntry>, servers: Vec<RemoteEntry>,
} }
@@ -448,6 +491,7 @@ impl DefaultState {
fn new(ssh_config_servers: &BTreeSet<SharedString>, cx: &mut App) -> Self { fn new(ssh_config_servers: &BTreeSet<SharedString>, cx: &mut App) -> Self {
let handle = ScrollHandle::new(); let handle = ScrollHandle::new();
let add_new_server = NavigableEntry::new(&handle, cx); 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 add_new_wsl = NavigableEntry::new(&handle, cx);
let ssh_settings = SshSettings::get_global(cx); let ssh_settings = SshSettings::get_global(cx);
@@ -517,6 +561,7 @@ impl DefaultState {
Self { Self {
scroll_handle: handle, scroll_handle: handle,
add_new_server, add_new_server,
add_new_devcontainer,
add_new_wsl, add_new_wsl,
servers, servers,
} }
@@ -552,6 +597,7 @@ enum Mode {
EditNickname(EditNicknameState), EditNickname(EditNicknameState),
ProjectPicker(Entity<ProjectPicker>), ProjectPicker(Entity<ProjectPicker>),
CreateRemoteServer(CreateRemoteServer), CreateRemoteServer(CreateRemoteServer),
CreateRemoteDevContainer(CreateRemoteDevContainer),
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
AddWslDistro(AddWslDistro), 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( fn new_inner(
mode: Mode, mode: Mode,
create_new_window: bool, create_new_window: bool,
@@ -703,6 +770,7 @@ impl RemoteServerProjects {
connection_options.connection_string(), connection_options.connection_string(),
connection_options.nickname.clone(), connection_options.nickname.clone(),
false, false,
false,
window, window,
cx, cx,
) )
@@ -778,6 +846,7 @@ impl RemoteServerProjects {
connection_options.distro_name.clone(), connection_options.distro_name.clone(),
None, None,
true, true,
false,
window, window,
cx, cx,
) )
@@ -862,6 +931,15 @@ impl RemoteServerProjects {
cx.notify(); 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( fn create_remote_project(
&mut self, &mut self,
index: ServerIndex, index: ServerIndex,
@@ -981,6 +1059,7 @@ impl RemoteServerProjects {
self.create_ssh_server(state.address_editor.clone(), window, cx); self.create_ssh_server(state.address_editor.clone(), window, cx);
} }
Mode::CreateRemoteDevContainer(_) => {}
Mode::EditNickname(state) => { Mode::EditNickname(state) => {
let text = Some(state.editor.read(cx).text(cx)).filter(|text| !text.is_empty()); let text = Some(state.editor.read(cx).text(cx)).filter(|text| !text.is_empty());
let index = state.index; let index = state.index;
@@ -1024,14 +1103,14 @@ impl RemoteServerProjects {
} }
} }
fn render_ssh_connection( fn render_remote_connection(
&mut self, &mut self,
ix: usize, ix: usize,
ssh_server: RemoteEntry, remote_server: RemoteEntry,
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> impl IntoElement { ) -> 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 { let (main_label, aux_label, is_wsl) = match &connection {
Connection::Ssh(connection) => { Connection::Ssh(connection) => {
@@ -1045,6 +1124,9 @@ impl RemoteServerProjects {
Connection::Wsl(wsl_connection_options) => { Connection::Wsl(wsl_connection_options) => {
(wsl_connection_options.distro_name.clone(), None, true) (wsl_connection_options.distro_name.clone(), None, true)
} }
Connection::DevContainer(dev_container_options) => {
(dev_container_options.name.clone(), None, false)
}
}; };
v_flex() v_flex()
.w_full() .w_full()
@@ -1082,7 +1164,7 @@ impl RemoteServerProjects {
}), }),
), ),
) )
.child(match &ssh_server { .child(match &remote_server {
RemoteEntry::Project { RemoteEntry::Project {
open_folder, open_folder,
projects, projects,
@@ -1094,9 +1176,9 @@ impl RemoteServerProjects {
List::new() List::new()
.empty_message("No projects.") .empty_message("No projects.")
.children(projects.iter().enumerate().map(|(pix, p)| { .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, index,
ssh_server.clone(), remote_server.clone(),
pix, pix,
p, p,
window, window,
@@ -1222,12 +1304,12 @@ impl RemoteServerProjects {
}) })
} }
fn render_ssh_project( fn render_remote_project(
&mut self, &mut self,
server_ix: ServerIndex, server_ix: ServerIndex,
server: RemoteEntry, server: RemoteEntry,
ix: usize, ix: usize,
(navigation, project): &(NavigableEntry, SshProject), (navigation, project): &(NavigableEntry, RemoteProject),
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> impl IntoElement { ) -> impl IntoElement {
@@ -1372,7 +1454,7 @@ impl RemoteServerProjects {
fn delete_remote_project( fn delete_remote_project(
&mut self, &mut self,
server: ServerIndex, server: ServerIndex,
project: &SshProject, project: &RemoteProject,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) {
match server { match server {
@@ -1388,7 +1470,7 @@ impl RemoteServerProjects {
fn delete_ssh_project( fn delete_ssh_project(
&mut self, &mut self,
server: SshServerIndex, server: SshServerIndex,
project: &SshProject, project: &RemoteProject,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) {
let project = project.clone(); let project = project.clone();
@@ -1406,7 +1488,7 @@ impl RemoteServerProjects {
fn delete_wsl_project( fn delete_wsl_project(
&mut self, &mut self,
server: WslServerIndex, server: WslServerIndex,
project: &SshProject, project: &RemoteProject,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) {
let project = project.clone(); 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( fn render_create_remote_server(
&self, &self,
state: &CreateRemoteServer, state: &CreateRemoteServer,
@@ -1571,6 +1989,7 @@ impl RemoteServerProjects {
paths: Default::default(), paths: Default::default(),
nickname: connection.nickname.clone().map(|s| s.into()), nickname: connection.nickname.clone().map(|s| s.into()),
is_wsl: false, is_wsl: false,
is_devcontainer: false,
} }
.render(window, cx) .render(window, cx)
.into_any_element(), .into_any_element(),
@@ -1579,6 +1998,7 @@ impl RemoteServerProjects {
paths: Default::default(), paths: Default::default(),
nickname: None, nickname: None,
is_wsl: true, is_wsl: true,
is_devcontainer: false,
} }
.render(window, cx) .render(window, cx)
.into_any_element(), .into_any_element(),
@@ -1917,6 +2337,7 @@ impl RemoteServerProjects {
paths: Default::default(), paths: Default::default(),
nickname, nickname,
is_wsl: false, is_wsl: false,
is_devcontainer: false,
} }
.render(window, cx), .render(window, cx),
) )
@@ -1998,7 +2419,7 @@ impl RemoteServerProjects {
.track_focus(&state.add_new_server.focus_handle) .track_focus(&state.add_new_server.focus_handle)
.anchor_scroll(state.add_new_server.scroll_anchor.clone()) .anchor_scroll(state.add_new_server.scroll_anchor.clone())
.child( .child(
ListItem::new("register-remove-server-button") ListItem::new("register-remote-server-button")
.toggle_state( .toggle_state(
state state
.add_new_server .add_new_server
@@ -2008,7 +2429,7 @@ impl RemoteServerProjects {
.inset(true) .inset(true)
.spacing(ui::ListItemSpacing::Sparse) .spacing(ui::ListItemSpacing::Sparse)
.start_slot(Icon::new(IconName::Plus).color(Color::Muted)) .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| { .on_click(cx.listener(|this, _, window, cx| {
let state = CreateRemoteServer::new(window, cx); let state = CreateRemoteServer::new(window, cx);
this.mode = Mode::CreateRemoteServer(state); this.mode = Mode::CreateRemoteServer(state);
@@ -2023,6 +2444,36 @@ impl RemoteServerProjects {
cx.notify(); 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")] #[cfg(target_os = "windows")]
let wsl_connect_button = div() let wsl_connect_button = div()
.id("wsl-connect-new-server") .id("wsl-connect-new-server")
@@ -2049,13 +2500,30 @@ impl RemoteServerProjects {
cx.notify(); 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() let modal_section = v_flex()
.track_focus(&self.focus_handle(cx)) .track_focus(&self.focus_handle(cx))
.id("ssh-server-list") .id("ssh-server-list")
.overflow_y_scroll() .overflow_y_scroll()
.track_scroll(&state.scroll_handle) .track_scroll(&state.scroll_handle)
.size_full() .size_full()
.child(connect_button); .child(connect_button)
.when(has_open_project, |this| {
this.child(connect_dev_container_button)
});
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
let modal_section = modal_section.child(wsl_connect_button); let modal_section = modal_section.child(wsl_connect_button);
@@ -2067,17 +2535,20 @@ impl RemoteServerProjects {
.child( .child(
List::new() List::new()
.empty_message( .empty_message(
v_flex() h_flex()
.size_full()
.p_2()
.justify_center()
.border_t_1()
.border_color(cx.theme().colors().border_variant)
.child( .child(
div().px_3().child( Label::new("No remote servers registered yet.")
Label::new("No remote servers registered yet.") .color(Color::Muted),
.color(Color::Muted),
),
) )
.into_any_element(), .into_any_element(),
) )
.children(state.servers.iter().enumerate().map(|(ix, connection)| { .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() .into_any_element()
})), })),
) )
@@ -2085,6 +2556,10 @@ impl RemoteServerProjects {
) )
.entry(state.add_new_server.clone()); .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") { if cfg!(target_os = "windows") {
modal_section = modal_section.entry(state.add_new_wsl.clone()); modal_section = modal_section.entry(state.add_new_wsl.clone());
} }
@@ -2297,6 +2772,9 @@ impl Render for RemoteServerProjects {
Mode::CreateRemoteServer(state) => self Mode::CreateRemoteServer(state) => self
.render_create_remote_server(state, window, cx) .render_create_remote_server(state, window, cx)
.into_any_element(), .into_any_element(),
Mode::CreateRemoteDevContainer(state) => self
.render_create_dev_container(state, window, cx)
.into_any_element(),
Mode::EditNickname(state) => self Mode::EditNickname(state) => self
.render_edit_nickname(state, window, cx) .render_edit_nickname(state, window, cx)
.into_any_element(), .into_any_element(),

View File

@@ -10,5 +10,6 @@ pub use remote_client::{
ConnectionIdentifier, ConnectionState, RemoteClient, RemoteClientDelegate, RemoteClientEvent, ConnectionIdentifier, ConnectionState, RemoteClient, RemoteClientDelegate, RemoteClientEvent,
RemoteConnection, RemoteConnectionOptions, RemotePlatform, connect, RemoteConnection, RemoteConnectionOptions, RemotePlatform, connect,
}; };
pub use transport::docker::DockerConnectionOptions;
pub use transport::ssh::{SshConnectionOptions, SshPortForwardOption}; pub use transport::ssh::{SshConnectionOptions, SshPortForwardOption};
pub use transport::wsl::WslConnectionOptions; pub use transport::wsl::WslConnectionOptions;

View File

@@ -3,6 +3,7 @@ use crate::{
protocol::MessageId, protocol::MessageId,
proxy::ProxyLaunchError, proxy::ProxyLaunchError,
transport::{ transport::{
docker::{DockerConnectionOptions, DockerExecConnection},
ssh::SshRemoteConnection, ssh::SshRemoteConnection,
wsl::{WslConnectionOptions, WslRemoteConnection}, wsl::{WslConnectionOptions, WslRemoteConnection},
}, },
@@ -1042,6 +1043,11 @@ impl ConnectionPool {
.await .await
.map(|connection| Arc::new(connection) as Arc<dyn RemoteConnection>) .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, _| { cx.update_global(|pool: &mut Self, _| {
@@ -1077,6 +1083,7 @@ impl ConnectionPool {
pub enum RemoteConnectionOptions { pub enum RemoteConnectionOptions {
Ssh(SshConnectionOptions), Ssh(SshConnectionOptions),
Wsl(WslConnectionOptions), Wsl(WslConnectionOptions),
Docker(DockerConnectionOptions),
} }
impl RemoteConnectionOptions { impl RemoteConnectionOptions {
@@ -1084,6 +1091,7 @@ impl RemoteConnectionOptions {
match self { match self {
RemoteConnectionOptions::Ssh(opts) => opts.host.clone(), RemoteConnectionOptions::Ssh(opts) => opts.host.clone(),
RemoteConnectionOptions::Wsl(opts) => opts.distro_name.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 rpc::proto::Envelope;
use smol::process::Child; use smol::process::Child;
pub mod docker;
pub mod ssh; pub mod ssh;
pub mod wsl; pub mod wsl;
@@ -64,15 +65,15 @@ fn parse_shell(output: &str, fallback_shell: &str) -> String {
} }
fn handle_rpc_messages_over_child_process_stdio( fn handle_rpc_messages_over_child_process_stdio(
mut ssh_proxy_process: Child, mut remote_proxy_process: Child,
incoming_tx: UnboundedSender<Envelope>, incoming_tx: UnboundedSender<Envelope>,
mut outgoing_rx: UnboundedReceiver<Envelope>, mut outgoing_rx: UnboundedReceiver<Envelope>,
mut connection_activity_tx: Sender<()>, mut connection_activity_tx: Sender<()>,
cx: &AsyncApp, cx: &AsyncApp,
) -> Task<Result<i32>> { ) -> Task<Result<i32>> {
let mut child_stderr = ssh_proxy_process.stderr.take().unwrap(); let mut child_stderr = remote_proxy_process.stderr.take().unwrap();
let mut child_stdout = ssh_proxy_process.stdout.take().unwrap(); let mut child_stdout = remote_proxy_process.stdout.take().unwrap();
let mut child_stdin = ssh_proxy_process.stdin.take().unwrap(); let mut child_stdin = remote_proxy_process.stdin.take().unwrap();
let mut stdin_buffer = Vec::new(); let mut stdin_buffer = Vec::new();
let mut stdout_buffer = Vec::new(); let mut stdout_buffer = Vec::new();
@@ -156,7 +157,7 @@ fn handle_rpc_messages_over_child_process_stdio(
result.context("stderr") 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 { match result {
Ok(_) => Ok(status), Ok(_) => Ok(status),
Err(error) => Err(error), 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::{ use util::{
paths::{PathStyle, RemotePathBuf}, paths::{PathStyle, RemotePathBuf},
rel_path::RelPath, rel_path::RelPath,
shell::ShellKind, shell::{Shell, ShellKind},
shell_builder::ShellBuilder,
}; };
pub(crate) struct SshRemoteConnection { pub(crate) struct SshRemoteConnection {
@@ -1362,6 +1363,8 @@ fn build_command(
} else { } else {
write!(exec, "{ssh_shell} -l")?; 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(); let mut args = Vec::new();
args.extend(ssh_args); args.extend(ssh_args);
@@ -1372,7 +1375,9 @@ fn build_command(
} }
args.push("-t".into()); args.push("-t".into());
args.push(exec); args.push(command);
args.extend(command_args);
Ok(CommandTemplate { Ok(CommandTemplate {
program: "ssh".into(), program: "ssh".into(),
args, args,
@@ -1411,6 +1416,9 @@ mod tests {
"-p", "-p",
"2222", "2222",
"-t", "-t",
"/bin/fish",
"-i",
"-c",
"cd \"$HOME/work\" && exec env INPUT_VA=val remote_program arg1 arg2" "cd \"$HOME/work\" && exec env INPUT_VA=val remote_program arg1 arg2"
] ]
); );
@@ -1443,6 +1451,9 @@ mod tests {
"-L", "-L",
"1:foo:2", "1:foo:2",
"-t", "-t",
"/bin/fish",
"-i",
"-c",
"cd && exec env INPUT_VA=val /bin/fish -l" "cd && exec env INPUT_VA=val /bin/fish -l"
] ]
); );

View File

@@ -23,7 +23,8 @@ use std::{
use util::{ use util::{
paths::{PathStyle, RemotePathBuf}, paths::{PathStyle, RemotePathBuf},
rel_path::RelPath, rel_path::RelPath,
shell::ShellKind, shell::{Shell, ShellKind},
shell_builder::ShellBuilder,
}; };
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Deserialize, schemars::JsonSchema)] #[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Deserialize, schemars::JsonSchema)]
@@ -453,8 +454,10 @@ impl RemoteConnection for WslRemoteConnection {
} else { } else {
write!(&mut exec, "{} -l", self.shell)?; 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![ vec![
"--distribution".to_string(), "--distribution".to_string(),
self.connection_options.distro_name.clone(), self.connection_options.distro_name.clone(),
@@ -463,9 +466,7 @@ impl RemoteConnection for WslRemoteConnection {
"--cd".to_string(), "--cd".to_string(),
working_dir, working_dir,
"--".to_string(), "--".to_string(),
self.shell.clone(), command,
"-c".to_string(),
exec,
] ]
} else { } else {
vec![ vec![
@@ -474,11 +475,10 @@ impl RemoteConnection for WslRemoteConnection {
"--cd".to_string(), "--cd".to_string(),
working_dir, working_dir,
"--".to_string(), "--".to_string(),
self.shell.clone(), command,
"-c".to_string(),
exec,
] ]
}; };
wsl_args.extend(args);
Ok(CommandTemplate { Ok(CommandTemplate {
program: "wsl.exe".to_string(), program: "wsl.exe".to_string(),

View File

@@ -511,6 +511,11 @@ pub struct GitPanelSettingsContent {
/// ///
/// Default: false /// Default: false
pub collapse_untracked_diff: Option<bool>, 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( #[derive(
@@ -889,9 +894,19 @@ pub enum ImageFileSizeUnit {
pub struct RemoteSettingsContent { pub struct RemoteSettingsContent {
pub ssh_connections: Option<Vec<SshConnection>>, pub ssh_connections: Option<Vec<SshConnection>>,
pub wsl_connections: Option<Vec<WslConnection>>, pub wsl_connections: Option<Vec<WslConnection>>,
pub dev_container_connections: Option<Vec<DevContainerConnection>>,
pub read_ssh_config: Option<bool>, 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] #[with_fallible_options]
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, JsonSchema, MergeFrom)] #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, JsonSchema, MergeFrom)]
pub struct SshConnection { pub struct SshConnection {
@@ -901,7 +916,7 @@ pub struct SshConnection {
#[serde(default)] #[serde(default)]
pub args: Vec<String>, pub args: Vec<String>,
#[serde(default)] #[serde(default)]
pub projects: collections::BTreeSet<SshProject>, pub projects: collections::BTreeSet<RemoteProject>,
/// Name to use for this server in UI. /// Name to use for this server in UI.
pub nickname: Option<String>, pub nickname: Option<String>,
// By default Zed will download the binary to the host directly. // By default Zed will download the binary to the host directly.
@@ -918,14 +933,14 @@ pub struct WslConnection {
pub distro_name: SharedString, pub distro_name: SharedString,
pub user: Option<String>, pub user: Option<String>,
#[serde(default)] #[serde(default)]
pub projects: BTreeSet<SshProject>, pub projects: BTreeSet<RemoteProject>,
} }
#[with_fallible_options] #[with_fallible_options]
#[derive( #[derive(
Clone, Debug, Default, Serialize, PartialEq, Eq, PartialOrd, Ord, Deserialize, JsonSchema, Clone, Debug, Default, Serialize, PartialEq, Eq, PartialOrd, Ord, Deserialize, JsonSchema,
)] )]
pub struct SshProject { pub struct RemoteProject {
pub paths: Vec<String>, pub paths: Vec<String>,
} }

View File

@@ -4314,6 +4314,24 @@ pub(crate) fn settings_data(cx: &App) -> Vec<SettingsPage> {
metadata: None, metadata: None,
files: USER, 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 { SettingsPageItem::SettingItem(SettingItem {
title: "Scroll Bar", title: "Scroll Bar",
description: "How and when the scrollbar should be displayed.", 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 options = self.project.read(cx).remote_connection_options(cx)?;
let host: SharedString = options.display_name().into(); let host: SharedString = options.display_name().into();
let (nickname, icon) = match options { let (nickname, tooltip_title, icon) = match options {
RemoteConnectionOptions::Ssh(options) => { RemoteConnectionOptions::Ssh(options) => (
(options.nickname.map(|nick| nick.into()), IconName::Server) 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 nickname = nickname.unwrap_or_else(|| host.clone());
let (indicator_color, meta) = match self.project.read(cx).remote_connection_state(cx)? { let (indicator_color, meta) = match self.project.read(cx).remote_connection_state(cx)? {
@@ -375,7 +381,7 @@ impl TitleBar {
) )
.tooltip(move |_window, cx| { .tooltip(move |_window, cx| {
Tooltip::with_meta( Tooltip::with_meta(
"Remote Project", tooltip_title,
Some(&OpenRemote { Some(&OpenRemote {
from_existing_connection: false, from_existing_connection: false,
create_new_window: false, create_new_window: false,

View File

@@ -56,7 +56,10 @@ pub enum ShellKind {
Tcsh, Tcsh,
Rc, Rc,
Fish, Fish,
/// Pre-installed "legacy" powershell for windows
PowerShell, PowerShell,
/// PowerShell 7.x
Pwsh,
Nushell, Nushell,
Cmd, Cmd,
Xonsh, Xonsh,
@@ -238,6 +241,7 @@ impl fmt::Display for ShellKind {
ShellKind::Tcsh => write!(f, "tcsh"), ShellKind::Tcsh => write!(f, "tcsh"),
ShellKind::Fish => write!(f, "fish"), ShellKind::Fish => write!(f, "fish"),
ShellKind::PowerShell => write!(f, "powershell"), ShellKind::PowerShell => write!(f, "powershell"),
ShellKind::Pwsh => write!(f, "pwsh"),
ShellKind::Nushell => write!(f, "nu"), ShellKind::Nushell => write!(f, "nu"),
ShellKind::Cmd => write!(f, "cmd"), ShellKind::Cmd => write!(f, "cmd"),
ShellKind::Rc => write!(f, "rc"), ShellKind::Rc => write!(f, "rc"),
@@ -260,7 +264,8 @@ impl ShellKind {
.to_string_lossy(); .to_string_lossy();
match &*program { match &*program {
"powershell" | "pwsh" => ShellKind::PowerShell, "powershell" => ShellKind::PowerShell,
"pwsh" => ShellKind::Pwsh,
"cmd" => ShellKind::Cmd, "cmd" => ShellKind::Cmd,
"nu" => ShellKind::Nushell, "nu" => ShellKind::Nushell,
"fish" => ShellKind::Fish, "fish" => ShellKind::Fish,
@@ -279,7 +284,7 @@ impl ShellKind {
pub fn to_shell_variable(self, input: &str) -> String { pub fn to_shell_variable(self, input: &str) -> String {
match self { 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::Cmd => Self::to_cmd_variable(input),
Self::Posix => input.to_owned(), Self::Posix => input.to_owned(),
Self::Fish => 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> { pub fn args_for_shell(&self, interactive: bool, combined_command: String) -> Vec<String> {
match self { match self {
ShellKind::PowerShell => vec!["-C".to_owned(), combined_command], ShellKind::PowerShell | ShellKind::Pwsh => vec!["-C".to_owned(), combined_command],
ShellKind::Cmd => vec!["/C".to_owned(), combined_command], ShellKind::Cmd => vec![
"/S".to_owned(),
"/C".to_owned(),
format!("\"{combined_command}\""),
],
ShellKind::Posix ShellKind::Posix
| ShellKind::Nushell | ShellKind::Nushell
| ShellKind::Fish | ShellKind::Fish
@@ -426,7 +435,7 @@ impl ShellKind {
pub const fn command_prefix(&self) -> Option<char> { pub const fn command_prefix(&self) -> Option<char> {
match self { match self {
ShellKind::PowerShell => Some('&'), ShellKind::PowerShell | ShellKind::Pwsh => Some('&'),
ShellKind::Nushell => Some('^'), ShellKind::Nushell => Some('^'),
ShellKind::Posix ShellKind::Posix
| ShellKind::Csh | ShellKind::Csh
@@ -457,6 +466,7 @@ impl ShellKind {
| ShellKind::Rc | ShellKind::Rc
| ShellKind::Fish | ShellKind::Fish
| ShellKind::PowerShell | ShellKind::PowerShell
| ShellKind::Pwsh
| ShellKind::Nushell | ShellKind::Nushell
| ShellKind::Xonsh | ShellKind::Xonsh
| ShellKind::Elvish => ';', | ShellKind::Elvish => ';',
@@ -471,6 +481,7 @@ impl ShellKind {
| ShellKind::Tcsh | ShellKind::Tcsh
| ShellKind::Rc | ShellKind::Rc
| ShellKind::Fish | ShellKind::Fish
| ShellKind::Pwsh
| ShellKind::PowerShell | ShellKind::PowerShell
| ShellKind::Xonsh => "&&", | ShellKind::Xonsh => "&&",
ShellKind::Nushell | ShellKind::Elvish => ";", ShellKind::Nushell | ShellKind::Elvish => ";",
@@ -478,11 +489,10 @@ impl ShellKind {
} }
pub fn try_quote<'a>(&self, arg: &'a str) -> Option<Cow<'a, str>> { pub fn try_quote<'a>(&self, arg: &'a str) -> Option<Cow<'a, str>> {
shlex::try_quote(arg).ok().map(|arg| match self { match self {
// If we are running in PowerShell, we want to take extra care when escaping strings. ShellKind::PowerShell => Some(Self::quote_powershell(arg)),
// In particular, we want to escape strings with a backtick (`) rather than a backslash (\). ShellKind::Pwsh => Some(Self::quote_pwsh(arg)),
ShellKind::PowerShell => Cow::Owned(arg.replace("\\\"", "`\"").replace("\\\\", "\\")), ShellKind::Cmd => Some(Self::quote_cmd(arg)),
ShellKind::Cmd => Cow::Owned(arg.replace("\\\\", "\\")),
ShellKind::Posix ShellKind::Posix
| ShellKind::Csh | ShellKind::Csh
| ShellKind::Tcsh | ShellKind::Tcsh
@@ -490,8 +500,173 @@ impl ShellKind {
| ShellKind::Fish | ShellKind::Fish
| ShellKind::Nushell | ShellKind::Nushell
| ShellKind::Xonsh | 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. /// Quotes the given argument if necessary, taking into account the command prefix.
@@ -538,7 +713,7 @@ impl ShellKind {
match self { match self {
ShellKind::Cmd => "", ShellKind::Cmd => "",
ShellKind::Nushell => "overlay use", ShellKind::Nushell => "overlay use",
ShellKind::PowerShell => ".", ShellKind::PowerShell | ShellKind::Pwsh => ".",
ShellKind::Fish ShellKind::Fish
| ShellKind::Csh | ShellKind::Csh
| ShellKind::Tcsh | ShellKind::Tcsh
@@ -558,6 +733,7 @@ impl ShellKind {
| ShellKind::Rc | ShellKind::Rc
| ShellKind::Fish | ShellKind::Fish
| ShellKind::PowerShell | ShellKind::PowerShell
| ShellKind::Pwsh
| ShellKind::Nushell | ShellKind::Nushell
| ShellKind::Xonsh | ShellKind::Xonsh
| ShellKind::Elvish => "clear", | ShellKind::Elvish => "clear",
@@ -576,6 +752,7 @@ impl ShellKind {
| ShellKind::Rc | ShellKind::Rc
| ShellKind::Fish | ShellKind::Fish
| ShellKind::PowerShell | ShellKind::PowerShell
| ShellKind::Pwsh
| ShellKind::Nushell | ShellKind::Nushell
| ShellKind::Xonsh | ShellKind::Xonsh
| ShellKind::Elvish => true, | 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\"") .try_quote("C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \"test_foo.py::test_foo\"")
.unwrap() .unwrap()
.into_owned(), .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\"") .try_quote("C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \"test_foo.py::test_foo\"")
.unwrap() .unwrap()
.into_owned(), .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::get_system_shell;
use crate::shell::{Shell, ShellKind}; use crate::shell::{Shell, ShellKind};
@@ -42,7 +44,7 @@ impl ShellBuilder {
self.program.clone() self.program.clone()
} else { } else {
match self.kind { match self.kind {
ShellKind::PowerShell => { ShellKind::PowerShell | ShellKind::Pwsh => {
format!("{} -C '{}'", self.program, command_to_use_in_label) format!("{} -C '{}'", self.program, command_to_use_in_label)
} }
ShellKind::Cmd => { ShellKind::Cmd => {
@@ -78,11 +80,27 @@ impl ShellBuilder {
task_args: &[String], task_args: &[String],
) -> (String, Vec<String>) { ) -> (String, Vec<String>) {
if let Some(task_command) = task_command { if let Some(task_command) = task_command {
let mut combined_command = task_args.iter().fold(task_command, |mut command, arg| { let task_command = self.kind.prepend_command_prefix(&task_command);
command.push(' '); let task_command = if !task_args.is_empty() {
command.push_str(&self.kind.to_shell_variable(arg)); match self.kind.try_quote_prefix_aware(&task_command) {
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 { if self.redirect_stdin {
match self.kind { match self.kind {
ShellKind::Fish => { ShellKind::Fish => {
@@ -99,7 +117,7 @@ impl ShellBuilder {
combined_command.insert(0, '('); combined_command.insert(0, '(');
combined_command.push_str(") </dev/null"); combined_command.push_str(") </dev/null");
} }
ShellKind::PowerShell => { ShellKind::PowerShell | ShellKind::Pwsh => {
combined_command.insert_str(0, "$null | & {"); combined_command.insert_str(0, "$null | & {");
combined_command.push_str("}"); combined_command.push_str("}");
} }
@@ -115,6 +133,10 @@ impl ShellBuilder {
(self.program, self.args) (self.program, self.args)
} }
pub fn kind(&self) -> ShellKind {
self.kind
}
} }
#[cfg(test)] #[cfg(test)]
@@ -144,7 +166,7 @@ mod test {
vec![ vec![
"-i", "-i",
"-c", "-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()]); .build(Some("echo".into()), &["nothing".to_string()]);
assert_eq!(program, "nu"); 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] #[test]

View File

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

View File

@@ -20,7 +20,9 @@ use project::debugger::breakpoint_store::{BreakpointState, SourceBreakpoint};
use language::{LanguageName, Toolchain, ToolchainScope}; use language::{LanguageName, Toolchain, ToolchainScope};
use project::WorktreeId; use project::WorktreeId;
use remote::{RemoteConnectionOptions, SshConnectionOptions, WslConnectionOptions}; use remote::{
DockerConnectionOptions, RemoteConnectionOptions, SshConnectionOptions, WslConnectionOptions,
};
use sqlez::{ use sqlez::{
bindable::{Bind, Column, StaticColumnCount}, bindable::{Bind, Column, StaticColumnCount},
statement::Statement, statement::Statement,
@@ -702,6 +704,10 @@ impl Domain for WorkspaceDb {
sql!( sql!(
DROP TABLE ssh_connections; 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 // 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>>( pub(crate) fn remote_workspace_for_roots<P: AsRef<Path>>(
&self, &self,
worktree_roots: &[P], worktree_roots: &[P],
ssh_project_id: RemoteConnectionId, remote_project_id: RemoteConnectionId,
) -> Option<SerializedWorkspace> { ) -> 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>>( pub(crate) fn workspace_for_roots_internal<P: AsRef<Path>>(
@@ -806,9 +812,20 @@ impl WorkspaceDb {
order: paths_order, 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 { Some(SerializedWorkspace {
id: workspace_id, id: workspace_id,
location: SerializedWorkspaceLocation::Local, location: match remote_connection_options {
Some(options) => SerializedWorkspaceLocation::Remote(options),
None => SerializedWorkspaceLocation::Local,
},
paths, paths,
center_group: self center_group: self
.get_center_pane_group(workspace_id) .get_center_pane_group(workspace_id)
@@ -1110,10 +1127,12 @@ impl WorkspaceDb {
options: RemoteConnectionOptions, options: RemoteConnectionOptions,
) -> Result<RemoteConnectionId> { ) -> Result<RemoteConnectionId> {
let kind; let kind;
let user; let mut user = None;
let mut host = None; let mut host = None;
let mut port = None; let mut port = None;
let mut distro = None; let mut distro = None;
let mut name = None;
let mut container_id = None;
match options { match options {
RemoteConnectionOptions::Ssh(options) => { RemoteConnectionOptions::Ssh(options) => {
kind = RemoteConnectionKind::Ssh; kind = RemoteConnectionKind::Ssh;
@@ -1126,8 +1145,22 @@ impl WorkspaceDb {
distro = Some(options.distro_name); distro = Some(options.distro_name);
user = options.user; 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( fn get_or_create_remote_connection_query(
@@ -1137,6 +1170,8 @@ impl WorkspaceDb {
port: Option<u16>, port: Option<u16>,
user: Option<String>, user: Option<String>,
distro: Option<String>, distro: Option<String>,
name: Option<String>,
container_id: Option<String>,
) -> Result<RemoteConnectionId> { ) -> Result<RemoteConnectionId> {
if let Some(id) = this.select_row_bound(sql!( if let Some(id) = this.select_row_bound(sql!(
SELECT id SELECT id
@@ -1146,7 +1181,9 @@ impl WorkspaceDb {
host IS ? AND host IS ? AND
port IS ? AND port IS ? AND
user IS ? AND user IS ? AND
distro IS ? distro IS ? AND
name IS ? AND
container_id IS ?
LIMIT 1 LIMIT 1
))?(( ))?((
kind.serialize(), kind.serialize(),
@@ -1154,6 +1191,8 @@ impl WorkspaceDb {
port, port,
user.clone(), user.clone(),
distro.clone(), distro.clone(),
name.clone(),
container_id.clone(),
))? { ))? {
Ok(RemoteConnectionId(id)) Ok(RemoteConnectionId(id))
} else { } else {
@@ -1163,10 +1202,20 @@ impl WorkspaceDb {
host, host,
port, port,
user, user,
distro distro,
) VALUES (?1, ?2, ?3, ?4, ?5) name,
container_id
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
RETURNING id RETURNING id
))?((kind.serialize(), host, port, user, distro))? ))?((
kind.serialize(),
host,
port,
user,
distro,
name,
container_id,
))?
.context("failed to insert remote project")?; .context("failed to insert remote project")?;
Ok(RemoteConnectionId(id)) Ok(RemoteConnectionId(id))
} }
@@ -1249,15 +1298,23 @@ impl WorkspaceDb {
fn remote_connections(&self) -> Result<HashMap<RemoteConnectionId, RemoteConnectionOptions>> { fn remote_connections(&self) -> Result<HashMap<RemoteConnectionId, RemoteConnectionOptions>> {
Ok(self.select(sql!( Ok(self.select(sql!(
SELECT SELECT
id, kind, host, port, user, distro id, kind, host, port, user, distro, container_id, name
FROM FROM
remote_connections remote_connections
))?()? ))?()?
.into_iter() .into_iter()
.filter_map(|(id, kind, host, port, user, distro)| { .filter_map(|(id, kind, host, port, user, distro, container_id, name)| {
Some(( Some((
RemoteConnectionId(id), 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()) .collect())
@@ -1267,13 +1324,13 @@ impl WorkspaceDb {
&self, &self,
id: RemoteConnectionId, id: RemoteConnectionId,
) -> Result<RemoteConnectionOptions> { ) -> Result<RemoteConnectionOptions> {
let (kind, host, port, user, distro) = self.select_row_bound(sql!( let (kind, host, port, user, distro, container_id, name) = self.select_row_bound(sql!(
SELECT kind, host, port, user, distro SELECT kind, host, port, user, distro, container_id, name
FROM remote_connections FROM remote_connections
WHERE id = ? WHERE id = ?
))?(id.0)? ))?(id.0)?
.context("no such remote connection")?; .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") .context("invalid remote_connection row")
} }
@@ -1283,6 +1340,8 @@ impl WorkspaceDb {
port: Option<u16>, port: Option<u16>,
user: Option<String>, user: Option<String>,
distro: Option<String>, distro: Option<String>,
container_id: Option<String>,
name: Option<String>,
) -> Option<RemoteConnectionOptions> { ) -> Option<RemoteConnectionOptions> {
match RemoteConnectionKind::deserialize(&kind)? { match RemoteConnectionKind::deserialize(&kind)? {
RemoteConnectionKind::Wsl => Some(RemoteConnectionOptions::Wsl(WslConnectionOptions { RemoteConnectionKind::Wsl => Some(RemoteConnectionOptions::Wsl(WslConnectionOptions {
@@ -1295,6 +1354,13 @@ impl WorkspaceDb {
username: user, username: user,
..Default::default() ..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 { pub(crate) enum RemoteConnectionKind {
Ssh, Ssh,
Wsl, Wsl,
Docker,
} }
#[derive(Debug, PartialEq, Clone)] #[derive(Debug, PartialEq, Clone)]
@@ -75,6 +76,7 @@ impl RemoteConnectionKind {
match self { match self {
RemoteConnectionKind::Ssh => "ssh", RemoteConnectionKind::Ssh => "ssh",
RemoteConnectionKind::Wsl => "wsl", RemoteConnectionKind::Wsl => "wsl",
RemoteConnectionKind::Docker => "docker",
} }
} }
@@ -82,6 +84,7 @@ impl RemoteConnectionKind {
match text { match text {
"ssh" => Some(Self::Ssh), "ssh" => Some(Self::Ssh),
"wsl" => Some(Self::Wsl), "wsl" => Some(Self::Wsl),
"docker" => Some(Self::Docker),
_ => None, _ => None,
} }
} }

View File

@@ -7780,7 +7780,7 @@ pub fn open_remote_project_with_new_connection(
) -> Task<Result<Vec<Option<Box<dyn ItemHandle>>>>> { ) -> Task<Result<Vec<Option<Box<dyn ItemHandle>>>>> {
cx.spawn(async move |cx| { cx.spawn(async move |cx| {
let (workspace_id, serialized_workspace) = 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?; .await?;
let session = match cx let session = match cx
@@ -7834,7 +7834,7 @@ pub fn open_remote_project_with_existing_connection(
) -> Task<Result<Vec<Option<Box<dyn ItemHandle>>>>> { ) -> Task<Result<Vec<Option<Box<dyn ItemHandle>>>>> {
cx.spawn(async move |cx| { cx.spawn(async move |cx| {
let (workspace_id, serialized_workspace) = 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( open_remote_project_inner(
project, project,
@@ -7936,7 +7936,7 @@ async fn open_remote_project_inner(
Ok(items.into_iter().map(|item| item?.ok()).collect()) Ok(items.into_iter().map(|item| item?.ok()).collect())
} }
fn serialize_remote_project( fn deserialize_remote_project(
connection_options: RemoteConnectionOptions, connection_options: RemoteConnectionOptions,
paths: Vec<PathBuf>, paths: Vec<PathBuf>,
cx: &AsyncApp, cx: &AsyncApp,

View File

@@ -2,7 +2,7 @@
description = "The fast, collaborative code editor." description = "The fast, collaborative code editor."
edition.workspace = true edition.workspace = true
name = "zed" name = "zed"
version = "0.217.0" version = "0.218.0"
publish.workspace = true publish.workspace = true
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"
authors = ["Zed Team <hi@zed.dev>"] authors = ["Zed Team <hi@zed.dev>"]

View File

@@ -428,6 +428,12 @@ pub struct OpenRemote {
pub create_new_window: bool, 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. /// Where to spawn the task in the UI.
#[derive(Default, Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[derive(Default, Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]