Compare commits
17 Commits
v0.211.4-p
...
v0.211.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d846d45519 | ||
|
|
f70f507f61 | ||
|
|
e517719132 | ||
|
|
74efd3adbc | ||
|
|
9f1a9016b6 | ||
|
|
506f333ce1 | ||
|
|
214a0bc116 | ||
|
|
d461acbc7b | ||
|
|
7acefd50cc | ||
|
|
62ece18dfe | ||
|
|
4475689e4c | ||
|
|
5ea5b5e7a9 | ||
|
|
d93f528a37 | ||
|
|
44140197a6 | ||
|
|
2e746791b1 | ||
|
|
a3f230f760 | ||
|
|
ff0eef98c9 |
69
.github/workflows/after_release.yml
vendored
Normal file
69
.github/workflows/after_release.yml
vendored
Normal file
@@ -0,0 +1,69 @@
|
||||
# Generated from xtask::workflows::after_release
|
||||
# Rebuild with `cargo xtask workflows`.
|
||||
name: after_release
|
||||
on:
|
||||
release:
|
||||
types:
|
||||
- published
|
||||
jobs:
|
||||
rebuild_releases_page:
|
||||
if: github.repository_owner == 'zed-industries'
|
||||
runs-on: namespace-profile-2x4-ubuntu-2404
|
||||
steps:
|
||||
- name: after_release::rebuild_releases_page
|
||||
run: 'curl https://zed.dev/api/revalidate-releases -H "Authorization: Bearer ${RELEASE_NOTES_API_TOKEN}"'
|
||||
shell: bash -euxo pipefail {0}
|
||||
env:
|
||||
RELEASE_NOTES_API_TOKEN: ${{ secrets.RELEASE_NOTES_API_TOKEN }}
|
||||
post_to_discord:
|
||||
needs:
|
||||
- rebuild_releases_page
|
||||
if: github.repository_owner == 'zed-industries'
|
||||
runs-on: namespace-profile-2x4-ubuntu-2404
|
||||
steps:
|
||||
- id: get-release-url
|
||||
name: after_release::post_to_discord::get_release_url
|
||||
run: |
|
||||
if [ "${{ github.event.release.prerelease }}" == "true" ]; then
|
||||
URL="https://zed.dev/releases/preview"
|
||||
else
|
||||
URL="https://zed.dev/releases/stable"
|
||||
fi
|
||||
|
||||
echo "URL=$URL" >> "$GITHUB_OUTPUT"
|
||||
shell: bash -euxo pipefail {0}
|
||||
- id: get-content
|
||||
name: after_release::post_to_discord::get_content
|
||||
uses: 2428392/gh-truncate-string-action@b3ff790d21cf42af3ca7579146eedb93c8fb0757
|
||||
with:
|
||||
stringToTruncate: |
|
||||
📣 Zed [${{ github.event.release.tag_name }}](<${{ steps.get-release-url.outputs.URL }}>) was just released!
|
||||
|
||||
${{ github.event.release.body }}
|
||||
maxLength: 2000
|
||||
truncationSymbol: '...'
|
||||
- name: after_release::post_to_discord::discord_webhook_action
|
||||
uses: tsickert/discord-webhook@c840d45a03a323fbc3f7507ac7769dbd91bfb164
|
||||
with:
|
||||
webhook-url: ${{ secrets.DISCORD_WEBHOOK_RELEASE_NOTES }}
|
||||
content: ${{ steps.get-content.outputs.string }}
|
||||
publish_winget:
|
||||
runs-on: namespace-profile-2x4-ubuntu-2404
|
||||
steps:
|
||||
- id: set-package-name
|
||||
name: after_release::publish_winget::set_package_name
|
||||
run: |
|
||||
if [ "${{ github.event.release.prerelease }}" == "true" ]; then
|
||||
PACKAGE_NAME=ZedIndustries.Zed.Preview
|
||||
else
|
||||
PACKAGE_NAME=ZedIndustries.Zed
|
||||
fi
|
||||
|
||||
echo "PACKAGE_NAME=$PACKAGE_NAME" >> "$GITHUB_OUTPUT"
|
||||
shell: bash -euxo pipefail {0}
|
||||
- name: after_release::publish_winget::winget_releaser
|
||||
uses: vedantmgoyal9/winget-releaser@19e706d4c9121098010096f9c495a70a7518b30f
|
||||
with:
|
||||
identifier: ${{ steps.set-package-name.outputs.PACKAGE_NAME }}
|
||||
max-versions-to-keep: 5
|
||||
token: ${{ secrets.WINGET_TOKEN }}
|
||||
93
.github/workflows/community_release_actions.yml
vendored
93
.github/workflows/community_release_actions.yml
vendored
@@ -1,93 +0,0 @@
|
||||
# IF YOU UPDATE THE NAME OF ANY GITHUB SECRET, YOU MUST CHERRY PICK THE COMMIT
|
||||
# TO BOTH STABLE AND PREVIEW CHANNELS
|
||||
|
||||
name: Release Actions
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
discord_release:
|
||||
if: github.repository_owner == 'zed-industries'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Get release URL
|
||||
id: get-release-url
|
||||
run: |
|
||||
if [ "${{ github.event.release.prerelease }}" == "true" ]; then
|
||||
URL="https://zed.dev/releases/preview"
|
||||
else
|
||||
URL="https://zed.dev/releases/stable"
|
||||
fi
|
||||
|
||||
echo "URL=$URL" >> "$GITHUB_OUTPUT"
|
||||
- name: Get content
|
||||
uses: 2428392/gh-truncate-string-action@b3ff790d21cf42af3ca7579146eedb93c8fb0757 # v1.4.1
|
||||
id: get-content
|
||||
with:
|
||||
stringToTruncate: |
|
||||
📣 Zed [${{ github.event.release.tag_name }}](<${{ steps.get-release-url.outputs.URL }}>) was just released!
|
||||
|
||||
${{ github.event.release.body }}
|
||||
maxLength: 2000
|
||||
truncationSymbol: "..."
|
||||
- name: Discord Webhook Action
|
||||
uses: tsickert/discord-webhook@c840d45a03a323fbc3f7507ac7769dbd91bfb164 # v5.3.0
|
||||
with:
|
||||
webhook-url: ${{ secrets.DISCORD_WEBHOOK_RELEASE_NOTES }}
|
||||
content: ${{ steps.get-content.outputs.string }}
|
||||
|
||||
publish-winget:
|
||||
runs-on:
|
||||
- ubuntu-latest
|
||||
steps:
|
||||
- name: Set Package Name
|
||||
id: set-package-name
|
||||
run: |
|
||||
if [ "${{ github.event.release.prerelease }}" == "true" ]; then
|
||||
PACKAGE_NAME=ZedIndustries.Zed.Preview
|
||||
else
|
||||
PACKAGE_NAME=ZedIndustries.Zed
|
||||
fi
|
||||
|
||||
echo "PACKAGE_NAME=$PACKAGE_NAME" >> "$GITHUB_OUTPUT"
|
||||
- uses: vedantmgoyal9/winget-releaser@19e706d4c9121098010096f9c495a70a7518b30f # v2
|
||||
with:
|
||||
identifier: ${{ steps.set-package-name.outputs.PACKAGE_NAME }}
|
||||
max-versions-to-keep: 5
|
||||
token: ${{ secrets.WINGET_TOKEN }}
|
||||
|
||||
send_release_notes_email:
|
||||
if: false && github.repository_owner == 'zed-industries' && !github.event.release.prerelease
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check if release was promoted from preview
|
||||
id: check-promotion-from-preview
|
||||
run: |
|
||||
VERSION="${{ github.event.release.tag_name }}"
|
||||
PREVIEW_TAG="${VERSION}-pre"
|
||||
|
||||
if git rev-parse "$PREVIEW_TAG" > /dev/null 2>&1; then
|
||||
echo "was_promoted_from_preview=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "was_promoted_from_preview=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Send release notes email
|
||||
if: steps.check-promotion-from-preview.outputs.was_promoted_from_preview == 'true'
|
||||
run: |
|
||||
TAG="${{ github.event.release.tag_name }}"
|
||||
cat << 'EOF' > release_body.txt
|
||||
${{ github.event.release.body }}
|
||||
EOF
|
||||
jq -n --arg tag "$TAG" --rawfile body release_body.txt '{version: $tag, markdown_body: $body}' \
|
||||
> release_data.json
|
||||
curl -X POST "https://zed.dev/api/send_release_notes_email" \
|
||||
-H "Authorization: Bearer ${{ secrets.RELEASE_NOTES_API_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d @release_data.json
|
||||
6
Cargo.lock
generated
6
Cargo.lock
generated
@@ -16492,7 +16492,7 @@ dependencies = [
|
||||
"editor",
|
||||
"file_icons",
|
||||
"gpui",
|
||||
"multi_buffer",
|
||||
"language",
|
||||
"ui",
|
||||
"workspace",
|
||||
]
|
||||
@@ -20928,6 +20928,7 @@ dependencies = [
|
||||
"gh-workflow",
|
||||
"indexmap 2.11.4",
|
||||
"indoc",
|
||||
"serde",
|
||||
"toml 0.8.23",
|
||||
"toml_edit 0.22.27",
|
||||
]
|
||||
@@ -21112,7 +21113,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zed"
|
||||
version = "0.211.4"
|
||||
version = "0.211.5"
|
||||
dependencies = [
|
||||
"acp_tools",
|
||||
"activity_indicator",
|
||||
@@ -21204,6 +21205,7 @@ dependencies = [
|
||||
"project_symbols",
|
||||
"prompt_store",
|
||||
"proto",
|
||||
"rayon",
|
||||
"recent_projects",
|
||||
"release_channel",
|
||||
"remote",
|
||||
|
||||
@@ -178,6 +178,7 @@ impl AcpConnection {
|
||||
meta: Some(serde_json::json!({
|
||||
// Experimental: Allow for rendering terminal output from the agents
|
||||
"terminal_output": true,
|
||||
"terminal-auth": true,
|
||||
})),
|
||||
},
|
||||
client_info: Some(acp::Implementation {
|
||||
|
||||
@@ -1469,6 +1469,114 @@ impl AcpThreadView {
|
||||
return;
|
||||
};
|
||||
|
||||
// Check for the experimental "terminal-auth" _meta field
|
||||
let auth_method = connection.auth_methods().iter().find(|m| m.id == method);
|
||||
|
||||
if let Some(auth_method) = auth_method {
|
||||
if let Some(meta) = &auth_method.meta {
|
||||
if let Some(terminal_auth) = meta.get("terminal-auth") {
|
||||
// Extract terminal auth details from meta
|
||||
if let (Some(command), Some(label)) = (
|
||||
terminal_auth.get("command").and_then(|v| v.as_str()),
|
||||
terminal_auth.get("label").and_then(|v| v.as_str()),
|
||||
) {
|
||||
let args = terminal_auth
|
||||
.get("args")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(String::from))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let env = terminal_auth
|
||||
.get("env")
|
||||
.and_then(|v| v.as_object())
|
||||
.map(|obj| {
|
||||
obj.iter()
|
||||
.filter_map(|(k, v)| {
|
||||
v.as_str().map(|val| (k.clone(), val.to_string()))
|
||||
})
|
||||
.collect::<HashMap<String, String>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
// Run SpawnInTerminal in the same dir as the ACP server
|
||||
let cwd = connection
|
||||
.clone()
|
||||
.downcast::<agent_servers::AcpConnection>()
|
||||
.map(|acp_conn| acp_conn.root_dir().to_path_buf());
|
||||
|
||||
// Build SpawnInTerminal from _meta
|
||||
let login = task::SpawnInTerminal {
|
||||
id: task::TaskId(format!("external-agent-{}-login", label)),
|
||||
full_label: label.to_string(),
|
||||
label: label.to_string(),
|
||||
command: Some(command.to_string()),
|
||||
args,
|
||||
command_label: label.to_string(),
|
||||
cwd,
|
||||
env,
|
||||
use_new_terminal: true,
|
||||
allow_concurrent_runs: true,
|
||||
hide: task::HideStrategy::Always,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
self.thread_error.take();
|
||||
configuration_view.take();
|
||||
pending_auth_method.replace(method.clone());
|
||||
|
||||
if let Some(workspace) = self.workspace.upgrade() {
|
||||
let project = self.project.clone();
|
||||
let authenticate = Self::spawn_external_agent_login(
|
||||
login, workspace, project, false, true, window, cx,
|
||||
);
|
||||
cx.notify();
|
||||
self.auth_task = Some(cx.spawn_in(window, {
|
||||
let agent = self.agent.clone();
|
||||
async move |this, cx| {
|
||||
let result = authenticate.await;
|
||||
|
||||
match &result {
|
||||
Ok(_) => telemetry::event!(
|
||||
"Authenticate Agent Succeeded",
|
||||
agent = agent.telemetry_id()
|
||||
),
|
||||
Err(_) => {
|
||||
telemetry::event!(
|
||||
"Authenticate Agent Failed",
|
||||
agent = agent.telemetry_id(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
if let Err(err) = result {
|
||||
if let ThreadState::Unauthenticated {
|
||||
pending_auth_method,
|
||||
..
|
||||
} = &mut this.thread_state
|
||||
{
|
||||
pending_auth_method.take();
|
||||
}
|
||||
this.handle_thread_error(err, cx);
|
||||
} else {
|
||||
this.reset(window, cx);
|
||||
}
|
||||
this.auth_task.take()
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}));
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if method.0.as_ref() == "gemini-api-key" {
|
||||
let registry = LanguageModelRegistry::global(cx);
|
||||
let provider = registry
|
||||
@@ -1567,7 +1675,10 @@ impl AcpThreadView {
|
||||
&& let Some(login) = self.login.clone()
|
||||
{
|
||||
if let Some(workspace) = self.workspace.upgrade() {
|
||||
Self::spawn_external_agent_login(login, workspace, false, window, cx)
|
||||
let project = self.project.clone();
|
||||
Self::spawn_external_agent_login(
|
||||
login, workspace, project, false, false, window, cx,
|
||||
)
|
||||
} else {
|
||||
Task::ready(Ok(()))
|
||||
}
|
||||
@@ -1617,17 +1728,40 @@ impl AcpThreadView {
|
||||
fn spawn_external_agent_login(
|
||||
login: task::SpawnInTerminal,
|
||||
workspace: Entity<Workspace>,
|
||||
project: Entity<Project>,
|
||||
previous_attempt: bool,
|
||||
check_exit_code: bool,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Task<Result<()>> {
|
||||
let Some(terminal_panel) = workspace.read(cx).panel::<TerminalPanel>(cx) else {
|
||||
return Task::ready(Ok(()));
|
||||
};
|
||||
let project = workspace.read(cx).project().clone();
|
||||
|
||||
window.spawn(cx, async move |cx| {
|
||||
let mut task = login.clone();
|
||||
if let Some(cmd) = &task.command {
|
||||
// Have "node" command use Zed's managed Node runtime by default
|
||||
if cmd == "node" {
|
||||
let resolved_node_runtime = project
|
||||
.update(cx, |project, cx| {
|
||||
let agent_server_store = project.agent_server_store().clone();
|
||||
agent_server_store.update(cx, |store, cx| {
|
||||
store.node_runtime().map(|node_runtime| {
|
||||
cx.background_spawn(async move {
|
||||
node_runtime.binary_path().await
|
||||
})
|
||||
})
|
||||
})
|
||||
});
|
||||
|
||||
if let Ok(Some(resolve_task)) = resolved_node_runtime {
|
||||
if let Ok(node_path) = resolve_task.await {
|
||||
task.command = Some(node_path.to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
task.shell = task::Shell::WithArguments {
|
||||
program: task.command.take().expect("login command should be set"),
|
||||
args: std::mem::take(&mut task.args),
|
||||
@@ -1645,44 +1779,65 @@ impl AcpThreadView {
|
||||
})?;
|
||||
|
||||
let terminal = terminal.await?;
|
||||
let mut exit_status = terminal
|
||||
.read_with(cx, |terminal, cx| terminal.wait_for_completed_task(cx))?
|
||||
.fuse();
|
||||
|
||||
let logged_in = cx
|
||||
.spawn({
|
||||
let terminal = terminal.clone();
|
||||
async move |cx| {
|
||||
loop {
|
||||
cx.background_executor().timer(Duration::from_secs(1)).await;
|
||||
let content =
|
||||
terminal.update(cx, |terminal, _cx| terminal.get_content())?;
|
||||
if content.contains("Login successful")
|
||||
|| content.contains("Type your message")
|
||||
{
|
||||
return anyhow::Ok(());
|
||||
if check_exit_code {
|
||||
// For extension-based auth, wait for the process to exit and check exit code
|
||||
let exit_status = terminal
|
||||
.read_with(cx, |terminal, cx| terminal.wait_for_completed_task(cx))?
|
||||
.await;
|
||||
|
||||
match exit_status {
|
||||
Some(status) if status.success() => {
|
||||
Ok(())
|
||||
}
|
||||
Some(status) => {
|
||||
Err(anyhow!("Login command failed with exit code: {:?}", status.code()))
|
||||
}
|
||||
None => {
|
||||
Err(anyhow!("Login command terminated without exit status"))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// For hardcoded agents (claude-login, gemini-cli): look for specific output
|
||||
let mut exit_status = terminal
|
||||
.read_with(cx, |terminal, cx| terminal.wait_for_completed_task(cx))?
|
||||
.fuse();
|
||||
|
||||
let logged_in = cx
|
||||
.spawn({
|
||||
let terminal = terminal.clone();
|
||||
async move |cx| {
|
||||
loop {
|
||||
cx.background_executor().timer(Duration::from_secs(1)).await;
|
||||
let content =
|
||||
terminal.update(cx, |terminal, _cx| terminal.get_content())?;
|
||||
if content.contains("Login successful")
|
||||
|| content.contains("Type your message")
|
||||
{
|
||||
return anyhow::Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.fuse();
|
||||
futures::pin_mut!(logged_in);
|
||||
futures::select_biased! {
|
||||
result = logged_in => {
|
||||
if let Err(e) = result {
|
||||
log::error!("{e}");
|
||||
return Err(anyhow!("exited before logging in"));
|
||||
}
|
||||
}
|
||||
})
|
||||
.fuse();
|
||||
futures::pin_mut!(logged_in);
|
||||
futures::select_biased! {
|
||||
result = logged_in => {
|
||||
if let Err(e) = result {
|
||||
log::error!("{e}");
|
||||
_ = exit_status => {
|
||||
if !previous_attempt && project.read_with(cx, |project, _| project.is_via_remote_server())? && login.label.contains("gemini") {
|
||||
return cx.update(|window, cx| Self::spawn_external_agent_login(login, workspace, project.clone(), true, false, window, cx))?.await
|
||||
}
|
||||
return Err(anyhow!("exited before logging in"));
|
||||
}
|
||||
}
|
||||
_ = exit_status => {
|
||||
if !previous_attempt && project.read_with(cx, |project, _| project.is_via_remote_server())? && login.label.contains("gemini") {
|
||||
return cx.update(|window, cx| Self::spawn_external_agent_login(login, workspace, true, window, cx))?.await
|
||||
}
|
||||
return Err(anyhow!("exited before logging in"));
|
||||
}
|
||||
terminal.update(cx, |terminal, _| terminal.kill_active_task())?;
|
||||
Ok(())
|
||||
}
|
||||
terminal.update(cx, |terminal, _| terminal.kill_active_task())?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -2040,7 +2040,7 @@ impl AgentPanel {
|
||||
let mut entry =
|
||||
ContextMenuEntry::new(format!("New {} Thread", agent_name));
|
||||
if let Some(icon_path) = icon_path {
|
||||
entry = entry.custom_icon_path(icon_path);
|
||||
entry = entry.custom_icon_svg(icon_path);
|
||||
} else {
|
||||
entry = entry.icon(IconName::Terminal);
|
||||
}
|
||||
@@ -2109,7 +2109,7 @@ impl AgentPanel {
|
||||
.when_some(selected_agent_custom_icon, |this, icon_path| {
|
||||
let label = selected_agent_label.clone();
|
||||
this.px(DynamicSpacing::Base02.rems(cx))
|
||||
.child(Icon::from_path(icon_path).color(Color::Muted))
|
||||
.child(Icon::from_external_svg(icon_path).color(Color::Muted))
|
||||
.tooltip(move |_window, cx| {
|
||||
Tooltip::with_meta(label.clone(), None, "Selected Agent", cx)
|
||||
})
|
||||
|
||||
@@ -489,7 +489,11 @@ impl Copilot {
|
||||
let node_path = node_runtime.binary_path().await?;
|
||||
ensure_node_version_for_copilot(&node_path).await?;
|
||||
|
||||
let arguments: Vec<OsString> = vec![server_path.into(), "--stdio".into()];
|
||||
let arguments: Vec<OsString> = vec![
|
||||
"--experimental-sqlite".into(),
|
||||
server_path.into(),
|
||||
"--stdio".into(),
|
||||
];
|
||||
let binary = LanguageServerBinary {
|
||||
path: node_path,
|
||||
arguments,
|
||||
|
||||
@@ -164,6 +164,19 @@ pub struct AgentServerManifestEntry {
|
||||
/// args = ["--serve"]
|
||||
/// sha256 = "abc123..." # optional
|
||||
/// ```
|
||||
///
|
||||
/// For Node.js-based agents, you can use "node" as the cmd to automatically
|
||||
/// use Zed's managed Node.js runtime instead of relying on the user's PATH:
|
||||
/// ```toml
|
||||
/// [agent_servers.nodeagent.targets.darwin-aarch64]
|
||||
/// archive = "https://example.com/nodeagent.zip"
|
||||
/// cmd = "node"
|
||||
/// args = ["index.js", "--port", "3000"]
|
||||
/// ```
|
||||
///
|
||||
/// Note: All commands are executed with the archive extraction directory as the
|
||||
/// working directory, so relative paths in args (like "index.js") will resolve
|
||||
/// relative to the extracted archive contents.
|
||||
pub targets: HashMap<String, TargetConfig>,
|
||||
}
|
||||
|
||||
|
||||
@@ -2,14 +2,13 @@ use crate::{
|
||||
AnyElement, AnyImageCache, App, Asset, AssetLogger, Bounds, DefiniteLength, Element, ElementId,
|
||||
Entity, GlobalElementId, Hitbox, Image, ImageCache, InspectorElementId, InteractiveElement,
|
||||
Interactivity, IntoElement, LayoutId, Length, ObjectFit, Pixels, RenderImage, Resource,
|
||||
SMOOTH_SVG_SCALE_FACTOR, SharedString, SharedUri, StyleRefinement, Styled, SvgSize, Task,
|
||||
Window, px, swap_rgba_pa_to_bgra,
|
||||
SharedString, SharedUri, StyleRefinement, Styled, Task, Window, px,
|
||||
};
|
||||
use anyhow::{Context as _, Result};
|
||||
|
||||
use futures::{AsyncReadExt, Future};
|
||||
use image::{
|
||||
AnimationDecoder, DynamicImage, Frame, ImageBuffer, ImageError, ImageFormat, Rgba,
|
||||
AnimationDecoder, DynamicImage, Frame, ImageError, ImageFormat, Rgba,
|
||||
codecs::{gif::GifDecoder, webp::WebPDecoder},
|
||||
};
|
||||
use smallvec::SmallVec;
|
||||
@@ -160,13 +159,15 @@ pub trait StyledImage: Sized {
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the object fit for the image.
|
||||
/// Set a fallback function that will be invoked to render an error view should
|
||||
/// the image fail to load.
|
||||
fn with_fallback(mut self, fallback: impl Fn() -> AnyElement + 'static) -> Self {
|
||||
self.image_style().fallback = Some(Box::new(fallback));
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the object fit for the image.
|
||||
/// Set a fallback function that will be invoked to render a view while the image
|
||||
/// is still being loaded.
|
||||
fn with_loading(mut self, loading: impl Fn() -> AnyElement + 'static) -> Self {
|
||||
self.image_style().loading = Some(Box::new(loading));
|
||||
self
|
||||
@@ -631,7 +632,7 @@ impl Asset for ImageAssetLoader {
|
||||
}
|
||||
};
|
||||
|
||||
let data = if let Ok(format) = image::guess_format(&bytes) {
|
||||
if let Ok(format) = image::guess_format(&bytes) {
|
||||
let data = match format {
|
||||
ImageFormat::Gif => {
|
||||
let decoder = GifDecoder::new(Cursor::new(&bytes))?;
|
||||
@@ -689,25 +690,12 @@ impl Asset for ImageAssetLoader {
|
||||
}
|
||||
};
|
||||
|
||||
RenderImage::new(data)
|
||||
Ok(Arc::new(RenderImage::new(data)))
|
||||
} else {
|
||||
let pixmap =
|
||||
// TODO: Can we make svgs always rescale?
|
||||
svg_renderer.render_pixmap(&bytes, SvgSize::ScaleFactor(SMOOTH_SVG_SCALE_FACTOR))?;
|
||||
|
||||
let mut buffer =
|
||||
ImageBuffer::from_raw(pixmap.width(), pixmap.height(), pixmap.take()).unwrap();
|
||||
|
||||
for pixel in buffer.chunks_exact_mut(4) {
|
||||
swap_rgba_pa_to_bgra(pixel);
|
||||
}
|
||||
|
||||
let mut image = RenderImage::new(SmallVec::from_elem(Frame::new(buffer), 1));
|
||||
image.scale_factor = SMOOTH_SVG_SCALE_FACTOR;
|
||||
image
|
||||
};
|
||||
|
||||
Ok(Arc::new(data))
|
||||
svg_renderer
|
||||
.render_single_frame(&bytes, 1.0, true)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use std::{fs, path::Path, sync::Arc};
|
||||
|
||||
use crate::{
|
||||
App, Bounds, Element, GlobalElementId, Hitbox, InspectorElementId, InteractiveElement,
|
||||
App, Asset, Bounds, Element, GlobalElementId, Hitbox, InspectorElementId, InteractiveElement,
|
||||
Interactivity, IntoElement, LayoutId, Pixels, Point, Radians, SharedString, Size,
|
||||
StyleRefinement, Styled, TransformationMatrix, Window, geometry::Negate as _, point, px,
|
||||
radians, size,
|
||||
@@ -11,6 +13,7 @@ pub struct Svg {
|
||||
interactivity: Interactivity,
|
||||
transformation: Option<Transformation>,
|
||||
path: Option<SharedString>,
|
||||
external_path: Option<SharedString>,
|
||||
}
|
||||
|
||||
/// Create a new SVG element.
|
||||
@@ -20,6 +23,7 @@ pub fn svg() -> Svg {
|
||||
interactivity: Interactivity::new(),
|
||||
transformation: None,
|
||||
path: None,
|
||||
external_path: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +34,12 @@ impl Svg {
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the path to the SVG file for this element.
|
||||
pub fn external_path(mut self, path: impl Into<SharedString>) -> Self {
|
||||
self.external_path = Some(path.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Transform the SVG element with the given transformation.
|
||||
/// Note that this won't effect the hitbox or layout of the element, only the rendering.
|
||||
pub fn with_transformation(mut self, transformation: Transformation) -> Self {
|
||||
@@ -117,7 +127,35 @@ impl Element for Svg {
|
||||
.unwrap_or_default();
|
||||
|
||||
window
|
||||
.paint_svg(bounds, path.clone(), transformation, color, cx)
|
||||
.paint_svg(bounds, path.clone(), None, transformation, color, cx)
|
||||
.log_err();
|
||||
} else if let Some((path, color)) =
|
||||
self.external_path.as_ref().zip(style.text.color)
|
||||
{
|
||||
let Some(bytes) = window
|
||||
.use_asset::<SvgAsset>(path, cx)
|
||||
.and_then(|asset| asset.log_err())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let transformation = self
|
||||
.transformation
|
||||
.as_ref()
|
||||
.map(|transformation| {
|
||||
transformation.into_matrix(bounds.center(), window.scale_factor())
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
window
|
||||
.paint_svg(
|
||||
bounds,
|
||||
path.clone(),
|
||||
Some(&bytes),
|
||||
transformation,
|
||||
color,
|
||||
cx,
|
||||
)
|
||||
.log_err();
|
||||
}
|
||||
},
|
||||
@@ -219,3 +257,21 @@ impl Transformation {
|
||||
.translate(center.scale(scale_factor).negate())
|
||||
}
|
||||
}
|
||||
|
||||
enum SvgAsset {}
|
||||
|
||||
impl Asset for SvgAsset {
|
||||
type Source = SharedString;
|
||||
type Output = Result<Arc<[u8]>, Arc<std::io::Error>>;
|
||||
|
||||
fn load(
|
||||
source: Self::Source,
|
||||
_cx: &mut App,
|
||||
) -> impl Future<Output = Self::Output> + Send + 'static {
|
||||
async move {
|
||||
let bytes = fs::read(Path::new(source.as_ref())).map_err(|e| Arc::new(e))?;
|
||||
let bytes = Arc::from(bytes);
|
||||
Ok(bytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ pub use smol::Timer;
|
||||
pub use style::*;
|
||||
pub use styled::*;
|
||||
pub use subscription::*;
|
||||
use svg_renderer::*;
|
||||
pub use svg_renderer::*;
|
||||
pub(crate) use tab_stop::*;
|
||||
pub use taffy::{AvailableSpace, LayoutId};
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
|
||||
@@ -40,7 +40,7 @@ use crate::{
|
||||
DEFAULT_WINDOW_SIZE, DevicePixels, DispatchEventResult, Font, FontId, FontMetrics, FontRun,
|
||||
ForegroundExecutor, GlyphId, GpuSpecs, ImageSource, Keymap, LineLayout, Pixels, PlatformInput,
|
||||
Point, RenderGlyphParams, RenderImage, RenderImageParams, RenderSvgParams, Scene, ShapedGlyph,
|
||||
ShapedRun, SharedString, Size, SvgRenderer, SvgSize, SystemWindowTab, Task, TaskLabel, Window,
|
||||
ShapedRun, SharedString, Size, SvgRenderer, SystemWindowTab, Task, TaskLabel, Window,
|
||||
WindowControlArea, hash, point, px, size,
|
||||
};
|
||||
use anyhow::Result;
|
||||
@@ -1817,13 +1817,9 @@ impl Image {
|
||||
ImageFormat::Tiff => frames_for_image(&self.bytes, image::ImageFormat::Tiff)?,
|
||||
ImageFormat::Ico => frames_for_image(&self.bytes, image::ImageFormat::Ico)?,
|
||||
ImageFormat::Svg => {
|
||||
let pixmap = svg_renderer.render_pixmap(&self.bytes, SvgSize::ScaleFactor(1.0))?;
|
||||
|
||||
let buffer =
|
||||
image::ImageBuffer::from_raw(pixmap.width(), pixmap.height(), pixmap.take())
|
||||
.unwrap();
|
||||
|
||||
SmallVec::from_elem(Frame::new(buffer), 1)
|
||||
return svg_renderer
|
||||
.render_single_frame(&self.bytes, 1.0, false)
|
||||
.map_err(Into::into);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
use crate::{AssetSource, DevicePixels, IsZero, Result, SharedString, Size};
|
||||
use crate::{
|
||||
AssetSource, DevicePixels, IsZero, RenderImage, Result, SharedString, Size,
|
||||
swap_rgba_pa_to_bgra,
|
||||
};
|
||||
use image::Frame;
|
||||
use resvg::tiny_skia::Pixmap;
|
||||
use smallvec::SmallVec;
|
||||
use std::{
|
||||
hash::Hash,
|
||||
sync::{Arc, LazyLock},
|
||||
@@ -15,17 +20,22 @@ pub(crate) struct RenderSvgParams {
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
/// A struct holding everything necessary to render SVGs.
|
||||
pub struct SvgRenderer {
|
||||
asset_source: Arc<dyn AssetSource>,
|
||||
usvg_options: Arc<usvg::Options<'static>>,
|
||||
}
|
||||
|
||||
/// The size in which to render the SVG.
|
||||
pub enum SvgSize {
|
||||
/// An absolute size in device pixels.
|
||||
Size(Size<DevicePixels>),
|
||||
/// A scaling factor to apply to the size provided by the SVG.
|
||||
ScaleFactor(f32),
|
||||
}
|
||||
|
||||
impl SvgRenderer {
|
||||
/// Creates a new SVG renderer with the provided asset source.
|
||||
pub fn new(asset_source: Arc<dyn AssetSource>) -> Self {
|
||||
static FONT_DB: LazyLock<Arc<usvg::fontdb::Database>> = LazyLock::new(|| {
|
||||
let mut db = usvg::fontdb::Database::new();
|
||||
@@ -54,33 +64,68 @@ impl SvgRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn render(
|
||||
/// Renders the given bytes into an image buffer.
|
||||
pub fn render_single_frame(
|
||||
&self,
|
||||
bytes: &[u8],
|
||||
scale_factor: f32,
|
||||
to_brga: bool,
|
||||
) -> Result<Arc<RenderImage>, usvg::Error> {
|
||||
self.render_pixmap(
|
||||
bytes,
|
||||
SvgSize::ScaleFactor(scale_factor * SMOOTH_SVG_SCALE_FACTOR),
|
||||
)
|
||||
.map(|pixmap| {
|
||||
let mut buffer =
|
||||
image::ImageBuffer::from_raw(pixmap.width(), pixmap.height(), pixmap.take())
|
||||
.unwrap();
|
||||
|
||||
if to_brga {
|
||||
for pixel in buffer.chunks_exact_mut(4) {
|
||||
swap_rgba_pa_to_bgra(pixel);
|
||||
}
|
||||
}
|
||||
|
||||
let mut image = RenderImage::new(SmallVec::from_const([Frame::new(buffer)]));
|
||||
image.scale_factor = SMOOTH_SVG_SCALE_FACTOR;
|
||||
Arc::new(image)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn render_alpha_mask(
|
||||
&self,
|
||||
params: &RenderSvgParams,
|
||||
bytes: Option<&[u8]>,
|
||||
) -> Result<Option<(Size<DevicePixels>, Vec<u8>)>> {
|
||||
anyhow::ensure!(!params.size.is_zero(), "can't render at a zero size");
|
||||
|
||||
// Load the tree.
|
||||
let Some(bytes) = self.asset_source.load(¶ms.path)? else {
|
||||
return Ok(None);
|
||||
let render_pixmap = |bytes| {
|
||||
let pixmap = self.render_pixmap(bytes, SvgSize::Size(params.size))?;
|
||||
|
||||
// Convert the pixmap's pixels into an alpha mask.
|
||||
let size = Size::new(
|
||||
DevicePixels(pixmap.width() as i32),
|
||||
DevicePixels(pixmap.height() as i32),
|
||||
);
|
||||
let alpha_mask = pixmap
|
||||
.pixels()
|
||||
.iter()
|
||||
.map(|p| p.alpha())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Ok(Some((size, alpha_mask)))
|
||||
};
|
||||
|
||||
let pixmap = self.render_pixmap(&bytes, SvgSize::Size(params.size))?;
|
||||
|
||||
// Convert the pixmap's pixels into an alpha mask.
|
||||
let size = Size::new(
|
||||
DevicePixels(pixmap.width() as i32),
|
||||
DevicePixels(pixmap.height() as i32),
|
||||
);
|
||||
let alpha_mask = pixmap
|
||||
.pixels()
|
||||
.iter()
|
||||
.map(|p| p.alpha())
|
||||
.collect::<Vec<_>>();
|
||||
Ok(Some((size, alpha_mask)))
|
||||
if let Some(bytes) = bytes {
|
||||
render_pixmap(bytes)
|
||||
} else if let Some(bytes) = self.asset_source.load(¶ms.path)? {
|
||||
render_pixmap(&bytes)
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_pixmap(&self, bytes: &[u8], size: SvgSize) -> Result<Pixmap, usvg::Error> {
|
||||
fn render_pixmap(&self, bytes: &[u8], size: SvgSize) -> Result<Pixmap, usvg::Error> {
|
||||
let tree = usvg::Tree::from_data(bytes, &self.usvg_options)?;
|
||||
let svg_size = tree.size();
|
||||
let scale = match size {
|
||||
|
||||
@@ -3084,6 +3084,7 @@ impl Window {
|
||||
&mut self,
|
||||
bounds: Bounds<Pixels>,
|
||||
path: SharedString,
|
||||
mut data: Option<&[u8]>,
|
||||
transformation: TransformationMatrix,
|
||||
color: Hsla,
|
||||
cx: &App,
|
||||
@@ -3104,7 +3105,8 @@ impl Window {
|
||||
let Some(tile) =
|
||||
self.sprite_atlas
|
||||
.get_or_insert_with(¶ms.clone().into(), &mut || {
|
||||
let Some((size, bytes)) = cx.svg_renderer.render(¶ms)? else {
|
||||
let Some((size, bytes)) = cx.svg_renderer.render_alpha_mask(¶ms, data)?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(Some((size, Cow::Owned(bytes))))
|
||||
|
||||
@@ -2324,17 +2324,19 @@ impl CodeLabel {
|
||||
}
|
||||
|
||||
pub fn plain(text: String, filter_text: Option<&str>) -> Self {
|
||||
Self::filtered(text, filter_text, Vec::new())
|
||||
Self::filtered(text.clone(), text.len(), filter_text, Vec::new())
|
||||
}
|
||||
|
||||
pub fn filtered(
|
||||
text: String,
|
||||
label_len: usize,
|
||||
filter_text: Option<&str>,
|
||||
runs: Vec<(Range<usize>, HighlightId)>,
|
||||
) -> Self {
|
||||
assert!(label_len <= text.len());
|
||||
let filter_range = filter_text
|
||||
.and_then(|filter| text.find(filter).map(|ix| ix..ix + filter.len()))
|
||||
.unwrap_or(0..text.len());
|
||||
.unwrap_or(0..label_len);
|
||||
Self::new(text, filter_range, runs)
|
||||
}
|
||||
|
||||
|
||||
@@ -406,6 +406,7 @@ impl LspAdapter for PyrightLspAdapter {
|
||||
language: &Arc<language::Language>,
|
||||
) -> Option<language::CodeLabel> {
|
||||
let label = &item.label;
|
||||
let label_len = label.len();
|
||||
let grammar = language.grammar()?;
|
||||
let highlight_id = match item.kind? {
|
||||
lsp::CompletionItemKind::METHOD => grammar.highlight_id_for_name("function.method"),
|
||||
@@ -427,9 +428,10 @@ impl LspAdapter for PyrightLspAdapter {
|
||||
}
|
||||
Some(language::CodeLabel::filtered(
|
||||
text,
|
||||
label_len,
|
||||
item.filter_text.as_deref(),
|
||||
highlight_id
|
||||
.map(|id| (0..label.len(), id))
|
||||
.map(|id| (0..label_len, id))
|
||||
.into_iter()
|
||||
.collect(),
|
||||
))
|
||||
@@ -1466,6 +1468,7 @@ impl LspAdapter for PyLspAdapter {
|
||||
language: &Arc<language::Language>,
|
||||
) -> Option<language::CodeLabel> {
|
||||
let label = &item.label;
|
||||
let label_len = label.len();
|
||||
let grammar = language.grammar()?;
|
||||
let highlight_id = match item.kind? {
|
||||
lsp::CompletionItemKind::METHOD => grammar.highlight_id_for_name("function.method")?,
|
||||
@@ -1476,6 +1479,7 @@ impl LspAdapter for PyLspAdapter {
|
||||
};
|
||||
Some(language::CodeLabel::filtered(
|
||||
label.clone(),
|
||||
label_len,
|
||||
item.filter_text.as_deref(),
|
||||
vec![(0..label.len(), highlight_id)],
|
||||
))
|
||||
@@ -1741,6 +1745,7 @@ impl LspAdapter for BasedPyrightLspAdapter {
|
||||
language: &Arc<language::Language>,
|
||||
) -> Option<language::CodeLabel> {
|
||||
let label = &item.label;
|
||||
let label_len = label.len();
|
||||
let grammar = language.grammar()?;
|
||||
let highlight_id = match item.kind? {
|
||||
lsp::CompletionItemKind::METHOD => grammar.highlight_id_for_name("function.method"),
|
||||
@@ -1762,6 +1767,7 @@ impl LspAdapter for BasedPyrightLspAdapter {
|
||||
}
|
||||
Some(language::CodeLabel::filtered(
|
||||
text,
|
||||
label_len,
|
||||
item.filter_text.as_deref(),
|
||||
highlight_id
|
||||
.map(|id| (0..label.len(), id))
|
||||
|
||||
@@ -754,7 +754,7 @@ impl LspAdapter for TypeScriptLspAdapter {
|
||||
language: &Arc<language::Language>,
|
||||
) -> Option<language::CodeLabel> {
|
||||
use lsp::CompletionItemKind as Kind;
|
||||
let len = item.label.len();
|
||||
let label_len = item.label.len();
|
||||
let grammar = language.grammar()?;
|
||||
let highlight_id = match item.kind? {
|
||||
Kind::CLASS | Kind::INTERFACE | Kind::ENUM => grammar.highlight_id_for_name("type"),
|
||||
@@ -779,8 +779,9 @@ impl LspAdapter for TypeScriptLspAdapter {
|
||||
};
|
||||
Some(language::CodeLabel::filtered(
|
||||
text,
|
||||
label_len,
|
||||
item.filter_text.as_deref(),
|
||||
vec![(0..len, highlight_id)],
|
||||
vec![(0..label_len, highlight_id)],
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
@@ -178,7 +178,7 @@ impl LspAdapter for VtslsLspAdapter {
|
||||
language: &Arc<language::Language>,
|
||||
) -> Option<language::CodeLabel> {
|
||||
use lsp::CompletionItemKind as Kind;
|
||||
let len = item.label.len();
|
||||
let label_len = item.label.len();
|
||||
let grammar = language.grammar()?;
|
||||
let highlight_id = match item.kind? {
|
||||
Kind::CLASS | Kind::INTERFACE | Kind::ENUM => grammar.highlight_id_for_name("type"),
|
||||
@@ -203,8 +203,9 @@ impl LspAdapter for VtslsLspAdapter {
|
||||
};
|
||||
Some(language::CodeLabel::filtered(
|
||||
text,
|
||||
label_len,
|
||||
item.filter_text.as_deref(),
|
||||
vec![(0..len, highlight_id)],
|
||||
vec![(0..label_len, highlight_id)],
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
@@ -267,6 +267,7 @@ impl AgentServerStore {
|
||||
// Insert agent servers from extension manifests
|
||||
match &self.state {
|
||||
AgentServerStoreState::Local {
|
||||
node_runtime,
|
||||
project_environment,
|
||||
fs,
|
||||
http_client,
|
||||
@@ -297,6 +298,7 @@ impl AgentServerStore {
|
||||
Box::new(LocalExtensionArchiveAgent {
|
||||
fs: fs.clone(),
|
||||
http_client: http_client.clone(),
|
||||
node_runtime: node_runtime.clone(),
|
||||
project_environment: project_environment.clone(),
|
||||
extension_id: Arc::from(ext_id),
|
||||
agent_id: agent_name.clone(),
|
||||
@@ -444,6 +446,13 @@ impl AgentServerStore {
|
||||
cx.emit(AgentServersUpdated);
|
||||
}
|
||||
|
||||
pub fn node_runtime(&self) -> Option<NodeRuntime> {
|
||||
match &self.state {
|
||||
AgentServerStoreState::Local { node_runtime, .. } => Some(node_runtime.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn local(
|
||||
node_runtime: NodeRuntime,
|
||||
fs: Arc<dyn Fs>,
|
||||
@@ -1364,6 +1373,7 @@ fn asset_name(version: &str) -> Option<String> {
|
||||
struct LocalExtensionArchiveAgent {
|
||||
fs: Arc<dyn Fs>,
|
||||
http_client: Arc<dyn HttpClient>,
|
||||
node_runtime: NodeRuntime,
|
||||
project_environment: Entity<ProjectEnvironment>,
|
||||
extension_id: Arc<str>,
|
||||
agent_id: Arc<str>,
|
||||
@@ -1387,6 +1397,7 @@ impl ExternalAgentServer for LocalExtensionArchiveAgent {
|
||||
) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
|
||||
let fs = self.fs.clone();
|
||||
let http_client = self.http_client.clone();
|
||||
let node_runtime = self.node_runtime.clone();
|
||||
let project_environment = self.project_environment.downgrade();
|
||||
let extension_id = self.extension_id.clone();
|
||||
let agent_id = self.agent_id.clone();
|
||||
@@ -1534,23 +1545,29 @@ impl ExternalAgentServer for LocalExtensionArchiveAgent {
|
||||
|
||||
// Validate and resolve cmd path
|
||||
let cmd = &target_config.cmd;
|
||||
if cmd.contains("..") {
|
||||
anyhow::bail!("command path cannot contain '..': {}", cmd);
|
||||
}
|
||||
|
||||
let cmd_path = if cmd.starts_with("./") || cmd.starts_with(".\\") {
|
||||
// Relative to extraction directory
|
||||
version_dir.join(&cmd[2..])
|
||||
let cmd_path = if cmd == "node" {
|
||||
// Use Zed's managed Node.js runtime
|
||||
node_runtime.binary_path().await?
|
||||
} else {
|
||||
// On PATH
|
||||
anyhow::bail!("command must be relative (start with './'): {}", cmd);
|
||||
};
|
||||
if cmd.contains("..") {
|
||||
anyhow::bail!("command path cannot contain '..': {}", cmd);
|
||||
}
|
||||
|
||||
anyhow::ensure!(
|
||||
fs.is_file(&cmd_path).await,
|
||||
"Missing command {} after extraction",
|
||||
cmd_path.to_string_lossy()
|
||||
);
|
||||
if cmd.starts_with("./") || cmd.starts_with(".\\") {
|
||||
// Relative to extraction directory
|
||||
let cmd_path = version_dir.join(&cmd[2..]);
|
||||
anyhow::ensure!(
|
||||
fs.is_file(&cmd_path).await,
|
||||
"Missing command {} after extraction",
|
||||
cmd_path.to_string_lossy()
|
||||
);
|
||||
cmd_path
|
||||
} else {
|
||||
// On PATH
|
||||
anyhow::bail!("command must be relative (start with './'): {}", cmd);
|
||||
}
|
||||
};
|
||||
|
||||
let command = AgentServerCommand {
|
||||
path: cmd_path,
|
||||
@@ -1558,7 +1575,7 @@ impl ExternalAgentServer for LocalExtensionArchiveAgent {
|
||||
env: Some(env),
|
||||
};
|
||||
|
||||
Ok((command, root_dir.to_string_lossy().into_owned(), None))
|
||||
Ok((command, version_dir.to_string_lossy().into_owned(), None))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1829,6 +1846,7 @@ mod extension_agent_tests {
|
||||
let agent = LocalExtensionArchiveAgent {
|
||||
fs,
|
||||
http_client,
|
||||
node_runtime: node_runtime::NodeRuntime::unavailable(),
|
||||
project_environment,
|
||||
extension_id: Arc::from("my-extension"),
|
||||
agent_id: Arc::from("my-agent"),
|
||||
@@ -1893,4 +1911,85 @@ mod extension_agent_tests {
|
||||
let target = manifest_entry.targets.get("linux-x86_64").unwrap();
|
||||
assert_eq!(target.cmd, "./release-agent");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_node_command_uses_managed_runtime(cx: &mut TestAppContext) {
|
||||
let fs = fs::FakeFs::new(cx.background_executor.clone());
|
||||
let http_client = http_client::FakeHttpClient::with_404_response();
|
||||
let node_runtime = NodeRuntime::unavailable();
|
||||
let project_environment = cx.new(|cx| crate::ProjectEnvironment::new(None, cx));
|
||||
|
||||
let agent = LocalExtensionArchiveAgent {
|
||||
fs,
|
||||
http_client,
|
||||
node_runtime,
|
||||
project_environment,
|
||||
extension_id: Arc::from("node-extension"),
|
||||
agent_id: Arc::from("node-agent"),
|
||||
targets: {
|
||||
let mut map = HashMap::default();
|
||||
map.insert(
|
||||
"darwin-aarch64".to_string(),
|
||||
extension::TargetConfig {
|
||||
archive: "https://example.com/node-agent.zip".into(),
|
||||
cmd: "node".into(),
|
||||
args: vec!["index.js".into()],
|
||||
sha256: None,
|
||||
},
|
||||
);
|
||||
map
|
||||
},
|
||||
env: HashMap::default(),
|
||||
};
|
||||
|
||||
// Verify that when cmd is "node", it attempts to use the node runtime
|
||||
assert_eq!(agent.extension_id.as_ref(), "node-extension");
|
||||
assert_eq!(agent.agent_id.as_ref(), "node-agent");
|
||||
|
||||
let target = agent.targets.get("darwin-aarch64").unwrap();
|
||||
assert_eq!(target.cmd, "node");
|
||||
assert_eq!(target.args, vec!["index.js"]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_commands_run_in_extraction_directory(cx: &mut TestAppContext) {
|
||||
let fs = fs::FakeFs::new(cx.background_executor.clone());
|
||||
let http_client = http_client::FakeHttpClient::with_404_response();
|
||||
let node_runtime = NodeRuntime::unavailable();
|
||||
let project_environment = cx.new(|cx| crate::ProjectEnvironment::new(None, cx));
|
||||
|
||||
let agent = LocalExtensionArchiveAgent {
|
||||
fs,
|
||||
http_client,
|
||||
node_runtime,
|
||||
project_environment,
|
||||
extension_id: Arc::from("test-ext"),
|
||||
agent_id: Arc::from("test-agent"),
|
||||
targets: {
|
||||
let mut map = HashMap::default();
|
||||
map.insert(
|
||||
"darwin-aarch64".to_string(),
|
||||
extension::TargetConfig {
|
||||
archive: "https://example.com/test.zip".into(),
|
||||
cmd: "node".into(),
|
||||
args: vec![
|
||||
"server.js".into(),
|
||||
"--config".into(),
|
||||
"./config.json".into(),
|
||||
],
|
||||
sha256: None,
|
||||
},
|
||||
);
|
||||
map
|
||||
},
|
||||
env: HashMap::default(),
|
||||
};
|
||||
|
||||
// Verify the agent is configured with relative paths in args
|
||||
let target = agent.targets.get("darwin-aarch64").unwrap();
|
||||
assert_eq!(target.args[0], "server.js");
|
||||
assert_eq!(target.args[2], "./config.json");
|
||||
// These relative paths will resolve relative to the extraction directory
|
||||
// when the command is executed
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12251,10 +12251,7 @@ impl LspStore {
|
||||
.update(cx, |buffer, _| buffer.wait_for_version(version))?
|
||||
.await?;
|
||||
lsp_store.update(cx, |lsp_store, cx| {
|
||||
let lsp_data = lsp_store
|
||||
.lsp_data
|
||||
.entry(buffer_id)
|
||||
.or_insert_with(|| BufferLspData::new(&buffer, cx));
|
||||
let lsp_data = lsp_store.latest_lsp_data(&buffer, cx);
|
||||
let chunks_queried_for = lsp_data
|
||||
.inlay_hints
|
||||
.applicable_chunks(&[range])
|
||||
|
||||
@@ -15,6 +15,6 @@ path = "src/svg_preview.rs"
|
||||
editor.workspace = true
|
||||
file_icons.workspace = true
|
||||
gpui.workspace = true
|
||||
multi_buffer.workspace = true
|
||||
language.workspace = true
|
||||
ui.workspace = true
|
||||
workspace.workspace = true
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
use std::path::PathBuf;
|
||||
use std::mem;
|
||||
use std::sync::Arc;
|
||||
|
||||
use editor::Editor;
|
||||
use file_icons::FileIcons;
|
||||
use gpui::{
|
||||
App, Context, Entity, EventEmitter, FocusHandle, Focusable, ImageSource, IntoElement,
|
||||
ParentElement, Render, Resource, RetainAllImageCache, Styled, Subscription, WeakEntity, Window,
|
||||
div, img,
|
||||
App, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement, ParentElement, Render,
|
||||
RenderImage, Styled, Subscription, Task, WeakEntity, Window, div, img,
|
||||
};
|
||||
use multi_buffer::{Event as MultiBufferEvent, MultiBuffer};
|
||||
use language::{Buffer, BufferEvent};
|
||||
use ui::prelude::*;
|
||||
use workspace::item::Item;
|
||||
use workspace::{Pane, Workspace};
|
||||
@@ -16,9 +16,10 @@ use crate::{OpenFollowingPreview, OpenPreview, OpenPreviewToTheSide};
|
||||
|
||||
pub struct SvgPreviewView {
|
||||
focus_handle: FocusHandle,
|
||||
svg_path: Option<PathBuf>,
|
||||
image_cache: Entity<RetainAllImageCache>,
|
||||
_buffer_subscription: Subscription,
|
||||
buffer: Option<Entity<Buffer>>,
|
||||
current_svg: Option<Result<Arc<RenderImage>, SharedString>>,
|
||||
_refresh: Task<()>,
|
||||
_buffer_subscription: Option<Subscription>,
|
||||
_workspace_subscription: Option<Subscription>,
|
||||
}
|
||||
|
||||
@@ -31,6 +32,182 @@ pub enum SvgPreviewMode {
|
||||
}
|
||||
|
||||
impl SvgPreviewView {
|
||||
pub fn new(
|
||||
mode: SvgPreviewMode,
|
||||
active_editor: Entity<Editor>,
|
||||
workspace_handle: WeakEntity<Workspace>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Workspace>,
|
||||
) -> Entity<Self> {
|
||||
cx.new(|cx| {
|
||||
let workspace_subscription = if mode == SvgPreviewMode::Follow
|
||||
&& let Some(workspace) = workspace_handle.upgrade()
|
||||
{
|
||||
Some(Self::subscribe_to_workspace(workspace, window, cx))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let buffer = active_editor
|
||||
.read(cx)
|
||||
.buffer()
|
||||
.clone()
|
||||
.read_with(cx, |buffer, _cx| buffer.as_singleton());
|
||||
|
||||
let subscription = buffer
|
||||
.as_ref()
|
||||
.map(|buffer| Self::create_buffer_subscription(buffer, window, cx));
|
||||
|
||||
let mut this = Self {
|
||||
focus_handle: cx.focus_handle(),
|
||||
buffer,
|
||||
current_svg: None,
|
||||
_buffer_subscription: subscription,
|
||||
_workspace_subscription: workspace_subscription,
|
||||
_refresh: Task::ready(()),
|
||||
};
|
||||
this.render_image(window, cx);
|
||||
|
||||
this
|
||||
})
|
||||
}
|
||||
|
||||
fn subscribe_to_workspace(
|
||||
workspace: Entity<Workspace>,
|
||||
window: &Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Subscription {
|
||||
cx.subscribe_in(
|
||||
&workspace,
|
||||
window,
|
||||
move |this: &mut SvgPreviewView, workspace, event: &workspace::Event, window, cx| {
|
||||
if let workspace::Event::ActiveItemChanged = event {
|
||||
let workspace = workspace.read(cx);
|
||||
if let Some(active_item) = workspace.active_item(cx)
|
||||
&& let Some(editor) = active_item.downcast::<Editor>()
|
||||
&& Self::is_svg_file(&editor, cx)
|
||||
{
|
||||
let Some(buffer) = editor.read(cx).buffer().read(cx).as_singleton() else {
|
||||
return;
|
||||
};
|
||||
if this.buffer.as_ref() != Some(&buffer) {
|
||||
this._buffer_subscription =
|
||||
Some(Self::create_buffer_subscription(&buffer, window, cx));
|
||||
this.buffer = Some(buffer);
|
||||
this.render_image(window, cx);
|
||||
cx.notify();
|
||||
}
|
||||
} else {
|
||||
this.set_current(None, window, cx);
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn render_image(&mut self, window: &Window, cx: &mut Context<Self>) {
|
||||
let Some(buffer) = self.buffer.as_ref() else {
|
||||
return;
|
||||
};
|
||||
const SCALE_FACTOR: f32 = 1.0;
|
||||
|
||||
let renderer = cx.svg_renderer();
|
||||
let content = buffer.read(cx).snapshot();
|
||||
let background_task = cx.background_spawn(async move {
|
||||
renderer.render_single_frame(content.text().as_bytes(), SCALE_FACTOR, true)
|
||||
});
|
||||
|
||||
self._refresh = cx.spawn_in(window, async move |this, cx| {
|
||||
let result = background_task.await;
|
||||
|
||||
this.update_in(cx, |view, window, cx| {
|
||||
let current = result.map_err(|e| e.to_string().into());
|
||||
view.set_current(Some(current), window, cx);
|
||||
})
|
||||
.ok();
|
||||
});
|
||||
}
|
||||
|
||||
fn set_current(
|
||||
&mut self,
|
||||
image: Option<Result<Arc<RenderImage>, SharedString>>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if let Some(Ok(image)) = mem::replace(&mut self.current_svg, image) {
|
||||
window.drop_image(image).ok();
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn find_existing_preview_item_idx(
|
||||
pane: &Pane,
|
||||
editor: &Entity<Editor>,
|
||||
cx: &App,
|
||||
) -> Option<usize> {
|
||||
let buffer_id = editor.read(cx).buffer().entity_id();
|
||||
pane.items_of_type::<SvgPreviewView>()
|
||||
.find(|view| {
|
||||
view.read(cx)
|
||||
.buffer
|
||||
.as_ref()
|
||||
.is_some_and(|buffer| buffer.entity_id() == buffer_id)
|
||||
})
|
||||
.and_then(|view| pane.index_for_item(&view))
|
||||
}
|
||||
|
||||
pub fn resolve_active_item_as_svg_editor(
|
||||
workspace: &Workspace,
|
||||
cx: &mut Context<Workspace>,
|
||||
) -> Option<Entity<Editor>> {
|
||||
workspace
|
||||
.active_item(cx)?
|
||||
.act_as::<Editor>(cx)
|
||||
.filter(|editor| Self::is_svg_file(&editor, cx))
|
||||
}
|
||||
|
||||
fn create_svg_view(
|
||||
mode: SvgPreviewMode,
|
||||
workspace: &mut Workspace,
|
||||
editor: Entity<Editor>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Workspace>,
|
||||
) -> Entity<SvgPreviewView> {
|
||||
let workspace_handle = workspace.weak_handle();
|
||||
SvgPreviewView::new(mode, editor, workspace_handle, window, cx)
|
||||
}
|
||||
|
||||
fn create_buffer_subscription(
|
||||
buffer: &Entity<Buffer>,
|
||||
window: &Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Subscription {
|
||||
cx.subscribe_in(
|
||||
buffer,
|
||||
window,
|
||||
move |this, _buffer, event: &BufferEvent, window, cx| match event {
|
||||
BufferEvent::Edited | BufferEvent::Saved => {
|
||||
this.render_image(window, cx);
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn is_svg_file(editor: &Entity<Editor>, cx: &App) -> bool {
|
||||
editor
|
||||
.read(cx)
|
||||
.buffer()
|
||||
.read(cx)
|
||||
.as_singleton()
|
||||
.and_then(|buffer| buffer.read(cx).file())
|
||||
.is_some_and(|file| {
|
||||
file.path()
|
||||
.extension()
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("svg"))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn register(workspace: &mut Workspace, _window: &mut Window, _cx: &mut Context<Workspace>) {
|
||||
workspace.register_action(move |workspace, _: &OpenPreview, window, cx| {
|
||||
if let Some(editor) = Self::resolve_active_item_as_svg_editor(workspace, cx)
|
||||
@@ -104,154 +281,6 @@ impl SvgPreviewView {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn find_existing_preview_item_idx(
|
||||
pane: &Pane,
|
||||
editor: &Entity<Editor>,
|
||||
cx: &App,
|
||||
) -> Option<usize> {
|
||||
let editor_path = Self::get_svg_path(editor.read(cx).buffer(), cx);
|
||||
pane.items_of_type::<SvgPreviewView>()
|
||||
.find(|view| {
|
||||
let view_read = view.read(cx);
|
||||
view_read.svg_path.is_some() && view_read.svg_path == editor_path
|
||||
})
|
||||
.and_then(|view| pane.index_for_item(&view))
|
||||
}
|
||||
|
||||
pub fn resolve_active_item_as_svg_editor(
|
||||
workspace: &Workspace,
|
||||
cx: &mut Context<Workspace>,
|
||||
) -> Option<Entity<Editor>> {
|
||||
let editor = workspace.active_item(cx)?.act_as::<Editor>(cx)?;
|
||||
|
||||
if Self::is_svg_file(&editor, cx) {
|
||||
Some(editor)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn create_svg_view(
|
||||
mode: SvgPreviewMode,
|
||||
workspace: &mut Workspace,
|
||||
editor: Entity<Editor>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Workspace>,
|
||||
) -> Entity<SvgPreviewView> {
|
||||
let workspace_handle = workspace.weak_handle();
|
||||
SvgPreviewView::new(mode, editor, workspace_handle, window, cx)
|
||||
}
|
||||
|
||||
pub fn new(
|
||||
mode: SvgPreviewMode,
|
||||
active_editor: Entity<Editor>,
|
||||
workspace_handle: WeakEntity<Workspace>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Workspace>,
|
||||
) -> Entity<Self> {
|
||||
cx.new(|cx| {
|
||||
let image_cache = RetainAllImageCache::new(cx);
|
||||
let buffer = active_editor.read(cx).buffer();
|
||||
let svg_path = Self::get_svg_path(buffer, cx);
|
||||
let subscription = Self::create_buffer_subscription(&buffer.clone(), window, cx);
|
||||
|
||||
// Subscribe to workspace active item changes to follow SVG files
|
||||
let workspace_subscription = if mode == SvgPreviewMode::Follow {
|
||||
workspace_handle.upgrade().map(|workspace_handle| {
|
||||
cx.subscribe_in(
|
||||
&workspace_handle,
|
||||
window,
|
||||
|this: &mut SvgPreviewView,
|
||||
workspace,
|
||||
event: &workspace::Event,
|
||||
window,
|
||||
cx| {
|
||||
if let workspace::Event::ActiveItemChanged = event {
|
||||
let workspace_read = workspace.read(cx);
|
||||
if let Some(active_item) = workspace_read.active_item(cx)
|
||||
&& let Some(editor) = active_item.downcast::<Editor>()
|
||||
&& Self::is_svg_file(&editor, cx)
|
||||
{
|
||||
let buffer = editor.read(cx).buffer();
|
||||
let new_path = Self::get_svg_path(&buffer, cx);
|
||||
if this.svg_path != new_path {
|
||||
this.svg_path = new_path;
|
||||
this._buffer_subscription =
|
||||
Self::create_buffer_subscription(
|
||||
&buffer.clone(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Self {
|
||||
focus_handle: cx.focus_handle(),
|
||||
svg_path,
|
||||
image_cache,
|
||||
_buffer_subscription: subscription,
|
||||
_workspace_subscription: workspace_subscription,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn create_buffer_subscription(
|
||||
active_buffer: &Entity<MultiBuffer>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Subscription {
|
||||
cx.subscribe_in(
|
||||
active_buffer,
|
||||
window,
|
||||
|this: &mut SvgPreviewView, buffer, event: &MultiBufferEvent, window, cx| {
|
||||
let potential_path_change = event == &MultiBufferEvent::FileHandleChanged;
|
||||
if event == &MultiBufferEvent::Saved || potential_path_change {
|
||||
// Remove cached image to force reload
|
||||
if let Some(svg_path) = &this.svg_path {
|
||||
let resource = Resource::Path(svg_path.clone().into());
|
||||
this.image_cache.update(cx, |cache, cx| {
|
||||
cache.remove(&resource, window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
if potential_path_change {
|
||||
this.svg_path = Self::get_svg_path(buffer, cx);
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn is_svg_file(editor: &Entity<Editor>, cx: &App) -> bool {
|
||||
let buffer = editor.read(cx).buffer().read(cx);
|
||||
if let Some(buffer) = buffer.as_singleton()
|
||||
&& let Some(file) = buffer.read(cx).file()
|
||||
{
|
||||
return file
|
||||
.path()
|
||||
.extension()
|
||||
.map(|ext| ext.eq_ignore_ascii_case("svg"))
|
||||
.unwrap_or(false);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn get_svg_path(buffer: &Entity<MultiBuffer>, cx: &App) -> Option<PathBuf> {
|
||||
let buffer = buffer.read(cx).as_singleton()?;
|
||||
let file = buffer.read(cx).file()?;
|
||||
let local_file = file.as_local()?;
|
||||
Some(local_file.abs_path(cx))
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for SvgPreviewView {
|
||||
@@ -265,20 +294,19 @@ impl Render for SvgPreviewView {
|
||||
.flex()
|
||||
.justify_center()
|
||||
.items_center()
|
||||
.child(if let Some(svg_path) = &self.svg_path {
|
||||
img(ImageSource::from(svg_path.clone()))
|
||||
.image_cache(&self.image_cache)
|
||||
.max_w_full()
|
||||
.max_h_full()
|
||||
.with_fallback(|| {
|
||||
div()
|
||||
.map(|this| match self.current_svg.clone() {
|
||||
Some(Ok(image)) => {
|
||||
this.child(img(image).max_w_full().max_h_full().with_fallback(|| {
|
||||
h_flex()
|
||||
.p_4()
|
||||
.child("Failed to load SVG file")
|
||||
.gap_2()
|
||||
.child(Icon::new(IconName::Warning))
|
||||
.child("Failed to load SVG image")
|
||||
.into_any_element()
|
||||
})
|
||||
.into_any_element()
|
||||
} else {
|
||||
div().p_4().child("No SVG file selected").into_any_element()
|
||||
}))
|
||||
}
|
||||
Some(Err(e)) => this.child(div().p_4().child(e).into_any_element()),
|
||||
None => this.child(div().p_4().child("No SVG file selected")),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -295,20 +323,19 @@ impl Item for SvgPreviewView {
|
||||
type Event = ();
|
||||
|
||||
fn tab_icon(&self, _window: &Window, cx: &App) -> Option<Icon> {
|
||||
// Use the same icon as SVG files in the file tree
|
||||
self.svg_path
|
||||
self.buffer
|
||||
.as_ref()
|
||||
.and_then(|svg_path| FileIcons::get_icon(svg_path, cx))
|
||||
.and_then(|buffer| buffer.read(cx).file())
|
||||
.and_then(|file| FileIcons::get_icon(file.path().as_std_path(), cx))
|
||||
.map(Icon::from_path)
|
||||
.or_else(|| Some(Icon::new(IconName::Image)))
|
||||
}
|
||||
|
||||
fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
|
||||
self.svg_path
|
||||
fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString {
|
||||
self.buffer
|
||||
.as_ref()
|
||||
.and_then(|svg_path| svg_path.file_name())
|
||||
.map(|name| name.to_string_lossy())
|
||||
.map(|name| format!("Preview {}", name).into())
|
||||
.and_then(|svg_path| svg_path.read(cx).file())
|
||||
.map(|name| format!("Preview {}", name.file_name(cx)).into())
|
||||
.unwrap_or_else(|| "SVG Preview".into())
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ pub struct ContextMenuEntry {
|
||||
label: SharedString,
|
||||
icon: Option<IconName>,
|
||||
custom_icon_path: Option<SharedString>,
|
||||
custom_icon_svg: Option<SharedString>,
|
||||
icon_position: IconPosition,
|
||||
icon_size: IconSize,
|
||||
icon_color: Option<Color>,
|
||||
@@ -68,6 +69,7 @@ impl ContextMenuEntry {
|
||||
label: label.into(),
|
||||
icon: None,
|
||||
custom_icon_path: None,
|
||||
custom_icon_svg: None,
|
||||
icon_position: IconPosition::Start,
|
||||
icon_size: IconSize::Small,
|
||||
icon_color: None,
|
||||
@@ -94,7 +96,15 @@ impl ContextMenuEntry {
|
||||
|
||||
pub fn custom_icon_path(mut self, path: impl Into<SharedString>) -> Self {
|
||||
self.custom_icon_path = Some(path.into());
|
||||
self.icon = None; // Clear IconName if custom path is set
|
||||
self.custom_icon_svg = None; // Clear other icon sources if custom path is set
|
||||
self.icon = None;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn custom_icon_svg(mut self, svg: impl Into<SharedString>) -> Self {
|
||||
self.custom_icon_svg = Some(svg.into());
|
||||
self.custom_icon_path = None; // Clear other icon sources if custom path is set
|
||||
self.icon = None;
|
||||
self
|
||||
}
|
||||
|
||||
@@ -396,6 +406,7 @@ impl ContextMenu {
|
||||
handler: Rc::new(move |_, window, cx| handler(window, cx)),
|
||||
icon: None,
|
||||
custom_icon_path: None,
|
||||
custom_icon_svg: None,
|
||||
icon_position: IconPosition::End,
|
||||
icon_size: IconSize::Small,
|
||||
icon_color: None,
|
||||
@@ -425,6 +436,7 @@ impl ContextMenu {
|
||||
handler: Rc::new(move |_, window, cx| handler(window, cx)),
|
||||
icon: None,
|
||||
custom_icon_path: None,
|
||||
custom_icon_svg: None,
|
||||
icon_position: IconPosition::End,
|
||||
icon_size: IconSize::Small,
|
||||
icon_color: None,
|
||||
@@ -454,6 +466,7 @@ impl ContextMenu {
|
||||
handler: Rc::new(move |_, window, cx| handler(window, cx)),
|
||||
icon: None,
|
||||
custom_icon_path: None,
|
||||
custom_icon_svg: None,
|
||||
icon_position: IconPosition::End,
|
||||
icon_size: IconSize::Small,
|
||||
icon_color: None,
|
||||
@@ -482,6 +495,7 @@ impl ContextMenu {
|
||||
handler: Rc::new(move |_, window, cx| handler(window, cx)),
|
||||
icon: None,
|
||||
custom_icon_path: None,
|
||||
custom_icon_svg: None,
|
||||
icon_position: position,
|
||||
icon_size: IconSize::Small,
|
||||
icon_color: None,
|
||||
@@ -541,6 +555,7 @@ impl ContextMenu {
|
||||
}),
|
||||
icon: None,
|
||||
custom_icon_path: None,
|
||||
custom_icon_svg: None,
|
||||
icon_position: IconPosition::End,
|
||||
icon_size: IconSize::Small,
|
||||
icon_color: None,
|
||||
@@ -572,6 +587,7 @@ impl ContextMenu {
|
||||
}),
|
||||
icon: None,
|
||||
custom_icon_path: None,
|
||||
custom_icon_svg: None,
|
||||
icon_size: IconSize::Small,
|
||||
icon_position: IconPosition::End,
|
||||
icon_color: None,
|
||||
@@ -593,6 +609,7 @@ impl ContextMenu {
|
||||
handler: Rc::new(move |_, window, cx| window.dispatch_action(action.boxed_clone(), cx)),
|
||||
icon: Some(IconName::ArrowUpRight),
|
||||
custom_icon_path: None,
|
||||
custom_icon_svg: None,
|
||||
icon_size: IconSize::XSmall,
|
||||
icon_position: IconPosition::End,
|
||||
icon_color: None,
|
||||
@@ -913,6 +930,7 @@ impl ContextMenu {
|
||||
handler,
|
||||
icon,
|
||||
custom_icon_path,
|
||||
custom_icon_svg,
|
||||
icon_position,
|
||||
icon_size,
|
||||
icon_color,
|
||||
@@ -965,6 +983,28 @@ impl ContextMenu {
|
||||
)
|
||||
})
|
||||
.into_any_element()
|
||||
} else if let Some(custom_icon_svg) = custom_icon_svg {
|
||||
h_flex()
|
||||
.gap_1p5()
|
||||
.when(
|
||||
*icon_position == IconPosition::Start && toggle.is_none(),
|
||||
|flex| {
|
||||
flex.child(
|
||||
Icon::from_external_svg(custom_icon_svg.clone())
|
||||
.size(*icon_size)
|
||||
.color(icon_color),
|
||||
)
|
||||
},
|
||||
)
|
||||
.child(Label::new(label.clone()).color(label_color).truncate())
|
||||
.when(*icon_position == IconPosition::End, |flex| {
|
||||
flex.child(
|
||||
Icon::from_external_svg(custom_icon_svg.clone())
|
||||
.size(*icon_size)
|
||||
.color(icon_color),
|
||||
)
|
||||
})
|
||||
.into_any_element()
|
||||
} else if let Some(icon_name) = icon {
|
||||
h_flex()
|
||||
.gap_1p5()
|
||||
|
||||
@@ -115,24 +115,24 @@ impl From<IconName> for Icon {
|
||||
/// The source of an icon.
|
||||
enum IconSource {
|
||||
/// An SVG embedded in the Zed binary.
|
||||
Svg(SharedString),
|
||||
Embedded(SharedString),
|
||||
/// An image file located at the specified path.
|
||||
///
|
||||
/// Currently our SVG renderer is missing support for the following features:
|
||||
/// 1. Loading SVGs from external files.
|
||||
/// 2. Rendering polychrome SVGs.
|
||||
/// Currently our SVG renderer is missing support for rendering polychrome SVGs.
|
||||
///
|
||||
/// In order to support icon themes, we render the icons as images instead.
|
||||
Image(Arc<Path>),
|
||||
External(Arc<Path>),
|
||||
/// An SVG not embedded in the Zed binary.
|
||||
ExternalSvg(SharedString),
|
||||
}
|
||||
|
||||
impl IconSource {
|
||||
fn from_path(path: impl Into<SharedString>) -> Self {
|
||||
let path = path.into();
|
||||
if path.starts_with("icons/") {
|
||||
Self::Svg(path)
|
||||
Self::Embedded(path)
|
||||
} else {
|
||||
Self::Image(Arc::from(PathBuf::from(path.as_ref())))
|
||||
Self::External(Arc::from(PathBuf::from(path.as_ref())))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -148,7 +148,7 @@ pub struct Icon {
|
||||
impl Icon {
|
||||
pub fn new(icon: IconName) -> Self {
|
||||
Self {
|
||||
source: IconSource::Svg(icon.path().into()),
|
||||
source: IconSource::Embedded(icon.path().into()),
|
||||
color: Color::default(),
|
||||
size: IconSize::default().rems(),
|
||||
transformation: Transformation::default(),
|
||||
@@ -164,6 +164,15 @@ impl Icon {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_external_svg(svg: SharedString) -> Self {
|
||||
Self {
|
||||
source: IconSource::ExternalSvg(svg),
|
||||
color: Color::default(),
|
||||
size: IconSize::default().rems(),
|
||||
transformation: Transformation::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn color(mut self, color: Color) -> Self {
|
||||
self.color = color;
|
||||
self
|
||||
@@ -193,14 +202,21 @@ impl Transformable for Icon {
|
||||
impl RenderOnce for Icon {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
match self.source {
|
||||
IconSource::Svg(path) => svg()
|
||||
IconSource::Embedded(path) => svg()
|
||||
.with_transformation(self.transformation)
|
||||
.size(self.size)
|
||||
.flex_none()
|
||||
.path(path)
|
||||
.text_color(self.color.color(cx))
|
||||
.into_any_element(),
|
||||
IconSource::Image(path) => img(path)
|
||||
IconSource::ExternalSvg(path) => svg()
|
||||
.external_path(path)
|
||||
.with_transformation(self.transformation)
|
||||
.size(self.size)
|
||||
.flex_none()
|
||||
.text_color(self.color.color(cx))
|
||||
.into_any_element(),
|
||||
IconSource::External(path) => img(path)
|
||||
.size(self.size)
|
||||
.flex_none()
|
||||
.text_color(self.color.color(cx))
|
||||
|
||||
@@ -31,7 +31,7 @@ pub trait NumberFieldType: Display + Copy + Clone + Sized + PartialOrd + FromStr
|
||||
fn saturating_sub(self, rhs: Self) -> Self;
|
||||
}
|
||||
|
||||
macro_rules! impl_newtype_numeric_stepper {
|
||||
macro_rules! impl_newtype_numeric_stepper_float {
|
||||
($type:ident, $default:expr, $large:expr, $small:expr, $min:expr, $max:expr) => {
|
||||
impl NumberFieldType for $type {
|
||||
fn default_step() -> Self {
|
||||
@@ -65,13 +65,47 @@ macro_rules! impl_newtype_numeric_stepper {
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! impl_newtype_numeric_stepper_int {
|
||||
($type:ident, $default:expr, $large:expr, $small:expr, $min:expr, $max:expr) => {
|
||||
impl NumberFieldType for $type {
|
||||
fn default_step() -> Self {
|
||||
$default.into()
|
||||
}
|
||||
|
||||
fn large_step() -> Self {
|
||||
$large.into()
|
||||
}
|
||||
|
||||
fn small_step() -> Self {
|
||||
$small.into()
|
||||
}
|
||||
|
||||
fn min_value() -> Self {
|
||||
$min.into()
|
||||
}
|
||||
|
||||
fn max_value() -> Self {
|
||||
$max.into()
|
||||
}
|
||||
|
||||
fn saturating_add(self, rhs: Self) -> Self {
|
||||
$type(self.0.saturating_add(rhs.0).min(Self::max_value().0))
|
||||
}
|
||||
|
||||
fn saturating_sub(self, rhs: Self) -> Self {
|
||||
$type(self.0.saturating_sub(rhs.0).max(Self::min_value().0))
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[rustfmt::skip]
|
||||
impl_newtype_numeric_stepper!(FontWeight, 50., 100., 10., FontWeight::THIN, FontWeight::BLACK);
|
||||
impl_newtype_numeric_stepper!(CodeFade, 0.1, 0.2, 0.05, 0.0, 0.9);
|
||||
impl_newtype_numeric_stepper!(InactiveOpacity, 0.1, 0.2, 0.05, 0.0, 1.0);
|
||||
impl_newtype_numeric_stepper!(MinimumContrast, 1., 10., 0.5, 0.0, 106.0);
|
||||
impl_newtype_numeric_stepper!(DelayMs, 100, 500, 10, 0, 2000);
|
||||
impl_newtype_numeric_stepper!(
|
||||
impl_newtype_numeric_stepper_float!(FontWeight, 50., 100., 10., FontWeight::THIN, FontWeight::BLACK);
|
||||
impl_newtype_numeric_stepper_float!(CodeFade, 0.1, 0.2, 0.05, 0.0, 0.9);
|
||||
impl_newtype_numeric_stepper_float!(InactiveOpacity, 0.1, 0.2, 0.05, 0.0, 1.0);
|
||||
impl_newtype_numeric_stepper_float!(MinimumContrast, 1., 10., 0.5, 0.0, 106.0);
|
||||
impl_newtype_numeric_stepper_int!(DelayMs, 100, 500, 10, 0, 2000);
|
||||
impl_newtype_numeric_stepper_float!(
|
||||
CenteredPaddingSettings,
|
||||
0.05,
|
||||
0.2,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
description = "The fast, collaborative code editor."
|
||||
edition.workspace = true
|
||||
name = "zed"
|
||||
version = "0.211.4"
|
||||
version = "0.211.5"
|
||||
publish.workspace = true
|
||||
license = "GPL-3.0-or-later"
|
||||
authors = ["Zed Team <hi@zed.dev>"]
|
||||
@@ -73,6 +73,7 @@ gpui = { workspace = true, features = [
|
||||
"windows-manifest",
|
||||
] }
|
||||
gpui_tokio.workspace = true
|
||||
rayon.workspace = true
|
||||
|
||||
edit_prediction_button.workspace = true
|
||||
http_client.workspace = true
|
||||
|
||||
@@ -1 +1 @@
|
||||
preview
|
||||
stable
|
||||
@@ -257,6 +257,13 @@ pub fn main() {
|
||||
return;
|
||||
}
|
||||
|
||||
rayon::ThreadPoolBuilder::new()
|
||||
.num_threads(4)
|
||||
.stack_size(10 * 1024 * 1024)
|
||||
.thread_name(|ix| format!("RayonWorker{}", ix))
|
||||
.build_global()
|
||||
.unwrap();
|
||||
|
||||
log::info!(
|
||||
"========== starting zed version {}, sha {} ==========",
|
||||
app_version,
|
||||
|
||||
@@ -41,6 +41,9 @@ const DEFAULT_FILTERS: &[(&str, log::LevelFilter)] = &[
|
||||
("blade_graphics", log::LevelFilter::Warn),
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "windows"))]
|
||||
("naga::back::spv::writer", log::LevelFilter::Warn),
|
||||
// usvg prints a lot of warnings on rendering an SVG with partial errors, which
|
||||
// can happen a lot with the SVG preview
|
||||
("usvg::parser::style", log::LevelFilter::Error),
|
||||
];
|
||||
|
||||
pub fn init_env_filter(filter: env_config::EnvFilter) {
|
||||
|
||||
@@ -12,9 +12,8 @@ CHANNEL="$3"
|
||||
|
||||
SHORT_SHA="${COMMIT_SHA:0:8}"
|
||||
NEW_BRANCH="cherry-pick-${BRANCH_NAME}-${SHORT_SHA}"
|
||||
git fetch origin
|
||||
git checkout "$BRANCH_NAME"
|
||||
git checkout -B "$NEW_BRANCH"
|
||||
git fetch --depth 2 origin +${COMMIT_SHA} ${BRANCH_NAME}
|
||||
git checkout --force "origin/$BRANCH_NAME" -B "$NEW_BRANCH"
|
||||
|
||||
git cherry-pick "$COMMIT_SHA"
|
||||
|
||||
|
||||
@@ -17,5 +17,6 @@ clap = { workspace = true, features = ["derive"] }
|
||||
toml.workspace = true
|
||||
indoc.workspace = true
|
||||
indexmap.workspace = true
|
||||
serde.workspace = true
|
||||
toml_edit.workspace = true
|
||||
gh-workflow.workspace = true
|
||||
|
||||
@@ -3,6 +3,7 @@ use clap::Parser;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
mod after_release;
|
||||
mod cherry_pick;
|
||||
mod compare_perf;
|
||||
mod danger;
|
||||
@@ -33,6 +34,7 @@ pub fn run_workflows(_: GenerateWorkflowArgs) -> Result<()> {
|
||||
("compare_perf.yml", compare_perf::compare_perf()),
|
||||
("run_unit_evals.yml", run_agent_evals::run_unit_evals()),
|
||||
("run_agent_evals.yml", run_agent_evals::run_agent_evals()),
|
||||
("after_release.yml", after_release::after_release()),
|
||||
];
|
||||
fs::create_dir_all(dir)
|
||||
.with_context(|| format!("Failed to create directory: {}", dir.display()))?;
|
||||
|
||||
123
tooling/xtask/src/tasks/workflows/after_release.rs
Normal file
123
tooling/xtask/src/tasks/workflows/after_release.rs
Normal file
@@ -0,0 +1,123 @@
|
||||
use gh_workflow::*;
|
||||
|
||||
use crate::tasks::workflows::{
|
||||
runners,
|
||||
steps::{NamedJob, dependant_job, named},
|
||||
vars::{self, StepOutput},
|
||||
};
|
||||
|
||||
pub fn after_release() -> Workflow {
|
||||
let refresh_zed_dev = rebuild_releases_page();
|
||||
let post_to_discord = post_to_discord(&[&refresh_zed_dev]);
|
||||
let publish_winget = publish_winget();
|
||||
|
||||
named::workflow()
|
||||
.on(Event::default().release(Release::default().types(vec![ReleaseType::Published])))
|
||||
.add_job(refresh_zed_dev.name, refresh_zed_dev.job)
|
||||
.add_job(post_to_discord.name, post_to_discord.job)
|
||||
.add_job(publish_winget.name, publish_winget.job)
|
||||
}
|
||||
|
||||
fn rebuild_releases_page() -> NamedJob {
|
||||
named::job(
|
||||
Job::default()
|
||||
.runs_on(runners::LINUX_SMALL)
|
||||
.cond(Expression::new(
|
||||
"github.repository_owner == 'zed-industries'",
|
||||
))
|
||||
.add_step(named::bash(
|
||||
"curl https://zed.dev/api/revalidate-releases -H \"Authorization: Bearer ${RELEASE_NOTES_API_TOKEN}\"",
|
||||
).add_env(("RELEASE_NOTES_API_TOKEN", vars::RELEASE_NOTES_API_TOKEN))),
|
||||
)
|
||||
}
|
||||
|
||||
fn post_to_discord(deps: &[&NamedJob]) -> NamedJob {
|
||||
fn get_release_url() -> Step<Run> {
|
||||
named::bash(indoc::indoc! {r#"
|
||||
if [ "${{ github.event.release.prerelease }}" == "true" ]; then
|
||||
URL="https://zed.dev/releases/preview"
|
||||
else
|
||||
URL="https://zed.dev/releases/stable"
|
||||
fi
|
||||
|
||||
echo "URL=$URL" >> "$GITHUB_OUTPUT"
|
||||
"#})
|
||||
.id("get-release-url")
|
||||
}
|
||||
|
||||
fn get_content() -> Step<Use> {
|
||||
named::uses(
|
||||
"2428392",
|
||||
"gh-truncate-string-action",
|
||||
"b3ff790d21cf42af3ca7579146eedb93c8fb0757", // v1.4.1
|
||||
)
|
||||
.id("get-content")
|
||||
.add_with((
|
||||
"stringToTruncate",
|
||||
indoc::indoc! {r#"
|
||||
📣 Zed [${{ github.event.release.tag_name }}](<${{ steps.get-release-url.outputs.URL }}>) was just released!
|
||||
|
||||
${{ github.event.release.body }}
|
||||
"#},
|
||||
))
|
||||
.add_with(("maxLength", 2000))
|
||||
.add_with(("truncationSymbol", "..."))
|
||||
}
|
||||
|
||||
fn discord_webhook_action() -> Step<Use> {
|
||||
named::uses(
|
||||
"tsickert",
|
||||
"discord-webhook",
|
||||
"c840d45a03a323fbc3f7507ac7769dbd91bfb164", // v5.3.0
|
||||
)
|
||||
.add_with(("webhook-url", vars::DISCORD_WEBHOOK_RELEASE_NOTES))
|
||||
.add_with(("content", "${{ steps.get-content.outputs.string }}"))
|
||||
}
|
||||
let job = dependant_job(deps)
|
||||
.runs_on(runners::LINUX_SMALL)
|
||||
.cond(Expression::new(
|
||||
"github.repository_owner == 'zed-industries'",
|
||||
))
|
||||
.add_step(get_release_url())
|
||||
.add_step(get_content())
|
||||
.add_step(discord_webhook_action());
|
||||
named::job(job)
|
||||
}
|
||||
|
||||
fn publish_winget() -> NamedJob {
|
||||
fn set_package_name() -> (Step<Run>, StepOutput) {
|
||||
let step = named::bash(indoc::indoc! {r#"
|
||||
if [ "${{ github.event.release.prerelease }}" == "true" ]; then
|
||||
PACKAGE_NAME=ZedIndustries.Zed.Preview
|
||||
else
|
||||
PACKAGE_NAME=ZedIndustries.Zed
|
||||
fi
|
||||
|
||||
echo "PACKAGE_NAME=$PACKAGE_NAME" >> "$GITHUB_OUTPUT"
|
||||
"#})
|
||||
.id("set-package-name");
|
||||
|
||||
let output = StepOutput::new(&step, "PACKAGE_NAME");
|
||||
(step, output)
|
||||
}
|
||||
|
||||
fn winget_releaser(package_name: &StepOutput) -> Step<Use> {
|
||||
named::uses(
|
||||
"vedantmgoyal9",
|
||||
"winget-releaser",
|
||||
"19e706d4c9121098010096f9c495a70a7518b30f", // v2
|
||||
)
|
||||
.add_with(("identifier", package_name.to_string()))
|
||||
.add_with(("max-versions-to-keep", 5))
|
||||
.add_with(("token", vars::WINGET_TOKEN))
|
||||
}
|
||||
|
||||
let (set_package_name, package_name) = set_package_name();
|
||||
|
||||
named::job(
|
||||
Job::default()
|
||||
.runs_on(runners::LINUX_SMALL)
|
||||
.add_step(set_package_name)
|
||||
.add_step(winget_releaser(&package_name)),
|
||||
)
|
||||
}
|
||||
@@ -36,6 +36,9 @@ secret!(ZED_SENTRY_MINIDUMP_ENDPOINT);
|
||||
secret!(SLACK_APP_ZED_UNIT_EVALS_BOT_TOKEN);
|
||||
secret!(ZED_ZIPPY_APP_ID);
|
||||
secret!(ZED_ZIPPY_APP_PRIVATE_KEY);
|
||||
secret!(DISCORD_WEBHOOK_RELEASE_NOTES);
|
||||
secret!(WINGET_TOKEN);
|
||||
secret!(RELEASE_NOTES_API_TOKEN);
|
||||
|
||||
// todo(ci) make these secrets too...
|
||||
var!(AZURE_SIGNING_ACCOUNT_NAME);
|
||||
@@ -136,6 +139,15 @@ impl StepOutput {
|
||||
}
|
||||
}
|
||||
|
||||
impl serde::Serialize for StepOutput {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
serializer.serialize_str(&self.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for StepOutput {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "${{{{ steps.{}.outputs.{} }}}}", self.step_id, self.name)
|
||||
@@ -173,6 +185,15 @@ impl std::fmt::Display for Input {
|
||||
}
|
||||
}
|
||||
|
||||
impl serde::Serialize for Input {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
serializer.serialize_str(&self.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub mod assets {
|
||||
// NOTE: these asset names also exist in the zed.dev codebase.
|
||||
pub const MAC_AARCH64: &str = "Zed-aarch64.dmg";
|
||||
|
||||
Reference in New Issue
Block a user