Compare commits

...

18 Commits

Author SHA1 Message Date
John D. Swanson
548df67759 Replace unwrap with expect in timeout tests for better error messages
- Replace .unwrap() with .expect() for set_user_settings calls
- Replace .unwrap() with .expect() for URL parsing in test setup
- Provides descriptive panic messages if test setup fails
- Follows Zed test patterns seen in project_tests.rs
- All tests still passing
2025-12-18 19:31:15 -05:00
John D. Swanson
a78647f8ae Fix timeout test implementations and add comprehensive test coverage
- Fix URL construction in tests (use url::Url::parse instead of .into())
- Fix test assertions to properly call create_context_server
- Add test_context_server_global_timeout: verifies global timeout setting
- Add test_context_server_per_server_timeout_override: verifies per-server override
- Add test_context_server_stdio_timeout: verifies stdio server timeout handling
- All three tests now passing successfully
2025-12-18 18:26:34 -05:00
John D. Swanson
4c2fbbadde Add global and per-server timeout settings for MCP context servers
- Add global context_server_timeout setting (default 60000ms)
- Add per-server timeout field to HTTP context server configuration
- Implement timeout precedence: per-server > global > 60s default
- Both Stdio and HTTP servers now support timeout configuration
- Update ContextServer to pass timeout to HTTP transport
- Document timeout settings with examples in default.json
2025-12-18 16:24:11 -05:00
Julia Ryan
d2bbfbb3bf lsp: Broadcast our capability for MessageActionItems (#45047)
Closes #37902

Release Notes:

- Enable LSP Message action items for more language servers. These are interactive prompts, often for things like downloading build inputs for a project.
2025-12-18 11:09:40 -08:00
Peter Tripp
413f4ea49c Redact environment variables from language server spawn errors (#44783)
Redact environment variables from zed logs when lsp fails to spawn.

Release Notes:

- N/A
2025-12-18 21:05:14 +02:00
Marshall Bowers
1b6d588413 danger: Deny conventional commits in PR titles (#45283)
This PR upgrades `danger-plugin-pr-hygiene` to v0.7.0 so that we can
have Danger deny conventional commits in PR titles.

Release Notes:

- N/A
2025-12-18 18:42:28 +00:00
Gaauwe Rombouts
334ca21857 Truncate code actions with a long label and show full label aside (#45268)
Closes #43355

Fixes the issue were code actions with long labels would get cut off
without being able to see the full description. We now properly truncate
those labels with an ellipsis and show the full description in an aside.

Release Notes:

- Added ellipsis to truncated code actions and an aside showing the full
action description.
2025-12-18 18:05:53 +01:00
Emmanuel Amoah
f58278aaf4 glossary: Fix grammar and typo (#45267)
Fixes grammar and a typo in `Picker` description.

Release Notes:

- N/A
2025-12-18 17:03:46 +00:00
Leo
e10b9b70ef git: Add global git integration enable/disable setting (#43326)
Closes #13304

Release Notes:

- Add global `git status` and `git diff` on/off in one place instead of
control everywhere

We can first review to ensure this change meets both `Zed` and user
requirements, as well as code rules. Currently, we only support
user-level settings. We can wait for this PR:
https://github.com/zed-industries/zed/pull/43173 to be merged, then
modify it to support both user and project levels.
2025-12-18 11:45:26 -05:00
Marco Mihai Condrache
098adf3bdd gpui: Enable direct-to-display optimization for metal (#44334)
When profiling Zed with Instruments, a warning appears indicating that
surfaces cannot be pushed directly to the display as they are
non-opaque. This happens because the metal layer is currently marked as
non-opaque by default, even though the window itself is not transparent.

<img width="590" height="55" alt="image"
src="https://github.com/user-attachments/assets/2647733e-c75b-4aec-aa19-e8b2ffd6194b"
/>

Metal on macOS can bypass compositing and present frames directly to the
display when several conditions are met. One of those conditions is that
the backing layer must be declared opaque. Apple’s documentation notes
that marking layers as opaque allows the system to avoid unnecessary
compositing work, reducing GPU load and improving frame pacing

Ref:
https://developer.apple.com/documentation/metal/managing-your-game-window-for-metal-in-macos

This PR updates the Metal renderer to mark the layer as opaque whenever
the window does not use transparency. This makes Zed eligible for
macOS’s direct-to-display optimization in scenarios where the system can
apply it.

Release Notes:

- gpui: Mark metal layers opaque for non-transparent windows to allow
direct-to-display when supported

---------

Signed-off-by: Marco Mihai Condrache <52580954+marcocondrache@users.noreply.github.com>
2025-12-18 11:29:25 -05:00
Jakub Konka
a85c508f69 Fix self-referential symbolic link (#45265)
Release Notes:

- N/A
2025-12-18 17:26:20 +01:00
tidely
2a713c546b gpui: Small tab group performance improvements (#41885)
Closes #ISSUE

Removes a few eager container clones and iterations.

Added a todo to `get_prev_tab_group_window` and
`get_next_tab_group_window`. They seem to use `HashMap::keys()` for
choosing the previous tab group, however `.keys()` returns an arbitrary
order, so I'm not sure if previous actually means anything here. Conrad
seems to have worked on this part previously, maybe he has some
insights. That can possibly be a follow-up PR, but I'd be willing to
work on it here as well since the other changes are so simple.

Release Notes:

- N/A
2025-12-18 11:24:38 -05:00
Bennet Bo Fenner
f937c1931f rules_library: Only store built-in prompts when they are customized (#45112)
Follow up to #45004

Release Notes:

- N/A
2025-12-18 17:21:41 +01:00
Danilo Leal
7a62f01ea5 agent_ui: Use display name for the message editor placeholder (#45264)
Follow up to a regression that happened when we introduced agent servers
that made everywhere displaying agent names use the extension name
instead of the display name. This has been since fixed in other places
and this PR now updates the agent panel's message editor, too:

| Before | After |
|--------|--------|
| <img width="1154" height="254" alt="Screenshot 2025-12-18 at 12  54
2@2x"
src="https://github.com/user-attachments/assets/5f3de9f9-4e11-42f6-90c2-56fc8cdff32e"
/> | <img width="1154" height="254" alt="Screenshot 2025-12-18 at 12 
54@2x"
src="https://github.com/user-attachments/assets/46ed5c45-7e1d-4cc6-b219-b6cc19206d1b"
/> |

Release Notes:

- N/A
2025-12-18 13:08:46 -03:00
Sean Hagstrom
2d071b0cb6 editor: Fix git-hunk toggling for adjacent hunks (#43187)
Closes #42934 

Release Notes:

- Fix toggling adjacent git-diff hunks based on the reported behaviour
in #42934

---------

Co-authored-by: Jakub Konka <kubkon@jakubkonka.com>
2025-12-18 16:45:55 +01:00
Alvaro Parker
bd2b0de231 gpui: Add modal dialog window kind (#40291)
Closes #ISSUE

A [modal dialog](https://en.wikipedia.org/wiki/Modal_window) window is a
window that demands the user's immediate attention and blocks
interaction with other parts of the application until it's closed.

- On Windows this is done by disabling the parent window when the dialog
window is created and re-enabling the parent window when closed.
- On Wayland this is done using the
[`XdgDialog`](https://wayland.app/protocols/xdg-dialog-v1) protocol,
which hints to the compositor that the dialog should be modal. While
compositors like GNOME and KDE block parent interaction automatically,
the XDG specification does not guarantee this behavior, compositors may
deliver events to the parent window unfiltered. Since the specification
explicitly requires clients to implement event filtering logic
themselves, this PR implements client-side blocking in GPUI to ensure
consistent modal behavior across all Wayland compositors, including
those like Hyprland that don't block parent interaction.
- On X11 this is done by enabling the application window property
[`_NET_WM_STATE_MODAL`](https://specifications.freedesktop.org/wm/latest/ar01s05.html#id-1.6.8)
state.

I'm unable to implement this on MacOS as I lack the experience and the
hardware to test it. If anyone is interested on implementing this let me
know.

|Window|Linux (wayland)| Linux (x11) |MacOS|
|-|-|-|-|
|<video
src="https://github.com/user-attachments/assets/bfd0733a-445d-4b63-ac6b-ebe098a7dc74"></video>|<video
src="https://github.com/user-attachments/assets/024cd6ec-ff81-4250-a5be-5d207a023f8c"></video>|
N/A | <video
src="https://github.com/user-attachments/assets/656e60a5-26b2-4ee2-8368-1fbbe872453c"></video>|

TODO:

- [x] Block parent interaction client-side on X11

Release Notes:

- Added modal dialog window kind on GPUI

---------

Co-authored-by: Jason Lee <huacnlee@gmail.com>
Co-authored-by: Anthony Eid <anthony@zed.dev>
Co-authored-by: Anthony Eid <hello@anthonyeid.me>
2025-12-18 16:45:06 +01:00
Bennet Bo Fenner
886de8f54b agent_ui: Improve UX when pasting code into message editor (#45254)
Follow up to #42982

Release Notes:

- agent: Allow pasting code without formatting via ctrl/cmd-shift-v.
- agent: Fixed an issue where pasting a single line of code would always
insert an @mention
2025-12-18 16:38:47 +01:00
Ben Brandt
7a783a91cc acp: Update to agent-client-protocol rust sdk v0.9.2 (#45255)
Release Notes:

- N/A
2025-12-18 15:01:20 +00:00
52 changed files with 1640 additions and 486 deletions

34
Cargo.lock generated
View File

@@ -226,9 +226,9 @@ dependencies = [
[[package]]
name = "agent-client-protocol"
version = "0.9.0"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2ffe7d502c1e451aafc5aff655000f84d09c9af681354ac0012527009b1af13"
checksum = "d3e527d7dfe0f334313d42d1d9318f0a79665f6f21c440d0798f230a77a7ed6c"
dependencies = [
"agent-client-protocol-schema",
"anyhow",
@@ -243,9 +243,9 @@ dependencies = [
[[package]]
name = "agent-client-protocol-schema"
version = "0.10.0"
version = "0.10.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8af81cc2d5c3f9c04f73db452efd058333735ba9d51c2cf7ef33c9fee038e7e6"
checksum = "6903a00e8ac822f9bacac59a1932754d7387c72ebb7c9c7439ad021505591da4"
dependencies = [
"anyhow",
"derive_more 2.0.1",
@@ -793,7 +793,7 @@ dependencies = [
"url",
"wayland-backend",
"wayland-client",
"wayland-protocols 0.32.9",
"wayland-protocols",
"zbus",
]
@@ -7370,7 +7370,7 @@ dependencies = [
"wayland-backend",
"wayland-client",
"wayland-cursor",
"wayland-protocols 0.31.2",
"wayland-protocols",
"wayland-protocols-plasma",
"wayland-protocols-wlr",
"windows 0.61.3",
@@ -12648,6 +12648,8 @@ dependencies = [
"paths",
"rope",
"serde",
"strum 0.27.2",
"tempfile",
"text",
"util",
"uuid",
@@ -18927,18 +18929,6 @@ dependencies = [
"xcursor",
]
[[package]]
name = "wayland-protocols"
version = "0.31.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f81f365b8b4a97f422ac0e8737c438024b5951734506b0e1d775c73030561f4"
dependencies = [
"bitflags 2.9.4",
"wayland-backend",
"wayland-client",
"wayland-scanner",
]
[[package]]
name = "wayland-protocols"
version = "0.32.9"
@@ -18953,14 +18943,14 @@ dependencies = [
[[package]]
name = "wayland-protocols-plasma"
version = "0.2.0"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23803551115ff9ea9bce586860c5c5a971e360825a0309264102a9495a5ff479"
checksum = "a07a14257c077ab3279987c4f8bb987851bf57081b93710381daea94f2c2c032"
dependencies = [
"bitflags 2.9.4",
"wayland-backend",
"wayland-client",
"wayland-protocols 0.31.2",
"wayland-protocols",
"wayland-scanner",
]
@@ -18973,7 +18963,7 @@ dependencies = [
"bitflags 2.9.4",
"wayland-backend",
"wayland-client",
"wayland-protocols 0.32.9",
"wayland-protocols",
"wayland-scanner",
]

View File

@@ -438,7 +438,7 @@ ztracing_macro = { path = "crates/ztracing_macro" }
# External crates
#
agent-client-protocol = { version = "=0.9.0", features = ["unstable"] }
agent-client-protocol = { version = "=0.9.2", features = ["unstable"] }
aho-corasick = "1.1"
alacritty_terminal = "0.25.1-rc1"
any_vec = "0.14"

View File

@@ -227,6 +227,7 @@
"ctrl-g": "search::SelectNextMatch",
"ctrl-shift-g": "search::SelectPreviousMatch",
"ctrl-k l": "agent::OpenRulesLibrary",
"ctrl-shift-v": "agent::PasteRaw",
},
},
{
@@ -293,6 +294,7 @@
"shift-ctrl-r": "agent::OpenAgentDiff",
"ctrl-shift-y": "agent::KeepAll",
"ctrl-shift-n": "agent::RejectAll",
"ctrl-shift-v": "agent::PasteRaw",
},
},
{
@@ -304,6 +306,7 @@
"shift-ctrl-r": "agent::OpenAgentDiff",
"ctrl-shift-y": "agent::KeepAll",
"ctrl-shift-n": "agent::RejectAll",
"ctrl-shift-v": "agent::PasteRaw",
},
},
{

View File

@@ -267,6 +267,7 @@
"cmd-shift-g": "search::SelectPreviousMatch",
"cmd-k l": "agent::OpenRulesLibrary",
"alt-tab": "agent::CycleFavoriteModels",
"cmd-shift-v": "agent::PasteRaw",
},
},
{
@@ -335,6 +336,7 @@
"shift-ctrl-r": "agent::OpenAgentDiff",
"cmd-shift-y": "agent::KeepAll",
"cmd-shift-n": "agent::RejectAll",
"cmd-shift-v": "agent::PasteRaw",
},
},
{
@@ -347,6 +349,7 @@
"shift-ctrl-r": "agent::OpenAgentDiff",
"cmd-shift-y": "agent::KeepAll",
"cmd-shift-n": "agent::RejectAll",
"cmd-shift-v": "agent::PasteRaw",
},
},
{

View File

@@ -227,6 +227,7 @@
"ctrl-g": "search::SelectNextMatch",
"ctrl-shift-g": "search::SelectPreviousMatch",
"ctrl-k l": "agent::OpenRulesLibrary",
"ctrl-shift-v": "agent::PasteRaw",
},
},
{
@@ -296,6 +297,7 @@
"ctrl-shift-r": "agent::OpenAgentDiff",
"ctrl-shift-y": "agent::KeepAll",
"ctrl-shift-n": "agent::RejectAll",
"ctrl-shift-v": "agent::PasteRaw",
},
},
{
@@ -308,6 +310,7 @@
"ctrl-shift-r": "agent::OpenAgentDiff",
"ctrl-shift-y": "agent::KeepAll",
"ctrl-shift-n": "agent::RejectAll",
"ctrl-shift-v": "agent::PasteRaw",
},
},
{

View File

@@ -1321,6 +1321,14 @@
"hidden_files": ["**/.*"],
// Git gutter behavior configuration.
"git": {
// Global switch to enable or disable all git integration features.
// If set to true, disables all git integration features.
// If set to false, individual git integration features below will be independently enabled or disabled.
"disable_git": false,
// Whether to enable git status tracking.
"enable_status": true,
// Whether to enable git diff display.
"enable_diff": true,
// Control whether the git gutter is shown. May take 2 values:
// 1. Show the gutter
// "git_gutter": "tracked_files"
@@ -2245,6 +2253,23 @@
"ssh_connections": [],
// Whether to read ~/.ssh/config for ssh connection sources.
"read_ssh_config": true,
// Default timeout in milliseconds for all context server tool calls.
// Individual servers can override this in their configuration.
// Examples:
// "context_servers": {
// "my-stdio-server": {
// "command": "/path/to/server",
// "args": ["--flag"],
// "timeout": 120000 // Override: 2 minutes for this server
// },
// "my-http-server": {
// "url": "https://example.com/mcp",
// "headers": { "Authorization": "Bearer token" },
// "timeout": 90000 // Override: 90 seconds for this server
// }
// }
// Default: 60000 (60 seconds)
"context_server_timeout": 60000,
// Configures context servers for use by the agent.
"context_servers": {},
// Configures agent servers available in the agent panel.

View File

@@ -426,7 +426,7 @@ impl NativeAgent {
.into_iter()
.flat_map(|(contents, prompt_metadata)| match contents {
Ok(contents) => Some(UserRulesContext {
uuid: prompt_metadata.id.user_id()?,
uuid: prompt_metadata.id.as_user()?,
title: prompt_metadata.title.map(|title| title.to_string()),
contents,
}),

View File

@@ -286,6 +286,7 @@ impl AgentConnection for AcpConnection {
project::context_server_store::ContextServerConfiguration::Http {
url,
headers,
timeout: _,
} => Some(acp::McpServer::Http(
acp::McpServerHttp::new(id.0.to_string(), url.to_string()).headers(
headers

View File

@@ -34,7 +34,7 @@ use theme::ThemeSettings;
use ui::prelude::*;
use util::{ResultExt, debug_panic};
use workspace::{CollaboratorId, Workspace};
use zed_actions::agent::Chat;
use zed_actions::agent::{Chat, PasteRaw};
pub struct MessageEditor {
mention_set: Entity<MentionSet>,
@@ -543,6 +543,9 @@ impl MessageEditor {
}
fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context<Self>) {
let Some(workspace) = self.workspace.upgrade() else {
return;
};
let editor_clipboard_selections = cx
.read_from_clipboard()
.and_then(|item| item.entries().first().cloned())
@@ -553,133 +556,127 @@ impl MessageEditor {
_ => None,
});
let has_file_context = editor_clipboard_selections
.as_ref()
.is_some_and(|selections| {
selections
.iter()
.any(|sel| sel.file_path.is_some() && sel.line_range.is_some())
});
if has_file_context {
if let Some((workspace, selections)) =
self.workspace.upgrade().zip(editor_clipboard_selections)
{
let Some(first_selection) = selections.first() else {
return;
};
if let Some(file_path) = &first_selection.file_path {
// In case someone pastes selections from another window
// with a different project, we don't want to insert the
// crease (containing the absolute path) since the agent
// cannot access files outside the project.
let is_in_project = workspace
.read(cx)
.project()
.read(cx)
.project_path_for_absolute_path(file_path, cx)
.is_some();
if !is_in_project {
return;
}
}
cx.stop_propagation();
let insertion_target = self
.editor
.read(cx)
.selections
.newest_anchor()
.start
.text_anchor;
let project = workspace.read(cx).project().clone();
for selection in selections {
if let (Some(file_path), Some(line_range)) =
(selection.file_path, selection.line_range)
{
let crease_text =
acp_thread::selection_name(Some(file_path.as_ref()), &line_range);
let mention_uri = MentionUri::Selection {
abs_path: Some(file_path.clone()),
line_range: line_range.clone(),
};
let mention_text = mention_uri.as_link().to_string();
let (excerpt_id, text_anchor, content_len) =
self.editor.update(cx, |editor, cx| {
let buffer = editor.buffer().read(cx);
let snapshot = buffer.snapshot(cx);
let (excerpt_id, _, buffer_snapshot) =
snapshot.as_singleton().unwrap();
let text_anchor = insertion_target.bias_left(&buffer_snapshot);
editor.insert(&mention_text, window, cx);
editor.insert(" ", window, cx);
(*excerpt_id, text_anchor, mention_text.len())
});
let Some((crease_id, tx)) = insert_crease_for_mention(
excerpt_id,
text_anchor,
content_len,
crease_text.into(),
mention_uri.icon_path(cx),
None,
self.editor.clone(),
window,
cx,
) else {
continue;
};
drop(tx);
let mention_task = cx
.spawn({
let project = project.clone();
async move |_, cx| {
let project_path = project
.update(cx, |project, cx| {
project.project_path_for_absolute_path(&file_path, cx)
})
.map_err(|e| e.to_string())?
.ok_or_else(|| "project path not found".to_string())?;
let buffer = project
.update(cx, |project, cx| {
project.open_buffer(project_path, cx)
})
.map_err(|e| e.to_string())?
.await
.map_err(|e| e.to_string())?;
buffer
.update(cx, |buffer, cx| {
let start = Point::new(*line_range.start(), 0)
.min(buffer.max_point());
let end = Point::new(*line_range.end() + 1, 0)
.min(buffer.max_point());
let content =
buffer.text_for_range(start..end).collect();
Mention::Text {
content,
tracked_buffers: vec![cx.entity()],
}
})
.map_err(|e| e.to_string())
}
})
.shared();
self.mention_set.update(cx, |mention_set, _cx| {
mention_set.insert_mention(crease_id, mention_uri.clone(), mention_task)
});
}
}
return;
// Insert creases for pasted clipboard selections that:
// 1. Contain exactly one selection
// 2. Have an associated file path
// 3. Span multiple lines (not single-line selections)
// 4. Belong to a file that exists in the current project
let should_insert_creases = util::maybe!({
let selections = editor_clipboard_selections.as_ref()?;
if selections.len() > 1 {
return Some(false);
}
let selection = selections.first()?;
let file_path = selection.file_path.as_ref()?;
let line_range = selection.line_range.as_ref()?;
if line_range.start() == line_range.end() {
return Some(false);
}
Some(
workspace
.read(cx)
.project()
.read(cx)
.project_path_for_absolute_path(file_path, cx)
.is_some(),
)
})
.unwrap_or(false);
if should_insert_creases && let Some(selections) = editor_clipboard_selections {
cx.stop_propagation();
let insertion_target = self
.editor
.read(cx)
.selections
.newest_anchor()
.start
.text_anchor;
let project = workspace.read(cx).project().clone();
for selection in selections {
if let (Some(file_path), Some(line_range)) =
(selection.file_path, selection.line_range)
{
let crease_text =
acp_thread::selection_name(Some(file_path.as_ref()), &line_range);
let mention_uri = MentionUri::Selection {
abs_path: Some(file_path.clone()),
line_range: line_range.clone(),
};
let mention_text = mention_uri.as_link().to_string();
let (excerpt_id, text_anchor, content_len) =
self.editor.update(cx, |editor, cx| {
let buffer = editor.buffer().read(cx);
let snapshot = buffer.snapshot(cx);
let (excerpt_id, _, buffer_snapshot) = snapshot.as_singleton().unwrap();
let text_anchor = insertion_target.bias_left(&buffer_snapshot);
editor.insert(&mention_text, window, cx);
editor.insert(" ", window, cx);
(*excerpt_id, text_anchor, mention_text.len())
});
let Some((crease_id, tx)) = insert_crease_for_mention(
excerpt_id,
text_anchor,
content_len,
crease_text.into(),
mention_uri.icon_path(cx),
None,
self.editor.clone(),
window,
cx,
) else {
continue;
};
drop(tx);
let mention_task = cx
.spawn({
let project = project.clone();
async move |_, cx| {
let project_path = project
.update(cx, |project, cx| {
project.project_path_for_absolute_path(&file_path, cx)
})
.map_err(|e| e.to_string())?
.ok_or_else(|| "project path not found".to_string())?;
let buffer = project
.update(cx, |project, cx| project.open_buffer(project_path, cx))
.map_err(|e| e.to_string())?
.await
.map_err(|e| e.to_string())?;
buffer
.update(cx, |buffer, cx| {
let start = Point::new(*line_range.start(), 0)
.min(buffer.max_point());
let end = Point::new(*line_range.end() + 1, 0)
.min(buffer.max_point());
let content = buffer.text_for_range(start..end).collect();
Mention::Text {
content,
tracked_buffers: vec![cx.entity()],
}
})
.map_err(|e| e.to_string())
}
})
.shared();
self.mention_set.update(cx, |mention_set, _cx| {
mention_set.insert_mention(crease_id, mention_uri.clone(), mention_task)
});
}
}
return;
}
if self.prompt_capabilities.borrow().image
@@ -690,6 +687,13 @@ impl MessageEditor {
}
}
fn paste_raw(&mut self, _: &PasteRaw, window: &mut Window, cx: &mut Context<Self>) {
let editor = self.editor.clone();
window.defer(cx, move |window, cx| {
editor.update(cx, |editor, cx| editor.paste(&Paste, window, cx));
});
}
pub fn insert_dragged_files(
&mut self,
paths: Vec<project::ProjectPath>,
@@ -967,6 +971,7 @@ impl Render for MessageEditor {
.on_action(cx.listener(Self::chat))
.on_action(cx.listener(Self::chat_with_follow))
.on_action(cx.listener(Self::cancel))
.on_action(cx.listener(Self::paste_raw))
.capture_action(cx.listener(Self::paste))
.flex_1()
.child({

View File

@@ -338,7 +338,13 @@ impl AcpThreadView {
let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
let available_commands = Rc::new(RefCell::new(vec![]));
let placeholder = placeholder_text(agent.name().as_ref(), false);
let agent_server_store = project.read(cx).agent_server_store().clone();
let agent_display_name = agent_server_store
.read(cx)
.agent_display_name(&ExternalAgentServerName(agent.name()))
.unwrap_or_else(|| agent.name());
let placeholder = placeholder_text(agent_display_name.as_ref(), false);
let message_editor = cx.new(|cx| {
let mut editor = MessageEditor::new(
@@ -377,7 +383,6 @@ impl AcpThreadView {
)
});
let agent_server_store = project.read(cx).agent_server_store().clone();
let subscriptions = [
cx.observe_global_in::<SettingsStore>(window, Self::agent_ui_font_size_changed),
cx.observe_global_in::<AgentFontSize>(window, Self::agent_ui_font_size_changed),
@@ -1498,7 +1503,13 @@ impl AcpThreadView {
let has_commands = !available_commands.is_empty();
self.available_commands.replace(available_commands);
let new_placeholder = placeholder_text(self.agent.name().as_ref(), has_commands);
let agent_display_name = self
.agent_server_store
.read(cx)
.agent_display_name(&ExternalAgentServerName(self.agent.name()))
.unwrap_or_else(|| self.agent.name());
let new_placeholder = placeholder_text(agent_display_name.as_ref(), has_commands);
self.message_editor.update(cx, |editor, cx| {
editor.set_placeholder_text(&new_placeholder, window, cx);

View File

@@ -172,6 +172,7 @@ impl ConfigurationSource {
enabled: true,
url,
headers: auth,
timeout: None,
},
)
})
@@ -411,6 +412,7 @@ impl ConfigureContextServerModal {
enabled: _,
url,
headers,
timeout: _,
} => Some(ConfigurationTarget::ExistingHttp {
id: server_id,
url,

View File

@@ -1586,7 +1586,7 @@ pub(crate) fn search_rules(
None
} else {
Some(RulesContextEntry {
prompt_id: metadata.id.user_id()?,
prompt_id: metadata.id.as_user()?,
title: metadata.title?,
})
}

View File

@@ -71,7 +71,7 @@ use workspace::{
pane,
searchable::{SearchEvent, SearchableItem},
};
use zed_actions::agent::{AddSelectionToThread, ToggleModelSelector};
use zed_actions::agent::{AddSelectionToThread, PasteRaw, ToggleModelSelector};
use crate::CycleFavoriteModels;
@@ -1698,6 +1698,9 @@ impl TextThreadEditor {
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(workspace) = self.workspace.upgrade() else {
return;
};
let editor_clipboard_selections = cx
.read_from_clipboard()
.and_then(|item| item.entries().first().cloned())
@@ -1708,84 +1711,101 @@ impl TextThreadEditor {
_ => None,
});
let has_file_context = editor_clipboard_selections
.as_ref()
.is_some_and(|selections| {
selections
.iter()
.any(|sel| sel.file_path.is_some() && sel.line_range.is_some())
});
// Insert creases for pasted clipboard selections that:
// 1. Contain exactly one selection
// 2. Have an associated file path
// 3. Span multiple lines (not single-line selections)
// 4. Belong to a file that exists in the current project
let should_insert_creases = util::maybe!({
let selections = editor_clipboard_selections.as_ref()?;
if selections.len() > 1 {
return Some(false);
}
let selection = selections.first()?;
let file_path = selection.file_path.as_ref()?;
let line_range = selection.line_range.as_ref()?;
if has_file_context {
if let Some(clipboard_item) = cx.read_from_clipboard() {
if let Some(ClipboardEntry::String(clipboard_text)) =
clipboard_item.entries().first()
{
if let Some(selections) = editor_clipboard_selections {
cx.stop_propagation();
if line_range.start() == line_range.end() {
return Some(false);
}
let text = clipboard_text.text();
self.editor.update(cx, |editor, cx| {
let mut current_offset = 0;
let weak_editor = cx.entity().downgrade();
Some(
workspace
.read(cx)
.project()
.read(cx)
.project_path_for_absolute_path(file_path, cx)
.is_some(),
)
})
.unwrap_or(false);
for selection in selections {
if let (Some(file_path), Some(line_range)) =
(selection.file_path, selection.line_range)
{
let selected_text =
&text[current_offset..current_offset + selection.len];
let fence = assistant_slash_commands::codeblock_fence_for_path(
file_path.to_str(),
Some(line_range.clone()),
);
let formatted_text = format!("{fence}{selected_text}\n```");
if should_insert_creases && let Some(clipboard_item) = cx.read_from_clipboard() {
if let Some(ClipboardEntry::String(clipboard_text)) = clipboard_item.entries().first() {
if let Some(selections) = editor_clipboard_selections {
cx.stop_propagation();
let insert_point = editor
.selections
.newest::<Point>(&editor.display_snapshot(cx))
.head();
let start_row = MultiBufferRow(insert_point.row);
let text = clipboard_text.text();
self.editor.update(cx, |editor, cx| {
let mut current_offset = 0;
let weak_editor = cx.entity().downgrade();
editor.insert(&formatted_text, window, cx);
for selection in selections {
if let (Some(file_path), Some(line_range)) =
(selection.file_path, selection.line_range)
{
let selected_text =
&text[current_offset..current_offset + selection.len];
let fence = assistant_slash_commands::codeblock_fence_for_path(
file_path.to_str(),
Some(line_range.clone()),
);
let formatted_text = format!("{fence}{selected_text}\n```");
let snapshot = editor.buffer().read(cx).snapshot(cx);
let anchor_before = snapshot.anchor_after(insert_point);
let anchor_after = editor
.selections
.newest_anchor()
.head()
.bias_left(&snapshot);
let insert_point = editor
.selections
.newest::<Point>(&editor.display_snapshot(cx))
.head();
let start_row = MultiBufferRow(insert_point.row);
editor.insert("\n", window, cx);
editor.insert(&formatted_text, window, cx);
let crease_text = acp_thread::selection_name(
Some(file_path.as_ref()),
&line_range,
);
let snapshot = editor.buffer().read(cx).snapshot(cx);
let anchor_before = snapshot.anchor_after(insert_point);
let anchor_after = editor
.selections
.newest_anchor()
.head()
.bias_left(&snapshot);
let fold_placeholder = quote_selection_fold_placeholder(
crease_text,
weak_editor.clone(),
);
let crease = Crease::inline(
anchor_before..anchor_after,
fold_placeholder,
render_quote_selection_output_toggle,
|_, _, _, _| Empty.into_any(),
);
editor.insert_creases(vec![crease], cx);
editor.fold_at(start_row, window, cx);
editor.insert("\n", window, cx);
current_offset += selection.len;
if !selection.is_entire_line && current_offset < text.len() {
current_offset += 1;
}
let crease_text = acp_thread::selection_name(
Some(file_path.as_ref()),
&line_range,
);
let fold_placeholder = quote_selection_fold_placeholder(
crease_text,
weak_editor.clone(),
);
let crease = Crease::inline(
anchor_before..anchor_after,
fold_placeholder,
render_quote_selection_output_toggle,
|_, _, _, _| Empty.into_any(),
);
editor.insert_creases(vec![crease], cx);
editor.fold_at(start_row, window, cx);
current_offset += selection.len;
if !selection.is_entire_line && current_offset < text.len() {
current_offset += 1;
}
}
});
return;
}
}
});
return;
}
}
}
@@ -1944,6 +1964,12 @@ impl TextThreadEditor {
}
}
fn paste_raw(&mut self, _: &PasteRaw, window: &mut Window, cx: &mut Context<Self>) {
self.editor.update(cx, |editor, cx| {
editor.paste(&editor::actions::Paste, window, cx);
});
}
fn update_image_blocks(&mut self, cx: &mut Context<Self>) {
self.editor.update(cx, |editor, cx| {
let buffer = editor.buffer().read(cx).snapshot(cx);
@@ -2627,6 +2653,7 @@ impl Render for TextThreadEditor {
.capture_action(cx.listener(TextThreadEditor::copy))
.capture_action(cx.listener(TextThreadEditor::cut))
.capture_action(cx.listener(TextThreadEditor::paste))
.on_action(cx.listener(TextThreadEditor::paste_raw))
.capture_action(cx.listener(TextThreadEditor::cycle_message_role))
.capture_action(cx.listener(TextThreadEditor::confirm_command))
.on_action(cx.listener(TextThreadEditor::assist))

View File

@@ -10,6 +10,7 @@ use collections::HashMap;
use http_client::HttpClient;
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use std::{fmt::Display, path::PathBuf};
use anyhow::Result;
@@ -39,6 +40,7 @@ pub struct ContextServer {
id: ContextServerId,
client: RwLock<Option<Arc<crate::protocol::InitializedContextServerProtocol>>>,
configuration: ContextServerTransport,
request_timeout: Option<Duration>,
}
impl ContextServer {
@@ -54,6 +56,7 @@ impl ContextServer {
command,
working_directory.map(|directory| directory.to_path_buf()),
),
request_timeout: None, // Stdio handles timeout through command
}
}
@@ -63,6 +66,7 @@ impl ContextServer {
headers: HashMap<String, String>,
http_client: Arc<dyn HttpClient>,
executor: gpui::BackgroundExecutor,
request_timeout: Option<Duration>,
) -> Result<Self> {
let transport = match endpoint.scheme() {
"http" | "https" => {
@@ -73,14 +77,23 @@ impl ContextServer {
}
_ => anyhow::bail!("unsupported MCP url scheme {}", endpoint.scheme()),
};
Ok(Self::new(id, transport))
Ok(Self::new_with_timeout(id, transport, request_timeout))
}
pub fn new(id: ContextServerId, transport: Arc<dyn crate::transport::Transport>) -> Self {
Self::new_with_timeout(id, transport, None)
}
pub fn new_with_timeout(
id: ContextServerId,
transport: Arc<dyn crate::transport::Transport>,
request_timeout: Option<Duration>,
) -> Self {
Self {
id,
client: RwLock::new(None),
configuration: ContextServerTransport::Custom(transport),
request_timeout,
}
}
@@ -113,7 +126,7 @@ impl ContextServer {
client::ContextServerId(self.id.0.clone()),
self.id().0,
transport.clone(),
None,
self.request_timeout,
cx.clone(),
)?,
})

View File

@@ -51,6 +51,8 @@ pub const MENU_ASIDE_MIN_WIDTH: Pixels = px(260.);
pub const MENU_ASIDE_MAX_WIDTH: Pixels = px(500.);
pub const COMPLETION_MENU_MIN_WIDTH: Pixels = px(280.);
pub const COMPLETION_MENU_MAX_WIDTH: Pixels = px(540.);
pub const CODE_ACTION_MENU_MIN_WIDTH: Pixels = px(220.);
pub const CODE_ACTION_MENU_MAX_WIDTH: Pixels = px(540.);
// Constants for the markdown cache. The purpose of this cache is to reduce flickering due to
// documentation not yet being parsed.
@@ -179,7 +181,7 @@ impl CodeContextMenu {
) -> Option<AnyElement> {
match self {
CodeContextMenu::Completions(menu) => menu.render_aside(max_size, window, cx),
CodeContextMenu::CodeActions(_) => None,
CodeContextMenu::CodeActions(menu) => menu.render_aside(max_size, window, cx),
}
}
@@ -1419,26 +1421,6 @@ pub enum CodeActionsItem {
}
impl CodeActionsItem {
fn as_task(&self) -> Option<&ResolvedTask> {
let Self::Task(_, task) = self else {
return None;
};
Some(task)
}
fn as_code_action(&self) -> Option<&CodeAction> {
let Self::CodeAction { action, .. } = self else {
return None;
};
Some(action)
}
fn as_debug_scenario(&self) -> Option<&DebugScenario> {
let Self::DebugScenario(scenario) = self else {
return None;
};
Some(scenario)
}
pub fn label(&self) -> String {
match self {
Self::CodeAction { action, .. } => action.lsp_action.title().to_owned(),
@@ -1446,6 +1428,14 @@ impl CodeActionsItem {
Self::DebugScenario(scenario) => scenario.label.to_string(),
}
}
pub fn menu_label(&self) -> String {
match self {
Self::CodeAction { action, .. } => action.lsp_action.title().replace("\n", ""),
Self::Task(_, task) => task.resolved_label.replace("\n", ""),
Self::DebugScenario(scenario) => format!("debug: {}", scenario.label),
}
}
}
pub struct CodeActionsMenu {
@@ -1555,60 +1545,33 @@ impl CodeActionsMenu {
let item_ix = range.start + ix;
let selected = item_ix == selected_item;
let colors = cx.theme().colors();
div().min_w(px(220.)).max_w(px(540.)).child(
ListItem::new(item_ix)
.inset(true)
.toggle_state(selected)
.when_some(action.as_code_action(), |this, action| {
this.child(
h_flex()
.overflow_hidden()
.when(is_quick_action_bar, |this| this.text_ui(cx))
.child(
// TASK: It would be good to make lsp_action.title a SharedString to avoid allocating here.
action.lsp_action.title().replace("\n", ""),
)
.when(selected, |this| {
this.text_color(colors.text_accent)
}),
)
})
.when_some(action.as_task(), |this, task| {
this.child(
h_flex()
.overflow_hidden()
.when(is_quick_action_bar, |this| this.text_ui(cx))
.child(task.resolved_label.replace("\n", ""))
.when(selected, |this| {
this.text_color(colors.text_accent)
}),
)
})
.when_some(action.as_debug_scenario(), |this, scenario| {
this.child(
h_flex()
.overflow_hidden()
.when(is_quick_action_bar, |this| this.text_ui(cx))
.child("debug: ")
.child(scenario.label.clone())
.when(selected, |this| {
this.text_color(colors.text_accent)
}),
)
})
.on_click(cx.listener(move |editor, _, window, cx| {
cx.stop_propagation();
if let Some(task) = editor.confirm_code_action(
&ConfirmCodeAction {
item_ix: Some(item_ix),
},
window,
cx,
) {
task.detach_and_log_err(cx)
}
})),
)
ListItem::new(item_ix)
.inset(true)
.toggle_state(selected)
.overflow_x()
.child(
div()
.min_w(CODE_ACTION_MENU_MIN_WIDTH)
.max_w(CODE_ACTION_MENU_MAX_WIDTH)
.overflow_hidden()
.text_ellipsis()
.when(is_quick_action_bar, |this| this.text_ui(cx))
.when(selected, |this| this.text_color(colors.text_accent))
.child(action.menu_label()),
)
.on_click(cx.listener(move |editor, _, window, cx| {
cx.stop_propagation();
if let Some(task) = editor.confirm_code_action(
&ConfirmCodeAction {
item_ix: Some(item_ix),
},
window,
cx,
) {
task.detach_and_log_err(cx)
}
}))
})
.collect()
}),
@@ -1635,4 +1598,42 @@ impl CodeActionsMenu {
Popover::new().child(list).into_any_element()
}
fn render_aside(
&mut self,
max_size: Size<Pixels>,
window: &mut Window,
_cx: &mut Context<Editor>,
) -> Option<AnyElement> {
let Some(action) = self.actions.get(self.selected_item) else {
return None;
};
let label = action.menu_label();
let text_system = window.text_system();
let mut line_wrapper = text_system.line_wrapper(
window.text_style().font(),
window.text_style().font_size.to_pixels(window.rem_size()),
);
let is_truncated =
line_wrapper.should_truncate_line(&label, CODE_ACTION_MENU_MAX_WIDTH, "");
if is_truncated.is_none() {
return None;
}
Some(
Popover::new()
.child(
div()
.child(label)
.id("code_actions_menu_extended")
.px(MENU_ASIDE_X_PADDING / 2.)
.max_w(max_size.width)
.max_h(max_size.height)
.occlude(),
)
.into_any_element(),
)
}
}

View File

@@ -215,7 +215,8 @@ impl Settings for EditorSettings {
},
scrollbar: Scrollbar {
show: scrollbar.show.map(Into::into).unwrap(),
git_diff: scrollbar.git_diff.unwrap(),
git_diff: scrollbar.git_diff.unwrap()
&& content.git.unwrap().enabled.unwrap().is_git_diff_enabled(),
selected_text: scrollbar.selected_text.unwrap(),
selected_symbol: scrollbar.selected_symbol.unwrap(),
search_results: scrollbar.search_results.unwrap(),

View File

@@ -20880,6 +20880,36 @@ async fn test_toggling_adjacent_diff_hunks(cx: &mut TestAppContext) {
.to_string(),
);
cx.update_editor(|editor, window, cx| {
editor.move_up(&MoveUp, window, cx);
editor.toggle_selected_diff_hunks(&Default::default(), window, cx);
});
cx.assert_state_with_diff(
indoc! { "
ˇone
- two
three
five
"}
.to_string(),
);
cx.update_editor(|editor, window, cx| {
editor.move_down(&MoveDown, window, cx);
editor.move_down(&MoveDown, window, cx);
editor.toggle_selected_diff_hunks(&Default::default(), window, cx);
});
cx.assert_state_with_diff(
indoc! { "
one
- two
ˇthree
- four
five
"}
.to_string(),
);
cx.set_state(indoc! { "
one
ˇTWO
@@ -20919,6 +20949,66 @@ async fn test_toggling_adjacent_diff_hunks(cx: &mut TestAppContext) {
);
}
#[gpui::test]
async fn test_toggling_adjacent_diff_hunks_2(
executor: BackgroundExecutor,
cx: &mut TestAppContext,
) {
init_test(cx, |_| {});
let mut cx = EditorTestContext::new(cx).await;
let diff_base = r#"
lineA
lineB
lineC
lineD
"#
.unindent();
cx.set_state(
&r#"
ˇlineA1
lineB
lineD
"#
.unindent(),
);
cx.set_head_text(&diff_base);
executor.run_until_parked();
cx.update_editor(|editor, window, cx| {
editor.toggle_selected_diff_hunks(&ToggleSelectedDiffHunks, window, cx);
});
executor.run_until_parked();
cx.assert_state_with_diff(
r#"
- lineA
+ ˇlineA1
lineB
lineD
"#
.unindent(),
);
cx.update_editor(|editor, window, cx| {
editor.move_down(&MoveDown, window, cx);
editor.move_right(&MoveRight, window, cx);
editor.toggle_selected_diff_hunks(&ToggleSelectedDiffHunks, window, cx);
});
executor.run_until_parked();
cx.assert_state_with_diff(
r#"
- lineA
+ lineA1
lˇineB
- lineC
lineD
"#
.unindent(),
);
}
#[gpui::test]
async fn test_edits_around_expanded_deletion_hunks(
executor: BackgroundExecutor,

View File

@@ -1 +1 @@
LICENSE-GPL
../../LICENSE-GPL

View File

@@ -58,7 +58,7 @@ use project::{
git_store::{GitStoreEvent, Repository, RepositoryEvent, RepositoryId, pending_op},
project_settings::{GitPathStyle, ProjectSettings},
};
use prompt_store::{PromptId, PromptStore, RULES_FILE_NAMES};
use prompt_store::{BuiltInPrompt, PromptId, PromptStore, RULES_FILE_NAMES};
use serde::{Deserialize, Serialize};
use settings::{Settings, SettingsStore, StatusStyle};
use std::future::Future;
@@ -2579,25 +2579,26 @@ impl GitPanel {
is_using_legacy_zed_pro: bool,
cx: &mut AsyncApp,
) -> String {
const DEFAULT_PROMPT: &str = include_str!("commit_message_prompt.txt");
// Remove this once we stop supporting legacy Zed Pro
// In legacy Zed Pro, Git commit summary generation did not count as a
// prompt. If the user changes the prompt, our classification will fail,
// meaning that users will be charged for generating commit messages.
if is_using_legacy_zed_pro {
return DEFAULT_PROMPT.to_string();
return BuiltInPrompt::CommitMessage.default_content().to_string();
}
let load = async {
let store = cx.update(|cx| PromptStore::global(cx)).ok()?.await.ok()?;
store
.update(cx, |s, cx| s.load(PromptId::CommitMessage, cx))
.update(cx, |s, cx| {
s.load(PromptId::BuiltIn(BuiltInPrompt::CommitMessage), cx)
})
.ok()?
.await
.ok()
};
load.await.unwrap_or_else(|| DEFAULT_PROMPT.to_string())
load.await
.unwrap_or_else(|| BuiltInPrompt::CommitMessage.default_content().to_string())
}
/// Generates a commit message using an LLM.

View File

@@ -198,14 +198,14 @@ wayland-backend = { version = "0.3.3", features = [
"client_system",
"dlopen",
], optional = true }
wayland-client = { version = "0.31.2", optional = true }
wayland-cursor = { version = "0.31.1", optional = true }
wayland-protocols = { version = "0.31.2", features = [
wayland-client = { version = "0.31.11", optional = true }
wayland-cursor = { version = "0.31.11", optional = true }
wayland-protocols = { version = "0.32.9", features = [
"client",
"staging",
"unstable",
], optional = true }
wayland-protocols-plasma = { version = "0.2.0", features = [
wayland-protocols-plasma = { version = "0.3.9", features = [
"client",
], optional = true }
wayland-protocols-wlr = { version = "0.3.9", features = [

View File

@@ -5,6 +5,7 @@ use gpui::{
struct SubWindow {
custom_titlebar: bool,
is_dialog: bool,
}
fn button(text: &str, on_click: impl Fn(&mut Window, &mut App) + 'static) -> impl IntoElement {
@@ -23,7 +24,10 @@ fn button(text: &str, on_click: impl Fn(&mut Window, &mut App) + 'static) -> imp
}
impl Render for SubWindow {
fn render(&mut self, _window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let window_bounds =
WindowBounds::Windowed(Bounds::centered(None, size(px(250.0), px(200.0)), cx));
div()
.flex()
.flex_col()
@@ -52,8 +56,28 @@ impl Render for SubWindow {
.child(
div()
.p_8()
.flex()
.flex_col()
.gap_2()
.child("SubWindow")
.when(self.is_dialog, |div| {
div.child(button("Open Nested Dialog", move |_, cx| {
cx.open_window(
WindowOptions {
window_bounds: Some(window_bounds),
kind: WindowKind::Dialog,
..Default::default()
},
|_, cx| {
cx.new(|_| SubWindow {
custom_titlebar: false,
is_dialog: true,
})
},
)
.unwrap();
}))
})
.child(button("Close", |window, _| {
window.remove_window();
})),
@@ -86,6 +110,7 @@ impl Render for WindowDemo {
|_, cx| {
cx.new(|_| SubWindow {
custom_titlebar: false,
is_dialog: false,
})
},
)
@@ -101,6 +126,39 @@ impl Render for WindowDemo {
|_, cx| {
cx.new(|_| SubWindow {
custom_titlebar: false,
is_dialog: false,
})
},
)
.unwrap();
}))
.child(button("Floating", move |_, cx| {
cx.open_window(
WindowOptions {
window_bounds: Some(window_bounds),
kind: WindowKind::Floating,
..Default::default()
},
|_, cx| {
cx.new(|_| SubWindow {
custom_titlebar: false,
is_dialog: false,
})
},
)
.unwrap();
}))
.child(button("Dialog", move |_, cx| {
cx.open_window(
WindowOptions {
window_bounds: Some(window_bounds),
kind: WindowKind::Dialog,
..Default::default()
},
|_, cx| {
cx.new(|_| SubWindow {
custom_titlebar: false,
is_dialog: true,
})
},
)
@@ -116,6 +174,7 @@ impl Render for WindowDemo {
|_, cx| {
cx.new(|_| SubWindow {
custom_titlebar: true,
is_dialog: false,
})
},
)
@@ -131,6 +190,7 @@ impl Render for WindowDemo {
|_, cx| {
cx.new(|_| SubWindow {
custom_titlebar: false,
is_dialog: false,
})
},
)
@@ -147,6 +207,7 @@ impl Render for WindowDemo {
|_, cx| {
cx.new(|_| SubWindow {
custom_titlebar: false,
is_dialog: false,
})
},
)
@@ -162,6 +223,7 @@ impl Render for WindowDemo {
|_, cx| {
cx.new(|_| SubWindow {
custom_titlebar: false,
is_dialog: false,
})
},
)
@@ -177,6 +239,7 @@ impl Render for WindowDemo {
|_, cx| {
cx.new(|_| SubWindow {
custom_titlebar: false,
is_dialog: false,
})
},
)

View File

@@ -316,6 +316,7 @@ impl SystemWindowTabController {
.find_map(|(group, tabs)| tabs.iter().find(|tab| tab.id == id).map(|_| group));
let current_group = current_group?;
// TODO: `.keys()` returns arbitrary order, what does "next" mean?
let mut group_ids: Vec<_> = controller.tab_groups.keys().collect();
let idx = group_ids.iter().position(|g| *g == current_group)?;
let next_idx = (idx + 1) % group_ids.len();
@@ -340,6 +341,7 @@ impl SystemWindowTabController {
.find_map(|(group, tabs)| tabs.iter().find(|tab| tab.id == id).map(|_| group));
let current_group = current_group?;
// TODO: `.keys()` returns arbitrary order, what does "previous" mean?
let mut group_ids: Vec<_> = controller.tab_groups.keys().collect();
let idx = group_ids.iter().position(|g| *g == current_group)?;
let prev_idx = if idx == 0 {
@@ -361,12 +363,9 @@ impl SystemWindowTabController {
/// Get all tabs in the same window.
pub fn tabs(&self, id: WindowId) -> Option<&Vec<SystemWindowTab>> {
let tab_group = self
.tab_groups
.iter()
.find_map(|(group, tabs)| tabs.iter().find(|tab| tab.id == id).map(|_| *group))?;
self.tab_groups.get(&tab_group)
self.tab_groups
.values()
.find(|tabs| tabs.iter().any(|tab| tab.id == id))
}
/// Initialize the visibility of the system window tab controller.
@@ -441,7 +440,7 @@ impl SystemWindowTabController {
/// Insert a tab into a tab group.
pub fn add_tab(cx: &mut App, id: WindowId, tabs: Vec<SystemWindowTab>) {
let mut controller = cx.global_mut::<SystemWindowTabController>();
let Some(tab) = tabs.clone().into_iter().find(|tab| tab.id == id) else {
let Some(tab) = tabs.iter().find(|tab| tab.id == id).cloned() else {
return;
};
@@ -504,16 +503,14 @@ impl SystemWindowTabController {
return;
};
let initial_tabs_len = initial_tabs.len();
let mut all_tabs = initial_tabs.clone();
for tabs in controller.tab_groups.values() {
all_tabs.extend(
tabs.iter()
.filter(|tab| !initial_tabs.contains(tab))
.cloned(),
);
for (_, mut tabs) in controller.tab_groups.drain() {
tabs.retain(|tab| !all_tabs[..initial_tabs_len].contains(tab));
all_tabs.extend(tabs);
}
controller.tab_groups.clear();
controller.tab_groups.insert(0, all_tabs);
}

View File

@@ -1348,6 +1348,10 @@ pub enum WindowKind {
/// docks, notifications or wallpapers.
#[cfg(all(target_os = "linux", feature = "wayland"))]
LayerShell(layer_shell::LayerShellOptions),
/// A window that appears on top of its parent window and blocks interaction with it
/// until the modal window is closed
Dialog,
}
/// The appearance of the window, as defined by the operating system.

View File

@@ -36,12 +36,6 @@ use wayland_client::{
wl_shm_pool, wl_surface,
},
};
use wayland_protocols::wp::cursor_shape::v1::client::{
wp_cursor_shape_device_v1, wp_cursor_shape_manager_v1,
};
use wayland_protocols::wp::fractional_scale::v1::client::{
wp_fractional_scale_manager_v1, wp_fractional_scale_v1,
};
use wayland_protocols::wp::primary_selection::zv1::client::zwp_primary_selection_offer_v1::{
self, ZwpPrimarySelectionOfferV1,
};
@@ -61,6 +55,14 @@ use wayland_protocols::xdg::decoration::zv1::client::{
zxdg_decoration_manager_v1, zxdg_toplevel_decoration_v1,
};
use wayland_protocols::xdg::shell::client::{xdg_surface, xdg_toplevel, xdg_wm_base};
use wayland_protocols::{
wp::cursor_shape::v1::client::{wp_cursor_shape_device_v1, wp_cursor_shape_manager_v1},
xdg::dialog::v1::client::xdg_wm_dialog_v1::{self, XdgWmDialogV1},
};
use wayland_protocols::{
wp::fractional_scale::v1::client::{wp_fractional_scale_manager_v1, wp_fractional_scale_v1},
xdg::dialog::v1::client::xdg_dialog_v1::XdgDialogV1,
};
use wayland_protocols_plasma::blur::client::{org_kde_kwin_blur, org_kde_kwin_blur_manager};
use wayland_protocols_wlr::layer_shell::v1::client::{zwlr_layer_shell_v1, zwlr_layer_surface_v1};
use xkbcommon::xkb::ffi::XKB_KEYMAP_FORMAT_TEXT_V1;
@@ -122,6 +124,7 @@ pub struct Globals {
pub layer_shell: Option<zwlr_layer_shell_v1::ZwlrLayerShellV1>,
pub blur_manager: Option<org_kde_kwin_blur_manager::OrgKdeKwinBlurManager>,
pub text_input_manager: Option<zwp_text_input_manager_v3::ZwpTextInputManagerV3>,
pub dialog: Option<xdg_wm_dialog_v1::XdgWmDialogV1>,
pub executor: ForegroundExecutor,
}
@@ -132,6 +135,7 @@ impl Globals {
qh: QueueHandle<WaylandClientStatePtr>,
seat: wl_seat::WlSeat,
) -> Self {
let dialog_v = XdgWmDialogV1::interface().version;
Globals {
activation: globals.bind(&qh, 1..=1, ()).ok(),
compositor: globals
@@ -160,6 +164,7 @@ impl Globals {
layer_shell: globals.bind(&qh, 1..=5, ()).ok(),
blur_manager: globals.bind(&qh, 1..=1, ()).ok(),
text_input_manager: globals.bind(&qh, 1..=1, ()).ok(),
dialog: globals.bind(&qh, dialog_v..=dialog_v, ()).ok(),
executor,
qh,
}
@@ -729,10 +734,7 @@ impl LinuxClient for WaylandClient {
) -> anyhow::Result<Box<dyn PlatformWindow>> {
let mut state = self.0.borrow_mut();
let parent = state
.keyboard_focused_window
.as_ref()
.and_then(|w| w.toplevel());
let parent = state.keyboard_focused_window.clone();
let (window, surface_id) = WaylandWindow::new(
handle,
@@ -751,7 +753,12 @@ impl LinuxClient for WaylandClient {
fn set_cursor_style(&self, style: CursorStyle) {
let mut state = self.0.borrow_mut();
let need_update = state.cursor_style != Some(style);
let need_update = state.cursor_style != Some(style)
&& (state.mouse_focused_window.is_none()
|| state
.mouse_focused_window
.as_ref()
.is_some_and(|w| !w.is_blocked()));
if need_update {
let serial = state.serial_tracker.get(SerialKind::MouseEnter);
@@ -1011,7 +1018,7 @@ impl Dispatch<WlCallback, ObjectId> for WaylandClientStatePtr {
}
}
fn get_window(
pub(crate) fn get_window(
mut state: &mut RefMut<WaylandClientState>,
surface_id: &ObjectId,
) -> Option<WaylandWindowStatePtr> {
@@ -1654,6 +1661,30 @@ impl Dispatch<wl_pointer::WlPointer, ()> for WaylandClientStatePtr {
state.mouse_location = Some(point(px(surface_x as f32), px(surface_y as f32)));
if let Some(window) = state.mouse_focused_window.clone() {
if window.is_blocked() {
let default_style = CursorStyle::Arrow;
if state.cursor_style != Some(default_style) {
let serial = state.serial_tracker.get(SerialKind::MouseEnter);
state.cursor_style = Some(default_style);
if let Some(cursor_shape_device) = &state.cursor_shape_device {
cursor_shape_device.set_shape(serial, default_style.to_shape());
} else {
// cursor-shape-v1 isn't supported, set the cursor using a surface.
let wl_pointer = state
.wl_pointer
.clone()
.expect("window is focused by pointer");
let scale = window.primary_output_scale();
state.cursor.set_icon(
&wl_pointer,
serial,
default_style.to_icon_names(),
scale,
);
}
}
}
if state
.keyboard_focused_window
.as_ref()
@@ -2225,3 +2256,27 @@ impl Dispatch<zwp_primary_selection_source_v1::ZwpPrimarySelectionSourceV1, ()>
}
}
}
impl Dispatch<XdgWmDialogV1, ()> for WaylandClientStatePtr {
fn event(
_: &mut Self,
_: &XdgWmDialogV1,
_: <XdgWmDialogV1 as Proxy>::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
}
}
impl Dispatch<XdgDialogV1, ()> for WaylandClientStatePtr {
fn event(
_state: &mut Self,
_proxy: &XdgDialogV1,
_event: <XdgDialogV1 as Proxy>::Event,
_data: &(),
_conn: &Connection,
_qhandle: &QueueHandle<Self>,
) {
}
}

View File

@@ -7,7 +7,7 @@ use std::{
};
use blade_graphics as gpu;
use collections::HashMap;
use collections::{FxHashSet, HashMap};
use futures::channel::oneshot::Receiver;
use raw_window_handle as rwh;
@@ -20,7 +20,7 @@ use wayland_protocols::xdg::shell::client::xdg_surface;
use wayland_protocols::xdg::shell::client::xdg_toplevel::{self};
use wayland_protocols::{
wp::fractional_scale::v1::client::wp_fractional_scale_v1,
xdg::shell::client::xdg_toplevel::XdgToplevel,
xdg::dialog::v1::client::xdg_dialog_v1::XdgDialogV1,
};
use wayland_protocols_plasma::blur::client::org_kde_kwin_blur;
use wayland_protocols_wlr::layer_shell::v1::client::zwlr_layer_surface_v1;
@@ -29,7 +29,7 @@ use crate::{
AnyWindowHandle, Bounds, Decorations, Globals, GpuSpecs, Modifiers, Output, Pixels,
PlatformDisplay, PlatformInput, Point, PromptButton, PromptLevel, RequestFrameOptions,
ResizeEdge, Size, Tiling, WaylandClientStatePtr, WindowAppearance, WindowBackgroundAppearance,
WindowBounds, WindowControlArea, WindowControls, WindowDecorations, WindowParams,
WindowBounds, WindowControlArea, WindowControls, WindowDecorations, WindowParams, get_window,
layer_shell::LayerShellNotSupportedError, px, size,
};
use crate::{
@@ -87,6 +87,8 @@ struct InProgressConfigure {
pub struct WaylandWindowState {
surface_state: WaylandSurfaceState,
acknowledged_first_configure: bool,
parent: Option<WaylandWindowStatePtr>,
children: FxHashSet<ObjectId>,
pub surface: wl_surface::WlSurface,
app_id: Option<String>,
appearance: WindowAppearance,
@@ -126,7 +128,7 @@ impl WaylandSurfaceState {
surface: &wl_surface::WlSurface,
globals: &Globals,
params: &WindowParams,
parent: Option<XdgToplevel>,
parent: Option<WaylandWindowStatePtr>,
) -> anyhow::Result<Self> {
// For layer_shell windows, create a layer surface instead of an xdg surface
if let WindowKind::LayerShell(options) = &params.kind {
@@ -178,10 +180,28 @@ impl WaylandSurfaceState {
.get_xdg_surface(&surface, &globals.qh, surface.id());
let toplevel = xdg_surface.get_toplevel(&globals.qh, surface.id());
if params.kind == WindowKind::Floating {
toplevel.set_parent(parent.as_ref());
let xdg_parent = parent.as_ref().and_then(|w| w.toplevel());
if params.kind == WindowKind::Floating || params.kind == WindowKind::Dialog {
toplevel.set_parent(xdg_parent.as_ref());
}
let dialog = if params.kind == WindowKind::Dialog {
let dialog = globals.dialog.as_ref().map(|dialog| {
let xdg_dialog = dialog.get_xdg_dialog(&toplevel, &globals.qh, ());
xdg_dialog.set_modal();
xdg_dialog
});
if let Some(parent) = parent.as_ref() {
parent.add_child(surface.id());
}
dialog
} else {
None
};
if let Some(size) = params.window_min_size {
toplevel.set_min_size(size.width.0 as i32, size.height.0 as i32);
}
@@ -198,6 +218,7 @@ impl WaylandSurfaceState {
xdg_surface,
toplevel,
decoration,
dialog,
}))
}
}
@@ -206,6 +227,7 @@ pub struct WaylandXdgSurfaceState {
xdg_surface: xdg_surface::XdgSurface,
toplevel: xdg_toplevel::XdgToplevel,
decoration: Option<zxdg_toplevel_decoration_v1::ZxdgToplevelDecorationV1>,
dialog: Option<XdgDialogV1>,
}
pub struct WaylandLayerSurfaceState {
@@ -258,7 +280,13 @@ impl WaylandSurfaceState {
xdg_surface,
toplevel,
decoration: _decoration,
dialog,
}) => {
// drop the dialog before toplevel so compositor can explicitly unapply it's effects
if let Some(dialog) = dialog {
dialog.destroy();
}
// The role object (toplevel) must always be destroyed before the xdg_surface.
// See https://wayland.app/protocols/xdg-shell#xdg_surface:request:destroy
toplevel.destroy();
@@ -288,6 +316,7 @@ impl WaylandWindowState {
globals: Globals,
gpu_context: &BladeContext,
options: WindowParams,
parent: Option<WaylandWindowStatePtr>,
) -> anyhow::Result<Self> {
let renderer = {
let raw_window = RawWindow {
@@ -319,6 +348,8 @@ impl WaylandWindowState {
Ok(Self {
surface_state,
acknowledged_first_configure: false,
parent,
children: FxHashSet::default(),
surface,
app_id: None,
blur: None,
@@ -391,6 +422,10 @@ impl Drop for WaylandWindow {
fn drop(&mut self) {
let mut state = self.0.state.borrow_mut();
let surface_id = state.surface.id();
if let Some(parent) = state.parent.as_ref() {
parent.state.borrow_mut().children.remove(&surface_id);
}
let client = state.client.clone();
state.renderer.destroy();
@@ -448,10 +483,10 @@ impl WaylandWindow {
client: WaylandClientStatePtr,
params: WindowParams,
appearance: WindowAppearance,
parent: Option<XdgToplevel>,
parent: Option<WaylandWindowStatePtr>,
) -> anyhow::Result<(Self, ObjectId)> {
let surface = globals.compositor.create_surface(&globals.qh, ());
let surface_state = WaylandSurfaceState::new(&surface, &globals, &params, parent)?;
let surface_state = WaylandSurfaceState::new(&surface, &globals, &params, parent.clone())?;
if let Some(fractional_scale_manager) = globals.fractional_scale_manager.as_ref() {
fractional_scale_manager.get_fractional_scale(&surface, &globals.qh, surface.id());
@@ -473,6 +508,7 @@ impl WaylandWindow {
globals,
gpu_context,
params,
parent,
)?)),
callbacks: Rc::new(RefCell::new(Callbacks::default())),
});
@@ -501,6 +537,16 @@ impl WaylandWindowStatePtr {
Rc::ptr_eq(&self.state, &other.state)
}
pub fn add_child(&self, child: ObjectId) {
let mut state = self.state.borrow_mut();
state.children.insert(child);
}
pub fn is_blocked(&self) -> bool {
let state = self.state.borrow();
!state.children.is_empty()
}
pub fn frame(&self) {
let mut state = self.state.borrow_mut();
state.surface.frame(&state.globals.qh, state.surface.id());
@@ -818,6 +864,9 @@ impl WaylandWindowStatePtr {
}
pub fn handle_ime(&self, ime: ImeInput) {
if self.is_blocked() {
return;
}
let mut state = self.state.borrow_mut();
if let Some(mut input_handler) = state.input_handler.take() {
drop(state);
@@ -894,6 +943,21 @@ impl WaylandWindowStatePtr {
}
pub fn close(&self) {
let state = self.state.borrow();
let client = state.client.get_client();
#[allow(clippy::mutable_key_type)]
let children = state.children.clone();
drop(state);
for child in children {
let mut client_state = client.borrow_mut();
let window = get_window(&mut client_state, &child);
drop(client_state);
if let Some(child) = window {
child.close();
}
}
let mut callbacks = self.callbacks.borrow_mut();
if let Some(fun) = callbacks.close.take() {
fun()
@@ -901,6 +965,9 @@ impl WaylandWindowStatePtr {
}
pub fn handle_input(&self, input: PlatformInput) {
if self.is_blocked() {
return;
}
if let Some(ref mut fun) = self.callbacks.borrow_mut().input
&& !fun(input.clone()).propagate
{

View File

@@ -222,7 +222,7 @@ pub struct X11ClientState {
pub struct X11ClientStatePtr(pub Weak<RefCell<X11ClientState>>);
impl X11ClientStatePtr {
fn get_client(&self) -> Option<X11Client> {
pub fn get_client(&self) -> Option<X11Client> {
self.0.upgrade().map(X11Client)
}
@@ -752,7 +752,7 @@ impl X11Client {
}
}
fn get_window(&self, win: xproto::Window) -> Option<X11WindowStatePtr> {
pub(crate) fn get_window(&self, win: xproto::Window) -> Option<X11WindowStatePtr> {
let state = self.0.borrow();
state
.windows
@@ -789,12 +789,12 @@ impl X11Client {
let [atom, arg1, arg2, arg3, arg4] = event.data.as_data32();
let mut state = self.0.borrow_mut();
if atom == state.atoms.WM_DELETE_WINDOW {
if atom == state.atoms.WM_DELETE_WINDOW && window.should_close() {
// window "x" button clicked by user
if window.should_close() {
// Rest of the close logic is handled in drop_window()
window.close();
}
// Rest of the close logic is handled in drop_window()
drop(state);
window.close();
state = self.0.borrow_mut();
} else if atom == state.atoms._NET_WM_SYNC_REQUEST {
window.state.borrow_mut().last_sync_counter =
Some(x11rb::protocol::sync::Int64 {
@@ -1216,6 +1216,33 @@ impl X11Client {
Event::XinputMotion(event) => {
let window = self.get_window(event.event)?;
let mut state = self.0.borrow_mut();
if window.is_blocked() {
// We want to set the cursor to the default arrow
// when the window is blocked
let style = CursorStyle::Arrow;
let current_style = state
.cursor_styles
.get(&window.x_window)
.unwrap_or(&CursorStyle::Arrow);
if *current_style != style
&& let Some(cursor) = state.get_cursor_icon(style)
{
state.cursor_styles.insert(window.x_window, style);
check_reply(
|| "Failed to set cursor style",
state.xcb_connection.change_window_attributes(
window.x_window,
&ChangeWindowAttributesAux {
cursor: Some(cursor),
..Default::default()
},
),
)
.log_err();
state.xcb_connection.flush().log_err();
};
}
let pressed_button = pressed_button_from_mask(event.button_mask[0]);
let position = point(
px(event.event_x as f32 / u16::MAX as f32 / state.scale_factor),
@@ -1489,7 +1516,7 @@ impl LinuxClient for X11Client {
let parent_window = state
.keyboard_focused_window
.and_then(|focused_window| state.windows.get(&focused_window))
.map(|window| window.window.x_window);
.map(|w| w.window.clone());
let x_window = state
.xcb_connection
.generate_id()
@@ -1544,7 +1571,15 @@ impl LinuxClient for X11Client {
.cursor_styles
.get(&focused_window)
.unwrap_or(&CursorStyle::Arrow);
if *current_style == style {
let window = state
.mouse_focused_window
.and_then(|w| state.windows.get(&w));
let should_change = *current_style != style
&& (window.is_none() || window.is_some_and(|w| !w.is_blocked()));
if !should_change {
return;
}

View File

@@ -11,6 +11,7 @@ use crate::{
};
use blade_graphics as gpu;
use collections::FxHashSet;
use raw_window_handle as rwh;
use util::{ResultExt, maybe};
use x11rb::{
@@ -74,6 +75,7 @@ x11rb::atom_manager! {
_NET_WM_WINDOW_TYPE,
_NET_WM_WINDOW_TYPE_NOTIFICATION,
_NET_WM_WINDOW_TYPE_DIALOG,
_NET_WM_STATE_MODAL,
_NET_WM_SYNC,
_NET_SUPPORTED,
_MOTIF_WM_HINTS,
@@ -249,6 +251,8 @@ pub struct Callbacks {
pub struct X11WindowState {
pub destroyed: bool,
parent: Option<X11WindowStatePtr>,
children: FxHashSet<xproto::Window>,
client: X11ClientStatePtr,
executor: ForegroundExecutor,
atoms: XcbAtoms,
@@ -394,7 +398,7 @@ impl X11WindowState {
atoms: &XcbAtoms,
scale_factor: f32,
appearance: WindowAppearance,
parent_window: Option<xproto::Window>,
parent_window: Option<X11WindowStatePtr>,
) -> anyhow::Result<Self> {
let x_screen_index = params
.display_id
@@ -546,8 +550,8 @@ impl X11WindowState {
)?;
}
if params.kind == WindowKind::Floating {
if let Some(parent_window) = parent_window {
if params.kind == WindowKind::Floating || params.kind == WindowKind::Dialog {
if let Some(parent_window) = parent_window.as_ref().map(|w| w.x_window) {
// WM_TRANSIENT_FOR hint indicating the main application window. For floating windows, we set
// a parent window (WM_TRANSIENT_FOR) such that the window manager knows where to
// place the floating window in relation to the main window.
@@ -563,11 +567,23 @@ impl X11WindowState {
),
)?;
}
}
let parent = if params.kind == WindowKind::Dialog
&& let Some(parent) = parent_window
{
parent.add_child(x_window);
Some(parent)
} else {
None
};
if params.kind == WindowKind::Dialog {
// _NET_WM_WINDOW_TYPE_DIALOG indicates that this is a dialog (floating) window
// https://specifications.freedesktop.org/wm-spec/1.4/ar01s05.html
check_reply(
|| "X11 ChangeProperty32 setting window type for floating window failed.",
|| "X11 ChangeProperty32 setting window type for dialog window failed.",
xcb.change_property32(
xproto::PropMode::REPLACE,
x_window,
@@ -576,6 +592,20 @@ impl X11WindowState {
&[atoms._NET_WM_WINDOW_TYPE_DIALOG],
),
)?;
// We set the modal state for dialog windows, so that the window manager
// can handle it appropriately (e.g., prevent interaction with the parent window
// while the dialog is open).
check_reply(
|| "X11 ChangeProperty32 setting modal state for dialog window failed.",
xcb.change_property32(
xproto::PropMode::REPLACE,
x_window,
atoms._NET_WM_STATE,
xproto::AtomEnum::ATOM,
&[atoms._NET_WM_STATE_MODAL],
),
)?;
}
check_reply(
@@ -667,6 +697,8 @@ impl X11WindowState {
let display = Rc::new(X11Display::new(xcb, scale_factor, x_screen_index)?);
Ok(Self {
parent,
children: FxHashSet::default(),
client,
executor,
display,
@@ -720,6 +752,11 @@ pub(crate) struct X11Window(pub X11WindowStatePtr);
impl Drop for X11Window {
fn drop(&mut self) {
let mut state = self.0.state.borrow_mut();
if let Some(parent) = state.parent.as_ref() {
parent.state.borrow_mut().children.remove(&self.0.x_window);
}
state.renderer.destroy();
let destroy_x_window = maybe!({
@@ -734,8 +771,6 @@ impl Drop for X11Window {
.log_err();
if destroy_x_window.is_some() {
// Mark window as destroyed so that we can filter out when X11 events
// for it still come in.
state.destroyed = true;
let this_ptr = self.0.clone();
@@ -773,7 +808,7 @@ impl X11Window {
atoms: &XcbAtoms,
scale_factor: f32,
appearance: WindowAppearance,
parent_window: Option<xproto::Window>,
parent_window: Option<X11WindowStatePtr>,
) -> anyhow::Result<Self> {
let ptr = X11WindowStatePtr {
state: Rc::new(RefCell::new(X11WindowState::new(
@@ -979,7 +1014,31 @@ impl X11WindowStatePtr {
Ok(())
}
pub fn add_child(&self, child: xproto::Window) {
let mut state = self.state.borrow_mut();
state.children.insert(child);
}
pub fn is_blocked(&self) -> bool {
let state = self.state.borrow();
!state.children.is_empty()
}
pub fn close(&self) {
let state = self.state.borrow();
let client = state.client.clone();
#[allow(clippy::mutable_key_type)]
let children = state.children.clone();
drop(state);
if let Some(client) = client.get_client() {
for child in children {
if let Some(child_window) = client.get_window(child) {
child_window.close();
}
}
}
let mut callbacks = self.callbacks.borrow_mut();
if let Some(fun) = callbacks.close.take() {
fun()
@@ -994,6 +1053,9 @@ impl X11WindowStatePtr {
}
pub fn handle_input(&self, input: PlatformInput) {
if self.is_blocked() {
return;
}
if let Some(ref mut fun) = self.callbacks.borrow_mut().input
&& !fun(input.clone()).propagate
{
@@ -1016,6 +1078,9 @@ impl X11WindowStatePtr {
}
pub fn handle_ime_commit(&self, text: String) {
if self.is_blocked() {
return;
}
let mut state = self.state.borrow_mut();
if let Some(mut input_handler) = state.input_handler.take() {
drop(state);
@@ -1026,6 +1091,9 @@ impl X11WindowStatePtr {
}
pub fn handle_ime_preedit(&self, text: String) {
if self.is_blocked() {
return;
}
let mut state = self.state.borrow_mut();
if let Some(mut input_handler) = state.input_handler.take() {
drop(state);
@@ -1036,6 +1104,9 @@ impl X11WindowStatePtr {
}
pub fn handle_ime_unmark(&self) {
if self.is_blocked() {
return;
}
let mut state = self.state.borrow_mut();
if let Some(mut input_handler) = state.input_handler.take() {
drop(state);
@@ -1046,6 +1117,9 @@ impl X11WindowStatePtr {
}
pub fn handle_ime_delete(&self) {
if self.is_blocked() {
return;
}
let mut state = self.state.borrow_mut();
if let Some(mut input_handler) = state.input_handler.take() {
drop(state);

View File

@@ -46,9 +46,9 @@ pub unsafe fn new_renderer(
_native_window: *mut c_void,
_native_view: *mut c_void,
_bounds: crate::Size<f32>,
_transparent: bool,
transparent: bool,
) -> Renderer {
MetalRenderer::new(context)
MetalRenderer::new(context, transparent)
}
pub(crate) struct InstanceBufferPool {
@@ -128,7 +128,7 @@ pub struct PathRasterizationVertex {
}
impl MetalRenderer {
pub fn new(instance_buffer_pool: Arc<Mutex<InstanceBufferPool>>) -> Self {
pub fn new(instance_buffer_pool: Arc<Mutex<InstanceBufferPool>>, transparent: bool) -> Self {
// Prefer lowpower integrated GPUs on Intel Mac. On Apple
// Silicon, there is only ever one GPU, so this is equivalent to
// `metal::Device::system_default()`.
@@ -152,8 +152,13 @@ impl MetalRenderer {
let layer = metal::MetalLayer::new();
layer.set_device(&device);
layer.set_pixel_format(MTLPixelFormat::BGRA8Unorm);
layer.set_opaque(false);
// Support direct-to-display rendering if the window is not transparent
// https://developer.apple.com/documentation/metal/managing-your-game-window-for-metal-in-macos
layer.set_opaque(!transparent);
layer.set_maximum_drawable_count(3);
// We already present at display sync with the display link
// This allows to use direct-to-display even in window mode
layer.set_display_sync_enabled(false);
unsafe {
let _: () = msg_send![&*layer, setAllowsNextDrawableTimeout: NO];
let _: () = msg_send![&*layer, setNeedsDisplayOnBoundsChange: YES];
@@ -352,8 +357,8 @@ impl MetalRenderer {
}
}
pub fn update_transparency(&self, _transparent: bool) {
// todo(mac)?
pub fn update_transparency(&self, transparent: bool) {
self.layer.set_opaque(!transparent);
}
pub fn destroy(&self) {

View File

@@ -62,9 +62,12 @@ static mut BLURRED_VIEW_CLASS: *const Class = ptr::null();
#[allow(non_upper_case_globals)]
const NSWindowStyleMaskNonactivatingPanel: NSWindowStyleMask =
NSWindowStyleMask::from_bits_retain(1 << 7);
// WindowLevel const value ref: https://docs.rs/core-graphics2/0.4.1/src/core_graphics2/window_level.rs.html
#[allow(non_upper_case_globals)]
const NSNormalWindowLevel: NSInteger = 0;
#[allow(non_upper_case_globals)]
const NSFloatingWindowLevel: NSInteger = 3;
#[allow(non_upper_case_globals)]
const NSPopUpWindowLevel: NSInteger = 101;
#[allow(non_upper_case_globals)]
const NSTrackingMouseEnteredAndExited: NSUInteger = 0x01;
@@ -423,6 +426,8 @@ struct MacWindowState {
select_previous_tab_callback: Option<Box<dyn FnMut()>>,
toggle_tab_bar_callback: Option<Box<dyn FnMut()>>,
activated_least_once: bool,
// The parent window if this window is a sheet (Dialog kind)
sheet_parent: Option<id>,
}
impl MacWindowState {
@@ -622,11 +627,16 @@ impl MacWindow {
}
let native_window: id = match kind {
WindowKind::Normal | WindowKind::Floating => msg_send![WINDOW_CLASS, alloc],
WindowKind::Normal => {
msg_send![WINDOW_CLASS, alloc]
}
WindowKind::PopUp => {
style_mask |= NSWindowStyleMaskNonactivatingPanel;
msg_send![PANEL_CLASS, alloc]
}
WindowKind::Floating | WindowKind::Dialog => {
msg_send![PANEL_CLASS, alloc]
}
};
let display = display_id
@@ -729,6 +739,7 @@ impl MacWindow {
select_previous_tab_callback: None,
toggle_tab_bar_callback: None,
activated_least_once: false,
sheet_parent: None,
})));
(*native_window).set_ivar(
@@ -779,9 +790,18 @@ impl MacWindow {
content_view.addSubview_(native_view.autorelease());
native_window.makeFirstResponder_(native_view);
let app: id = NSApplication::sharedApplication(nil);
let main_window: id = msg_send![app, mainWindow];
let mut sheet_parent = None;
match kind {
WindowKind::Normal | WindowKind::Floating => {
native_window.setLevel_(NSNormalWindowLevel);
if kind == WindowKind::Floating {
// Let the window float keep above normal windows.
native_window.setLevel_(NSFloatingWindowLevel);
} else {
native_window.setLevel_(NSNormalWindowLevel);
}
native_window.setAcceptsMouseMovedEvents_(YES);
if let Some(tabbing_identifier) = tabbing_identifier {
@@ -816,10 +836,23 @@ impl MacWindow {
NSWindowCollectionBehavior::NSWindowCollectionBehaviorFullScreenAuxiliary
);
}
WindowKind::Dialog => {
if !main_window.is_null() {
let parent = {
let active_sheet: id = msg_send![main_window, attachedSheet];
if active_sheet.is_null() {
main_window
} else {
active_sheet
}
};
let _: () =
msg_send![parent, beginSheet: native_window completionHandler: nil];
sheet_parent = Some(parent);
}
}
}
let app = NSApplication::sharedApplication(nil);
let main_window: id = msg_send![app, mainWindow];
if allows_automatic_window_tabbing
&& !main_window.is_null()
&& main_window != native_window
@@ -861,7 +894,11 @@ impl MacWindow {
// the window position might be incorrect if the main screen (the screen that contains the window that has focus)
// is different from the primary screen.
NSWindow::setFrameTopLeftPoint_(native_window, window_rect.origin);
window.0.lock().move_traffic_light();
{
let mut window_state = window.0.lock();
window_state.move_traffic_light();
window_state.sheet_parent = sheet_parent;
}
pool.drain();
@@ -938,6 +975,7 @@ impl Drop for MacWindow {
let mut this = self.0.lock();
this.renderer.destroy();
let window = this.native_window;
let sheet_parent = this.sheet_parent.take();
this.display_link.take();
unsafe {
this.native_window.setDelegate_(nil);
@@ -946,6 +984,9 @@ impl Drop for MacWindow {
this.executor
.spawn(async move {
unsafe {
if let Some(parent) = sheet_parent {
let _: () = msg_send![parent, endSheet: window];
}
window.close();
window.autorelease();
}

View File

@@ -270,6 +270,14 @@ impl WindowsWindowInner {
fn handle_destroy_msg(&self, handle: HWND) -> Option<isize> {
let callback = { self.state.callbacks.close.take() };
// Re-enable parent window if this was a modal dialog
if let Some(parent_hwnd) = self.parent_hwnd {
unsafe {
let _ = EnableWindow(parent_hwnd, true);
let _ = SetForegroundWindow(parent_hwnd);
}
}
if let Some(callback) = callback {
callback();
}

View File

@@ -83,6 +83,7 @@ pub(crate) struct WindowsWindowInner {
pub(crate) validation_number: usize,
pub(crate) main_receiver: flume::Receiver<RunnableVariant>,
pub(crate) platform_window_handle: HWND,
pub(crate) parent_hwnd: Option<HWND>,
}
impl WindowsWindowState {
@@ -241,6 +242,7 @@ impl WindowsWindowInner {
main_receiver: context.main_receiver.clone(),
platform_window_handle: context.platform_window_handle,
system_settings: WindowsSystemSettings::new(context.display),
parent_hwnd: context.parent_hwnd,
}))
}
@@ -368,6 +370,7 @@ struct WindowCreateContext {
disable_direct_composition: bool,
directx_devices: DirectXDevices,
invalidate_devices: Arc<AtomicBool>,
parent_hwnd: Option<HWND>,
}
impl WindowsWindow {
@@ -390,6 +393,20 @@ impl WindowsWindow {
invalidate_devices,
} = creation_info;
register_window_class(icon);
let parent_hwnd = if params.kind == WindowKind::Dialog {
let parent_window = unsafe { GetActiveWindow() };
if parent_window.is_invalid() {
None
} else {
// Disable the parent window to make this dialog modal
unsafe {
EnableWindow(parent_window, false).as_bool();
};
Some(parent_window)
}
} else {
None
};
let hide_title_bar = params
.titlebar
.as_ref()
@@ -416,8 +433,14 @@ impl WindowsWindow {
if params.is_minimizable {
dwstyle |= WS_MINIMIZEBOX;
}
let dwexstyle = if params.kind == WindowKind::Dialog {
dwstyle |= WS_POPUP | WS_CAPTION;
WS_EX_DLGMODALFRAME
} else {
WS_EX_APPWINDOW
};
(WS_EX_APPWINDOW, dwstyle)
(dwexstyle, dwstyle)
};
if !disable_direct_composition {
dwexstyle |= WS_EX_NOREDIRECTIONBITMAP;
@@ -449,6 +472,7 @@ impl WindowsWindow {
disable_direct_composition,
directx_devices,
invalidate_devices,
parent_hwnd,
};
let creation_result = unsafe {
CreateWindowExW(
@@ -460,7 +484,7 @@ impl WindowsWindow {
CW_USEDEFAULT,
CW_USEDEFAULT,
CW_USEDEFAULT,
None,
parent_hwnd,
None,
Some(hinstance.into()),
Some(&context as *const _ as *const _),

View File

@@ -128,22 +128,21 @@ impl LineWrapper {
})
}
/// Truncate a line of text to the given width with this wrapper's font and font size.
pub fn truncate_line<'a>(
/// Determines if a line should be truncated based on its width.
pub fn should_truncate_line(
&mut self,
line: SharedString,
line: &str,
truncate_width: Pixels,
truncation_suffix: &str,
runs: &'a [TextRun],
) -> (SharedString, Cow<'a, [TextRun]>) {
) -> Option<usize> {
let mut width = px(0.);
let mut suffix_width = truncation_suffix
let suffix_width = truncation_suffix
.chars()
.map(|c| self.width_for_char(c))
.fold(px(0.0), |a, x| a + x);
let mut char_indices = line.char_indices();
let mut truncate_ix = 0;
for (ix, c) in char_indices {
for (ix, c) in line.char_indices() {
if width + suffix_width < truncate_width {
truncate_ix = ix;
}
@@ -152,16 +151,32 @@ impl LineWrapper {
width += char_width;
if width.floor() > truncate_width {
let result =
SharedString::from(format!("{}{}", &line[..truncate_ix], truncation_suffix));
let mut runs = runs.to_vec();
update_runs_after_truncation(&result, truncation_suffix, &mut runs);
return (result, Cow::Owned(runs));
return Some(truncate_ix);
}
}
(line, Cow::Borrowed(runs))
None
}
/// Truncate a line of text to the given width with this wrapper's font and font size.
pub fn truncate_line<'a>(
&mut self,
line: SharedString,
truncate_width: Pixels,
truncation_suffix: &str,
runs: &'a [TextRun],
) -> (SharedString, Cow<'a, [TextRun]>) {
if let Some(truncate_ix) =
self.should_truncate_line(&line, truncate_width, truncation_suffix)
{
let result =
SharedString::from(format!("{}{}", &line[..truncate_ix], truncation_suffix));
let mut runs = runs.to_vec();
update_runs_after_truncation(&result, truncation_suffix, &mut runs);
(result, Cow::Owned(runs))
} else {
(line, Cow::Borrowed(runs))
}
}
/// Any character in this list should be treated as a word character,

View File

@@ -882,7 +882,9 @@ impl LanguageServer {
window: Some(WindowClientCapabilities {
work_done_progress: Some(true),
show_message: Some(ShowMessageRequestClientCapabilities {
message_action_item: None,
message_action_item: Some(MessageActionItemCapabilities {
additional_properties_support: Some(true),
}),
}),
..WindowClientCapabilities::default()
}),

View File

@@ -2610,9 +2610,8 @@ impl MultiBuffer {
for range in ranges {
let range = range.to_point(&snapshot);
let start = snapshot.point_to_offset(Point::new(range.start.row, 0));
let end = snapshot.point_to_offset(Point::new(range.end.row + 1, 0));
let start = start.saturating_sub_usize(1);
let end = snapshot.len().min(end + 1usize);
let end = (snapshot.point_to_offset(Point::new(range.end.row + 1, 0)) + 1usize)
.min(snapshot.len());
cursor.seek(&start, Bias::Right);
while let Some(item) = cursor.item() {
if *cursor.start() >= end {

View File

@@ -50,7 +50,13 @@ impl Settings for OutlinePanelSettings {
dock: panel.dock.unwrap(),
file_icons: panel.file_icons.unwrap(),
folder_icons: panel.folder_icons.unwrap(),
git_status: panel.git_status.unwrap(),
git_status: panel.git_status.unwrap()
&& content
.git
.unwrap()
.enabled
.unwrap()
.is_git_status_enabled(),
indent_size: panel.indent_size.unwrap(),
indent_guides: IndentGuidesSettings {
show: panel.indent_guides.unwrap().show.unwrap(),

View File

@@ -2,6 +2,7 @@ pub mod extension;
pub mod registry;
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context as _, Result};
use collections::{HashMap, HashSet};
@@ -18,6 +19,10 @@ use crate::{
worktree_store::WorktreeStore,
};
/// Maximum timeout for context server requests (10 minutes).
/// Prevents extremely large timeout values from tying up resources indefinitely.
const MAX_TIMEOUT_MS: u64 = 600_000;
pub fn init(cx: &mut App) {
extension::init(cx);
}
@@ -102,6 +107,7 @@ pub enum ContextServerConfiguration {
Http {
url: url::Url,
headers: HashMap<String, String>,
timeout: Option<u64>,
},
}
@@ -151,9 +157,14 @@ impl ContextServerConfiguration {
enabled: _,
url,
headers: auth,
timeout,
} => {
let url = url::Url::parse(&url).log_err()?;
Some(ContextServerConfiguration::Http { url, headers: auth })
Some(ContextServerConfiguration::Http {
url,
headers: auth,
timeout,
})
}
}
}
@@ -482,18 +493,32 @@ impl ContextServerStore {
configuration: Arc<ContextServerConfiguration>,
cx: &mut Context<Self>,
) -> Result<Arc<ContextServer>> {
// Get global timeout from settings
let global_timeout = ProjectSettings::get_global(cx).context_server_timeout;
if let Some(factory) = self.context_server_factory.as_ref() {
return Ok(factory(id, configuration));
}
match configuration.as_ref() {
ContextServerConfiguration::Http { url, headers } => Ok(Arc::new(ContextServer::http(
id,
ContextServerConfiguration::Http {
url,
headers.clone(),
cx.http_client(),
cx.background_executor().clone(),
)?)),
headers,
timeout,
} => {
// Apply timeout precedence for HTTP servers: per-server > global
// Cap at MAX_TIMEOUT_MS to prevent extremely large timeout values
let resolved_timeout = timeout.unwrap_or(global_timeout).min(MAX_TIMEOUT_MS);
Ok(Arc::new(ContextServer::http(
id,
url,
headers.clone(),
cx.http_client(),
cx.background_executor().clone(),
Some(Duration::from_millis(resolved_timeout)),
)?))
}
_ => {
let root_path = self
.project
@@ -511,9 +536,23 @@ impl ContextServerStore {
})
})
});
// Apply timeout precedence for stdio servers: per-server > global
// Cap at MAX_TIMEOUT_MS to prevent extremely large timeout values
let mut command_with_timeout = configuration
.command()
.context("Missing command configuration for stdio context server")?
.clone();
if command_with_timeout.timeout.is_none() {
command_with_timeout.timeout = Some(global_timeout.min(MAX_TIMEOUT_MS));
} else {
command_with_timeout.timeout =
command_with_timeout.timeout.map(|t| t.min(MAX_TIMEOUT_MS));
}
Ok(Arc::new(ContextServer::stdio(
id,
configuration.command().unwrap().clone(),
command_with_timeout,
root_path,
)))
}
@@ -1257,6 +1296,7 @@ mod tests {
enabled: true,
url: server_url.to_string(),
headers: Default::default(),
timeout: None,
},
)],
)
@@ -1327,6 +1367,165 @@ mod tests {
}
}
#[gpui::test]
async fn test_context_server_global_timeout(cx: &mut TestAppContext) {
// Configure global timeout to 90 seconds
cx.update(|cx| {
let settings_store = SettingsStore::test(cx);
cx.set_global(settings_store);
SettingsStore::update_global(cx, |store, cx| {
store
.set_user_settings(r#"{"context_server_timeout": 90000}"#, cx)
.expect("Failed to set test user settings");
});
});
let (_fs, project) = setup_context_server_test(cx, json!({"code.rs": ""}), vec![]).await;
let registry = cx.new(|_| ContextServerDescriptorRegistry::new());
let store = cx.new(|cx| {
ContextServerStore::test(
registry.clone(),
project.read(cx).worktree_store(),
project.downgrade(),
cx,
)
});
// Test that create_context_server applies global timeout
let result = store.update(cx, |store, cx| {
store.create_context_server(
ContextServerId("test-server".into()),
Arc::new(ContextServerConfiguration::Http {
url: url::Url::parse("http://localhost:8080")
.expect("Failed to parse test URL"),
headers: Default::default(),
timeout: None, // Should use global timeout of 90 seconds
}),
cx,
)
});
assert!(
result.is_ok(),
"Server should be created successfully with global timeout"
);
}
#[gpui::test]
async fn test_context_server_per_server_timeout_override(cx: &mut TestAppContext) {
const SERVER_ID: &str = "test-server";
// Configure global timeout to 60 seconds
cx.update(|cx| {
let settings_store = SettingsStore::test(cx);
cx.set_global(settings_store);
SettingsStore::update_global(cx, |store, cx| {
store
.set_user_settings(r#"{"context_server_timeout": 60000}"#, cx)
.expect("Failed to set test user settings");
});
});
let (_fs, project) = setup_context_server_test(
cx,
json!({"code.rs": ""}),
vec![(
SERVER_ID.into(),
ContextServerSettings::Http {
enabled: true,
url: "http://localhost:8080".to_string(),
headers: Default::default(),
timeout: Some(120000), // Override to 120 seconds
},
)],
)
.await;
let registry = cx.new(|_| ContextServerDescriptorRegistry::new());
let store = cx.new(|cx| {
ContextServerStore::test(
registry.clone(),
project.read(cx).worktree_store(),
project.downgrade(),
cx,
)
});
// Test that create_context_server applies per-server timeout override
let result = store.update(cx, |store, cx| {
store.create_context_server(
ContextServerId("test-server".into()),
Arc::new(ContextServerConfiguration::Http {
url: url::Url::parse("http://localhost:8080")
.expect("Failed to parse test URL"),
headers: Default::default(),
timeout: Some(120000), // Override: should use 120 seconds, not global 60
}),
cx,
)
});
assert!(
result.is_ok(),
"Server should be created successfully with per-server timeout override"
);
}
#[gpui::test]
async fn test_context_server_stdio_timeout(cx: &mut TestAppContext) {
const SERVER_ID: &str = "stdio-server";
let (_fs, project) = setup_context_server_test(
cx,
json!({"code.rs": ""}),
vec![(
SERVER_ID.into(),
ContextServerSettings::Stdio {
enabled: true,
command: ContextServerCommand {
path: "/usr/bin/node".into(),
args: vec!["server.js".into()],
env: None,
timeout: Some(180000), // 3 minutes
},
},
)],
)
.await;
let registry = cx.new(|_| ContextServerDescriptorRegistry::new());
let store = cx.new(|cx| {
ContextServerStore::test(
registry.clone(),
project.read(cx).worktree_store(),
project.downgrade(),
cx,
)
});
// Test that create_context_server works with stdio timeout
let result = store.update(cx, |store, cx| {
store.create_context_server(
ContextServerId("stdio-server".into()),
Arc::new(ContextServerConfiguration::Custom {
command: ContextServerCommand {
path: "/usr/bin/node".into(),
args: vec!["server.js".into()],
env: None,
timeout: Some(180000), // 3 minutes
},
}),
cx,
)
});
assert!(
result.is_ok(),
"Stdio server should be created successfully with timeout"
);
}
fn dummy_server_settings() -> ContextServerSettings {
ContextServerSettings::Stdio {
enabled: true,

View File

@@ -128,6 +128,7 @@ use util::{
ConnectionResult, ResultExt as _, debug_panic, defer, maybe, merge_json_value_into,
paths::{PathStyle, SanitizedPath},
post_inc,
redact::redact_command,
rel_path::RelPath,
};
@@ -577,9 +578,12 @@ impl LocalLspStore {
},
},
);
log::error!("Failed to start language server {server_name:?}: {err:?}");
log::error!(
"Failed to start language server {server_name:?}: {}",
redact_command(&format!("{err:?}"))
);
if !log.is_empty() {
log::error!("server stderr: {log}");
log::error!("server stderr: {}", redact_command(&log));
}
None
}

View File

@@ -59,6 +59,9 @@ pub struct ProjectSettings {
/// Settings for context servers used for AI-related features.
pub context_servers: HashMap<Arc<str>, ContextServerSettings>,
/// Default timeout for context server requests in milliseconds.
pub context_server_timeout: u64,
/// Configuration for Diagnostics-related features.
pub diagnostics: DiagnosticsSettings,
@@ -141,6 +144,8 @@ pub enum ContextServerSettings {
/// Optional authentication configuration for the remote server.
#[serde(skip_serializing_if = "HashMap::is_empty", default)]
headers: HashMap<String, String>,
/// Timeout for tool calls in milliseconds.
timeout: Option<u64>,
},
Extension {
/// Whether the context server is enabled.
@@ -167,10 +172,12 @@ impl From<settings::ContextServerSettingsContent> for ContextServerSettings {
enabled,
url,
headers,
timeout,
} => ContextServerSettings::Http {
enabled,
url,
headers,
timeout,
},
}
}
@@ -188,10 +195,12 @@ impl Into<settings::ContextServerSettingsContent> for ContextServerSettings {
enabled,
url,
headers,
timeout,
} => settings::ContextServerSettingsContent::Http {
enabled,
url,
headers,
timeout,
},
}
}
@@ -332,6 +341,10 @@ impl GoToDiagnosticSeverityFilter {
#[derive(Copy, Clone, Debug)]
pub struct GitSettings {
/// Whether or not git integration is enabled.
///
/// Default: true
pub enabled: GitEnabledSettings,
/// Whether or not to show the git gutter.
///
/// Default: tracked_files
@@ -361,6 +374,18 @@ pub struct GitSettings {
pub path_style: GitPathStyle,
}
#[derive(Clone, Copy, Debug)]
pub struct GitEnabledSettings {
/// Whether git integration is enabled for showing git status.
///
/// Default: true
pub status: bool,
/// Whether git integration is enabled for showing diffs.
///
/// Default: true
pub diff: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Default)]
pub enum GitPathStyle {
#[default]
@@ -502,7 +527,14 @@ impl Settings for ProjectSettings {
let inline_diagnostics = diagnostics.inline.as_ref().unwrap();
let git = content.git.as_ref().unwrap();
let git_enabled = {
GitEnabledSettings {
status: git.enabled.as_ref().unwrap().is_git_status_enabled(),
diff: git.enabled.as_ref().unwrap().is_git_diff_enabled(),
}
};
let git_settings = GitSettings {
enabled: git_enabled,
git_gutter: git.git_gutter.unwrap(),
gutter_debounce: git.gutter_debounce.unwrap_or_default(),
inline_blame: {
@@ -537,6 +569,7 @@ impl Settings for ProjectSettings {
.into_iter()
.map(|(key, value)| (key, value.into()))
.collect(),
context_server_timeout: project.context_server_timeout.unwrap_or(60000),
lsp: project
.lsp
.clone()

View File

@@ -92,7 +92,13 @@ impl Settings for ProjectPanelSettings {
entry_spacing: project_panel.entry_spacing.unwrap(),
file_icons: project_panel.file_icons.unwrap(),
folder_icons: project_panel.folder_icons.unwrap(),
git_status: project_panel.git_status.unwrap(),
git_status: project_panel.git_status.unwrap()
&& content
.git
.unwrap()
.enabled
.unwrap()
.is_git_status_enabled(),
indent_size: project_panel.indent_size.unwrap(),
indent_guides: IndentGuidesSettings {
show: project_panel.indent_guides.unwrap().show.unwrap(),

View File

@@ -28,6 +28,11 @@ parking_lot.workspace = true
paths.workspace = true
rope.workspace = true
serde.workspace = true
strum.workspace = true
text.workspace = true
util.workspace = true
uuid.workspace = true
[dev-dependencies]
gpui = { workspace = true, features = ["test-support"] }
tempfile.workspace = true

View File

@@ -1,6 +1,6 @@
mod prompts;
use anyhow::{Context as _, Result, anyhow};
use anyhow::{Result, anyhow};
use chrono::{DateTime, Utc};
use collections::HashMap;
use futures::FutureExt as _;
@@ -23,6 +23,7 @@ use std::{
path::PathBuf,
sync::{Arc, atomic::AtomicBool},
};
use strum::{EnumIter, IntoEnumIterator as _};
use text::LineEnding;
use util::ResultExt;
use uuid::Uuid;
@@ -51,11 +52,51 @@ pub struct PromptMetadata {
pub saved_at: DateTime<Utc>,
}
impl PromptMetadata {
fn builtin(builtin: BuiltInPrompt) -> Self {
Self {
id: PromptId::BuiltIn(builtin),
title: Some(builtin.title().into()),
default: false,
saved_at: DateTime::default(),
}
}
}
/// Built-in prompts that have default content and can be customized by users.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, EnumIter)]
pub enum BuiltInPrompt {
CommitMessage,
}
impl BuiltInPrompt {
pub fn title(&self) -> &'static str {
match self {
Self::CommitMessage => "Commit message",
}
}
/// Returns the default content for this built-in prompt.
pub fn default_content(&self) -> &'static str {
match self {
Self::CommitMessage => include_str!("../../git_ui/src/commit_message_prompt.txt"),
}
}
}
impl std::fmt::Display for BuiltInPrompt {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::CommitMessage => write!(f, "Commit message"),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(tag = "kind")]
pub enum PromptId {
User { uuid: UserPromptId },
CommitMessage,
BuiltIn(BuiltInPrompt),
}
impl PromptId {
@@ -63,31 +104,37 @@ impl PromptId {
UserPromptId::new().into()
}
pub fn user_id(&self) -> Option<UserPromptId> {
pub fn as_user(&self) -> Option<UserPromptId> {
match self {
Self::User { uuid } => Some(*uuid),
_ => None,
Self::BuiltIn { .. } => None,
}
}
pub fn as_built_in(&self) -> Option<BuiltInPrompt> {
match self {
Self::User { .. } => None,
Self::BuiltIn(builtin) => Some(*builtin),
}
}
pub fn is_built_in(&self) -> bool {
match self {
Self::User { .. } => false,
Self::CommitMessage => true,
}
matches!(self, Self::BuiltIn { .. })
}
pub fn can_edit(&self) -> bool {
match self {
Self::User { .. } | Self::CommitMessage => true,
Self::User { .. } => true,
Self::BuiltIn(builtin) => match builtin {
BuiltInPrompt::CommitMessage => true,
},
}
}
}
pub fn default_content(&self) -> Option<&'static str> {
match self {
Self::User { .. } => None,
Self::CommitMessage => Some(include_str!("../../git_ui/src/commit_message_prompt.txt")),
}
impl From<BuiltInPrompt> for PromptId {
fn from(builtin: BuiltInPrompt) -> Self {
PromptId::BuiltIn(builtin)
}
}
@@ -117,7 +164,7 @@ impl std::fmt::Display for PromptId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PromptId::User { uuid } => write!(f, "{}", uuid.0),
PromptId::CommitMessage => write!(f, "Commit message"),
PromptId::BuiltIn(builtin) => write!(f, "{}", builtin),
}
}
}
@@ -150,6 +197,16 @@ impl MetadataCache {
cache.metadata.push(metadata.clone());
cache.metadata_by_id.insert(prompt_id, metadata);
}
// Insert all the built-in prompts that were not customized by the user
for builtin in BuiltInPrompt::iter() {
let builtin_id = PromptId::BuiltIn(builtin);
if !cache.metadata_by_id.contains_key(&builtin_id) {
let metadata = PromptMetadata::builtin(builtin);
cache.metadata.push(metadata.clone());
cache.metadata_by_id.insert(builtin_id, metadata);
}
}
cache.sort();
Ok(cache)
}
@@ -198,10 +255,6 @@ impl PromptStore {
let mut txn = db_env.write_txn()?;
let metadata = db_env.create_database(&mut txn, Some("metadata.v2"))?;
let bodies = db_env.create_database(&mut txn, Some("bodies.v2"))?;
metadata.delete(&mut txn, &PromptId::CommitMessage)?;
bodies.delete(&mut txn, &PromptId::CommitMessage)?;
txn.commit()?;
Self::upgrade_dbs(&db_env, metadata, bodies).log_err();
@@ -294,7 +347,16 @@ impl PromptStore {
let bodies = self.bodies;
cx.background_spawn(async move {
let txn = env.read_txn()?;
let mut prompt = bodies.get(&txn, &id)?.context("prompt not found")?.into();
let mut prompt: String = match bodies.get(&txn, &id)? {
Some(body) => body.into(),
None => {
if let Some(built_in) = id.as_built_in() {
built_in.default_content().into()
} else {
anyhow::bail!("prompt not found")
}
}
};
LineEnding::normalize(&mut prompt);
Ok(prompt)
})
@@ -339,11 +401,6 @@ impl PromptStore {
})
}
/// Returns the number of prompts in the store.
pub fn prompt_count(&self) -> usize {
self.metadata_cache.read().metadata.len()
}
pub fn metadata(&self, id: PromptId) -> Option<PromptMetadata> {
self.metadata_cache.read().metadata_by_id.get(&id).cloned()
}
@@ -412,23 +469,38 @@ impl PromptStore {
return Task::ready(Err(anyhow!("this prompt cannot be edited")));
}
let prompt_metadata = PromptMetadata {
id,
title,
default,
saved_at: Utc::now(),
let body = body.to_string();
let is_default_content = id
.as_built_in()
.is_some_and(|builtin| body.trim() == builtin.default_content().trim());
let metadata = if let Some(builtin) = id.as_built_in() {
PromptMetadata::builtin(builtin)
} else {
PromptMetadata {
id,
title,
default,
saved_at: Utc::now(),
}
};
self.metadata_cache.write().insert(prompt_metadata.clone());
self.metadata_cache.write().insert(metadata.clone());
let db_connection = self.env.clone();
let bodies = self.bodies;
let metadata = self.metadata;
let metadata_db = self.metadata;
let task = cx.background_spawn(async move {
let mut txn = db_connection.write_txn()?;
metadata.put(&mut txn, &id, &prompt_metadata)?;
bodies.put(&mut txn, &id, &body.to_string())?;
if is_default_content {
metadata_db.delete(&mut txn, &id)?;
bodies.delete(&mut txn, &id)?;
} else {
metadata_db.put(&mut txn, &id, &metadata)?;
bodies.put(&mut txn, &id, &body)?;
}
txn.commit()?;
@@ -490,3 +562,122 @@ impl PromptStore {
pub struct GlobalPromptStore(Shared<Task<Result<Entity<PromptStore>, Arc<anyhow::Error>>>>);
impl Global for GlobalPromptStore {}
#[cfg(test)]
mod tests {
use super::*;
use gpui::TestAppContext;
#[gpui::test]
async fn test_built_in_prompt_load_save(cx: &mut TestAppContext) {
cx.executor().allow_parking();
let temp_dir = tempfile::tempdir().unwrap();
let db_path = temp_dir.path().join("prompts-db");
let store = cx.update(|cx| PromptStore::new(db_path, cx)).await.unwrap();
let store = cx.new(|_cx| store);
let commit_message_id = PromptId::BuiltIn(BuiltInPrompt::CommitMessage);
let loaded_content = store
.update(cx, |store, cx| store.load(commit_message_id, cx))
.await
.unwrap();
let mut expected_content = BuiltInPrompt::CommitMessage.default_content().to_string();
LineEnding::normalize(&mut expected_content);
assert_eq!(
loaded_content.trim(),
expected_content.trim(),
"Loading a built-in prompt not in DB should return default content"
);
let metadata = store.read_with(cx, |store, _| store.metadata(commit_message_id));
assert!(
metadata.is_some(),
"Built-in prompt should always have metadata"
);
assert!(
store.read_with(cx, |store, _| {
store
.metadata_cache
.read()
.metadata_by_id
.contains_key(&commit_message_id)
}),
"Built-in prompt should always be in cache"
);
let custom_content = "Custom commit message prompt";
store
.update(cx, |store, cx| {
store.save(
commit_message_id,
Some("Commit message".into()),
false,
Rope::from(custom_content),
cx,
)
})
.await
.unwrap();
let loaded_custom = store
.update(cx, |store, cx| store.load(commit_message_id, cx))
.await
.unwrap();
assert_eq!(
loaded_custom.trim(),
custom_content.trim(),
"Custom content should be loaded after saving"
);
assert!(
store
.read_with(cx, |store, _| store.metadata(commit_message_id))
.is_some(),
"Built-in prompt should have metadata after customization"
);
store
.update(cx, |store, cx| {
store.save(
commit_message_id,
Some("Commit message".into()),
false,
Rope::from(BuiltInPrompt::CommitMessage.default_content()),
cx,
)
})
.await
.unwrap();
let metadata_after_reset =
store.read_with(cx, |store, _| store.metadata(commit_message_id));
assert!(
metadata_after_reset.is_some(),
"Built-in prompt should still have metadata after reset"
);
assert_eq!(
metadata_after_reset
.as_ref()
.and_then(|m| m.title.as_ref().map(|t| t.as_ref())),
Some("Commit message"),
"Built-in prompt should have default title after reset"
);
let loaded_after_reset = store
.update(cx, |store, cx| store.load(commit_message_id, cx))
.await
.unwrap();
let mut expected_content_after_reset =
BuiltInPrompt::CommitMessage.default_content().to_string();
LineEnding::normalize(&mut expected_content_after_reset);
assert_eq!(
loaded_after_reset.trim(),
expected_content_after_reset.trim(),
"After saving default content, load should return default"
);
}
}

View File

@@ -3,9 +3,9 @@ use collections::{HashMap, HashSet};
use editor::{CompletionProvider, SelectionEffects};
use editor::{CurrentLineHighlight, Editor, EditorElement, EditorEvent, EditorStyle, actions::Tab};
use gpui::{
Action, App, Bounds, DEFAULT_ADDITIONAL_WINDOW_SIZE, Entity, EventEmitter, Focusable,
PromptLevel, Subscription, Task, TextStyle, TitlebarOptions, WindowBounds, WindowHandle,
WindowOptions, actions, point, size, transparent_black,
App, Bounds, DEFAULT_ADDITIONAL_WINDOW_SIZE, Entity, EventEmitter, Focusable, PromptLevel,
Subscription, Task, TextStyle, TitlebarOptions, WindowBounds, WindowHandle, WindowOptions,
actions, point, size, transparent_black,
};
use language::{Buffer, LanguageRegistry, language_settings::SoftWrap};
use language_model::{
@@ -21,7 +21,7 @@ use std::sync::atomic::AtomicBool;
use std::time::Duration;
use theme::ThemeSettings;
use title_bar::platform_title_bar::PlatformTitleBar;
use ui::{Divider, KeyBinding, ListItem, ListItemSpacing, ListSubHeader, Tooltip, prelude::*};
use ui::{Divider, ListItem, ListItemSpacing, ListSubHeader, Tooltip, prelude::*};
use util::{ResultExt, TryFutureExt};
use workspace::{Workspace, WorkspaceSettings, client_side_decorations};
use zed_actions::assistant::InlineAssist;
@@ -206,13 +206,8 @@ impl PickerDelegate for RulePickerDelegate {
self.filtered_entries.len()
}
fn no_matches_text(&self, _window: &mut Window, cx: &mut App) -> Option<SharedString> {
let text = if self.store.read(cx).prompt_count() == 0 {
"No rules.".into()
} else {
"No rules found matching your search.".into()
};
Some(text)
fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
Some("No rules found matching your search.".into())
}
fn selected_index(&self) -> usize {
@@ -680,13 +675,13 @@ impl RulesLibrary {
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(default_content) = prompt_id.default_content() else {
let Some(built_in) = prompt_id.as_built_in() else {
return;
};
if let Some(rule_editor) = self.rule_editors.get(&prompt_id) {
rule_editor.body_editor.update(cx, |editor, cx| {
editor.set_text(default_content, window, cx);
editor.set_text(built_in.default_content(), window, cx);
});
}
}
@@ -1428,31 +1423,7 @@ impl Render for RulesLibrary {
this.border_t_1().border_color(cx.theme().colors().border)
})
.child(self.render_rule_list(cx))
.map(|el| {
if self.store.read(cx).prompt_count() == 0 {
el.child(
v_flex()
.h_full()
.flex_1()
.items_center()
.justify_center()
.border_l_1()
.border_color(cx.theme().colors().border)
.bg(cx.theme().colors().editor_background)
.child(
Button::new("create-rule", "New Rule")
.style(ButtonStyle::Outlined)
.key_binding(KeyBinding::for_action(&NewRule, cx))
.on_click(|_, window, cx| {
window
.dispatch_action(NewRule.boxed_clone(), cx)
}),
),
)
} else {
el.child(self.render_active_rule(cx))
}
}),
.child(self.render_active_rule(cx)),
),
window,
cx,

View File

@@ -41,6 +41,12 @@ pub struct ProjectSettingsContent {
#[serde(default)]
pub context_servers: HashMap<Arc<str>, ContextServerSettingsContent>,
/// Default timeout in milliseconds for context server tool calls.
/// Can be overridden per-server in context_servers configuration.
///
/// Default: 60000 (60 seconds)
pub context_server_timeout: Option<u64>,
/// Configuration for how direnv configuration should be loaded
pub load_direnv: Option<DirenvSettings>,
@@ -215,6 +221,8 @@ pub enum ContextServerSettingsContent {
/// Optional headers to send.
#[serde(skip_serializing_if = "HashMap::is_empty", default)]
headers: HashMap<String, String>,
/// Timeout for tool calls in milliseconds. Defaults to global context_server_timeout if not specified.
timeout: Option<u64>,
},
Extension {
/// Whether the context server is enabled.
@@ -288,6 +296,11 @@ impl std::fmt::Debug for ContextServerCommand {
#[with_fallible_options]
#[derive(Copy, Clone, Debug, PartialEq, Default, Serialize, Deserialize, JsonSchema, MergeFrom)]
pub struct GitSettings {
/// Whether or not to enable git integration.
///
/// Default: true
#[serde(flatten)]
pub enabled: Option<GitEnabledSettings>,
/// Whether or not to show the git gutter.
///
/// Default: tracked_files
@@ -317,6 +330,25 @@ pub struct GitSettings {
pub path_style: Option<GitPathStyle>,
}
#[with_fallible_options]
#[derive(Clone, Copy, Debug, PartialEq, Default, Serialize, Deserialize, JsonSchema, MergeFrom)]
#[serde(rename_all = "snake_case")]
pub struct GitEnabledSettings {
pub disable_git: Option<bool>,
pub enable_status: Option<bool>,
pub enable_diff: Option<bool>,
}
impl GitEnabledSettings {
pub fn is_git_status_enabled(&self) -> bool {
!self.disable_git.unwrap_or(false) && self.enable_status.unwrap_or(true)
}
pub fn is_git_diff_enabled(&self) -> bool {
!self.disable_git.unwrap_or(false) && self.enable_diff.unwrap_or(true)
}
}
#[derive(
Clone,
Copy,

View File

@@ -402,6 +402,7 @@ impl VsCodeSettings {
terminal: None,
dap: Default::default(),
context_servers: self.context_servers(),
context_server_timeout: None,
load_direnv: None,
slash_commands: None,
git_hosting_providers: None,

View File

@@ -5519,6 +5519,102 @@ pub(crate) fn settings_data(cx: &App) -> Vec<SettingsPage> {
SettingsPage {
title: "Version Control",
items: vec![
SettingsPageItem::SectionHeader("Git Integration"),
SettingsPageItem::DynamicItem(DynamicItem {
discriminant: SettingItem {
files: USER,
title: "Disable Git Integration",
description: "Disable all Git integration features in Zed.",
field: Box::new(SettingField::<bool> {
json_path: Some("git.disable_git"),
pick: |settings_content| {
settings_content
.git
.as_ref()?
.enabled
.as_ref()?
.disable_git
.as_ref()
},
write: |settings_content, value| {
settings_content
.git
.get_or_insert_default()
.enabled
.get_or_insert_default()
.disable_git = value;
},
}),
metadata: None,
},
pick_discriminant: |settings_content| {
let disabled = settings_content
.git
.as_ref()?
.enabled
.as_ref()?
.disable_git
.unwrap_or(false);
Some(if disabled { 0 } else { 1 })
},
fields: vec![
vec![],
vec![
SettingItem {
files: USER,
title: "Enable Git Status",
description: "Show Git status information in the editor.",
field: Box::new(SettingField::<bool> {
json_path: Some("git.enable_status"),
pick: |settings_content| {
settings_content
.git
.as_ref()?
.enabled
.as_ref()?
.enable_status
.as_ref()
},
write: |settings_content, value| {
settings_content
.git
.get_or_insert_default()
.enabled
.get_or_insert_default()
.enable_status = value;
},
}),
metadata: None,
},
SettingItem {
files: USER,
title: "Enable Git Diff",
description: "Show Git diff information in the editor.",
field: Box::new(SettingField::<bool> {
json_path: Some("git.enable_diff"),
pick: |settings_content| {
settings_content
.git
.as_ref()?
.enabled
.as_ref()?
.enable_diff
.as_ref()
},
write: |settings_content, value| {
settings_content
.git
.get_or_insert_default()
.enabled
.get_or_insert_default()
.enable_diff = value;
},
}),
metadata: None,
},
],
],
}),
SettingsPageItem::SectionHeader("Git Gutter"),
SettingsPageItem::SettingItem(SettingItem {
title: "Visibility",

View File

@@ -1,3 +1,9 @@
use std::sync::LazyLock;
static REDACT_REGEX: LazyLock<regex::Regex> = LazyLock::new(|| {
regex::Regex::new(r#"([A-Z_][A-Z0-9_]*)=("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|\S+)"#).unwrap()
});
/// Whether a given environment variable name should have its value redacted
pub fn should_redact(env_var_name: &str) -> bool {
const REDACTED_SUFFIXES: &[&str] = &[
@@ -13,3 +19,31 @@ pub fn should_redact(env_var_name: &str) -> bool {
.iter()
.any(|suffix| env_var_name.ends_with(suffix))
}
/// Redact a string which could include a command with environment variables
pub fn redact_command(command: &str) -> String {
REDACT_REGEX
.replace_all(command, |caps: &regex::Captures| {
let var_name = &caps[1];
let value = &caps[2];
if should_redact(var_name) {
format!(r#"{}="[REDACTED]""#, var_name)
} else {
format!("{}={}", var_name, value)
}
})
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_redact_string_with_multiple_env_vars() {
let input = r#"failed to spawn command cd "/code/something" && ANTHROPIC_API_KEY="sk-ant-api03-WOOOO" COMMAND_MODE="unix2003" GEMINI_API_KEY="AIGEMINIFACE" HOME="/Users/foo""#;
let result = redact_command(input);
let expected = r#"failed to spawn command cd "/code/something" && ANTHROPIC_API_KEY="[REDACTED]" COMMAND_MODE="unix2003" GEMINI_API_KEY="[REDACTED]" HOME="/Users/foo""#;
assert_eq!(result, expected);
}
}

View File

@@ -76,7 +76,13 @@ impl Settings for ItemSettings {
fn from_settings(content: &settings::SettingsContent) -> Self {
let tabs = content.tabs.as_ref().unwrap();
Self {
git_status: tabs.git_status.unwrap(),
git_status: tabs.git_status.unwrap()
&& content
.git
.unwrap()
.enabled
.unwrap()
.is_git_status_enabled(),
close_position: tabs.close_position.unwrap(),
activate_on_close: tabs.activate_on_close.unwrap(),
file_icons: tabs.file_icons.unwrap(),

View File

@@ -354,6 +354,8 @@ pub mod agent {
ResetAgentZoom,
/// Toggles the utility/agent pane open/closed state.
ToggleAgentPane,
/// Pastes clipboard content without any formatting.
PasteRaw,
]
);
}

View File

@@ -73,7 +73,7 @@ h_flex()
- `Window`: A struct in zed representing a zed window in your desktop environment (see image below). There can be multiple if you have multiple zed instances open. Mostly passed around for rendering.
- `Modal`: A UI element that floats on top of the rest of the UI
- `Picker`: A struct representing a list of items in floating on top of the UI (Modal). You can select an item and confirm. What happens on select or confirm is determined by the picker's delegate. (The 'Model' in the image below is a picker.)
- `Picker`: A struct representing a list of items floating on top of the UI (Modal). You can select an item and confirm. What happens on select or confirm is determined by the picker's delegate. (The 'Modal' in the image below is a picker.)
- `PickerDelegate`: A trait used to specialize behavior for a `Picker`. The `Picker` stores the `PickerDelegate` in the field delegate.
- `Center`: The middle of the zed window, the center is split into multiple `Pane`s. In the codebase this is a field on the `Workspace` struct. (see image below).
- `Pane`: An area in the `Center` where we can place items, such as an editor, multi-buffer or terminal (see image below).

View File

@@ -6,6 +6,9 @@ prHygiene({
rules: {
// Don't enable this rule just yet, as it can have false positives.
useImperativeMood: "off",
noConventionalCommits: {
bannedTypes: ["feat", "fix", "style", "refactor", "perf", "test", "chore", "build", "revert"],
},
},
});

View File

@@ -8,6 +8,6 @@
},
"devDependencies": {
"danger": "13.0.4",
"danger-plugin-pr-hygiene": "0.6.1"
"danger-plugin-pr-hygiene": "0.7.0"
}
}

View File

@@ -12,8 +12,8 @@ importers:
specifier: 13.0.4
version: 13.0.4
danger-plugin-pr-hygiene:
specifier: 0.6.1
version: 0.6.1
specifier: 0.7.0
version: 0.7.0
packages:
@@ -134,8 +134,8 @@ packages:
core-js@3.45.1:
resolution: {integrity: sha512-L4NPsJlCfZsPeXukyzHFlg/i7IIVwHSItR0wg0FLNqYClJ4MQYTYLbC7EkjKYRLZF2iof2MUgN0EGy7MdQFChg==}
danger-plugin-pr-hygiene@0.6.1:
resolution: {integrity: sha512-nb+iUQvirE3BlKXI1WoOND6sujyGzHar590mJm5tt4RLi65HXFaU5hqONxgDoWFujJNHYnXse9yaZdxnxEi4QA==}
danger-plugin-pr-hygiene@0.7.0:
resolution: {integrity: sha512-YDWhEodP0fg/t9YO3SxufWS9j1Rcxbig+1flTlUlojBDFiKQyVmaj8PIvnJxJItjHWTlNKI9wMSRq5vUql6zyA==}
danger@13.0.4:
resolution: {integrity: sha512-IAdQ5nSJyIs4zKj6AN35ixt2B0Ce3WZUm3IFe/CMnL/Op7wV7IGg4D348U0EKNaNPP58QgXbdSk9pM+IXP1QXg==}
@@ -573,7 +573,7 @@ snapshots:
core-js@3.45.1: {}
danger-plugin-pr-hygiene@0.6.1: {}
danger-plugin-pr-hygiene@0.7.0: {}
danger@13.0.4:
dependencies: