{
div()
.id(id)
@@ -52,12 +60,13 @@ impl Render for Example {
.border_color(gpui::black())
.bg(gpui::black())
.text_color(gpui::white())
- .focus(|this| this.border_color(gpui::blue()))
+ .focus(tab_stop_style)
.shadow_sm()
}
div()
.id("app")
+ .track_focus(&self.focus_handle)
.on_action(cx.listener(Self::on_tab))
.on_action(cx.listener(Self::on_tab_prev))
.size_full()
@@ -86,7 +95,7 @@ impl Render for Example {
.border_color(gpui::black())
.when(
item_handle.tab_stop && item_handle.is_focused(window),
- |this| this.border_color(gpui::blue()),
+ tab_stop_style,
)
.map(|this| match item_handle.tab_stop {
true => this
diff --git a/crates/gpui/src/tab_stop.rs b/crates/gpui/src/tab_stop.rs
index 1aa4cd6d9f..7dde42efed 100644
--- a/crates/gpui/src/tab_stop.rs
+++ b/crates/gpui/src/tab_stop.rs
@@ -32,20 +32,18 @@ impl TabHandles {
self.handles.clear();
}
- fn current_index(&self, focused_id: Option<&FocusId>) -> usize {
- self.handles
- .iter()
- .position(|h| Some(&h.id) == focused_id)
- .unwrap_or_default()
+ fn current_index(&self, focused_id: Option<&FocusId>) -> Option
{
+ self.handles.iter().position(|h| Some(&h.id) == focused_id)
}
pub(crate) fn next(&self, focused_id: Option<&FocusId>) -> Option {
- let ix = self.current_index(focused_id);
-
- let mut next_ix = ix + 1;
- if next_ix + 1 > self.handles.len() {
- next_ix = 0;
- }
+ let next_ix = self
+ .current_index(focused_id)
+ .and_then(|ix| {
+ let next_ix = ix + 1;
+ (next_ix < self.handles.len()).then_some(next_ix)
+ })
+ .unwrap_or_default();
if let Some(next_handle) = self.handles.get(next_ix) {
Some(next_handle.clone())
@@ -55,7 +53,7 @@ impl TabHandles {
}
pub(crate) fn prev(&self, focused_id: Option<&FocusId>) -> Option {
- let ix = self.current_index(focused_id);
+ let ix = self.current_index(focused_id).unwrap_or_default();
let prev_ix;
if ix == 0 {
prev_ix = self.handles.len().saturating_sub(1);
@@ -108,8 +106,14 @@ mod tests {
]
);
- // next
- assert_eq!(tab.next(None), Some(tab.handles[1].clone()));
+ // Select first tab index if no handle is currently focused.
+ assert_eq!(tab.next(None), Some(tab.handles[0].clone()));
+ // Select last tab index if no handle is currently focused.
+ assert_eq!(
+ tab.prev(None),
+ Some(tab.handles[tab.handles.len() - 1].clone())
+ );
+
assert_eq!(
tab.next(Some(&tab.handles[0].id)),
Some(tab.handles[1].clone())
From 9353ba788774c7d82905db9feebf374937487715 Mon Sep 17 00:00:00 2001
From: Agus Zubiaga
Date: Tue, 29 Jul 2025 09:40:59 -0300
Subject: [PATCH 03/35] Fix remaining agent server integration tests (#35222)
Release Notes:
- N/A
---
crates/acp_thread/src/acp_thread.rs | 1 +
crates/acp_thread/src/old_acp_support.rs | 6 +++-
crates/agent_servers/src/codex.rs | 2 +-
crates/agent_servers/src/e2e_tests.rs | 43 ++++++++++++++----------
crates/agent_servers/src/gemini.rs | 1 +
5 files changed, 33 insertions(+), 20 deletions(-)
diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs
index d572992c54..7203580410 100644
--- a/crates/acp_thread/src/acp_thread.rs
+++ b/crates/acp_thread/src/acp_thread.rs
@@ -1597,6 +1597,7 @@ mod tests {
name: "test",
connection,
child_status: io_task,
+ current_thread: thread_rc,
};
AcpThread::new(
diff --git a/crates/acp_thread/src/old_acp_support.rs b/crates/acp_thread/src/old_acp_support.rs
index 44cd00348f..571023239f 100644
--- a/crates/acp_thread/src/old_acp_support.rs
+++ b/crates/acp_thread/src/old_acp_support.rs
@@ -7,6 +7,7 @@ use gpui::{AppContext as _, AsyncApp, Entity, Task, WeakEntity};
use project::Project;
use std::{cell::RefCell, error::Error, fmt, path::Path, rc::Rc};
use ui::App;
+use util::ResultExt as _;
use crate::{AcpThread, AgentConnection};
@@ -46,7 +47,7 @@ impl acp_old::Client for OldAcpClientDelegate {
thread.push_assistant_content_block(thought.into(), true, cx)
}
})
- .ok();
+ .log_err();
})?;
Ok(())
@@ -364,6 +365,7 @@ pub struct OldAcpAgentConnection {
pub name: &'static str,
pub connection: acp_old::AgentConnection,
pub child_status: Task>,
+ pub current_thread: Rc>>,
}
impl AgentConnection for OldAcpAgentConnection {
@@ -383,6 +385,7 @@ impl AgentConnection for OldAcpAgentConnection {
}
.into_any(),
);
+ let current_thread = self.current_thread.clone();
cx.spawn(async move |cx| {
let result = task.await?;
let result = acp_old::InitializeParams::response_from_any(result)?;
@@ -396,6 +399,7 @@ impl AgentConnection for OldAcpAgentConnection {
let session_id = acp::SessionId("acp-old-no-id".into());
AcpThread::new(self.clone(), project, session_id, cx)
});
+ current_thread.replace(thread.downgrade());
thread
})
})
diff --git a/crates/agent_servers/src/codex.rs b/crates/agent_servers/src/codex.rs
index b10ce9cf54..d713f0d11c 100644
--- a/crates/agent_servers/src/codex.rs
+++ b/crates/agent_servers/src/codex.rs
@@ -310,7 +310,7 @@ pub(crate) mod tests {
AgentServerCommand {
path: cli_path,
- args: vec!["mcp".into()],
+ args: vec![],
env: None,
}
}
diff --git a/crates/agent_servers/src/e2e_tests.rs b/crates/agent_servers/src/e2e_tests.rs
index aca9001c79..e9c72eabc9 100644
--- a/crates/agent_servers/src/e2e_tests.rs
+++ b/crates/agent_servers/src/e2e_tests.rs
@@ -12,7 +12,6 @@ use futures::{FutureExt, StreamExt, channel::mpsc, select};
use gpui::{Entity, TestAppContext};
use indoc::indoc;
use project::{FakeFs, Project};
-use serde_json::json;
use settings::{Settings, SettingsStore};
use util::path;
@@ -27,7 +26,11 @@ pub async fn test_basic(server: impl AgentServer + 'static, cx: &mut TestAppCont
.unwrap();
thread.read_with(cx, |thread, _| {
- assert_eq!(thread.entries().len(), 2);
+ assert!(
+ thread.entries().len() >= 2,
+ "Expected at least 2 entries. Got: {:?}",
+ thread.entries()
+ );
assert!(matches!(
thread.entries()[0],
AgentThreadEntry::UserMessage(_)
@@ -108,19 +111,19 @@ pub async fn test_path_mentions(server: impl AgentServer + 'static, cx: &mut Tes
}
pub async fn test_tool_call(server: impl AgentServer + 'static, cx: &mut TestAppContext) {
- let fs = init_test(cx).await;
- fs.insert_tree(
- path!("/private/tmp"),
- json!({"foo": "Lorem ipsum dolor", "bar": "bar", "baz": "baz"}),
- )
- .await;
- let project = Project::test(fs, [path!("/private/tmp").as_ref()], cx).await;
+ let _fs = init_test(cx).await;
+
+ let tempdir = tempfile::tempdir().unwrap();
+ let foo_path = tempdir.path().join("foo");
+ std::fs::write(&foo_path, "Lorem ipsum dolor").expect("failed to write file");
+
+ let project = Project::example([tempdir.path()], &mut cx.to_async()).await;
let thread = new_test_thread(server, project.clone(), "/private/tmp", cx).await;
thread
.update(cx, |thread, cx| {
thread.send_raw(
- "Read the '/private/tmp/foo' file and tell me what you see.",
+ &format!("Read {} and tell me what you see.", foo_path.display()),
cx,
)
})
@@ -143,6 +146,8 @@ pub async fn test_tool_call(server: impl AgentServer + 'static, cx: &mut TestApp
.any(|entry| { matches!(entry, AgentThreadEntry::AssistantMessage(_)) })
);
});
+
+ drop(tempdir);
}
pub async fn test_tool_call_with_confirmation(
@@ -155,7 +160,7 @@ pub async fn test_tool_call_with_confirmation(
let thread = new_test_thread(server, project.clone(), "/private/tmp", cx).await;
let full_turn = thread.update(cx, |thread, cx| {
thread.send_raw(
- r#"Run `touch hello.txt && echo "Hello, world!" | tee hello.txt`"#,
+ r#"Run exactly `touch hello.txt && echo "Hello, world!" | tee hello.txt` in the terminal."#,
cx,
)
});
@@ -175,10 +180,10 @@ pub async fn test_tool_call_with_confirmation(
)
.await;
- let tool_call_id = thread.read_with(cx, |thread, _cx| {
+ let tool_call_id = thread.read_with(cx, |thread, cx| {
let AgentThreadEntry::ToolCall(ToolCall {
id,
- content,
+ label,
status: ToolCallStatus::WaitingForConfirmation { .. },
..
}) = &thread
@@ -190,7 +195,8 @@ pub async fn test_tool_call_with_confirmation(
panic!();
};
- assert!(content.iter().any(|c| c.to_markdown(_cx).contains("touch")));
+ let label = label.read(cx).source();
+ assert!(label.contains("touch"), "Got: {}", label);
id.clone()
});
@@ -242,7 +248,7 @@ pub async fn test_cancel(server: impl AgentServer + 'static, cx: &mut TestAppCon
let thread = new_test_thread(server, project.clone(), "/private/tmp", cx).await;
let full_turn = thread.update(cx, |thread, cx| {
thread.send_raw(
- r#"Run `touch hello.txt && echo "Hello, world!" >> hello.txt`"#,
+ r#"Run exactly `touch hello.txt && echo "Hello, world!" | tee hello.txt` in the terminal."#,
cx,
)
});
@@ -262,10 +268,10 @@ pub async fn test_cancel(server: impl AgentServer + 'static, cx: &mut TestAppCon
)
.await;
- thread.read_with(cx, |thread, _cx| {
+ thread.read_with(cx, |thread, cx| {
let AgentThreadEntry::ToolCall(ToolCall {
id,
- content,
+ label,
status: ToolCallStatus::WaitingForConfirmation { .. },
..
}) = &thread.entries()[first_tool_call_ix]
@@ -273,7 +279,8 @@ pub async fn test_cancel(server: impl AgentServer + 'static, cx: &mut TestAppCon
panic!("{:?}", thread.entries()[1]);
};
- assert!(content.iter().any(|c| c.to_markdown(_cx).contains("touch")));
+ let label = label.read(cx).source();
+ assert!(label.contains("touch"), "Got: {}", label);
id.clone()
});
diff --git a/crates/agent_servers/src/gemini.rs b/crates/agent_servers/src/gemini.rs
index 8b9fed5777..a97ff3f462 100644
--- a/crates/agent_servers/src/gemini.rs
+++ b/crates/agent_servers/src/gemini.rs
@@ -107,6 +107,7 @@ impl AgentServer for Gemini {
name,
connection,
child_status,
+ current_thread: thread_rc,
});
Ok(connection)
From 5a218d83231647ec22fe8defa0904cdae11e22be Mon Sep 17 00:00:00 2001
From: Kirill Bulatov
Date: Tue, 29 Jul 2025 18:24:52 +0300
Subject: [PATCH 04/35] Add more data to see which extension got leaked
(#35272)
Part of https://github.com/zed-industries/zed/issues/35185
Release Notes:
- N/A
---
crates/extension_host/src/wasm_host.rs | 29 ++++++++++++++++++++++----
1 file changed, 25 insertions(+), 4 deletions(-)
diff --git a/crates/extension_host/src/wasm_host.rs b/crates/extension_host/src/wasm_host.rs
index 1f6f5035e3..d909d06f6b 100644
--- a/crates/extension_host/src/wasm_host.rs
+++ b/crates/extension_host/src/wasm_host.rs
@@ -777,8 +777,18 @@ impl WasmExtension {
}
.boxed()
}))
- .expect("wasm extension channel should not be closed yet");
- return_rx.await.expect("wasm extension channel")
+ .unwrap_or_else(|_| {
+ panic!(
+ "wasm extension channel should not be closed yet, extension {} (id {})",
+ self.manifest.name, self.manifest.id,
+ )
+ });
+ return_rx.await.unwrap_or_else(|_| {
+ panic!(
+ "wasm extension channel, extension {} (id {})",
+ self.manifest.name, self.manifest.id,
+ )
+ })
}
}
@@ -799,8 +809,19 @@ impl WasmState {
}
.boxed_local()
}))
- .expect("main thread message channel should not be closed yet");
- async move { return_rx.await.expect("main thread message channel") }
+ .unwrap_or_else(|_| {
+ panic!(
+ "main thread message channel should not be closed yet, extension {} (id {})",
+ self.manifest.name, self.manifest.id,
+ )
+ });
+ let name = self.manifest.name.clone();
+ let id = self.manifest.id.clone();
+ async move {
+ return_rx.await.unwrap_or_else(|_| {
+ panic!("main thread message channel, extension {name} (id {id})")
+ })
+ }
}
fn work_dir(&self) -> PathBuf {
From 3fc84f8a62977a6e2c732a6536f30cafb11ad55c Mon Sep 17 00:00:00 2001
From: Peter Tripp
Date: Tue, 29 Jul 2025 11:29:12 -0400
Subject: [PATCH 05/35] Comment on source of ctrl-m in keymaps (#35273)
Closes https://github.com/zed-industries/zed/issues/23896
Release Notes:
- N/A
---
assets/keymaps/default-linux.json | 2 +-
assets/keymaps/default-macos.json | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json
index a4f812b2fc..e36e093e22 100644
--- a/assets/keymaps/default-linux.json
+++ b/assets/keymaps/default-linux.json
@@ -495,7 +495,7 @@
"shift-f12": "editor::GoToImplementation",
"alt-ctrl-f12": "editor::GoToTypeDefinitionSplit",
"alt-shift-f12": "editor::FindAllReferences",
- "ctrl-m": "editor::MoveToEnclosingBracket",
+ "ctrl-m": "editor::MoveToEnclosingBracket", // from jetbrains
"ctrl-|": "editor::MoveToEnclosingBracket",
"ctrl-{": "editor::Fold",
"ctrl-}": "editor::UnfoldLines",
diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json
index eded8c73e6..0114e2da1d 100644
--- a/assets/keymaps/default-macos.json
+++ b/assets/keymaps/default-macos.json
@@ -549,7 +549,7 @@
"alt-cmd-f12": "editor::GoToTypeDefinitionSplit",
"alt-shift-f12": "editor::FindAllReferences",
"cmd-|": "editor::MoveToEnclosingBracket",
- "ctrl-m": "editor::MoveToEnclosingBracket",
+ "ctrl-m": "editor::MoveToEnclosingBracket", // From Jetbrains
"alt-cmd-[": "editor::Fold",
"alt-cmd-]": "editor::UnfoldLines",
"cmd-k cmd-l": "editor::ToggleFold",
From 2fced602b805c1645daf2486fc53035632f75d65 Mon Sep 17 00:00:00 2001
From: devjasperwang
Date: Tue, 29 Jul 2025 23:31:54 +0800
Subject: [PATCH 06/35] paths: Fix using relative path as custom_data_dir
(#35256)
This PR fixes issue of incorrect LSP path args caused by using a
relative path when customizing data directory.
command:
```bash
.\target\debug\zed.exe --user-data-dir=.\target\data
```
before:
```log
2025-07-29T14:17:18+08:00 INFO [lsp] starting language server process. binary path: "F:\\nvm\\nodejs\\node.exe", working directory: "F:\\zed\\target\\data\\config", args: [".\\target\\data\\languages\\json-language-server\\node_modules/vscode-langservers-extracted/bin/vscode-json-language-server", "--stdio"]
2025-07-29T14:17:18+08:00 INFO [project::prettier_store] Installing default prettier and plugins: [("prettier", "3.6.2")]
2025-07-29T14:17:18+08:00 ERROR [lsp] cannot read LSP message headers
2025-07-29T14:17:18+08:00 ERROR [lsp] Shutdown request failure, server json-language-server (id 1): server shut down
2025-07-29T14:17:43+08:00 ERROR [project] Invalid file path provided to LSP request: ".\\target\\data\\config\\settings.json"
Thread "main" panicked with "called `Result::unwrap()` on an `Err` value: ()" at crates\project\src\lsp_store.rs:7203:54
https://github.com/zed-industries/zed/blob/cfd5b8ff10cd88a97988292c964689f67301520b/src/crates\project\src\lsp_store.rs#L7203 (may not be uploaded, line may be incorrect if files modified)
```
after:
```log
2025-07-29T14:24:20+08:00 INFO [lsp] starting language server process. binary path: "F:\\nvm\\nodejs\\node.exe", working directory: "F:\\zed\\target\\data\\config", args: ["F:\\zed\\target\\data\\languages\\json-language-server\\node_modules/vscode-langservers-extracted/bin/vscode-json-language-server", "--stdio"]
```
Release Notes:
- N/A
---
crates/paths/src/paths.rs | 10 +++++++++-
1 file changed, 9 insertions(+), 1 deletion(-)
diff --git a/crates/paths/src/paths.rs b/crates/paths/src/paths.rs
index 2f3b188980..47a0f12c06 100644
--- a/crates/paths/src/paths.rs
+++ b/crates/paths/src/paths.rs
@@ -35,6 +35,7 @@ pub fn remote_server_dir_relative() -> &'static Path {
/// Sets a custom directory for all user data, overriding the default data directory.
/// This function must be called before any other path operations that depend on the data directory.
+/// The directory's path will be canonicalized to an absolute path by a blocking FS operation.
/// The directory will be created if it doesn't exist.
///
/// # Arguments
@@ -50,13 +51,20 @@ pub fn remote_server_dir_relative() -> &'static Path {
///
/// Panics if:
/// * Called after the data directory has been initialized (e.g., via `data_dir` or `config_dir`)
+/// * The directory's path cannot be canonicalized to an absolute path
/// * The directory cannot be created
pub fn set_custom_data_dir(dir: &str) -> &'static PathBuf {
if CURRENT_DATA_DIR.get().is_some() || CONFIG_DIR.get().is_some() {
panic!("set_custom_data_dir called after data_dir or config_dir was initialized");
}
CUSTOM_DATA_DIR.get_or_init(|| {
- let path = PathBuf::from(dir);
+ let mut path = PathBuf::from(dir);
+ if path.is_relative() {
+ let abs_path = path
+ .canonicalize()
+ .expect("failed to canonicalize custom data directory's path to an absolute path");
+ path = PathBuf::from(util::paths::SanitizedPath::from(abs_path))
+ }
std::fs::create_dir_all(&path).expect("failed to create custom data directory");
path
})
From a8bdf30259e93be218c6402c2f544f4932b92c68 Mon Sep 17 00:00:00 2001
From: Marshall Bowers
Date: Tue, 29 Jul 2025 11:45:49 -0400
Subject: [PATCH 07/35] client: Fix typo in the error message (#35275)
This PR fixes a typo in the error message for when we fail to parse the
Collab URL.
Release Notes:
- N/A
---
crates/client/src/client.rs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs
index 81bb95b514..07df7043b5 100644
--- a/crates/client/src/client.rs
+++ b/crates/client/src/client.rs
@@ -1138,7 +1138,7 @@ impl Client {
.to_str()
.map_err(EstablishConnectionError::other)?
.to_string();
- Url::parse(&collab_url).with_context(|| format!("parsing colab rpc url {collab_url}"))
+ Url::parse(&collab_url).with_context(|| format!("parsing collab rpc url {collab_url}"))
}
}
From 511fdaed43e6002778f4bc0693cc5f70552f90b2 Mon Sep 17 00:00:00 2001
From: localcc
Date: Tue, 29 Jul 2025 17:58:28 +0200
Subject: [PATCH 08/35] Allow searching Windows paths with forward slash
(#35198)
Release Notes:
- Searching windows paths is now possible with a forward slash
---
crates/file_finder/src/file_finder.rs | 21 ++++++++++++++-------
1 file changed, 14 insertions(+), 7 deletions(-)
diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs
index a4d61dd56f..e5ac70bb58 100644
--- a/crates/file_finder/src/file_finder.rs
+++ b/crates/file_finder/src/file_finder.rs
@@ -1404,14 +1404,21 @@ impl PickerDelegate for FileFinderDelegate {
} else {
let path_position = PathWithPosition::parse_str(&raw_query);
+ #[cfg(windows)]
+ let raw_query = raw_query.trim().to_owned().replace("/", "\\");
+ #[cfg(not(windows))]
+ let raw_query = raw_query.trim().to_owned();
+
+ let file_query_end = if path_position.path.to_str().unwrap_or(&raw_query) == raw_query {
+ None
+ } else {
+ // Safe to unwrap as we won't get here when the unwrap in if fails
+ Some(path_position.path.to_str().unwrap().len())
+ };
+
let query = FileSearchQuery {
- raw_query: raw_query.trim().to_owned(),
- file_query_end: if path_position.path.to_str().unwrap_or(raw_query) == raw_query {
- None
- } else {
- // Safe to unwrap as we won't get here when the unwrap in if fails
- Some(path_position.path.to_str().unwrap().len())
- },
+ raw_query,
+ file_query_end,
path_position,
};
From d43f4641748bc17fbfb60ba03b6c4f04fc82817e Mon Sep 17 00:00:00 2001
From: localcc
Date: Tue, 29 Jul 2025 18:01:07 +0200
Subject: [PATCH 09/35] Fix nightly icon (#35204)
Release Notes:
- N/A
---
.github/workflows/ci.yml | 2 +-
script/bundle-windows.ps1 | 1 +
2 files changed, 2 insertions(+), 1 deletion(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a9ef1531e7..009fcc8337 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -771,7 +771,7 @@ jobs:
timeout-minutes: 120
name: Create a Windows installer
runs-on: [self-hosted, Windows, X64]
- if: false && (startsWith(github.ref, 'refs/tags/v') || contains(github.event.pull_request.labels.*.name, 'run-bundling'))
+ if: (startsWith(github.ref, 'refs/tags/v') || contains(github.event.pull_request.labels.*.name, 'run-bundling'))
needs: [windows_tests]
env:
AZURE_TENANT_ID: ${{ secrets.AZURE_SIGNING_TENANT_ID }}
diff --git a/script/bundle-windows.ps1 b/script/bundle-windows.ps1
index 3aac8700ce..2f751f1d10 100644
--- a/script/bundle-windows.ps1
+++ b/script/bundle-windows.ps1
@@ -26,6 +26,7 @@ if ($Help) {
Push-Location -Path crates/zed
$channel = Get-Content "RELEASE_CHANNEL"
$env:ZED_RELEASE_CHANNEL = $channel
+$env:RELEASE_CHANNEL = $channel
Pop-Location
function CheckEnvironmentVariables {
From 397b5f930197b470e4323b252300931162ebfe0f Mon Sep 17 00:00:00 2001
From: Finn Evers
Date: Tue, 29 Jul 2025 18:03:43 +0200
Subject: [PATCH 10/35] Ensure context servers are spawned in the workspace
directory (#35271)
This fixes an issue where we were not setting the context server working
directory at all.
Release Notes:
- Context servers will now be spawned in the currently active project
root.
---------
Co-authored-by: Danilo Leal
---
crates/agent_servers/src/codex.rs | 2 +
crates/context_server/src/client.rs | 3 +-
crates/context_server/src/context_server.rs | 16 +++-
.../src/transport/stdio_transport.rs | 11 ++-
crates/project/src/context_server_store.rs | 82 ++++++++++++++-----
crates/project/src/project.rs | 12 ++-
6 files changed, 97 insertions(+), 29 deletions(-)
diff --git a/crates/agent_servers/src/codex.rs b/crates/agent_servers/src/codex.rs
index d713f0d11c..712c333221 100644
--- a/crates/agent_servers/src/codex.rs
+++ b/crates/agent_servers/src/codex.rs
@@ -47,6 +47,7 @@ impl AgentServer for Codex {
cx: &mut App,
) -> Task>> {
let project = project.clone();
+ let working_directory = project.read(cx).active_project_directory(cx);
cx.spawn(async move |cx| {
let settings = cx.read_global(|settings: &SettingsStore, _| {
settings.get::(None).codex.clone()
@@ -65,6 +66,7 @@ impl AgentServer for Codex {
args: command.args,
env: command.env,
},
+ working_directory,
)
.into();
ContextServer::start(client.clone(), cx).await?;
diff --git a/crates/context_server/src/client.rs b/crates/context_server/src/client.rs
index ff4d79c07d..1eb29bbbf9 100644
--- a/crates/context_server/src/client.rs
+++ b/crates/context_server/src/client.rs
@@ -158,6 +158,7 @@ impl Client {
pub fn stdio(
server_id: ContextServerId,
binary: ModelContextServerBinary,
+ working_directory: &Option,
cx: AsyncApp,
) -> Result {
log::info!(
@@ -172,7 +173,7 @@ impl Client {
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_else(String::new);
- let transport = Arc::new(StdioTransport::new(binary, &cx)?);
+ let transport = Arc::new(StdioTransport::new(binary, working_directory, &cx)?);
Self::new(server_id, server_name.into(), transport, cx)
}
diff --git a/crates/context_server/src/context_server.rs b/crates/context_server/src/context_server.rs
index f2517feb27..e76e7972f7 100644
--- a/crates/context_server/src/context_server.rs
+++ b/crates/context_server/src/context_server.rs
@@ -53,7 +53,7 @@ impl std::fmt::Debug for ContextServerCommand {
}
enum ContextServerTransport {
- Stdio(ContextServerCommand),
+ Stdio(ContextServerCommand, Option),
Custom(Arc),
}
@@ -64,11 +64,18 @@ pub struct ContextServer {
}
impl ContextServer {
- pub fn stdio(id: ContextServerId, command: ContextServerCommand) -> Self {
+ pub fn stdio(
+ id: ContextServerId,
+ command: ContextServerCommand,
+ working_directory: Option>,
+ ) -> Self {
Self {
id,
client: RwLock::new(None),
- configuration: ContextServerTransport::Stdio(command),
+ configuration: ContextServerTransport::Stdio(
+ command,
+ working_directory.map(|directory| directory.to_path_buf()),
+ ),
}
}
@@ -90,13 +97,14 @@ impl ContextServer {
pub async fn start(self: Arc, cx: &AsyncApp) -> Result<()> {
let client = match &self.configuration {
- ContextServerTransport::Stdio(command) => Client::stdio(
+ ContextServerTransport::Stdio(command, working_directory) => Client::stdio(
client::ContextServerId(self.id.0.clone()),
client::ModelContextServerBinary {
executable: Path::new(&command.path).to_path_buf(),
args: command.args.clone(),
env: command.env.clone(),
},
+ working_directory,
cx.clone(),
)?,
ContextServerTransport::Custom(transport) => Client::new(
diff --git a/crates/context_server/src/transport/stdio_transport.rs b/crates/context_server/src/transport/stdio_transport.rs
index 56d0240fa5..443b8c16f1 100644
--- a/crates/context_server/src/transport/stdio_transport.rs
+++ b/crates/context_server/src/transport/stdio_transport.rs
@@ -1,3 +1,4 @@
+use std::path::PathBuf;
use std::pin::Pin;
use anyhow::{Context as _, Result};
@@ -22,7 +23,11 @@ pub struct StdioTransport {
}
impl StdioTransport {
- pub fn new(binary: ModelContextServerBinary, cx: &AsyncApp) -> Result {
+ pub fn new(
+ binary: ModelContextServerBinary,
+ working_directory: &Option,
+ cx: &AsyncApp,
+ ) -> Result {
let mut command = util::command::new_smol_command(&binary.executable);
command
.args(&binary.args)
@@ -32,6 +37,10 @@ impl StdioTransport {
.stderr(std::process::Stdio::piped())
.kill_on_drop(true);
+ if let Some(working_directory) = working_directory {
+ command.current_dir(working_directory);
+ }
+
let mut server = command.spawn().with_context(|| {
format!(
"failed to spawn command. (path={:?}, args={:?})",
diff --git a/crates/project/src/context_server_store.rs b/crates/project/src/context_server_store.rs
index ceec0c0a52..c96ab4e8f3 100644
--- a/crates/project/src/context_server_store.rs
+++ b/crates/project/src/context_server_store.rs
@@ -13,6 +13,7 @@ use settings::{Settings as _, SettingsStore};
use util::ResultExt as _;
use crate::{
+ Project,
project_settings::{ContextServerSettings, ProjectSettings},
worktree_store::WorktreeStore,
};
@@ -144,6 +145,7 @@ pub struct ContextServerStore {
context_server_settings: HashMap, ContextServerSettings>,
servers: HashMap,
worktree_store: Entity,
+ project: WeakEntity,
registry: Entity,
update_servers_task: Option>>,
context_server_factory: Option,
@@ -161,12 +163,17 @@ pub enum Event {
impl EventEmitter for ContextServerStore {}
impl ContextServerStore {
- pub fn new(worktree_store: Entity, cx: &mut Context) -> Self {
+ pub fn new(
+ worktree_store: Entity,
+ weak_project: WeakEntity,
+ cx: &mut Context,
+ ) -> Self {
Self::new_internal(
true,
None,
ContextServerDescriptorRegistry::default_global(cx),
worktree_store,
+ weak_project,
cx,
)
}
@@ -184,9 +191,10 @@ impl ContextServerStore {
pub fn test(
registry: Entity,
worktree_store: Entity,
+ weak_project: WeakEntity,
cx: &mut Context,
) -> Self {
- Self::new_internal(false, None, registry, worktree_store, cx)
+ Self::new_internal(false, None, registry, worktree_store, weak_project, cx)
}
#[cfg(any(test, feature = "test-support"))]
@@ -194,6 +202,7 @@ impl ContextServerStore {
context_server_factory: ContextServerFactory,
registry: Entity,
worktree_store: Entity,
+ weak_project: WeakEntity,
cx: &mut Context,
) -> Self {
Self::new_internal(
@@ -201,6 +210,7 @@ impl ContextServerStore {
Some(context_server_factory),
registry,
worktree_store,
+ weak_project,
cx,
)
}
@@ -210,6 +220,7 @@ impl ContextServerStore {
context_server_factory: Option,
registry: Entity,
worktree_store: Entity,
+ weak_project: WeakEntity,
cx: &mut Context,
) -> Self {
let subscriptions = if maintain_server_loop {
@@ -235,6 +246,7 @@ impl ContextServerStore {
context_server_settings: Self::resolve_context_server_settings(&worktree_store, cx)
.clone(),
worktree_store,
+ project: weak_project,
registry,
needs_server_update: false,
servers: HashMap::default(),
@@ -360,7 +372,7 @@ impl ContextServerStore {
let configuration = state.configuration();
self.stop_server(&state.server().id(), cx)?;
- let new_server = self.create_context_server(id.clone(), configuration.clone())?;
+ let new_server = self.create_context_server(id.clone(), configuration.clone(), cx);
self.run_server(new_server, configuration, cx);
}
Ok(())
@@ -449,14 +461,33 @@ impl ContextServerStore {
&self,
id: ContextServerId,
configuration: Arc,
- ) -> Result> {
+ cx: &mut Context,
+ ) -> Arc {
+ let root_path = self
+ .project
+ .read_with(cx, |project, cx| project.active_project_directory(cx))
+ .ok()
+ .flatten()
+ .or_else(|| {
+ self.worktree_store.read_with(cx, |store, cx| {
+ store.visible_worktrees(cx).fold(None, |acc, item| {
+ if acc.is_none() {
+ item.read(cx).root_dir()
+ } else {
+ acc
+ }
+ })
+ })
+ });
+
if let Some(factory) = self.context_server_factory.as_ref() {
- Ok(factory(id, configuration))
+ factory(id, configuration)
} else {
- Ok(Arc::new(ContextServer::stdio(
+ Arc::new(ContextServer::stdio(
id,
configuration.command().clone(),
- )))
+ root_path,
+ ))
}
}
@@ -553,7 +584,7 @@ impl ContextServerStore {
let mut servers_to_remove = HashSet::default();
let mut servers_to_stop = HashSet::default();
- this.update(cx, |this, _cx| {
+ this.update(cx, |this, cx| {
for server_id in this.servers.keys() {
// All servers that are not in desired_servers should be removed from the store.
// This can happen if the user removed a server from the context server settings.
@@ -572,14 +603,10 @@ impl ContextServerStore {
let existing_config = state.as_ref().map(|state| state.configuration());
if existing_config.as_deref() != Some(&config) || is_stopped {
let config = Arc::new(config);
- if let Some(server) = this
- .create_context_server(id.clone(), config.clone())
- .log_err()
- {
- servers_to_start.push((server, config));
- if this.servers.contains_key(&id) {
- servers_to_stop.insert(id);
- }
+ let server = this.create_context_server(id.clone(), config.clone(), cx);
+ servers_to_start.push((server, config));
+ if this.servers.contains_key(&id) {
+ servers_to_stop.insert(id);
}
}
}
@@ -630,7 +657,12 @@ mod tests {
let registry = cx.new(|_| ContextServerDescriptorRegistry::new());
let store = cx.new(|cx| {
- ContextServerStore::test(registry.clone(), project.read(cx).worktree_store(), cx)
+ ContextServerStore::test(
+ registry.clone(),
+ project.read(cx).worktree_store(),
+ project.downgrade(),
+ cx,
+ )
});
let server_1_id = ContextServerId(SERVER_1_ID.into());
@@ -705,7 +737,12 @@ mod tests {
let registry = cx.new(|_| ContextServerDescriptorRegistry::new());
let store = cx.new(|cx| {
- ContextServerStore::test(registry.clone(), project.read(cx).worktree_store(), cx)
+ ContextServerStore::test(
+ registry.clone(),
+ project.read(cx).worktree_store(),
+ project.downgrade(),
+ cx,
+ )
});
let server_1_id = ContextServerId(SERVER_1_ID.into());
@@ -758,7 +795,12 @@ mod tests {
let registry = cx.new(|_| ContextServerDescriptorRegistry::new());
let store = cx.new(|cx| {
- ContextServerStore::test(registry.clone(), project.read(cx).worktree_store(), cx)
+ ContextServerStore::test(
+ registry.clone(),
+ project.read(cx).worktree_store(),
+ project.downgrade(),
+ cx,
+ )
});
let server_id = ContextServerId(SERVER_1_ID.into());
@@ -842,6 +884,7 @@ mod tests {
}),
registry.clone(),
project.read(cx).worktree_store(),
+ project.downgrade(),
cx,
)
});
@@ -1074,6 +1117,7 @@ mod tests {
}),
registry.clone(),
project.read(cx).worktree_store(),
+ project.downgrade(),
cx,
)
});
diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs
index a4e76ed475..6b943216b3 100644
--- a/crates/project/src/project.rs
+++ b/crates/project/src/project.rs
@@ -998,8 +998,9 @@ impl Project {
cx.subscribe(&worktree_store, Self::on_worktree_store_event)
.detach();
+ let weak_self = cx.weak_entity();
let context_server_store =
- cx.new(|cx| ContextServerStore::new(worktree_store.clone(), cx));
+ cx.new(|cx| ContextServerStore::new(worktree_store.clone(), weak_self, cx));
let environment = cx.new(|_| ProjectEnvironment::new(env));
let manifest_tree = ManifestTree::new(worktree_store.clone(), cx);
@@ -1167,8 +1168,9 @@ impl Project {
cx.subscribe(&worktree_store, Self::on_worktree_store_event)
.detach();
+ let weak_self = cx.weak_entity();
let context_server_store =
- cx.new(|cx| ContextServerStore::new(worktree_store.clone(), cx));
+ cx.new(|cx| ContextServerStore::new(worktree_store.clone(), weak_self, cx));
let buffer_store = cx.new(|cx| {
BufferStore::remote(
@@ -1428,8 +1430,6 @@ impl Project {
let image_store = cx.new(|cx| {
ImageStore::remote(worktree_store.clone(), client.clone().into(), remote_id, cx)
})?;
- let context_server_store =
- cx.new(|cx| ContextServerStore::new(worktree_store.clone(), cx))?;
let environment = cx.new(|_| ProjectEnvironment::new(None))?;
@@ -1496,6 +1496,10 @@ impl Project {
let snippets = SnippetProvider::new(fs.clone(), BTreeSet::from_iter([]), cx);
+ let weak_self = cx.weak_entity();
+ let context_server_store =
+ cx.new(|cx| ContextServerStore::new(worktree_store.clone(), weak_self, cx));
+
let mut worktrees = Vec::new();
for worktree in response.payload.worktrees {
let worktree =
From aa3437e98fe61cc6387a1a993d38431a517c554b Mon Sep 17 00:00:00 2001
From: localcc
Date: Tue, 29 Jul 2025 18:03:57 +0200
Subject: [PATCH 11/35] Allow installing from an administrator user (#35202)
Release Notes:
- N/A
---
crates/zed/resources/windows/zed.iss | 10 ----------
1 file changed, 10 deletions(-)
diff --git a/crates/zed/resources/windows/zed.iss b/crates/zed/resources/windows/zed.iss
index 9d104d1f15..51c1dd096e 100644
--- a/crates/zed/resources/windows/zed.iss
+++ b/crates/zed/resources/windows/zed.iss
@@ -1245,16 +1245,6 @@ Root: HKCU; Subkey: "Software\Classes\zed\DefaultIcon"; ValueType: "string"; Val
Root: HKCU; Subkey: "Software\Classes\zed\shell\open\command"; ValueType: "string"; ValueData: """{app}\Zed.exe"" ""%1"""
[Code]
-function InitializeSetup(): Boolean;
-begin
- Result := True;
-
- if not WizardSilent() and IsAdmin() then begin
- MsgBox('This User Installer is not meant to be run as an Administrator.', mbError, MB_OK);
- Result := False;
- end;
-end;
-
function WizardNotSilent(): Boolean;
begin
Result := not WizardSilent();
From f9224b1d7486ea43d2ced75597ff0ea6f96d9aa9 Mon Sep 17 00:00:00 2001
From: Marshall Bowers
Date: Tue, 29 Jul 2025 12:53:56 -0400
Subject: [PATCH 12/35] client: Send `User-Agent` header on WebSocket
connection requests (#35280)
This PR makes it so we send the `User-Agent` header on the WebSocket
connection requests when connecting to Collab.
We use the user agent set on the parent HTTP client.
Release Notes:
- N/A
---
crates/client/src/client.rs | 8 ++++--
crates/gpui/src/app.rs | 4 +++
crates/http_client/src/http_client.rs | 29 +++++++++++++++++++++
crates/reqwest_client/src/reqwest_client.rs | 13 +++++++--
4 files changed, 50 insertions(+), 4 deletions(-)
diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs
index 07df7043b5..e0f4a70b15 100644
--- a/crates/client/src/client.rs
+++ b/crates/client/src/client.rs
@@ -21,7 +21,7 @@ use futures::{
channel::oneshot, future::BoxFuture,
};
use gpui::{App, AsyncApp, Entity, Global, Task, WeakEntity, actions};
-use http_client::{AsyncBody, HttpClient, HttpClientWithUrl};
+use http_client::{AsyncBody, HttpClient, HttpClientWithUrl, http};
use parking_lot::RwLock;
use postage::watch;
use proxy::connect_proxy_stream;
@@ -1158,6 +1158,7 @@ impl Client {
let http = self.http.clone();
let proxy = http.proxy().cloned();
+ let user_agent = http.user_agent().cloned();
let credentials = credentials.clone();
let rpc_url = self.rpc_url(http, release_channel);
let system_id = self.telemetry.system_id();
@@ -1209,7 +1210,7 @@ impl Client {
// We then modify the request to add our desired headers.
let request_headers = request.headers_mut();
request_headers.insert(
- "Authorization",
+ http::header::AUTHORIZATION,
HeaderValue::from_str(&credentials.authorization_header())?,
);
request_headers.insert(
@@ -1221,6 +1222,9 @@ impl Client {
"x-zed-release-channel",
HeaderValue::from_str(release_channel.map(|r| r.dev_name()).unwrap_or("unknown"))?,
);
+ if let Some(user_agent) = user_agent {
+ request_headers.insert(http::header::USER_AGENT, user_agent);
+ }
if let Some(system_id) = system_id {
request_headers.insert("x-zed-system-id", HeaderValue::from_str(&system_id)?);
}
diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs
index 759d33563e..ded7bae316 100644
--- a/crates/gpui/src/app.rs
+++ b/crates/gpui/src/app.rs
@@ -2023,6 +2023,10 @@ impl HttpClient for NullHttpClient {
.boxed()
}
+ fn user_agent(&self) -> Option<&http_client::http::HeaderValue> {
+ None
+ }
+
fn proxy(&self) -> Option<&Url> {
None
}
diff --git a/crates/http_client/src/http_client.rs b/crates/http_client/src/http_client.rs
index eebab86e21..434bd74fc8 100644
--- a/crates/http_client/src/http_client.rs
+++ b/crates/http_client/src/http_client.rs
@@ -4,6 +4,7 @@ pub mod github;
pub use anyhow::{Result, anyhow};
pub use async_body::{AsyncBody, Inner};
use derive_more::Deref;
+use http::HeaderValue;
pub use http::{self, Method, Request, Response, StatusCode, Uri};
use futures::future::BoxFuture;
@@ -39,6 +40,8 @@ impl HttpRequestExt for http::request::Builder {
pub trait HttpClient: 'static + Send + Sync {
fn type_name(&self) -> &'static str;
+ fn user_agent(&self) -> Option<&HeaderValue>;
+
fn send(
&self,
req: http::Request,
@@ -118,6 +121,10 @@ impl HttpClient for HttpClientWithProxy {
self.client.send(req)
}
+ fn user_agent(&self) -> Option<&HeaderValue> {
+ self.client.user_agent()
+ }
+
fn proxy(&self) -> Option<&Url> {
self.proxy.as_ref()
}
@@ -135,6 +142,10 @@ impl HttpClient for Arc {
self.client.send(req)
}
+ fn user_agent(&self) -> Option<&HeaderValue> {
+ self.client.user_agent()
+ }
+
fn proxy(&self) -> Option<&Url> {
self.proxy.as_ref()
}
@@ -250,6 +261,10 @@ impl HttpClient for Arc {
self.client.send(req)
}
+ fn user_agent(&self) -> Option<&HeaderValue> {
+ self.client.user_agent()
+ }
+
fn proxy(&self) -> Option<&Url> {
self.client.proxy.as_ref()
}
@@ -267,6 +282,10 @@ impl HttpClient for HttpClientWithUrl {
self.client.send(req)
}
+ fn user_agent(&self) -> Option<&HeaderValue> {
+ self.client.user_agent()
+ }
+
fn proxy(&self) -> Option<&Url> {
self.client.proxy.as_ref()
}
@@ -314,6 +333,10 @@ impl HttpClient for BlockedHttpClient {
})
}
+ fn user_agent(&self) -> Option<&HeaderValue> {
+ None
+ }
+
fn proxy(&self) -> Option<&Url> {
None
}
@@ -334,6 +357,7 @@ type FakeHttpHandler = Box<
#[cfg(feature = "test-support")]
pub struct FakeHttpClient {
handler: FakeHttpHandler,
+ user_agent: HeaderValue,
}
#[cfg(feature = "test-support")]
@@ -348,6 +372,7 @@ impl FakeHttpClient {
client: HttpClientWithProxy {
client: Arc::new(Self {
handler: Box::new(move |req| Box::pin(handler(req))),
+ user_agent: HeaderValue::from_static(type_name::()),
}),
proxy: None,
},
@@ -390,6 +415,10 @@ impl HttpClient for FakeHttpClient {
future
}
+ fn user_agent(&self) -> Option<&HeaderValue> {
+ Some(&self.user_agent)
+ }
+
fn proxy(&self) -> Option<&Url> {
None
}
diff --git a/crates/reqwest_client/src/reqwest_client.rs b/crates/reqwest_client/src/reqwest_client.rs
index daff20ac4a..e02768876d 100644
--- a/crates/reqwest_client/src/reqwest_client.rs
+++ b/crates/reqwest_client/src/reqwest_client.rs
@@ -20,6 +20,7 @@ static REDACT_REGEX: LazyLock = LazyLock::new(|| Regex::new(r"key=[^&]+")
pub struct ReqwestClient {
client: reqwest::Client,
proxy: Option,
+ user_agent: Option,
handle: tokio::runtime::Handle,
}
@@ -44,9 +45,11 @@ impl ReqwestClient {
Ok(client.into())
}
- pub fn proxy_and_user_agent(proxy: Option, agent: &str) -> anyhow::Result {
+ pub fn proxy_and_user_agent(proxy: Option, user_agent: &str) -> anyhow::Result {
+ let user_agent = HeaderValue::from_str(user_agent)?;
+
let mut map = HeaderMap::new();
- map.insert(http::header::USER_AGENT, HeaderValue::from_str(agent)?);
+ map.insert(http::header::USER_AGENT, user_agent.clone());
let mut client = Self::builder().default_headers(map);
let client_has_proxy;
@@ -73,6 +76,7 @@ impl ReqwestClient {
.build()?;
let mut client: ReqwestClient = client.into();
client.proxy = client_has_proxy.then_some(proxy).flatten();
+ client.user_agent = Some(user_agent);
Ok(client)
}
}
@@ -96,6 +100,7 @@ impl From for ReqwestClient {
client,
handle,
proxy: None,
+ user_agent: None,
}
}
}
@@ -216,6 +221,10 @@ impl http_client::HttpClient for ReqwestClient {
type_name::()
}
+ fn user_agent(&self) -> Option<&HeaderValue> {
+ self.user_agent.as_ref()
+ }
+
fn send(
&self,
req: http::Request,
From 77dc65d8261f38d8c2c8648de26786f253d8de5a Mon Sep 17 00:00:00 2001
From: Marshall Bowers
Date: Tue, 29 Jul 2025 13:06:27 -0400
Subject: [PATCH 13/35] collab: Attach `User-Agent` to `handle connection` span
(#35282)
This PR makes it so we attach the value from the `User-Agent` header to
the `handle connection` span.
We'll start sending this header in
https://github.com/zed-industries/zed/pull/35280.
Release Notes:
- N/A
---
crates/collab/src/rpc.rs | 9 +++++++++
crates/collab/src/tests/test_server.rs | 1 +
2 files changed, 10 insertions(+)
diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs
index 515647f97d..b7e5ce0739 100644
--- a/crates/collab/src/rpc.rs
+++ b/crates/collab/src/rpc.rs
@@ -23,6 +23,7 @@ use anyhow::{Context as _, anyhow, bail};
use async_tungstenite::tungstenite::{
Message as TungsteniteMessage, protocol::CloseFrame as TungsteniteCloseFrame,
};
+use axum::headers::UserAgent;
use axum::{
Extension, Router, TypedHeader,
body::Body,
@@ -750,6 +751,7 @@ impl Server {
address: String,
principal: Principal,
zed_version: ZedVersion,
+ user_agent: Option,
geoip_country_code: Option,
system_id: Option,
send_connection_id: Option>,
@@ -762,9 +764,14 @@ impl Server {
user_id=field::Empty,
login=field::Empty,
impersonator=field::Empty,
+ user_agent=field::Empty,
geoip_country_code=field::Empty
);
principal.update_span(&span);
+ if let Some(user_agent) = user_agent {
+ span.record("user_agent", user_agent);
+ }
+
if let Some(country_code) = geoip_country_code.as_ref() {
span.record("geoip_country_code", country_code);
}
@@ -1172,6 +1179,7 @@ pub async fn handle_websocket_request(
ConnectInfo(socket_address): ConnectInfo,
Extension(server): Extension>,
Extension(principal): Extension,
+ user_agent: Option>,
country_code_header: Option>,
system_id_header: Option>,
ws: WebSocketUpgrade,
@@ -1227,6 +1235,7 @@ pub async fn handle_websocket_request(
socket_address,
principal,
version,
+ user_agent.map(|header| header.to_string()),
country_code_header.map(|header| header.to_string()),
system_id_header.map(|header| header.to_string()),
None,
diff --git a/crates/collab/src/tests/test_server.rs b/crates/collab/src/tests/test_server.rs
index ab84e02b19..5192db16a7 100644
--- a/crates/collab/src/tests/test_server.rs
+++ b/crates/collab/src/tests/test_server.rs
@@ -256,6 +256,7 @@ impl TestServer {
ZedVersion(SemanticVersion::new(1, 0, 0)),
None,
None,
+ None,
Some(connection_id_tx),
Executor::Deterministic(cx.background_executor().clone()),
None,
From 65250fe08d29c27d7414cdea5201550f720a7307 Mon Sep 17 00:00:00 2001
From: Michael Sloan
Date: Tue, 29 Jul 2025 11:28:18 -0600
Subject: [PATCH 14/35] cloud provider: Use `CompletionEvent` type from
`zed_llm_client` (#35285)
Release Notes:
- N/A
---
crates/language_models/src/provider/cloud.rs | 31 ++++++++------------
1 file changed, 12 insertions(+), 19 deletions(-)
diff --git a/crates/language_models/src/provider/cloud.rs b/crates/language_models/src/provider/cloud.rs
index 09a2ac6e0a..1e6e7b96d0 100644
--- a/crates/language_models/src/provider/cloud.rs
+++ b/crates/language_models/src/provider/cloud.rs
@@ -35,8 +35,8 @@ use ui::{TintColor, prelude::*};
use util::{ResultExt as _, maybe};
use zed_llm_client::{
CLIENT_SUPPORTS_STATUS_MESSAGES_HEADER_NAME, CURRENT_PLAN_HEADER_NAME, CompletionBody,
- CompletionRequestStatus, CountTokensBody, CountTokensResponse, EXPIRED_LLM_TOKEN_HEADER_NAME,
- ListModelsResponse, MODEL_REQUESTS_RESOURCE_HEADER_VALUE,
+ CompletionEvent, CompletionRequestStatus, CountTokensBody, CountTokensResponse,
+ EXPIRED_LLM_TOKEN_HEADER_NAME, ListModelsResponse, MODEL_REQUESTS_RESOURCE_HEADER_VALUE,
SERVER_SUPPORTS_STATUS_MESSAGES_HEADER_NAME, SUBSCRIPTION_LIMIT_RESOURCE_HEADER_NAME,
TOOL_USE_LIMIT_REACHED_HEADER_NAME, ZED_VERSION_HEADER_NAME,
};
@@ -1040,15 +1040,8 @@ impl LanguageModel for CloudLanguageModel {
}
}
-#[derive(Serialize, Deserialize)]
-#[serde(rename_all = "snake_case")]
-pub enum CloudCompletionEvent {
- Status(CompletionRequestStatus),
- Event(T),
-}
-
fn map_cloud_completion_events(
- stream: Pin>> + Send>>,
+ stream: Pin>> + Send>>,
mut map_callback: F,
) -> BoxStream<'static, Result>
where
@@ -1063,10 +1056,10 @@ where
Err(error) => {
vec![Err(LanguageModelCompletionError::from(error))]
}
- Ok(CloudCompletionEvent::Status(event)) => {
+ Ok(CompletionEvent::Status(event)) => {
vec![Ok(LanguageModelCompletionEvent::StatusUpdate(event))]
}
- Ok(CloudCompletionEvent::Event(event)) => map_callback(event),
+ Ok(CompletionEvent::Event(event)) => map_callback(event),
})
})
.boxed()
@@ -1074,9 +1067,9 @@ where
fn usage_updated_event(
usage: Option,
-) -> impl Stream- >> {
+) -> impl Stream
- >> {
futures::stream::iter(usage.map(|usage| {
- Ok(CloudCompletionEvent::Status(
+ Ok(CompletionEvent::Status(
CompletionRequestStatus::UsageUpdated {
amount: usage.amount as usize,
limit: usage.limit,
@@ -1087,9 +1080,9 @@ fn usage_updated_event(
fn tool_use_limit_reached_event(
tool_use_limit_reached: bool,
-) -> impl Stream
- >> {
+) -> impl Stream
- >> {
futures::stream::iter(tool_use_limit_reached.then(|| {
- Ok(CloudCompletionEvent::Status(
+ Ok(CompletionEvent::Status(
CompletionRequestStatus::ToolUseLimitReached,
))
}))
@@ -1098,7 +1091,7 @@ fn tool_use_limit_reached_event(
fn response_lines(
response: Response,
includes_status_messages: bool,
-) -> impl Stream
- >> {
+) -> impl Stream
- >> {
futures::stream::try_unfold(
(String::new(), BufReader::new(response.into_body())),
move |(mut line, mut body)| async move {
@@ -1106,9 +1099,9 @@ fn response_lines(
Ok(0) => Ok(None),
Ok(_) => {
let event = if includes_status_messages {
- serde_json::from_str::>(&line)?
+ serde_json::from_str::>(&line)?
} else {
- CloudCompletionEvent::Event(serde_json::from_str::(&line)?)
+ CompletionEvent::Event(serde_json::from_str::(&line)?)
};
line.clear();
From efa3cc13efa0bf100fcdac315e81dc31433e9c46 Mon Sep 17 00:00:00 2001
From: Ben Kunkle
Date: Tue, 29 Jul 2025 13:10:51 -0500
Subject: [PATCH 15/35] keymap_ui: Test keystroke input (#35286)
Closes #ISSUE
Separate out the keystroke input into it's own component and add a bunch
of tests for it's core keystroke+modifier event handling logic
Release Notes:
- N/A *or* Added/Fixed/Improved ...
---
crates/settings_ui/Cargo.toml | 4 +
crates/settings_ui/src/keybindings.rs | 546 +-------
.../src/ui_components/keystroke_input.rs | 1165 +++++++++++++++++
crates/settings_ui/src/ui_components/mod.rs | 1 +
4 files changed, 1179 insertions(+), 537 deletions(-)
create mode 100644 crates/settings_ui/src/ui_components/keystroke_input.rs
diff --git a/crates/settings_ui/Cargo.toml b/crates/settings_ui/Cargo.toml
index 25f033469d..e8434c1a32 100644
--- a/crates/settings_ui/Cargo.toml
+++ b/crates/settings_ui/Cargo.toml
@@ -48,3 +48,7 @@ workspace.workspace = true
[dev-dependencies]
db = {"workspace"= true, "features" = ["test-support"]}
+fs = { workspace = true, features = ["test-support"] }
+gpui = { workspace = true, features = ["test-support"] }
+project = { workspace = true, features = ["test-support"] }
+workspace = { workspace = true, features = ["test-support"] }
diff --git a/crates/settings_ui/src/keybindings.rs b/crates/settings_ui/src/keybindings.rs
index 5ff91246f4..70afe1729c 100644
--- a/crates/settings_ui/src/keybindings.rs
+++ b/crates/settings_ui/src/keybindings.rs
@@ -11,11 +11,10 @@ use editor::{CompletionProvider, Editor, EditorEvent};
use fs::Fs;
use fuzzy::{StringMatch, StringMatchCandidate};
use gpui::{
- Action, Animation, AnimationExt, AppContext as _, AsyncApp, Axis, ClickEvent, Context,
- DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, FontWeight, Global, IsZero,
- KeyContext, Keystroke, Modifiers, ModifiersChangedEvent, MouseButton, Point, ScrollStrategy,
- ScrollWheelEvent, Stateful, StyledText, Subscription, Task, TextStyleRefinement, WeakEntity,
- actions, anchored, deferred, div,
+ Action, AppContext as _, AsyncApp, Axis, ClickEvent, Context, DismissEvent, Entity,
+ EventEmitter, FocusHandle, Focusable, Global, IsZero, KeyContext, Keystroke, MouseButton,
+ Point, ScrollStrategy, ScrollWheelEvent, Stateful, StyledText, Subscription, Task,
+ TextStyleRefinement, WeakEntity, actions, anchored, deferred, div,
};
use language::{Language, LanguageConfig, ToOffset as _};
use notifications::status_toast::{StatusToast, ToastIcon};
@@ -35,7 +34,10 @@ use workspace::{
use crate::{
keybindings::persistence::KEYBINDING_EDITORS,
- ui_components::table::{ColumnWidths, ResizeBehavior, Table, TableInteractionState},
+ ui_components::{
+ keystroke_input::{ClearKeystrokes, KeystrokeInput, StartRecording, StopRecording},
+ table::{ColumnWidths, ResizeBehavior, Table, TableInteractionState},
+ },
};
const NO_ACTION_ARGUMENTS_TEXT: SharedString = SharedString::new_static("");
@@ -72,18 +74,6 @@ actions!(
]
);
-actions!(
- keystroke_input,
- [
- /// Starts recording keystrokes
- StartRecording,
- /// Stops recording keystrokes
- StopRecording,
- /// Clears the recorded keystrokes
- ClearKeystrokes,
- ]
-);
-
pub fn init(cx: &mut App) {
let keymap_event_channel = KeymapEventChannel::new();
cx.set_global(keymap_event_channel);
@@ -393,7 +383,7 @@ impl KeymapEditor {
let keystroke_editor = cx.new(|cx| {
let mut keystroke_editor = KeystrokeInput::new(None, window, cx);
- keystroke_editor.search = true;
+ keystroke_editor.set_search(true);
keystroke_editor
});
@@ -2979,524 +2969,6 @@ async fn remove_keybinding(
Ok(())
}
-#[derive(PartialEq, Eq, Debug, Copy, Clone)]
-enum CloseKeystrokeResult {
- Partial,
- Close,
- None,
-}
-
-struct KeystrokeInput {
- keystrokes: Vec,
- placeholder_keystrokes: Option>,
- outer_focus_handle: FocusHandle,
- inner_focus_handle: FocusHandle,
- intercept_subscription: Option,
- _focus_subscriptions: [Subscription; 2],
- search: bool,
- /// Handles tripe escape to stop recording
- close_keystrokes: Option>,
- close_keystrokes_start: Option,
- previous_modifiers: Modifiers,
-}
-
-impl KeystrokeInput {
- const KEYSTROKE_COUNT_MAX: usize = 3;
-
- fn new(
- placeholder_keystrokes: Option>,
- window: &mut Window,
- cx: &mut Context,
- ) -> Self {
- let outer_focus_handle = cx.focus_handle();
- let inner_focus_handle = cx.focus_handle();
- let _focus_subscriptions = [
- cx.on_focus_in(&inner_focus_handle, window, Self::on_inner_focus_in),
- cx.on_focus_out(&inner_focus_handle, window, Self::on_inner_focus_out),
- ];
- Self {
- keystrokes: Vec::new(),
- placeholder_keystrokes,
- inner_focus_handle,
- outer_focus_handle,
- intercept_subscription: None,
- _focus_subscriptions,
- search: false,
- close_keystrokes: None,
- close_keystrokes_start: None,
- previous_modifiers: Modifiers::default(),
- }
- }
-
- fn set_keystrokes(&mut self, keystrokes: Vec, cx: &mut Context) {
- self.keystrokes = keystrokes;
- self.keystrokes_changed(cx);
- }
-
- fn dummy(modifiers: Modifiers) -> Keystroke {
- return Keystroke {
- modifiers,
- key: "".to_string(),
- key_char: None,
- };
- }
-
- fn keystrokes_changed(&self, cx: &mut Context) {
- cx.emit(());
- cx.notify();
- }
-
- fn key_context() -> KeyContext {
- let mut key_context = KeyContext::default();
- key_context.add("KeystrokeInput");
- key_context
- }
-
- fn handle_possible_close_keystroke(
- &mut self,
- keystroke: &Keystroke,
- window: &mut Window,
- cx: &mut Context,
- ) -> CloseKeystrokeResult {
- let Some(keybind_for_close_action) = window
- .highest_precedence_binding_for_action_in_context(&StopRecording, Self::key_context())
- else {
- log::trace!("No keybinding to stop recording keystrokes in keystroke input");
- self.close_keystrokes.take();
- self.close_keystrokes_start.take();
- return CloseKeystrokeResult::None;
- };
- let action_keystrokes = keybind_for_close_action.keystrokes();
-
- if let Some(mut close_keystrokes) = self.close_keystrokes.take() {
- let mut index = 0;
-
- while index < action_keystrokes.len() && index < close_keystrokes.len() {
- if !close_keystrokes[index].should_match(&action_keystrokes[index]) {
- break;
- }
- index += 1;
- }
- if index == close_keystrokes.len() {
- if index >= action_keystrokes.len() {
- self.close_keystrokes_start.take();
- return CloseKeystrokeResult::None;
- }
- if keystroke.should_match(&action_keystrokes[index]) {
- if action_keystrokes.len() >= 1 && index == action_keystrokes.len() - 1 {
- self.stop_recording(&StopRecording, window, cx);
- return CloseKeystrokeResult::Close;
- } else {
- close_keystrokes.push(keystroke.clone());
- self.close_keystrokes = Some(close_keystrokes);
- return CloseKeystrokeResult::Partial;
- }
- } else {
- self.close_keystrokes_start.take();
- return CloseKeystrokeResult::None;
- }
- }
- } else if let Some(first_action_keystroke) = action_keystrokes.first()
- && keystroke.should_match(first_action_keystroke)
- {
- self.close_keystrokes = Some(vec![keystroke.clone()]);
- return CloseKeystrokeResult::Partial;
- }
- self.close_keystrokes_start.take();
- return CloseKeystrokeResult::None;
- }
-
- fn on_modifiers_changed(
- &mut self,
- event: &ModifiersChangedEvent,
- _window: &mut Window,
- cx: &mut Context,
- ) {
- let keystrokes_len = self.keystrokes.len();
-
- if self.previous_modifiers.modified()
- && event.modifiers.is_subset_of(&self.previous_modifiers)
- {
- self.previous_modifiers &= event.modifiers;
- cx.stop_propagation();
- return;
- }
-
- if let Some(last) = self.keystrokes.last_mut()
- && last.key.is_empty()
- && keystrokes_len <= Self::KEYSTROKE_COUNT_MAX
- {
- if self.search {
- if self.previous_modifiers.modified() {
- last.modifiers |= event.modifiers;
- self.previous_modifiers |= event.modifiers;
- } else {
- self.keystrokes.push(Self::dummy(event.modifiers));
- self.previous_modifiers |= event.modifiers;
- }
- } else if !event.modifiers.modified() {
- self.keystrokes.pop();
- } else {
- last.modifiers = event.modifiers;
- }
-
- self.keystrokes_changed(cx);
- } else if keystrokes_len < Self::KEYSTROKE_COUNT_MAX {
- self.keystrokes.push(Self::dummy(event.modifiers));
- if self.search {
- self.previous_modifiers |= event.modifiers;
- }
- self.keystrokes_changed(cx);
- }
- cx.stop_propagation();
- }
-
- fn handle_keystroke(
- &mut self,
- keystroke: &Keystroke,
- window: &mut Window,
- cx: &mut Context,
- ) {
- let close_keystroke_result = self.handle_possible_close_keystroke(keystroke, window, cx);
- if close_keystroke_result != CloseKeystrokeResult::Close {
- let key_len = self.keystrokes.len();
- if let Some(last) = self.keystrokes.last_mut()
- && last.key.is_empty()
- && key_len <= Self::KEYSTROKE_COUNT_MAX
- {
- if self.search {
- last.key = keystroke.key.clone();
- if close_keystroke_result == CloseKeystrokeResult::Partial
- && self.close_keystrokes_start.is_none()
- {
- self.close_keystrokes_start = Some(self.keystrokes.len() - 1);
- }
- if self.search {
- self.previous_modifiers = keystroke.modifiers;
- }
- self.keystrokes_changed(cx);
- cx.stop_propagation();
- return;
- } else {
- self.keystrokes.pop();
- }
- }
- if self.keystrokes.len() < Self::KEYSTROKE_COUNT_MAX {
- if close_keystroke_result == CloseKeystrokeResult::Partial
- && self.close_keystrokes_start.is_none()
- {
- self.close_keystrokes_start = Some(self.keystrokes.len());
- }
- self.keystrokes.push(keystroke.clone());
- if self.search {
- self.previous_modifiers = keystroke.modifiers;
- } else if self.keystrokes.len() < Self::KEYSTROKE_COUNT_MAX {
- self.keystrokes.push(Self::dummy(keystroke.modifiers));
- }
- } else if close_keystroke_result != CloseKeystrokeResult::Partial {
- self.clear_keystrokes(&ClearKeystrokes, window, cx);
- }
- }
- self.keystrokes_changed(cx);
- cx.stop_propagation();
- }
-
- fn on_inner_focus_in(&mut self, _window: &mut Window, cx: &mut Context) {
- if self.intercept_subscription.is_none() {
- let listener = cx.listener(|this, event: &gpui::KeystrokeEvent, window, cx| {
- this.handle_keystroke(&event.keystroke, window, cx);
- });
- self.intercept_subscription = Some(cx.intercept_keystrokes(listener))
- }
- }
-
- fn on_inner_focus_out(
- &mut self,
- _event: gpui::FocusOutEvent,
- _window: &mut Window,
- cx: &mut Context,
- ) {
- self.intercept_subscription.take();
- cx.notify();
- }
-
- fn keystrokes(&self) -> &[Keystroke] {
- if let Some(placeholders) = self.placeholder_keystrokes.as_ref()
- && self.keystrokes.is_empty()
- {
- return placeholders;
- }
- if !self.search
- && self
- .keystrokes
- .last()
- .map_or(false, |last| last.key.is_empty())
- {
- return &self.keystrokes[..self.keystrokes.len() - 1];
- }
- return &self.keystrokes;
- }
-
- fn render_keystrokes(&self, is_recording: bool) -> impl Iterator
- {
- let keystrokes = if let Some(placeholders) = self.placeholder_keystrokes.as_ref()
- && self.keystrokes.is_empty()
- {
- if is_recording {
- &[]
- } else {
- placeholders.as_slice()
- }
- } else {
- &self.keystrokes
- };
- keystrokes.iter().map(move |keystroke| {
- h_flex().children(ui::render_keystroke(
- keystroke,
- Some(Color::Default),
- Some(rems(0.875).into()),
- ui::PlatformStyle::platform(),
- false,
- ))
- })
- }
-
- fn start_recording(&mut self, _: &StartRecording, window: &mut Window, cx: &mut Context) {
- window.focus(&self.inner_focus_handle);
- self.clear_keystrokes(&ClearKeystrokes, window, cx);
- self.previous_modifiers = window.modifiers();
- cx.stop_propagation();
- }
-
- fn stop_recording(&mut self, _: &StopRecording, window: &mut Window, cx: &mut Context) {
- if !self.inner_focus_handle.is_focused(window) {
- return;
- }
- window.focus(&self.outer_focus_handle);
- if let Some(close_keystrokes_start) = self.close_keystrokes_start.take()
- && close_keystrokes_start < self.keystrokes.len()
- {
- self.keystrokes.drain(close_keystrokes_start..);
- }
- self.close_keystrokes.take();
- cx.notify();
- }
-
- fn clear_keystrokes(
- &mut self,
- _: &ClearKeystrokes,
- _window: &mut Window,
- cx: &mut Context,
- ) {
- self.keystrokes.clear();
- self.keystrokes_changed(cx);
- }
-}
-
-impl EventEmitter<()> for KeystrokeInput {}
-
-impl Focusable for KeystrokeInput {
- fn focus_handle(&self, _cx: &App) -> FocusHandle {
- self.outer_focus_handle.clone()
- }
-}
-
-impl Render for KeystrokeInput {
- fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement {
- let colors = cx.theme().colors();
- let is_focused = self.outer_focus_handle.contains_focused(window, cx);
- let is_recording = self.inner_focus_handle.is_focused(window);
-
- let horizontal_padding = rems_from_px(64.);
-
- let recording_bg_color = colors
- .editor_background
- .blend(colors.text_accent.opacity(0.1));
-
- let recording_pulse = |color: Color| {
- Icon::new(IconName::Circle)
- .size(IconSize::Small)
- .color(Color::Error)
- .with_animation(
- "recording-pulse",
- Animation::new(std::time::Duration::from_secs(2))
- .repeat()
- .with_easing(gpui::pulsating_between(0.4, 0.8)),
- {
- let color = color.color(cx);
- move |this, delta| this.color(Color::Custom(color.opacity(delta)))
- },
- )
- };
-
- let recording_indicator = h_flex()
- .h_4()
- .pr_1()
- .gap_0p5()
- .border_1()
- .border_color(colors.border)
- .bg(colors
- .editor_background
- .blend(colors.text_accent.opacity(0.1)))
- .rounded_sm()
- .child(recording_pulse(Color::Error))
- .child(
- Label::new("REC")
- .size(LabelSize::XSmall)
- .weight(FontWeight::SEMIBOLD)
- .color(Color::Error),
- );
-
- let search_indicator = h_flex()
- .h_4()
- .pr_1()
- .gap_0p5()
- .border_1()
- .border_color(colors.border)
- .bg(colors
- .editor_background
- .blend(colors.text_accent.opacity(0.1)))
- .rounded_sm()
- .child(recording_pulse(Color::Accent))
- .child(
- Label::new("SEARCH")
- .size(LabelSize::XSmall)
- .weight(FontWeight::SEMIBOLD)
- .color(Color::Accent),
- );
-
- let record_icon = if self.search {
- IconName::MagnifyingGlass
- } else {
- IconName::PlayFilled
- };
-
- h_flex()
- .id("keystroke-input")
- .track_focus(&self.outer_focus_handle)
- .py_2()
- .px_3()
- .gap_2()
- .min_h_10()
- .w_full()
- .flex_1()
- .justify_between()
- .rounded_lg()
- .overflow_hidden()
- .map(|this| {
- if is_recording {
- this.bg(recording_bg_color)
- } else {
- this.bg(colors.editor_background)
- }
- })
- .border_1()
- .border_color(colors.border_variant)
- .when(is_focused, |parent| {
- parent.border_color(colors.border_focused)
- })
- .key_context(Self::key_context())
- .on_action(cx.listener(Self::start_recording))
- .on_action(cx.listener(Self::clear_keystrokes))
- .child(
- h_flex()
- .w(horizontal_padding)
- .gap_0p5()
- .justify_start()
- .flex_none()
- .when(is_recording, |this| {
- this.map(|this| {
- if self.search {
- this.child(search_indicator)
- } else {
- this.child(recording_indicator)
- }
- })
- }),
- )
- .child(
- h_flex()
- .id("keystroke-input-inner")
- .track_focus(&self.inner_focus_handle)
- .on_modifiers_changed(cx.listener(Self::on_modifiers_changed))
- .size_full()
- .when(!self.search, |this| {
- this.focus(|mut style| {
- style.border_color = Some(colors.border_focused);
- style
- })
- })
- .w_full()
- .min_w_0()
- .justify_center()
- .flex_wrap()
- .gap(ui::DynamicSpacing::Base04.rems(cx))
- .children(self.render_keystrokes(is_recording)),
- )
- .child(
- h_flex()
- .w(horizontal_padding)
- .gap_0p5()
- .justify_end()
- .flex_none()
- .map(|this| {
- if is_recording {
- this.child(
- IconButton::new("stop-record-btn", IconName::StopFilled)
- .shape(ui::IconButtonShape::Square)
- .map(|this| {
- this.tooltip(Tooltip::for_action_title(
- if self.search {
- "Stop Searching"
- } else {
- "Stop Recording"
- },
- &StopRecording,
- ))
- })
- .icon_color(Color::Error)
- .on_click(cx.listener(|this, _event, window, cx| {
- this.stop_recording(&StopRecording, window, cx);
- })),
- )
- } else {
- this.child(
- IconButton::new("record-btn", record_icon)
- .shape(ui::IconButtonShape::Square)
- .map(|this| {
- this.tooltip(Tooltip::for_action_title(
- if self.search {
- "Start Searching"
- } else {
- "Start Recording"
- },
- &StartRecording,
- ))
- })
- .when(!is_focused, |this| this.icon_color(Color::Muted))
- .on_click(cx.listener(|this, _event, window, cx| {
- this.start_recording(&StartRecording, window, cx);
- })),
- )
- }
- })
- .child(
- IconButton::new("clear-btn", IconName::Delete)
- .shape(ui::IconButtonShape::Square)
- .tooltip(Tooltip::for_action_title(
- "Clear Keystrokes",
- &ClearKeystrokes,
- ))
- .when(!is_recording || !is_focused, |this| {
- this.icon_color(Color::Muted)
- })
- .on_click(cx.listener(|this, _event, window, cx| {
- this.clear_keystrokes(&ClearKeystrokes, window, cx);
- })),
- ),
- )
- }
-}
-
fn collect_contexts_from_assets() -> Vec {
let mut keymap_assets = vec![
util::asset_str::(settings::DEFAULT_KEYMAP_PATH),
diff --git a/crates/settings_ui/src/ui_components/keystroke_input.rs b/crates/settings_ui/src/ui_components/keystroke_input.rs
new file mode 100644
index 0000000000..08ffe3575b
--- /dev/null
+++ b/crates/settings_ui/src/ui_components/keystroke_input.rs
@@ -0,0 +1,1165 @@
+use gpui::{
+ Animation, AnimationExt, Context, EventEmitter, FocusHandle, Focusable, FontWeight, KeyContext,
+ Keystroke, Modifiers, ModifiersChangedEvent, Subscription, actions,
+};
+use ui::{
+ ActiveTheme as _, Color, IconButton, IconButtonShape, IconName, IconSize, Label, LabelSize,
+ ParentElement as _, Render, Styled as _, Tooltip, Window, prelude::*,
+};
+
+actions!(
+ keystroke_input,
+ [
+ /// Starts recording keystrokes
+ StartRecording,
+ /// Stops recording keystrokes
+ StopRecording,
+ /// Clears the recorded keystrokes
+ ClearKeystrokes,
+ ]
+);
+
+const KEY_CONTEXT_VALUE: &'static str = "KeystrokeInput";
+
+enum CloseKeystrokeResult {
+ Partial,
+ Close,
+ None,
+}
+
+impl PartialEq for CloseKeystrokeResult {
+ fn eq(&self, other: &Self) -> bool {
+ matches!(
+ (self, other),
+ (CloseKeystrokeResult::Partial, CloseKeystrokeResult::Partial)
+ | (CloseKeystrokeResult::Close, CloseKeystrokeResult::Close)
+ | (CloseKeystrokeResult::None, CloseKeystrokeResult::None)
+ )
+ }
+}
+
+pub struct KeystrokeInput {
+ keystrokes: Vec,
+ placeholder_keystrokes: Option>,
+ outer_focus_handle: FocusHandle,
+ inner_focus_handle: FocusHandle,
+ intercept_subscription: Option,
+ _focus_subscriptions: [Subscription; 2],
+ search: bool,
+ /// Handles triple escape to stop recording
+ close_keystrokes: Option>,
+ close_keystrokes_start: Option,
+ previous_modifiers: Modifiers,
+ #[cfg(test)]
+ recording: bool,
+}
+
+impl KeystrokeInput {
+ const KEYSTROKE_COUNT_MAX: usize = 3;
+
+ pub fn new(
+ placeholder_keystrokes: Option>,
+ window: &mut Window,
+ cx: &mut Context,
+ ) -> Self {
+ let outer_focus_handle = cx.focus_handle();
+ let inner_focus_handle = cx.focus_handle();
+ let _focus_subscriptions = [
+ cx.on_focus_in(&inner_focus_handle, window, Self::on_inner_focus_in),
+ cx.on_focus_out(&inner_focus_handle, window, Self::on_inner_focus_out),
+ ];
+ Self {
+ keystrokes: Vec::new(),
+ placeholder_keystrokes,
+ inner_focus_handle,
+ outer_focus_handle,
+ intercept_subscription: None,
+ _focus_subscriptions,
+ search: false,
+ close_keystrokes: None,
+ close_keystrokes_start: None,
+ previous_modifiers: Modifiers::default(),
+ #[cfg(test)]
+ recording: false,
+ }
+ }
+
+ pub fn set_keystrokes(&mut self, keystrokes: Vec, cx: &mut Context) {
+ self.keystrokes = keystrokes;
+ self.keystrokes_changed(cx);
+ }
+
+ pub fn set_search(&mut self, search: bool) {
+ self.search = search;
+ }
+
+ pub fn keystrokes(&self) -> &[Keystroke] {
+ if let Some(placeholders) = self.placeholder_keystrokes.as_ref()
+ && self.keystrokes.is_empty()
+ {
+ return placeholders;
+ }
+ if !self.search
+ && self
+ .keystrokes
+ .last()
+ .map_or(false, |last| last.key.is_empty())
+ {
+ return &self.keystrokes[..self.keystrokes.len() - 1];
+ }
+ return &self.keystrokes;
+ }
+
+ fn dummy(modifiers: Modifiers) -> Keystroke {
+ return Keystroke {
+ modifiers,
+ key: "".to_string(),
+ key_char: None,
+ };
+ }
+
+ fn keystrokes_changed(&self, cx: &mut Context) {
+ cx.emit(());
+ cx.notify();
+ }
+
+ fn key_context() -> KeyContext {
+ let mut key_context = KeyContext::default();
+ key_context.add(KEY_CONTEXT_VALUE);
+ key_context
+ }
+
+ fn determine_stop_recording_binding(window: &mut Window) -> Option {
+ if cfg!(test) {
+ Some(gpui::KeyBinding::new(
+ "escape escape escape",
+ StopRecording,
+ Some(KEY_CONTEXT_VALUE),
+ ))
+ } else {
+ window.highest_precedence_binding_for_action_in_context(
+ &StopRecording,
+ Self::key_context(),
+ )
+ }
+ }
+
+ fn handle_possible_close_keystroke(
+ &mut self,
+ keystroke: &Keystroke,
+ window: &mut Window,
+ cx: &mut Context,
+ ) -> CloseKeystrokeResult {
+ let Some(keybind_for_close_action) = Self::determine_stop_recording_binding(window) else {
+ log::trace!("No keybinding to stop recording keystrokes in keystroke input");
+ self.close_keystrokes.take();
+ self.close_keystrokes_start.take();
+ return CloseKeystrokeResult::None;
+ };
+ let action_keystrokes = keybind_for_close_action.keystrokes();
+
+ if let Some(mut close_keystrokes) = self.close_keystrokes.take() {
+ let mut index = 0;
+
+ while index < action_keystrokes.len() && index < close_keystrokes.len() {
+ if !close_keystrokes[index].should_match(&action_keystrokes[index]) {
+ break;
+ }
+ index += 1;
+ }
+ if index == close_keystrokes.len() {
+ if index >= action_keystrokes.len() {
+ self.close_keystrokes_start.take();
+ return CloseKeystrokeResult::None;
+ }
+ if keystroke.should_match(&action_keystrokes[index]) {
+ if action_keystrokes.len() >= 1 && index == action_keystrokes.len() - 1 {
+ self.stop_recording(&StopRecording, window, cx);
+ return CloseKeystrokeResult::Close;
+ } else {
+ close_keystrokes.push(keystroke.clone());
+ self.close_keystrokes = Some(close_keystrokes);
+ return CloseKeystrokeResult::Partial;
+ }
+ } else {
+ self.close_keystrokes_start.take();
+ return CloseKeystrokeResult::None;
+ }
+ }
+ } else if let Some(first_action_keystroke) = action_keystrokes.first()
+ && keystroke.should_match(first_action_keystroke)
+ {
+ self.close_keystrokes = Some(vec![keystroke.clone()]);
+ return CloseKeystrokeResult::Partial;
+ }
+ self.close_keystrokes_start.take();
+ return CloseKeystrokeResult::None;
+ }
+
+ fn on_modifiers_changed(
+ &mut self,
+ event: &ModifiersChangedEvent,
+ _window: &mut Window,
+ cx: &mut Context,
+ ) {
+ let keystrokes_len = self.keystrokes.len();
+
+ if self.previous_modifiers.modified()
+ && event.modifiers.is_subset_of(&self.previous_modifiers)
+ {
+ self.previous_modifiers &= event.modifiers;
+ cx.stop_propagation();
+ return;
+ }
+
+ if let Some(last) = self.keystrokes.last_mut()
+ && last.key.is_empty()
+ && keystrokes_len <= Self::KEYSTROKE_COUNT_MAX
+ {
+ if self.search {
+ if self.previous_modifiers.modified() {
+ last.modifiers |= event.modifiers;
+ self.previous_modifiers |= event.modifiers;
+ } else {
+ self.keystrokes.push(Self::dummy(event.modifiers));
+ self.previous_modifiers |= event.modifiers;
+ }
+ } else if !event.modifiers.modified() {
+ self.keystrokes.pop();
+ } else {
+ last.modifiers = event.modifiers;
+ }
+
+ self.keystrokes_changed(cx);
+ } else if keystrokes_len < Self::KEYSTROKE_COUNT_MAX {
+ self.keystrokes.push(Self::dummy(event.modifiers));
+ if self.search {
+ self.previous_modifiers |= event.modifiers;
+ }
+ self.keystrokes_changed(cx);
+ }
+ cx.stop_propagation();
+ }
+
+ fn handle_keystroke(
+ &mut self,
+ keystroke: &Keystroke,
+ window: &mut Window,
+ cx: &mut Context,
+ ) {
+ let close_keystroke_result = self.handle_possible_close_keystroke(keystroke, window, cx);
+ if close_keystroke_result != CloseKeystrokeResult::Close {
+ let key_len = self.keystrokes.len();
+ if let Some(last) = self.keystrokes.last_mut()
+ && last.key.is_empty()
+ && key_len <= Self::KEYSTROKE_COUNT_MAX
+ {
+ if self.search {
+ last.key = keystroke.key.clone();
+ if close_keystroke_result == CloseKeystrokeResult::Partial
+ && self.close_keystrokes_start.is_none()
+ {
+ self.close_keystrokes_start = Some(self.keystrokes.len() - 1);
+ }
+ if self.search {
+ self.previous_modifiers = keystroke.modifiers;
+ }
+ self.keystrokes_changed(cx);
+ cx.stop_propagation();
+ return;
+ } else {
+ self.keystrokes.pop();
+ }
+ }
+ if self.keystrokes.len() < Self::KEYSTROKE_COUNT_MAX {
+ if close_keystroke_result == CloseKeystrokeResult::Partial
+ && self.close_keystrokes_start.is_none()
+ {
+ self.close_keystrokes_start = Some(self.keystrokes.len());
+ }
+ self.keystrokes.push(keystroke.clone());
+ if self.search {
+ self.previous_modifiers = keystroke.modifiers;
+ } else if self.keystrokes.len() < Self::KEYSTROKE_COUNT_MAX
+ && keystroke.modifiers.modified()
+ {
+ self.keystrokes.push(Self::dummy(keystroke.modifiers));
+ }
+ } else if close_keystroke_result != CloseKeystrokeResult::Partial {
+ self.clear_keystrokes(&ClearKeystrokes, window, cx);
+ }
+ }
+ self.keystrokes_changed(cx);
+ cx.stop_propagation();
+ }
+
+ fn on_inner_focus_in(&mut self, _window: &mut Window, cx: &mut Context) {
+ if self.intercept_subscription.is_none() {
+ let listener = cx.listener(|this, event: &gpui::KeystrokeEvent, window, cx| {
+ this.handle_keystroke(&event.keystroke, window, cx);
+ });
+ self.intercept_subscription = Some(cx.intercept_keystrokes(listener))
+ }
+ }
+
+ fn on_inner_focus_out(
+ &mut self,
+ _event: gpui::FocusOutEvent,
+ _window: &mut Window,
+ cx: &mut Context,
+ ) {
+ self.intercept_subscription.take();
+ cx.notify();
+ }
+
+ fn render_keystrokes(&self, is_recording: bool) -> impl Iterator
- {
+ let keystrokes = if let Some(placeholders) = self.placeholder_keystrokes.as_ref()
+ && self.keystrokes.is_empty()
+ {
+ if is_recording {
+ &[]
+ } else {
+ placeholders.as_slice()
+ }
+ } else {
+ &self.keystrokes
+ };
+ keystrokes.iter().map(move |keystroke| {
+ h_flex().children(ui::render_keystroke(
+ keystroke,
+ Some(Color::Default),
+ Some(rems(0.875).into()),
+ ui::PlatformStyle::platform(),
+ false,
+ ))
+ })
+ }
+
+ pub fn start_recording(
+ &mut self,
+ _: &StartRecording,
+ window: &mut Window,
+ cx: &mut Context,
+ ) {
+ window.focus(&self.inner_focus_handle);
+ self.clear_keystrokes(&ClearKeystrokes, window, cx);
+ self.previous_modifiers = window.modifiers();
+ #[cfg(test)]
+ {
+ self.recording = true;
+ }
+ cx.stop_propagation();
+ }
+
+ pub fn stop_recording(
+ &mut self,
+ _: &StopRecording,
+ window: &mut Window,
+ cx: &mut Context,
+ ) {
+ if !self.is_recording(window) {
+ return;
+ }
+ window.focus(&self.outer_focus_handle);
+ if let Some(close_keystrokes_start) = self.close_keystrokes_start.take()
+ && close_keystrokes_start < self.keystrokes.len()
+ {
+ self.keystrokes.drain(close_keystrokes_start..);
+ }
+ self.close_keystrokes.take();
+ #[cfg(test)]
+ {
+ self.recording = false;
+ }
+ cx.notify();
+ }
+
+ pub fn clear_keystrokes(
+ &mut self,
+ _: &ClearKeystrokes,
+ _window: &mut Window,
+ cx: &mut Context,
+ ) {
+ self.keystrokes.clear();
+ self.keystrokes_changed(cx);
+ }
+
+ fn is_recording(&self, window: &Window) -> bool {
+ #[cfg(test)]
+ {
+ if true {
+ // in tests, we just need a simple bool that is toggled on start and stop recording
+ return self.recording;
+ }
+ }
+ // however, in the real world, checking if the inner focus handle is focused
+ // is a much more reliable check, as the intercept keystroke handlers are installed
+ // on focus of the inner focus handle, thereby ensuring our recording state does
+ // not get de-synced
+ return self.inner_focus_handle.is_focused(window);
+ }
+}
+
+impl EventEmitter<()> for KeystrokeInput {}
+
+impl Focusable for KeystrokeInput {
+ fn focus_handle(&self, _cx: &gpui::App) -> FocusHandle {
+ self.outer_focus_handle.clone()
+ }
+}
+
+impl Render for KeystrokeInput {
+ fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement {
+ let colors = cx.theme().colors();
+ let is_focused = self.outer_focus_handle.contains_focused(window, cx);
+ let is_recording = self.is_recording(window);
+
+ let horizontal_padding = rems_from_px(64.);
+
+ let recording_bg_color = colors
+ .editor_background
+ .blend(colors.text_accent.opacity(0.1));
+
+ let recording_pulse = |color: Color| {
+ Icon::new(IconName::Circle)
+ .size(IconSize::Small)
+ .color(Color::Error)
+ .with_animation(
+ "recording-pulse",
+ Animation::new(std::time::Duration::from_secs(2))
+ .repeat()
+ .with_easing(gpui::pulsating_between(0.4, 0.8)),
+ {
+ let color = color.color(cx);
+ move |this, delta| this.color(Color::Custom(color.opacity(delta)))
+ },
+ )
+ };
+
+ let recording_indicator = h_flex()
+ .h_4()
+ .pr_1()
+ .gap_0p5()
+ .border_1()
+ .border_color(colors.border)
+ .bg(colors
+ .editor_background
+ .blend(colors.text_accent.opacity(0.1)))
+ .rounded_sm()
+ .child(recording_pulse(Color::Error))
+ .child(
+ Label::new("REC")
+ .size(LabelSize::XSmall)
+ .weight(FontWeight::SEMIBOLD)
+ .color(Color::Error),
+ );
+
+ let search_indicator = h_flex()
+ .h_4()
+ .pr_1()
+ .gap_0p5()
+ .border_1()
+ .border_color(colors.border)
+ .bg(colors
+ .editor_background
+ .blend(colors.text_accent.opacity(0.1)))
+ .rounded_sm()
+ .child(recording_pulse(Color::Accent))
+ .child(
+ Label::new("SEARCH")
+ .size(LabelSize::XSmall)
+ .weight(FontWeight::SEMIBOLD)
+ .color(Color::Accent),
+ );
+
+ let record_icon = if self.search {
+ IconName::MagnifyingGlass
+ } else {
+ IconName::PlayFilled
+ };
+
+ h_flex()
+ .id("keystroke-input")
+ .track_focus(&self.outer_focus_handle)
+ .py_2()
+ .px_3()
+ .gap_2()
+ .min_h_10()
+ .w_full()
+ .flex_1()
+ .justify_between()
+ .rounded_lg()
+ .overflow_hidden()
+ .map(|this| {
+ if is_recording {
+ this.bg(recording_bg_color)
+ } else {
+ this.bg(colors.editor_background)
+ }
+ })
+ .border_1()
+ .border_color(colors.border_variant)
+ .when(is_focused, |parent| {
+ parent.border_color(colors.border_focused)
+ })
+ .key_context(Self::key_context())
+ .on_action(cx.listener(Self::start_recording))
+ .on_action(cx.listener(Self::clear_keystrokes))
+ .child(
+ h_flex()
+ .w(horizontal_padding)
+ .gap_0p5()
+ .justify_start()
+ .flex_none()
+ .when(is_recording, |this| {
+ this.map(|this| {
+ if self.search {
+ this.child(search_indicator)
+ } else {
+ this.child(recording_indicator)
+ }
+ })
+ }),
+ )
+ .child(
+ h_flex()
+ .id("keystroke-input-inner")
+ .track_focus(&self.inner_focus_handle)
+ .on_modifiers_changed(cx.listener(Self::on_modifiers_changed))
+ .size_full()
+ .when(!self.search, |this| {
+ this.focus(|mut style| {
+ style.border_color = Some(colors.border_focused);
+ style
+ })
+ })
+ .w_full()
+ .min_w_0()
+ .justify_center()
+ .flex_wrap()
+ .gap(ui::DynamicSpacing::Base04.rems(cx))
+ .children(self.render_keystrokes(is_recording)),
+ )
+ .child(
+ h_flex()
+ .w(horizontal_padding)
+ .gap_0p5()
+ .justify_end()
+ .flex_none()
+ .map(|this| {
+ if is_recording {
+ this.child(
+ IconButton::new("stop-record-btn", IconName::StopFilled)
+ .shape(IconButtonShape::Square)
+ .map(|this| {
+ this.tooltip(Tooltip::for_action_title(
+ if self.search {
+ "Stop Searching"
+ } else {
+ "Stop Recording"
+ },
+ &StopRecording,
+ ))
+ })
+ .icon_color(Color::Error)
+ .on_click(cx.listener(|this, _event, window, cx| {
+ this.stop_recording(&StopRecording, window, cx);
+ })),
+ )
+ } else {
+ this.child(
+ IconButton::new("record-btn", record_icon)
+ .shape(IconButtonShape::Square)
+ .map(|this| {
+ this.tooltip(Tooltip::for_action_title(
+ if self.search {
+ "Start Searching"
+ } else {
+ "Start Recording"
+ },
+ &StartRecording,
+ ))
+ })
+ .when(!is_focused, |this| this.icon_color(Color::Muted))
+ .on_click(cx.listener(|this, _event, window, cx| {
+ this.start_recording(&StartRecording, window, cx);
+ })),
+ )
+ }
+ })
+ .child(
+ IconButton::new("clear-btn", IconName::Delete)
+ .shape(IconButtonShape::Square)
+ .tooltip(Tooltip::for_action_title(
+ "Clear Keystrokes",
+ &ClearKeystrokes,
+ ))
+ .when(!is_recording || !is_focused, |this| {
+ this.icon_color(Color::Muted)
+ })
+ .on_click(cx.listener(|this, _event, window, cx| {
+ this.clear_keystrokes(&ClearKeystrokes, window, cx);
+ })),
+ ),
+ )
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use fs::FakeFs;
+ use gpui::{Entity, TestAppContext, VisualTestContext};
+ use project::Project;
+ use settings::SettingsStore;
+ use workspace::Workspace;
+
+ pub struct KeystrokeInputTestHelper {
+ input: Entity,
+ current_modifiers: Modifiers,
+ cx: VisualTestContext,
+ }
+
+ impl KeystrokeInputTestHelper {
+ /// Creates a new test helper with default settings
+ pub fn new(mut cx: VisualTestContext) -> Self {
+ let input = cx.new_window_entity(|window, cx| KeystrokeInput::new(None, window, cx));
+
+ let mut helper = Self {
+ input,
+ current_modifiers: Modifiers::default(),
+ cx,
+ };
+
+ helper.start_recording();
+ helper
+ }
+
+ /// Sets search mode on the input
+ pub fn with_search_mode(&mut self, search: bool) -> &mut Self {
+ self.input.update(&mut self.cx, |input, _| {
+ input.set_search(search);
+ });
+ self
+ }
+
+ /// Sends a keystroke event based on string description
+ /// Examples: "a", "ctrl-a", "cmd-shift-z", "escape"
+ pub fn send_keystroke(&mut self, keystroke_input: &str) -> &mut Self {
+ self.expect_is_recording(true);
+ let keystroke_str = if keystroke_input.ends_with('-') {
+ format!("{}_", keystroke_input)
+ } else {
+ keystroke_input.to_string()
+ };
+
+ let mut keystroke = Keystroke::parse(&keystroke_str)
+ .unwrap_or_else(|_| panic!("Invalid keystroke: {}", keystroke_input));
+
+ // Remove the dummy key if we added it for modifier-only keystrokes
+ if keystroke_input.ends_with('-') && keystroke_str.ends_with("_") {
+ keystroke.key = "".to_string();
+ }
+
+ // Combine current modifiers with keystroke modifiers
+ keystroke.modifiers |= self.current_modifiers;
+
+ self.input.update_in(&mut self.cx, |input, window, cx| {
+ input.handle_keystroke(&keystroke, window, cx);
+ });
+
+ // Don't update current_modifiers for keystrokes with actual keys
+ if keystroke.key.is_empty() {
+ self.current_modifiers = keystroke.modifiers;
+ }
+ self
+ }
+
+ /// Sends a modifier change event based on string description
+ /// Examples: "+ctrl", "-ctrl", "+cmd+shift", "-all"
+ pub fn send_modifiers(&mut self, modifiers: &str) -> &mut Self {
+ self.expect_is_recording(true);
+ let new_modifiers = if modifiers == "-all" {
+ Modifiers::default()
+ } else {
+ self.parse_modifier_change(modifiers)
+ };
+
+ let event = ModifiersChangedEvent {
+ modifiers: new_modifiers,
+ capslock: gpui::Capslock::default(),
+ };
+
+ self.input.update_in(&mut self.cx, |input, window, cx| {
+ input.on_modifiers_changed(&event, window, cx);
+ });
+
+ self.current_modifiers = new_modifiers;
+ self
+ }
+
+ /// Sends multiple events in sequence
+ /// Each event string is either a keystroke or modifier change
+ pub fn send_events(&mut self, events: &[&str]) -> &mut Self {
+ self.expect_is_recording(true);
+ for event in events {
+ if event.starts_with('+') || event.starts_with('-') {
+ self.send_modifiers(event);
+ } else {
+ self.send_keystroke(event);
+ }
+ }
+ self
+ }
+
+ /// Verifies that the keystrokes match the expected strings
+ #[track_caller]
+ pub fn expect_keystrokes(&mut self, expected: &[&str]) -> &mut Self {
+ let expected_keystrokes: Result, _> = expected
+ .iter()
+ .map(|s| {
+ let keystroke_str = if s.ends_with('-') {
+ format!("{}_", s)
+ } else {
+ s.to_string()
+ };
+
+ let mut keystroke = Keystroke::parse(&keystroke_str)?;
+
+ // Remove the dummy key if we added it for modifier-only keystrokes
+ if s.ends_with('-') && keystroke_str.ends_with("_") {
+ keystroke.key = "".to_string();
+ }
+
+ Ok(keystroke)
+ })
+ .collect();
+
+ let expected_keystrokes = expected_keystrokes
+ .unwrap_or_else(|e: anyhow::Error| panic!("Invalid expected keystroke: {}", e));
+
+ let actual = self
+ .input
+ .read_with(&mut self.cx, |input, _| input.keystrokes.clone());
+ assert_eq!(
+ actual.len(),
+ expected_keystrokes.len(),
+ "Keystroke count mismatch. Expected: {:?}, Actual: {:?}",
+ expected_keystrokes
+ .iter()
+ .map(|k| k.unparse())
+ .collect::>(),
+ actual.iter().map(|k| k.unparse()).collect::>()
+ );
+
+ for (i, (actual, expected)) in actual.iter().zip(expected_keystrokes.iter()).enumerate()
+ {
+ assert_eq!(
+ actual.unparse(),
+ expected.unparse(),
+ "Keystroke {} mismatch. Expected: '{}', Actual: '{}'",
+ i,
+ expected.unparse(),
+ actual.unparse()
+ );
+ }
+ self
+ }
+
+ /// Verifies that there are no keystrokes
+ #[track_caller]
+ pub fn expect_empty(&mut self) -> &mut Self {
+ self.expect_keystrokes(&[])
+ }
+
+ /// Starts recording keystrokes
+ #[track_caller]
+ pub fn start_recording(&mut self) -> &mut Self {
+ self.expect_is_recording(false);
+ self.input.update_in(&mut self.cx, |input, window, cx| {
+ input.start_recording(&StartRecording, window, cx);
+ });
+ self
+ }
+
+ /// Stops recording keystrokes
+ pub fn stop_recording(&mut self) -> &mut Self {
+ self.expect_is_recording(true);
+ self.input.update_in(&mut self.cx, |input, window, cx| {
+ input.stop_recording(&StopRecording, window, cx);
+ });
+ self
+ }
+
+ /// Clears all keystrokes
+ pub fn clear_keystrokes(&mut self) -> &mut Self {
+ self.input.update_in(&mut self.cx, |input, window, cx| {
+ input.clear_keystrokes(&ClearKeystrokes, window, cx);
+ });
+ self
+ }
+
+ /// Verifies the recording state
+ #[track_caller]
+ pub fn expect_is_recording(&mut self, expected: bool) -> &mut Self {
+ let actual = self
+ .input
+ .update_in(&mut self.cx, |input, window, _| input.is_recording(window));
+ assert_eq!(
+ actual, expected,
+ "Recording state mismatch. Expected: {}, Actual: {}",
+ expected, actual
+ );
+ self
+ }
+
+ /// Parses modifier change strings like "+ctrl", "-shift", "+cmd+alt"
+ fn parse_modifier_change(&self, modifiers_str: &str) -> Modifiers {
+ let mut modifiers = self.current_modifiers;
+
+ if let Some(to_add) = modifiers_str.strip_prefix('+') {
+ // Add modifiers
+ for modifier in to_add.split('+') {
+ match modifier {
+ "ctrl" | "control" => modifiers.control = true,
+ "alt" | "option" => modifiers.alt = true,
+ "shift" => modifiers.shift = true,
+ "cmd" | "command" => modifiers.platform = true,
+ "fn" | "function" => modifiers.function = true,
+ _ => panic!("Unknown modifier: {}", modifier),
+ }
+ }
+ } else if let Some(to_remove) = modifiers_str.strip_prefix('-') {
+ // Remove modifiers
+ for modifier in to_remove.split('+') {
+ match modifier {
+ "ctrl" | "control" => modifiers.control = false,
+ "alt" | "option" => modifiers.alt = false,
+ "shift" => modifiers.shift = false,
+ "cmd" | "command" => modifiers.platform = false,
+ "fn" | "function" => modifiers.function = false,
+ _ => panic!("Unknown modifier: {}", modifier),
+ }
+ }
+ }
+
+ modifiers
+ }
+ }
+
+ async fn init_test(cx: &mut TestAppContext) -> KeystrokeInputTestHelper {
+ cx.update(|cx| {
+ let settings_store = SettingsStore::test(cx);
+ cx.set_global(settings_store);
+ theme::init(theme::LoadThemes::JustBase, cx);
+ language::init(cx);
+ project::Project::init_settings(cx);
+ workspace::init_settings(cx);
+ });
+
+ let fs = FakeFs::new(cx.executor());
+ let project = Project::test(fs, [], cx).await;
+ let workspace =
+ cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
+ let cx = VisualTestContext::from_window(*workspace, cx);
+ KeystrokeInputTestHelper::new(cx)
+ }
+
+ #[gpui::test]
+ async fn test_basic_keystroke_input(cx: &mut TestAppContext) {
+ init_test(cx)
+ .await
+ .send_keystroke("a")
+ .clear_keystrokes()
+ .expect_empty();
+ }
+
+ #[gpui::test]
+ async fn test_modifier_handling(cx: &mut TestAppContext) {
+ init_test(cx)
+ .await
+ .with_search_mode(true)
+ .send_events(&["+ctrl", "a", "-ctrl"])
+ .expect_keystrokes(&["ctrl-a"]);
+ }
+
+ #[gpui::test]
+ async fn test_multiple_modifiers(cx: &mut TestAppContext) {
+ init_test(cx)
+ .await
+ .send_keystroke("cmd-shift-z")
+ .expect_keystrokes(&["cmd-shift-z", "cmd-shift-"]);
+ }
+
+ #[gpui::test]
+ async fn test_search_mode_behavior(cx: &mut TestAppContext) {
+ init_test(cx)
+ .await
+ .with_search_mode(true)
+ .send_events(&["+cmd", "shift-f", "-cmd"])
+ // In search mode, when completing a modifier-only keystroke with a key,
+ // only the original modifiers are preserved, not the keystroke's modifiers
+ .expect_keystrokes(&["cmd-f"]);
+ }
+
+ #[gpui::test]
+ async fn test_keystroke_limit(cx: &mut TestAppContext) {
+ init_test(cx)
+ .await
+ .send_keystroke("a")
+ .send_keystroke("b")
+ .send_keystroke("c")
+ .expect_keystrokes(&["a", "b", "c"]) // At max limit
+ .send_keystroke("d")
+ .expect_empty(); // Should clear when exceeding limit
+ }
+
+ #[gpui::test]
+ async fn test_modifier_release_all(cx: &mut TestAppContext) {
+ init_test(cx)
+ .await
+ .with_search_mode(true)
+ .send_events(&["+ctrl+shift", "a", "-all"])
+ .expect_keystrokes(&["ctrl-shift-a"]);
+ }
+
+ #[gpui::test]
+ async fn test_search_new_modifiers_not_added_until_all_released(cx: &mut TestAppContext) {
+ init_test(cx)
+ .await
+ .with_search_mode(true)
+ .send_events(&["+ctrl+shift", "a", "-ctrl"])
+ .expect_keystrokes(&["ctrl-shift-a"])
+ .send_events(&["+ctrl"])
+ .expect_keystrokes(&["ctrl-shift-a", "ctrl-shift-"]);
+ }
+
+ #[gpui::test]
+ async fn test_previous_modifiers_no_effect_when_not_search(cx: &mut TestAppContext) {
+ init_test(cx)
+ .await
+ .with_search_mode(false)
+ .send_events(&["+ctrl+shift", "a", "-all"])
+ .expect_keystrokes(&["ctrl-shift-a"]);
+ }
+
+ #[gpui::test]
+ async fn test_keystroke_limit_overflow_non_search_mode(cx: &mut TestAppContext) {
+ init_test(cx)
+ .await
+ .with_search_mode(false)
+ .send_events(&["a", "b", "c", "d"]) // 4 keystrokes, exceeds limit of 3
+ .expect_empty(); // Should clear when exceeding limit
+ }
+
+ #[gpui::test]
+ async fn test_complex_modifier_sequences(cx: &mut TestAppContext) {
+ init_test(cx)
+ .await
+ .with_search_mode(true)
+ .send_events(&["+ctrl", "+shift", "+alt", "a", "-ctrl", "-shift", "-alt"])
+ .expect_keystrokes(&["ctrl-shift-alt-a"]);
+ }
+
+ #[gpui::test]
+ async fn test_modifier_only_keystrokes_search_mode(cx: &mut TestAppContext) {
+ init_test(cx)
+ .await
+ .with_search_mode(true)
+ .send_events(&["+ctrl", "+shift", "-ctrl", "-shift"])
+ .expect_keystrokes(&["ctrl-shift-"]); // Modifier-only sequences create modifier-only keystrokes
+ }
+
+ #[gpui::test]
+ async fn test_modifier_only_keystrokes_non_search_mode(cx: &mut TestAppContext) {
+ init_test(cx)
+ .await
+ .with_search_mode(false)
+ .send_events(&["+ctrl", "+shift", "-ctrl", "-shift"])
+ .expect_empty(); // Modifier-only sequences get filtered in non-search mode
+ }
+
+ #[gpui::test]
+ async fn test_rapid_modifier_changes(cx: &mut TestAppContext) {
+ init_test(cx)
+ .await
+ .with_search_mode(true)
+ .send_events(&["+ctrl", "-ctrl", "+shift", "-shift", "+alt", "a", "-alt"])
+ .expect_keystrokes(&["ctrl-", "shift-", "alt-a"]);
+ }
+
+ #[gpui::test]
+ async fn test_clear_keystrokes_search_mode(cx: &mut TestAppContext) {
+ init_test(cx)
+ .await
+ .with_search_mode(true)
+ .send_events(&["+ctrl", "a", "-ctrl", "b"])
+ .expect_keystrokes(&["ctrl-a", "b"])
+ .clear_keystrokes()
+ .expect_empty();
+ }
+
+ #[gpui::test]
+ async fn test_non_search_mode_modifier_key_sequence(cx: &mut TestAppContext) {
+ init_test(cx)
+ .await
+ .with_search_mode(false)
+ .send_events(&["+ctrl", "a"])
+ .expect_keystrokes(&["ctrl-a", "ctrl-"])
+ .send_events(&["-ctrl"])
+ .expect_keystrokes(&["ctrl-a"]); // Non-search mode filters trailing empty keystrokes
+ }
+
+ #[gpui::test]
+ async fn test_all_modifiers_at_once(cx: &mut TestAppContext) {
+ init_test(cx)
+ .await
+ .with_search_mode(true)
+ .send_events(&["+ctrl+shift+alt+cmd", "a", "-all"])
+ .expect_keystrokes(&["ctrl-shift-alt-cmd-a"]);
+ }
+
+ #[gpui::test]
+ async fn test_keystrokes_at_exact_limit(cx: &mut TestAppContext) {
+ init_test(cx)
+ .await
+ .with_search_mode(true)
+ .send_events(&["a", "b", "c"]) // exactly 3 keystrokes (at limit)
+ .expect_keystrokes(&["a", "b", "c"])
+ .send_events(&["d"]) // should clear when exceeding
+ .expect_empty();
+ }
+
+ #[gpui::test]
+ async fn test_function_modifier_key(cx: &mut TestAppContext) {
+ init_test(cx)
+ .await
+ .with_search_mode(true)
+ .send_events(&["+fn", "f1", "-fn"])
+ .expect_keystrokes(&["fn-f1"]);
+ }
+
+ #[gpui::test]
+ async fn test_start_stop_recording(cx: &mut TestAppContext) {
+ init_test(cx)
+ .await
+ .send_events(&["a", "b"])
+ .expect_keystrokes(&["a", "b"]) // start_recording clears existing keystrokes
+ .stop_recording()
+ .expect_is_recording(false)
+ .start_recording()
+ .send_events(&["c"])
+ .expect_keystrokes(&["c"]);
+ }
+
+ #[gpui::test]
+ async fn test_modifier_sequence_with_interruption(cx: &mut TestAppContext) {
+ init_test(cx)
+ .await
+ .with_search_mode(true)
+ .send_events(&["+ctrl", "+shift", "a", "-shift", "b", "-ctrl"])
+ .expect_keystrokes(&["ctrl-shift-a", "ctrl-b"]);
+ }
+
+ #[gpui::test]
+ async fn test_empty_key_sequence_search_mode(cx: &mut TestAppContext) {
+ init_test(cx)
+ .await
+ .with_search_mode(true)
+ .send_events(&[]) // No events at all
+ .expect_empty();
+ }
+
+ #[gpui::test]
+ async fn test_modifier_sequence_completion_search_mode(cx: &mut TestAppContext) {
+ init_test(cx)
+ .await
+ .with_search_mode(true)
+ .send_events(&["+ctrl", "+shift", "-shift", "a", "-ctrl"])
+ .expect_keystrokes(&["ctrl-shift-a"]);
+ }
+
+ #[gpui::test]
+ async fn test_triple_escape_stops_recording_search_mode(cx: &mut TestAppContext) {
+ init_test(cx)
+ .await
+ .with_search_mode(true)
+ .send_events(&["a", "escape", "escape", "escape"])
+ .expect_keystrokes(&["a"]) // Triple escape removes final escape, stops recording
+ .expect_is_recording(false);
+ }
+
+ #[gpui::test]
+ async fn test_triple_escape_stops_recording_non_search_mode(cx: &mut TestAppContext) {
+ init_test(cx)
+ .await
+ .with_search_mode(false)
+ .send_events(&["a", "escape", "escape", "escape"])
+ .expect_keystrokes(&["a"]); // Triple escape stops recording but only removes final escape
+ }
+
+ #[gpui::test]
+ async fn test_triple_escape_at_keystroke_limit(cx: &mut TestAppContext) {
+ init_test(cx)
+ .await
+ .with_search_mode(true)
+ .send_events(&["a", "b", "c", "escape", "escape", "escape"]) // 6 keystrokes total, exceeds limit
+ .expect_keystrokes(&["a", "b", "c"]); // Triple escape stops recording and removes escapes, leaves original keystrokes
+ }
+
+ #[gpui::test]
+ async fn test_interrupted_escape_sequence(cx: &mut TestAppContext) {
+ init_test(cx)
+ .await
+ .with_search_mode(true)
+ .send_events(&["escape", "escape", "a", "escape"]) // Partial escape sequence interrupted by 'a'
+ .expect_keystrokes(&["escape", "escape", "a"]); // Escape sequence interrupted by 'a', no close triggered
+ }
+
+ #[gpui::test]
+ async fn test_interrupted_escape_sequence_within_limit(cx: &mut TestAppContext) {
+ init_test(cx)
+ .await
+ .with_search_mode(true)
+ .send_events(&["escape", "escape", "a"]) // Partial escape sequence interrupted by 'a' (3 keystrokes, at limit)
+ .expect_keystrokes(&["escape", "escape", "a"]); // Should not trigger close, interruption resets escape detection
+ }
+
+ #[gpui::test]
+ async fn test_partial_escape_sequence_no_close(cx: &mut TestAppContext) {
+ init_test(cx)
+ .await
+ .with_search_mode(true)
+ .send_events(&["escape", "escape"]) // Only 2 escapes, not enough to close
+ .expect_keystrokes(&["escape", "escape"])
+ .expect_is_recording(true); // Should remain in keystrokes, no close triggered
+ }
+
+ #[gpui::test]
+ async fn test_recording_state_after_triple_escape(cx: &mut TestAppContext) {
+ init_test(cx)
+ .await
+ .with_search_mode(true)
+ .send_events(&["a", "escape", "escape", "escape"])
+ .expect_keystrokes(&["a"]) // Triple escape stops recording, removes final escape
+ .expect_is_recording(false);
+ }
+
+ #[gpui::test]
+ async fn test_triple_escape_mixed_with_other_keystrokes(cx: &mut TestAppContext) {
+ init_test(cx)
+ .await
+ .with_search_mode(true)
+ .send_events(&["a", "escape", "b", "escape", "escape"]) // Mixed sequence, should not trigger close
+ .expect_keystrokes(&["a", "escape", "b"]); // No complete triple escape sequence, stays at limit
+ }
+
+ #[gpui::test]
+ async fn test_triple_escape_only(cx: &mut TestAppContext) {
+ init_test(cx)
+ .await
+ .with_search_mode(true)
+ .send_events(&["escape", "escape", "escape"]) // Pure triple escape sequence
+ .expect_empty();
+ }
+}
diff --git a/crates/settings_ui/src/ui_components/mod.rs b/crates/settings_ui/src/ui_components/mod.rs
index 13971b0a5d..5d6463a61a 100644
--- a/crates/settings_ui/src/ui_components/mod.rs
+++ b/crates/settings_ui/src/ui_components/mod.rs
@@ -1 +1,2 @@
+pub mod keystroke_input;
pub mod table;
From 902c17ac1a1c5d012c9ba0a7675e7f1ed1b98de2 Mon Sep 17 00:00:00 2001
From: "Joseph T. Lyons"
Date: Tue, 29 Jul 2025 14:15:17 -0400
Subject: [PATCH 16/35] Add Zed badge to README.md (#35287)
Release Notes:
- N/A
---
README.md | 2 ++
assets/badge/v0.json | 8 ++++++++
2 files changed, 10 insertions(+)
create mode 100644 assets/badge/v0.json
diff --git a/README.md b/README.md
index 4c794efc3d..9ea7b81de0 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,7 @@
# Zed
+[](https://zed.dev)
+
[](https://github.com/zed-industries/zed/actions/workflows/ci.yml)
Welcome to Zed, a high-performance, multiplayer code editor from the creators of [Atom](https://github.com/atom/atom) and [Tree-sitter](https://github.com/tree-sitter/tree-sitter).
diff --git a/assets/badge/v0.json b/assets/badge/v0.json
new file mode 100644
index 0000000000..4b3bbf45ca
--- /dev/null
+++ b/assets/badge/v0.json
@@ -0,0 +1,8 @@
+{
+ "label": "",
+ "message": "zed",
+ "logoSvg": "",
+ "logoWidth": 16,
+ "labelColor": "grey",
+ "color": "#261230"
+}
From 72f8fa6d1e4d0a09a54e3a25d6be333bd692ed08 Mon Sep 17 00:00:00 2001
From: "Joseph T. Lyons"
Date: Tue, 29 Jul 2025 14:24:10 -0400
Subject: [PATCH 17/35] Adjust Zed badge (#35290)
- Inline badges
- Set label background fill color to black
- Uppercase Zed text
- Remove gray padding
Release Notes:
- N/A
---
README.md | 3 +--
assets/badge/v0.json | 4 ++--
2 files changed, 3 insertions(+), 4 deletions(-)
diff --git a/README.md b/README.md
index 9ea7b81de0..38547c1ca4 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,6 @@
# Zed
-[](https://zed.dev)
-
+[](https://zed.dev)
[](https://github.com/zed-industries/zed/actions/workflows/ci.yml)
Welcome to Zed, a high-performance, multiplayer code editor from the creators of [Atom](https://github.com/atom/atom) and [Tree-sitter](https://github.com/tree-sitter/tree-sitter).
diff --git a/assets/badge/v0.json b/assets/badge/v0.json
index 4b3bbf45ca..3ff95d3378 100644
--- a/assets/badge/v0.json
+++ b/assets/badge/v0.json
@@ -1,8 +1,8 @@
{
"label": "",
"message": "zed",
- "logoSvg": "",
+ "logoSvg": "",
"logoWidth": 16,
- "labelColor": "grey",
+ "labelColor": "black",
"color": "#261230"
}
From 7878eacc7348d23468370b24b1412b78d86c967e Mon Sep 17 00:00:00 2001
From: Cole Miller
Date: Tue, 29 Jul 2025 15:00:41 -0400
Subject: [PATCH 18/35] python: Use a single workspace folder for basedpyright
(#35292)
Treat the new basedpyright adapter the same as pyright was treated in
#35243.
Release Notes:
- N/A
---
crates/languages/src/python.rs | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/crates/languages/src/python.rs b/crates/languages/src/python.rs
index 4a0cc7078b..0524c02fd5 100644
--- a/crates/languages/src/python.rs
+++ b/crates/languages/src/python.rs
@@ -1625,6 +1625,10 @@ impl LspAdapter for BasedPyrightLspAdapter {
fn manifest_name(&self) -> Option {
Some(SharedString::new_static("pyproject.toml").into())
}
+
+ fn workspace_folders_content(&self) -> WorkspaceFoldersContent {
+ WorkspaceFoldersContent::WorktreeRoot
+ }
}
#[cfg(test)]
From 397314232451ba589aacb6a2053c8ab36d19dfdd Mon Sep 17 00:00:00 2001
From: "Joseph T. Lyons"
Date: Tue, 29 Jul 2025 15:09:31 -0400
Subject: [PATCH 19/35] Adjust Zed badge (#35294)
- Make right side background white
- Fix Zed casing
Release Notes:
- N/A
---
assets/badge/v0.json | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/assets/badge/v0.json b/assets/badge/v0.json
index 3ff95d3378..c7d18bb42b 100644
--- a/assets/badge/v0.json
+++ b/assets/badge/v0.json
@@ -1,8 +1,8 @@
{
"label": "",
- "message": "zed",
+ "message": "Zed",
"logoSvg": "",
"logoWidth": 16,
"labelColor": "black",
- "color": "#261230"
+ "color": "white"
}
From 1501ae001356fbf16490083f9f1bd3ad93d022b5 Mon Sep 17 00:00:00 2001
From: David Kleingeld
Date: Tue, 29 Jul 2025 22:24:34 +0200
Subject: [PATCH 20/35] Upgrade rodio to 0.21 (#34368)
Hi all,
We just released [Rodio
0.21](https://github.com/RustAudio/rodio/blob/master/CHANGELOG.md)
:partying_face: with quite some breaking changes. This should take care
of those for zed. I tested it by hopping in and out some of the zed
channels, sound seems to still work.
Given zed uses tracing I also took the liberty of enabling the tracing
feature for rodio.
edit:
We changed the default wav decoder from hound to symphonia. The latter
has a slightly more restrictive license however that should be no issue
here (as the audio crate uses the GPL)
Release Notes:
- N/A
---
Cargo.lock | 171 +++++++++++++-----------------
crates/audio/Cargo.toml | 2 +-
crates/audio/src/assets.rs | 9 +-
crates/audio/src/audio.rs | 14 +--
tooling/workspace-hack/Cargo.toml | 6 --
5 files changed, 85 insertions(+), 117 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 7ab4a85c7d..5e35202e90 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3684,17 +3684,6 @@ dependencies = [
"libm",
]
-[[package]]
-name = "coreaudio-rs"
-version = "0.11.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace"
-dependencies = [
- "bitflags 1.3.2",
- "core-foundation-sys",
- "coreaudio-sys",
-]
-
[[package]]
name = "coreaudio-rs"
version = "0.12.1"
@@ -3752,29 +3741,6 @@ dependencies = [
"unicode-segmentation",
]
-[[package]]
-name = "cpal"
-version = "0.15.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779"
-dependencies = [
- "alsa",
- "core-foundation-sys",
- "coreaudio-rs 0.11.3",
- "dasp_sample",
- "jni",
- "js-sys",
- "libc",
- "mach2",
- "ndk 0.8.0",
- "ndk-context",
- "oboe",
- "wasm-bindgen",
- "wasm-bindgen-futures",
- "web-sys",
- "windows 0.54.0",
-]
-
[[package]]
name = "cpal"
version = "0.16.0"
@@ -3788,7 +3754,7 @@ dependencies = [
"js-sys",
"libc",
"mach2",
- "ndk 0.9.0",
+ "ndk",
"ndk-context",
"num-derive",
"num-traits",
@@ -5367,6 +5333,12 @@ dependencies = [
"zune-inflate",
]
+[[package]]
+name = "extended"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365"
+
[[package]]
name = "extension"
version = "0.1.0"
@@ -7742,12 +7714,6 @@ dependencies = [
"windows-sys 0.59.0",
]
-[[package]]
-name = "hound"
-version = "3.5.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f"
-
[[package]]
name = "html5ever"
version = "0.27.0"
@@ -9595,7 +9561,7 @@ dependencies = [
"core-foundation 0.10.0",
"core-video",
"coreaudio-rs 0.12.1",
- "cpal 0.16.0",
+ "cpal",
"futures 0.3.31",
"gpui",
"gpui_tokio",
@@ -10366,20 +10332,6 @@ dependencies = [
"workspace-hack",
]
-[[package]]
-name = "ndk"
-version = "0.8.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7"
-dependencies = [
- "bitflags 2.9.0",
- "jni-sys",
- "log",
- "ndk-sys 0.5.0+25.2.9519653",
- "num_enum",
- "thiserror 1.0.69",
-]
-
[[package]]
name = "ndk"
version = "0.9.0"
@@ -10389,7 +10341,7 @@ dependencies = [
"bitflags 2.9.0",
"jni-sys",
"log",
- "ndk-sys 0.6.0+11769913",
+ "ndk-sys",
"num_enum",
"thiserror 1.0.69",
]
@@ -10400,15 +10352,6 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
-[[package]]
-name = "ndk-sys"
-version = "0.5.0+25.2.9519653"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691"
-dependencies = [
- "jni-sys",
-]
-
[[package]]
name = "ndk-sys"
version = "0.6.0+11769913"
@@ -10978,29 +10921,6 @@ dependencies = [
"memchr",
]
-[[package]]
-name = "oboe"
-version = "0.6.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb"
-dependencies = [
- "jni",
- "ndk 0.8.0",
- "ndk-context",
- "num-derive",
- "num-traits",
- "oboe-sys",
-]
-
-[[package]]
-name = "oboe-sys"
-version = "0.6.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d"
-dependencies = [
- "cc",
-]
-
[[package]]
name = "ollama"
version = "0.1.0"
@@ -13780,12 +13700,15 @@ dependencies = [
[[package]]
name = "rodio"
-version = "0.20.1"
+version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e7ceb6607dd738c99bc8cb28eff249b7cd5c8ec88b9db96c0608c1480d140fb1"
+checksum = "e40ecf59e742e03336be6a3d53755e789fd05a059fa22dfa0ed624722319e183"
dependencies = [
- "cpal 0.15.3",
- "hound",
+ "cpal",
+ "dasp_sample",
+ "num-rational",
+ "symphonia",
+ "tracing",
]
[[package]]
@@ -15806,6 +15729,66 @@ dependencies = [
"zeno",
]
+[[package]]
+name = "symphonia"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "815c942ae7ee74737bb00f965fa5b5a2ac2ce7b6c01c0cc169bbeaf7abd5f5a9"
+dependencies = [
+ "lazy_static",
+ "symphonia-codec-pcm",
+ "symphonia-core",
+ "symphonia-format-riff",
+ "symphonia-metadata",
+]
+
+[[package]]
+name = "symphonia-codec-pcm"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f395a67057c2ebc5e84d7bb1be71cce1a7ba99f64e0f0f0e303a03f79116f89b"
+dependencies = [
+ "log",
+ "symphonia-core",
+]
+
+[[package]]
+name = "symphonia-core"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "798306779e3dc7d5231bd5691f5a813496dc79d3f56bf82e25789f2094e022c3"
+dependencies = [
+ "arrayvec",
+ "bitflags 1.3.2",
+ "bytemuck",
+ "lazy_static",
+ "log",
+]
+
+[[package]]
+name = "symphonia-format-riff"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05f7be232f962f937f4b7115cbe62c330929345434c834359425e043bfd15f50"
+dependencies = [
+ "extended",
+ "log",
+ "symphonia-core",
+ "symphonia-metadata",
+]
+
+[[package]]
+name = "symphonia-metadata"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc622b9841a10089c5b18e99eb904f4341615d5aa55bbf4eedde1be721a4023c"
+dependencies = [
+ "encoding_rs",
+ "lazy_static",
+ "log",
+ "symphonia-core",
+]
+
[[package]]
name = "syn"
version = "1.0.109"
@@ -19693,14 +19676,12 @@ dependencies = [
"cc",
"chrono",
"cipher",
- "clang-sys",
"clap",
"clap_builder",
"codespan-reporting 0.12.0",
"concurrent-queue",
"core-foundation 0.9.4",
"core-foundation-sys",
- "coreaudio-sys",
"cranelift-codegen",
"crc32fast",
"crossbeam-epoch",
diff --git a/crates/audio/Cargo.toml b/crates/audio/Cargo.toml
index 960aaf8e08..d857a3eb2f 100644
--- a/crates/audio/Cargo.toml
+++ b/crates/audio/Cargo.toml
@@ -18,6 +18,6 @@ collections.workspace = true
derive_more.workspace = true
gpui.workspace = true
parking_lot.workspace = true
-rodio = { version = "0.20.0", default-features = false, features = ["wav"] }
+rodio = { version = "0.21.1", default-features = false, features = ["wav", "playback", "tracing"] }
util.workspace = true
workspace-hack.workspace = true
diff --git a/crates/audio/src/assets.rs b/crates/audio/src/assets.rs
index 02da79dc24..fd5c935d87 100644
--- a/crates/audio/src/assets.rs
+++ b/crates/audio/src/assets.rs
@@ -3,12 +3,9 @@ use std::{io::Cursor, sync::Arc};
use anyhow::{Context as _, Result};
use collections::HashMap;
use gpui::{App, AssetSource, Global};
-use rodio::{
- Decoder, Source,
- source::{Buffered, SamplesConverter},
-};
+use rodio::{Decoder, Source, source::Buffered};
-type Sound = Buffered>>, f32>>;
+type Sound = Buffered>>>;
pub struct SoundRegistry {
cache: Arc>>,
@@ -48,7 +45,7 @@ impl SoundRegistry {
.with_context(|| format!("No asset available for path {path}"))??
.into_owned();
let cursor = Cursor::new(bytes);
- let source = Decoder::new(cursor)?.convert_samples::().buffered();
+ let source = Decoder::new(cursor)?.buffered();
self.cache.lock().insert(name.to_string(), source.clone());
diff --git a/crates/audio/src/audio.rs b/crates/audio/src/audio.rs
index e7b9a59e8f..44baa16aa2 100644
--- a/crates/audio/src/audio.rs
+++ b/crates/audio/src/audio.rs
@@ -1,7 +1,7 @@
use assets::SoundRegistry;
use derive_more::{Deref, DerefMut};
use gpui::{App, AssetSource, BorrowAppContext, Global};
-use rodio::{OutputStream, OutputStreamHandle};
+use rodio::{OutputStream, OutputStreamBuilder};
use util::ResultExt;
mod assets;
@@ -37,8 +37,7 @@ impl Sound {
#[derive(Default)]
pub struct Audio {
- _output_stream: Option,
- output_handle: Option,
+ output_handle: Option,
}
#[derive(Deref, DerefMut)]
@@ -51,11 +50,9 @@ impl Audio {
Self::default()
}
- fn ensure_output_exists(&mut self) -> Option<&OutputStreamHandle> {
+ fn ensure_output_exists(&mut self) -> Option<&OutputStream> {
if self.output_handle.is_none() {
- let (_output_stream, output_handle) = OutputStream::try_default().log_err().unzip();
- self.output_handle = output_handle;
- self._output_stream = _output_stream;
+ self.output_handle = OutputStreamBuilder::open_default_stream().log_err();
}
self.output_handle.as_ref()
@@ -69,7 +66,7 @@ impl Audio {
cx.update_global::(|this, cx| {
let output_handle = this.ensure_output_exists()?;
let source = SoundRegistry::global(cx).get(sound.file()).log_err()?;
- output_handle.play_raw(source).log_err()?;
+ output_handle.mixer().add(source);
Some(())
});
}
@@ -80,7 +77,6 @@ impl Audio {
}
cx.update_global::(|this, _| {
- this._output_stream.take();
this.output_handle.take();
});
}
diff --git a/tooling/workspace-hack/Cargo.toml b/tooling/workspace-hack/Cargo.toml
index 1026454026..e5123d5ab3 100644
--- a/tooling/workspace-hack/Cargo.toml
+++ b/tooling/workspace-hack/Cargo.toml
@@ -284,7 +284,6 @@ winnow = { version = "0.7", features = ["simd"] }
codespan-reporting = { version = "0.12" }
core-foundation = { version = "0.9" }
core-foundation-sys = { version = "0.8" }
-coreaudio-sys = { version = "0.2", default-features = false, features = ["audio_toolbox", "audio_unit", "core_audio", "core_midi", "open_al"] }
foldhash = { version = "0.1", default-features = false, features = ["std"] }
getrandom-468e82937335b1c9 = { package = "getrandom", version = "0.3", default-features = false, features = ["std"] }
gimli = { version = "0.31", default-features = false, features = ["read", "std", "write"] }
@@ -310,11 +309,9 @@ tokio-stream = { version = "0.1", features = ["fs"] }
tower = { version = "0.5", default-features = false, features = ["timeout", "util"] }
[target.x86_64-apple-darwin.build-dependencies]
-clang-sys = { version = "1", default-features = false, features = ["clang_11_0", "runtime"] }
codespan-reporting = { version = "0.12" }
core-foundation = { version = "0.9" }
core-foundation-sys = { version = "0.8" }
-coreaudio-sys = { version = "0.2", default-features = false, features = ["audio_toolbox", "audio_unit", "core_audio", "core_midi", "open_al"] }
foldhash = { version = "0.1", default-features = false, features = ["std"] }
getrandom-468e82937335b1c9 = { package = "getrandom", version = "0.3", default-features = false, features = ["std"] }
gimli = { version = "0.31", default-features = false, features = ["read", "std", "write"] }
@@ -344,7 +341,6 @@ tower = { version = "0.5", default-features = false, features = ["timeout", "uti
codespan-reporting = { version = "0.12" }
core-foundation = { version = "0.9" }
core-foundation-sys = { version = "0.8" }
-coreaudio-sys = { version = "0.2", default-features = false, features = ["audio_toolbox", "audio_unit", "core_audio", "core_midi", "open_al"] }
foldhash = { version = "0.1", default-features = false, features = ["std"] }
getrandom-468e82937335b1c9 = { package = "getrandom", version = "0.3", default-features = false, features = ["std"] }
gimli = { version = "0.31", default-features = false, features = ["read", "std", "write"] }
@@ -370,11 +366,9 @@ tokio-stream = { version = "0.1", features = ["fs"] }
tower = { version = "0.5", default-features = false, features = ["timeout", "util"] }
[target.aarch64-apple-darwin.build-dependencies]
-clang-sys = { version = "1", default-features = false, features = ["clang_11_0", "runtime"] }
codespan-reporting = { version = "0.12" }
core-foundation = { version = "0.9" }
core-foundation-sys = { version = "0.8" }
-coreaudio-sys = { version = "0.2", default-features = false, features = ["audio_toolbox", "audio_unit", "core_audio", "core_midi", "open_al"] }
foldhash = { version = "0.1", default-features = false, features = ["std"] }
getrandom-468e82937335b1c9 = { package = "getrandom", version = "0.3", default-features = false, features = ["std"] }
gimli = { version = "0.31", default-features = false, features = ["read", "std", "write"] }
From 5fa212183ace0388735c7aa05e9bc3955a7970a5 Mon Sep 17 00:00:00 2001
From: Daniel Sauble
Date: Tue, 29 Jul 2025 14:22:53 -0700
Subject: [PATCH 21/35] Fix animations in the component preview (#33673)
Fixes #33869
The Animation page in the Component Preview had a few issues.
* The animations only ran once, so you couldn't watch animations below
the fold.
* The offset math was wrong, so some animated elements were rendered
outside of their parent container.
* The "animate in from right" elements were defined with an initial
`.left()` offset, which overrode the animation behavior.
I made fixes to address these issues. In particular, every time you
click the active list item, it renders the preview again (which causes
the animations to run again).
Before:
https://github.com/user-attachments/assets/a1fa2e3f-653c-4b83-a6ed-c55ca9c78ad4
After:
https://github.com/user-attachments/assets/3623bbbc-9047-4443-b7f3-96bd92f582bf
Release Notes:
- N/A
---
crates/ui/src/styles/animation.rs | 18 +++++++++---------
crates/zed/src/zed/component_preview.rs | 16 ++++++++++++++--
2 files changed, 23 insertions(+), 11 deletions(-)
diff --git a/crates/ui/src/styles/animation.rs b/crates/ui/src/styles/animation.rs
index 50c4e0eb0d..0649bee1f8 100644
--- a/crates/ui/src/styles/animation.rs
+++ b/crates/ui/src/styles/animation.rs
@@ -109,7 +109,7 @@ impl Component for Animation {
fn preview(_window: &mut Window, _cx: &mut App) -> Option {
let container_size = 128.0;
let element_size = 32.0;
- let left_offset = element_size - container_size / 2.0;
+ let offset = container_size / 2.0 - element_size / 2.0;
Some(
v_flex()
.gap_6()
@@ -129,7 +129,7 @@ impl Component for Animation {
.id("animate-in-from-bottom")
.absolute()
.size(px(element_size))
- .left(px(left_offset))
+ .left(px(offset))
.rounded_md()
.bg(gpui::red())
.animate_in(AnimationDirection::FromBottom, false),
@@ -148,7 +148,7 @@ impl Component for Animation {
.id("animate-in-from-top")
.absolute()
.size(px(element_size))
- .left(px(left_offset))
+ .left(px(offset))
.rounded_md()
.bg(gpui::blue())
.animate_in(AnimationDirection::FromTop, false),
@@ -167,7 +167,7 @@ impl Component for Animation {
.id("animate-in-from-left")
.absolute()
.size(px(element_size))
- .left(px(left_offset))
+ .top(px(offset))
.rounded_md()
.bg(gpui::green())
.animate_in(AnimationDirection::FromLeft, false),
@@ -186,7 +186,7 @@ impl Component for Animation {
.id("animate-in-from-right")
.absolute()
.size(px(element_size))
- .left(px(left_offset))
+ .top(px(offset))
.rounded_md()
.bg(gpui::yellow())
.animate_in(AnimationDirection::FromRight, false),
@@ -211,7 +211,7 @@ impl Component for Animation {
.id("fade-animate-in-from-bottom")
.absolute()
.size(px(element_size))
- .left(px(left_offset))
+ .left(px(offset))
.rounded_md()
.bg(gpui::red())
.animate_in(AnimationDirection::FromBottom, true),
@@ -230,7 +230,7 @@ impl Component for Animation {
.id("fade-animate-in-from-top")
.absolute()
.size(px(element_size))
- .left(px(left_offset))
+ .left(px(offset))
.rounded_md()
.bg(gpui::blue())
.animate_in(AnimationDirection::FromTop, true),
@@ -249,7 +249,7 @@ impl Component for Animation {
.id("fade-animate-in-from-left")
.absolute()
.size(px(element_size))
- .left(px(left_offset))
+ .top(px(offset))
.rounded_md()
.bg(gpui::green())
.animate_in(AnimationDirection::FromLeft, true),
@@ -268,7 +268,7 @@ impl Component for Animation {
.id("fade-animate-in-from-right")
.absolute()
.size(px(element_size))
- .left(px(left_offset))
+ .top(px(offset))
.rounded_md()
.bg(gpui::yellow())
.animate_in(AnimationDirection::FromRight, true),
diff --git a/crates/zed/src/zed/component_preview.rs b/crates/zed/src/zed/component_preview.rs
index 670793cff3..2e57152c62 100644
--- a/crates/zed/src/zed/component_preview.rs
+++ b/crates/zed/src/zed/component_preview.rs
@@ -105,6 +105,7 @@ enum PreviewPage {
struct ComponentPreview {
active_page: PreviewPage,
active_thread: Option>,
+ reset_key: usize,
component_list: ListState,
component_map: HashMap,
components: Vec,
@@ -188,6 +189,7 @@ impl ComponentPreview {
let mut component_preview = Self {
active_page,
active_thread: None,
+ reset_key: 0,
component_list,
component_map: component_registry.component_map(),
components: sorted_components,
@@ -265,8 +267,13 @@ impl ComponentPreview {
}
fn set_active_page(&mut self, page: PreviewPage, cx: &mut Context) {
- self.active_page = page;
- cx.emit(ItemEvent::UpdateTab);
+ if self.active_page == page {
+ // Force the current preview page to render again
+ self.reset_key = self.reset_key.wrapping_add(1);
+ } else {
+ self.active_page = page;
+ cx.emit(ItemEvent::UpdateTab);
+ }
cx.notify();
}
@@ -690,6 +697,7 @@ impl ComponentPreview {
component.clone(),
self.workspace.clone(),
self.active_thread.clone(),
+ self.reset_key,
))
.into_any_element()
} else {
@@ -1041,6 +1049,7 @@ pub struct ComponentPreviewPage {
component: ComponentMetadata,
workspace: WeakEntity,
active_thread: Option>,
+ reset_key: usize,
}
impl ComponentPreviewPage {
@@ -1048,6 +1057,7 @@ impl ComponentPreviewPage {
component: ComponentMetadata,
workspace: WeakEntity,
active_thread: Option>,
+ reset_key: usize,
// languages: Arc
) -> Self {
Self {
@@ -1055,6 +1065,7 @@ impl ComponentPreviewPage {
component,
workspace,
active_thread,
+ reset_key,
}
}
@@ -1155,6 +1166,7 @@ impl ComponentPreviewPage {
};
v_flex()
+ .id(("component-preview", self.reset_key))
.size_full()
.flex_1()
.px_12()
From 85b712c04e77cb3500facc0cd67836c5c3fdb719 Mon Sep 17 00:00:00 2001
From: Ben Kunkle
Date: Tue, 29 Jul 2025 16:24:57 -0500
Subject: [PATCH 22/35] keymap_ui: Clear close keystroke capture on timeout
(#35289)
Closes #ISSUE
Introduces a mechanism whereby keystrokes that have a post-fix which
matches the prefix of the stop recording binding can still be entered.
The solution is to introduce a (as of right now) 300ms timeout before
the close keystroke state is wiped.
Previously, with the default stop recording binding `esc esc esc`,
searching or entering a binding ending in esc was not possible without
using the mouse. `e.g.` entering keystroke `ctrl-g esc` and then
attempting to hit `esc` three times would stop recording on the
penultimate `esc` press and the final `esc` would not be intercepted.
Now with the timeout, it is possible to enter `ctrl-g esc`, pause for a
moment, then hit `esc esc esc` and end the recording with the keystroke
input state being `ctrl-g esc`.
I arrived at 300ms for this delay as it was long enough that I didn't
run into it very often when trying to escape, but short enough that a
natural pause will almost always work as expected.
Release Notes:
- Keymap Editor: Added a short timeout to the stop recording keybind
handling in the keystroke input, so that it is now possible using the
default bindings as an example (custom bindings should work as well) to
search for/enter a binding ending with `escape` (with no modifier),
pause for a moment, then hit `escape escape escape` to stop recording
and search for/enter a keystroke ending with `escape`.
---
.../src/ui_components/keystroke_input.rs | 180 +++++++++++++-----
1 file changed, 136 insertions(+), 44 deletions(-)
diff --git a/crates/settings_ui/src/ui_components/keystroke_input.rs b/crates/settings_ui/src/ui_components/keystroke_input.rs
index 08ffe3575b..a34d0a2bbd 100644
--- a/crates/settings_ui/src/ui_components/keystroke_input.rs
+++ b/crates/settings_ui/src/ui_components/keystroke_input.rs
@@ -1,6 +1,6 @@
use gpui::{
Animation, AnimationExt, Context, EventEmitter, FocusHandle, Focusable, FontWeight, KeyContext,
- Keystroke, Modifiers, ModifiersChangedEvent, Subscription, actions,
+ Keystroke, Modifiers, ModifiersChangedEvent, Subscription, Task, actions,
};
use ui::{
ActiveTheme as _, Color, IconButton, IconButtonShape, IconName, IconSize, Label, LabelSize,
@@ -21,6 +21,9 @@ actions!(
const KEY_CONTEXT_VALUE: &'static str = "KeystrokeInput";
+const CLOSE_KEYSTROKE_CAPTURE_END_TIMEOUT: std::time::Duration =
+ std::time::Duration::from_millis(300);
+
enum CloseKeystrokeResult {
Partial,
Close,
@@ -46,10 +49,19 @@ pub struct KeystrokeInput {
intercept_subscription: Option,
_focus_subscriptions: [Subscription; 2],
search: bool,
- /// Handles triple escape to stop recording
+ /// The sequence of close keystrokes being typed
close_keystrokes: Option>,
close_keystrokes_start: Option,
previous_modifiers: Modifiers,
+ /// In order to support inputting keystrokes that end with a prefix of the
+ /// close keybind keystrokes, we clear the close keystroke capture info
+ /// on a timeout after a close keystroke is pressed
+ ///
+ /// e.g. if close binding is `esc esc esc` and user wants to search for
+ /// `ctrl-g esc`, after entering the `ctrl-g esc`, hitting `esc` twice would
+ /// stop recording because of the sequence of three escapes making it
+ /// impossible to search for anything ending in `esc`
+ clear_close_keystrokes_timer: Option>,
#[cfg(test)]
recording: bool,
}
@@ -79,6 +91,7 @@ impl KeystrokeInput {
close_keystrokes: None,
close_keystrokes_start: None,
previous_modifiers: Modifiers::default(),
+ clear_close_keystrokes_timer: None,
#[cfg(test)]
recording: false,
}
@@ -144,6 +157,34 @@ impl KeystrokeInput {
}
}
+ fn upsert_close_keystrokes_start(&mut self, start: usize, cx: &mut Context) {
+ if self.close_keystrokes_start.is_some() {
+ return;
+ }
+ self.close_keystrokes_start = Some(start);
+ self.update_clear_close_keystrokes_timer(cx);
+ }
+
+ fn update_clear_close_keystrokes_timer(&mut self, cx: &mut Context) {
+ self.clear_close_keystrokes_timer = Some(cx.spawn(async |this, cx| {
+ cx.background_executor()
+ .timer(CLOSE_KEYSTROKE_CAPTURE_END_TIMEOUT)
+ .await;
+ this.update(cx, |this, _cx| {
+ this.end_close_keystrokes_capture();
+ })
+ .ok();
+ }));
+ }
+
+ /// Interrupt the capture of close keystrokes, but do not clear the close keystrokes
+ /// from the input
+ fn end_close_keystrokes_capture(&mut self) -> Option {
+ self.close_keystrokes.take();
+ self.clear_close_keystrokes_timer.take();
+ return self.close_keystrokes_start.take();
+ }
+
fn handle_possible_close_keystroke(
&mut self,
keystroke: &Keystroke,
@@ -152,8 +193,7 @@ impl KeystrokeInput {
) -> CloseKeystrokeResult {
let Some(keybind_for_close_action) = Self::determine_stop_recording_binding(window) else {
log::trace!("No keybinding to stop recording keystrokes in keystroke input");
- self.close_keystrokes.take();
- self.close_keystrokes_start.take();
+ self.end_close_keystrokes_capture();
return CloseKeystrokeResult::None;
};
let action_keystrokes = keybind_for_close_action.keystrokes();
@@ -169,20 +209,20 @@ impl KeystrokeInput {
}
if index == close_keystrokes.len() {
if index >= action_keystrokes.len() {
- self.close_keystrokes_start.take();
+ self.end_close_keystrokes_capture();
return CloseKeystrokeResult::None;
}
if keystroke.should_match(&action_keystrokes[index]) {
- if action_keystrokes.len() >= 1 && index == action_keystrokes.len() - 1 {
- self.stop_recording(&StopRecording, window, cx);
+ close_keystrokes.push(keystroke.clone());
+ if close_keystrokes.len() == action_keystrokes.len() {
return CloseKeystrokeResult::Close;
} else {
- close_keystrokes.push(keystroke.clone());
self.close_keystrokes = Some(close_keystrokes);
+ self.update_clear_close_keystrokes_timer(cx);
return CloseKeystrokeResult::Partial;
}
} else {
- self.close_keystrokes_start.take();
+ self.end_close_keystrokes_capture();
return CloseKeystrokeResult::None;
}
}
@@ -192,7 +232,7 @@ impl KeystrokeInput {
self.close_keystrokes = Some(vec![keystroke.clone()]);
return CloseKeystrokeResult::Partial;
}
- self.close_keystrokes_start.take();
+ self.end_close_keystrokes_capture();
return CloseKeystrokeResult::None;
}
@@ -248,36 +288,22 @@ impl KeystrokeInput {
cx: &mut Context,
) {
let close_keystroke_result = self.handle_possible_close_keystroke(keystroke, window, cx);
- if close_keystroke_result != CloseKeystrokeResult::Close {
- let key_len = self.keystrokes.len();
- if let Some(last) = self.keystrokes.last_mut()
- && last.key.is_empty()
- && key_len <= Self::KEYSTROKE_COUNT_MAX
- {
- if self.search {
- last.key = keystroke.key.clone();
- if close_keystroke_result == CloseKeystrokeResult::Partial
- && self.close_keystrokes_start.is_none()
- {
- self.close_keystrokes_start = Some(self.keystrokes.len() - 1);
- }
- if self.search {
- self.previous_modifiers = keystroke.modifiers;
- }
- self.keystrokes_changed(cx);
- cx.stop_propagation();
- return;
- } else {
- self.keystrokes.pop();
- }
- }
- if self.keystrokes.len() < Self::KEYSTROKE_COUNT_MAX {
+ if close_keystroke_result == CloseKeystrokeResult::Close {
+ self.stop_recording(&StopRecording, window, cx);
+ return;
+ }
+ let key_len = self.keystrokes.len();
+ if let Some(last) = self.keystrokes.last_mut()
+ && last.key.is_empty()
+ && key_len <= Self::KEYSTROKE_COUNT_MAX
+ {
+ if self.search {
+ last.key = keystroke.key.clone();
if close_keystroke_result == CloseKeystrokeResult::Partial
&& self.close_keystrokes_start.is_none()
{
- self.close_keystrokes_start = Some(self.keystrokes.len());
+ self.upsert_close_keystrokes_start(self.keystrokes.len() - 1, cx);
}
- self.keystrokes.push(keystroke.clone());
if self.search {
self.previous_modifiers = keystroke.modifiers;
} else if self.keystrokes.len() < Self::KEYSTROKE_COUNT_MAX
@@ -285,10 +311,30 @@ impl KeystrokeInput {
{
self.keystrokes.push(Self::dummy(keystroke.modifiers));
}
- } else if close_keystroke_result != CloseKeystrokeResult::Partial {
- self.clear_keystrokes(&ClearKeystrokes, window, cx);
+ self.keystrokes_changed(cx);
+ cx.stop_propagation();
+ return;
+ } else {
+ self.keystrokes.pop();
}
}
+ if self.keystrokes.len() < Self::KEYSTROKE_COUNT_MAX {
+ if close_keystroke_result == CloseKeystrokeResult::Partial
+ && self.close_keystrokes_start.is_none()
+ {
+ self.upsert_close_keystrokes_start(self.keystrokes.len(), cx);
+ }
+ self.keystrokes.push(keystroke.clone());
+ if self.search {
+ self.previous_modifiers = keystroke.modifiers;
+ } else if self.keystrokes.len() < Self::KEYSTROKE_COUNT_MAX
+ && keystroke.modifiers.modified()
+ {
+ self.keystrokes.push(Self::dummy(keystroke.modifiers));
+ }
+ } else if close_keystroke_result != CloseKeystrokeResult::Partial {
+ self.clear_keystrokes(&ClearKeystrokes, window, cx);
+ }
self.keystrokes_changed(cx);
cx.stop_propagation();
}
@@ -365,8 +411,9 @@ impl KeystrokeInput {
&& close_keystrokes_start < self.keystrokes.len()
{
self.keystrokes.drain(close_keystrokes_start..);
+ self.keystrokes_changed(cx);
}
- self.close_keystrokes.take();
+ self.end_close_keystrokes_capture();
#[cfg(test)]
{
self.recording = false;
@@ -645,6 +692,7 @@ mod tests {
/// Sends a keystroke event based on string description
/// Examples: "a", "ctrl-a", "cmd-shift-z", "escape"
+ #[track_caller]
pub fn send_keystroke(&mut self, keystroke_input: &str) -> &mut Self {
self.expect_is_recording(true);
let keystroke_str = if keystroke_input.ends_with('-') {
@@ -677,6 +725,7 @@ mod tests {
/// Sends a modifier change event based on string description
/// Examples: "+ctrl", "-ctrl", "+cmd+shift", "-all"
+ #[track_caller]
pub fn send_modifiers(&mut self, modifiers: &str) -> &mut Self {
self.expect_is_recording(true);
let new_modifiers = if modifiers == "-all" {
@@ -700,6 +749,7 @@ mod tests {
/// Sends multiple events in sequence
/// Each event string is either a keystroke or modifier change
+ #[track_caller]
pub fn send_events(&mut self, events: &[&str]) -> &mut Self {
self.expect_is_recording(true);
for event in events {
@@ -712,9 +762,8 @@ mod tests {
self
}
- /// Verifies that the keystrokes match the expected strings
#[track_caller]
- pub fn expect_keystrokes(&mut self, expected: &[&str]) -> &mut Self {
+ fn expect_keystrokes_equal(actual: &[Keystroke], expected: &[&str]) {
let expected_keystrokes: Result, _> = expected
.iter()
.map(|s| {
@@ -738,9 +787,6 @@ mod tests {
let expected_keystrokes = expected_keystrokes
.unwrap_or_else(|e: anyhow::Error| panic!("Invalid expected keystroke: {}", e));
- let actual = self
- .input
- .read_with(&mut self.cx, |input, _| input.keystrokes.clone());
assert_eq!(
actual.len(),
expected_keystrokes.len(),
@@ -763,6 +809,25 @@ mod tests {
actual.unparse()
);
}
+ }
+
+ /// Verifies that the keystrokes match the expected strings
+ #[track_caller]
+ pub fn expect_keystrokes(&mut self, expected: &[&str]) -> &mut Self {
+ let actual = self
+ .input
+ .read_with(&mut self.cx, |input, _| input.keystrokes.clone());
+ Self::expect_keystrokes_equal(&actual, expected);
+ self
+ }
+
+ #[track_caller]
+ pub fn expect_close_keystrokes(&mut self, expected: &[&str]) -> &mut Self {
+ let actual = self
+ .input
+ .read_with(&mut self.cx, |input, _| input.close_keystrokes.clone())
+ .unwrap_or_default();
+ Self::expect_keystrokes_equal(&actual, expected);
self
}
@@ -813,6 +878,18 @@ mod tests {
self
}
+ pub async fn wait_for_close_keystroke_capture_end(&mut self) -> &mut Self {
+ let task = self.input.update_in(&mut self.cx, |input, _, _| {
+ input.clear_close_keystrokes_timer.take()
+ });
+ let task = task.expect("No close keystroke capture end timer task");
+ self.cx
+ .executor()
+ .advance_clock(CLOSE_KEYSTROKE_CAPTURE_END_TIMEOUT);
+ task.await;
+ self
+ }
+
/// Parses modifier change strings like "+ctrl", "-shift", "+cmd+alt"
fn parse_modifier_change(&self, modifiers_str: &str) -> Modifiers {
let mut modifiers = self.current_modifiers;
@@ -1162,4 +1239,19 @@ mod tests {
.send_events(&["escape", "escape", "escape"]) // Pure triple escape sequence
.expect_empty();
}
+
+ #[gpui::test]
+ async fn test_end_close_keystroke_capture(cx: &mut TestAppContext) {
+ init_test(cx)
+ .await
+ .send_events(&["+ctrl", "g", "-ctrl", "escape"])
+ .expect_keystrokes(&["ctrl-g", "escape"])
+ .wait_for_close_keystroke_capture_end()
+ .await
+ .send_events(&["escape", "escape"])
+ .expect_keystrokes(&["ctrl-g", "escape", "escape"])
+ .expect_close_keystrokes(&["escape", "escape"])
+ .send_keystroke("escape")
+ .expect_keystrokes(&["ctrl-g", "escape"]);
+ }
}
From c110f7801516a1948ade4a51213f1fc8ea7f8efc Mon Sep 17 00:00:00 2001
From: Ridan Vandenbergh
Date: Tue, 29 Jul 2025 23:26:30 +0200
Subject: [PATCH 23/35] gpui: Implement support for wlr layer shell (#32651)
I was interested in potentially using gpui for a hobby project, but
needed [layer
shell](https://wayland.app/protocols/wlr-layer-shell-unstable-v1)
support for it. Turns out gpui's (excellent!) architecture made that
super easy to implement, so I went ahead and did it.
Layer shell is a window role used for notification windows, lock
screens, docks, backgrounds, etc. Supporting it in gpui opens the door
to implementing applications like that using the framework.
If this turns out interesting enough to merge - I'm also happy to
provide a follow-up PR (when I have the time to) to implement some of
the desirable window options for layer shell surfaces, such as:
- namespace (currently always `""`)
- keyboard interactivity (currently always `OnDemand`, which mimics
normal keyboard interactivity)
- anchor, exclusive zone, margins
- popups
Release Notes:
- Added support for wayland layer shell surfaces in gpui
---------
Co-authored-by: Mikayla Maki
---
Cargo.lock | 38 ++-
crates/gpui/Cargo.toml | 4 +
crates/gpui/src/platform.rs | 4 +
.../gpui/src/platform/linux/wayland/client.rs | 29 ++
.../gpui/src/platform/linux/wayland/window.rs | 295 +++++++++++++-----
crates/gpui/src/platform/mac/window.rs | 4 +-
6 files changed, 296 insertions(+), 78 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 5e35202e90..7f09342879 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -7354,8 +7354,9 @@ dependencies = [
"wayland-backend",
"wayland-client",
"wayland-cursor",
- "wayland-protocols",
+ "wayland-protocols 0.31.2",
"wayland-protocols-plasma",
+ "wayland-protocols-wlr",
"windows 0.61.1",
"windows-core 0.61.0",
"windows-numerics",
@@ -18369,9 +18370,9 @@ dependencies = [
[[package]]
name = "wayland-backend"
-version = "0.3.8"
+version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b7208998eaa3870dad37ec8836979581506e0c5c64c20c9e79e9d2a10d6f47bf"
+checksum = "fe770181423e5fc79d3e2a7f4410b7799d5aab1de4372853de3c6aa13ca24121"
dependencies = [
"cc",
"downcast-rs",
@@ -18383,9 +18384,9 @@ dependencies = [
[[package]]
name = "wayland-client"
-version = "0.31.8"
+version = "0.31.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c2120de3d33638aaef5b9f4472bff75f07c56379cf76ea320bd3a3d65ecaf73f"
+checksum = "978fa7c67b0847dbd6a9f350ca2569174974cd4082737054dbb7fbb79d7d9a61"
dependencies = [
"bitflags 2.9.0",
"rustix 0.38.44",
@@ -18416,6 +18417,18 @@ dependencies = [
"wayland-scanner",
]
+[[package]]
+name = "wayland-protocols"
+version = "0.32.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "779075454e1e9a521794fed15886323ea0feda3f8b0fc1390f5398141310422a"
+dependencies = [
+ "bitflags 2.9.0",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-scanner",
+]
+
[[package]]
name = "wayland-protocols-plasma"
version = "0.2.0"
@@ -18425,7 +18438,20 @@ dependencies = [
"bitflags 2.9.0",
"wayland-backend",
"wayland-client",
- "wayland-protocols",
+ "wayland-protocols 0.31.2",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-protocols-wlr"
+version = "0.3.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1cb6cdc73399c0e06504c437fe3cf886f25568dd5454473d565085b36d6a8bbf"
+dependencies = [
+ "bitflags 2.9.0",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-protocols 0.32.8",
"wayland-scanner",
]
diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml
index 680111a6ce..4023ddf2dc 100644
--- a/crates/gpui/Cargo.toml
+++ b/crates/gpui/Cargo.toml
@@ -47,6 +47,7 @@ wayland = [
"wayland-cursor",
"wayland-protocols",
"wayland-protocols-plasma",
+ "wayland-protocols-wlr",
"filedescriptor",
"xkbcommon",
"open",
@@ -193,6 +194,9 @@ wayland-protocols = { version = "0.31.2", features = [
wayland-protocols-plasma = { version = "0.2.0", features = [
"client",
], optional = true }
+wayland-protocols-wlr = { version = "0.3.8", features = [
+ "client"
+], optional = true}
# X11
as-raw-xcb-connection = { version = "1", optional = true }
diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs
index 1e72d23868..febf294e48 100644
--- a/crates/gpui/src/platform.rs
+++ b/crates/gpui/src/platform.rs
@@ -1216,6 +1216,10 @@ pub enum WindowKind {
/// A window that appears above all other windows, usually used for alerts or popups
/// use sparingly!
PopUp,
+ /// An overlay such as a notification window, a launcher, ...
+ ///
+ /// Only supported on wayland
+ Overlay,
}
/// The appearance of the window, as defined by the operating system.
diff --git a/crates/gpui/src/platform/linux/wayland/client.rs b/crates/gpui/src/platform/linux/wayland/client.rs
index 72e4477ecf..33b22e7ce5 100644
--- a/crates/gpui/src/platform/linux/wayland/client.rs
+++ b/crates/gpui/src/platform/linux/wayland/client.rs
@@ -61,6 +61,7 @@ use wayland_protocols::xdg::decoration::zv1::client::{
};
use wayland_protocols::xdg::shell::client::{xdg_surface, xdg_toplevel, xdg_wm_base};
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;
use xkbcommon::xkb::{self, KEYMAP_COMPILE_NO_FLAGS, Keycode};
@@ -114,6 +115,7 @@ pub struct Globals {
pub fractional_scale_manager:
Option,
pub decoration_manager: Option,
+ pub layer_shell: Option,
pub blur_manager: Option,
pub text_input_manager: Option,
pub executor: ForegroundExecutor,
@@ -151,6 +153,7 @@ impl Globals {
viewporter: globals.bind(&qh, 1..=1, ()).ok(),
fractional_scale_manager: globals.bind(&qh, 1..=1, ()).ok(),
decoration_manager: globals.bind(&qh, 1..=1, ()).ok(),
+ layer_shell: globals.bind(&qh, 1..=1, ()).ok(),
blur_manager: globals.bind(&qh, 1..=1, ()).ok(),
text_input_manager: globals.bind(&qh, 1..=1, ()).ok(),
executor,
@@ -929,6 +932,7 @@ delegate_noop!(WaylandClientStatePtr: ignore wl_buffer::WlBuffer);
delegate_noop!(WaylandClientStatePtr: ignore wl_region::WlRegion);
delegate_noop!(WaylandClientStatePtr: ignore wp_fractional_scale_manager_v1::WpFractionalScaleManagerV1);
delegate_noop!(WaylandClientStatePtr: ignore zxdg_decoration_manager_v1::ZxdgDecorationManagerV1);
+delegate_noop!(WaylandClientStatePtr: ignore zwlr_layer_shell_v1::ZwlrLayerShellV1);
delegate_noop!(WaylandClientStatePtr: ignore org_kde_kwin_blur_manager::OrgKdeKwinBlurManager);
delegate_noop!(WaylandClientStatePtr: ignore zwp_text_input_manager_v3::ZwpTextInputManagerV3);
delegate_noop!(WaylandClientStatePtr: ignore org_kde_kwin_blur::OrgKdeKwinBlur);
@@ -1074,6 +1078,31 @@ impl Dispatch for WaylandClientStatePtr {
}
}
+impl Dispatch for WaylandClientStatePtr {
+ fn event(
+ this: &mut Self,
+ _: &zwlr_layer_surface_v1::ZwlrLayerSurfaceV1,
+ event: ::Event,
+ surface_id: &ObjectId,
+ _: &Connection,
+ _: &QueueHandle,
+ ) {
+ let client = this.get_client();
+ let mut state = client.borrow_mut();
+ let Some(window) = get_window(&mut state, surface_id) else {
+ return;
+ };
+ drop(state);
+
+ let should_close = window.handle_layersurface_event(event);
+
+ if should_close {
+ // The close logic will be handled in drop_window()
+ window.close();
+ }
+ }
+}
+
impl Dispatch for WaylandClientStatePtr {
fn event(
_: &mut Self,
diff --git a/crates/gpui/src/platform/linux/wayland/window.rs b/crates/gpui/src/platform/linux/wayland/window.rs
index 2b2207e22c..33c908d1b2 100644
--- a/crates/gpui/src/platform/linux/wayland/window.rs
+++ b/crates/gpui/src/platform/linux/wayland/window.rs
@@ -1,3 +1,6 @@
+use blade_graphics as gpu;
+use collections::HashMap;
+use futures::channel::oneshot::Receiver;
use std::{
cell::{Ref, RefCell, RefMut},
ffi::c_void,
@@ -6,9 +9,14 @@ use std::{
sync::Arc,
};
-use blade_graphics as gpu;
-use collections::HashMap;
-use futures::channel::oneshot::Receiver;
+use crate::{
+ Capslock,
+ platform::{
+ PlatformAtlas, PlatformInputHandler, PlatformWindow,
+ blade::{BladeContext, BladeRenderer, BladeSurfaceConfig},
+ linux::wayland::{display::WaylandDisplay, serial::SerialKind},
+ },
+};
use raw_window_handle as rwh;
use wayland_backend::client::ObjectId;
@@ -20,6 +28,8 @@ use wayland_protocols::xdg::decoration::zv1::client::zxdg_toplevel_decoration_v1
use wayland_protocols::xdg::shell::client::xdg_surface;
use wayland_protocols::xdg::shell::client::xdg_toplevel::{self};
use wayland_protocols_plasma::blur::client::org_kde_kwin_blur;
+use wayland_protocols_wlr::layer_shell::v1::client::zwlr_layer_shell_v1::Layer;
+use wayland_protocols_wlr::layer_shell::v1::client::zwlr_layer_surface_v1;
use crate::scene::Scene;
use crate::{
@@ -27,15 +37,7 @@ use crate::{
PlatformDisplay, PlatformInput, Point, PromptButton, PromptLevel, RequestFrameOptions,
ResizeEdge, ScaledPixels, Size, Tiling, WaylandClientStatePtr, WindowAppearance,
WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowControls, WindowDecorations,
- WindowParams, px, size,
-};
-use crate::{
- Capslock,
- platform::{
- PlatformAtlas, PlatformInputHandler, PlatformWindow,
- blade::{BladeContext, BladeRenderer, BladeSurfaceConfig},
- linux::wayland::{display::WaylandDisplay, serial::SerialKind},
- },
+ WindowKind, WindowParams, px, size,
};
#[derive(Default)]
@@ -81,14 +83,12 @@ struct InProgressConfigure {
}
pub struct WaylandWindowState {
- xdg_surface: xdg_surface::XdgSurface,
+ surface_state: WaylandSurfaceState,
acknowledged_first_configure: bool,
pub surface: wl_surface::WlSurface,
- decoration: Option,
app_id: Option,
appearance: WindowAppearance,
blur: Option,
- toplevel: xdg_toplevel::XdgToplevel,
viewport: Option,
outputs: HashMap,
display: Option<(ObjectId, Output)>,
@@ -114,6 +114,78 @@ pub struct WaylandWindowState {
client_inset: Option,
}
+pub enum WaylandSurfaceState {
+ Xdg(WaylandXdgSurfaceState),
+ LayerShell(WaylandLayerSurfaceState),
+}
+
+pub struct WaylandXdgSurfaceState {
+ xdg_surface: xdg_surface::XdgSurface,
+ toplevel: xdg_toplevel::XdgToplevel,
+ decoration: Option,
+}
+
+pub struct WaylandLayerSurfaceState {
+ layer_surface: zwlr_layer_surface_v1::ZwlrLayerSurfaceV1,
+}
+
+impl WaylandSurfaceState {
+ fn ack_configure(&self, serial: u32) {
+ match self {
+ WaylandSurfaceState::Xdg(WaylandXdgSurfaceState { xdg_surface, .. }) => {
+ xdg_surface.ack_configure(serial);
+ }
+ WaylandSurfaceState::LayerShell(WaylandLayerSurfaceState { layer_surface, .. }) => {
+ layer_surface.ack_configure(serial);
+ }
+ }
+ }
+
+ fn decoration(&self) -> Option<&zxdg_toplevel_decoration_v1::ZxdgToplevelDecorationV1> {
+ if let WaylandSurfaceState::Xdg(WaylandXdgSurfaceState { decoration, .. }) = self {
+ decoration.as_ref()
+ } else {
+ None
+ }
+ }
+
+ fn toplevel(&self) -> Option<&xdg_toplevel::XdgToplevel> {
+ if let WaylandSurfaceState::Xdg(WaylandXdgSurfaceState { toplevel, .. }) = self {
+ Some(toplevel)
+ } else {
+ None
+ }
+ }
+
+ fn set_geometry(&self, x: i32, y: i32, width: i32, height: i32) {
+ match self {
+ WaylandSurfaceState::Xdg(WaylandXdgSurfaceState { xdg_surface, .. }) => {
+ xdg_surface.set_window_geometry(x, y, width, height);
+ }
+ WaylandSurfaceState::LayerShell(WaylandLayerSurfaceState { layer_surface, .. }) => {
+ // cannot set window position of a layer surface
+ layer_surface.set_size(width as u32, height as u32);
+ }
+ }
+ }
+
+ fn destroy(&mut self) {
+ match self {
+ WaylandSurfaceState::Xdg(WaylandXdgSurfaceState {
+ xdg_surface,
+ toplevel,
+ decoration: _decoration,
+ }) => {
+ toplevel.destroy();
+ xdg_surface.destroy();
+ }
+ WaylandSurfaceState::LayerShell(WaylandLayerSurfaceState { layer_surface }) => {
+ layer_surface.destroy();
+ }
+ }
+ }
+}
+
#[derive(Clone)]
pub struct WaylandWindowStatePtr {
state: Rc>,
@@ -124,9 +196,7 @@ impl WaylandWindowState {
pub(crate) fn new(
handle: AnyWindowHandle,
surface: wl_surface::WlSurface,
- xdg_surface: xdg_surface::XdgSurface,
- toplevel: xdg_toplevel::XdgToplevel,
- decoration: Option,
+ surface_state: WaylandSurfaceState,
appearance: WindowAppearance,
viewport: Option,
client: WaylandClientStatePtr,
@@ -156,13 +226,11 @@ impl WaylandWindowState {
};
Ok(Self {
- xdg_surface,
+ surface_state,
acknowledged_first_configure: false,
surface,
- decoration,
app_id: None,
blur: None,
- toplevel,
viewport,
globals,
outputs: HashMap::default(),
@@ -235,17 +303,16 @@ impl Drop for WaylandWindow {
let client = state.client.clone();
state.renderer.destroy();
- if let Some(decoration) = &state.decoration {
+ if let Some(decoration) = &state.surface_state.decoration() {
decoration.destroy();
}
if let Some(blur) = &state.blur {
blur.release();
}
- state.toplevel.destroy();
+ state.surface_state.destroy();
if let Some(viewport) = &state.viewport {
viewport.destroy();
}
- state.xdg_surface.destroy();
state.surface.destroy();
let state_ptr = self.0.clone();
@@ -279,27 +346,65 @@ impl WaylandWindow {
appearance: WindowAppearance,
) -> anyhow::Result<(Self, ObjectId)> {
let surface = globals.compositor.create_surface(&globals.qh, ());
- let xdg_surface = globals
- .wm_base
- .get_xdg_surface(&surface, &globals.qh, surface.id());
- let toplevel = xdg_surface.get_toplevel(&globals.qh, surface.id());
- if let Some(size) = params.window_min_size {
- toplevel.set_min_size(size.width.0 as i32, size.height.0 as i32);
- }
+ let surface_state = match (params.kind, globals.layer_shell.as_ref()) {
+ // Matching on layer_shell here means that if kind is Overlay, but the compositor doesn't support layer_shell,
+ // we end up defaulting to xdg_surface anyway
+ (WindowKind::Overlay, Some(layer_shell)) => {
+ let layer_surface = layer_shell.get_layer_surface(
+ &surface,
+ None,
+ Layer::Overlay,
+ "".to_string(),
+ &globals.qh,
+ surface.id(),
+ );
+
+ let width = params.bounds.size.width.0;
+ let height = params.bounds.size.height.0;
+ layer_surface.set_size(width as u32, height as u32);
+ layer_surface.set_keyboard_interactivity(
+ zwlr_layer_surface_v1::KeyboardInteractivity::OnDemand,
+ );
+
+ WaylandSurfaceState::LayerShell(WaylandLayerSurfaceState { layer_surface })
+ }
+ _ => {
+ let xdg_surface =
+ globals
+ .wm_base
+ .get_xdg_surface(&surface, &globals.qh, surface.id());
+
+ let toplevel = xdg_surface.get_toplevel(&globals.qh, surface.id());
+
+ if let Some(size) = params.window_min_size {
+ toplevel.set_min_size(size.width.0 as i32, size.height.0 as i32);
+ }
+
+ // Attempt to set up window decorations based on the requested configuration
+ let decoration = globals
+ .decoration_manager
+ .as_ref()
+ .map(|decoration_manager| {
+ decoration_manager.get_toplevel_decoration(
+ &toplevel,
+ &globals.qh,
+ surface.id(),
+ )
+ });
+
+ WaylandSurfaceState::Xdg(WaylandXdgSurfaceState {
+ xdg_surface,
+ toplevel,
+ decoration,
+ })
+ }
+ };
if let Some(fractional_scale_manager) = globals.fractional_scale_manager.as_ref() {
fractional_scale_manager.get_fractional_scale(&surface, &globals.qh, surface.id());
}
- // Attempt to set up window decorations based on the requested configuration
- let decoration = globals
- .decoration_manager
- .as_ref()
- .map(|decoration_manager| {
- decoration_manager.get_toplevel_decoration(&toplevel, &globals.qh, surface.id())
- });
-
let viewport = globals
.viewporter
.as_ref()
@@ -309,9 +414,7 @@ impl WaylandWindow {
state: Rc::new(RefCell::new(WaylandWindowState::new(
handle,
surface.clone(),
- xdg_surface,
- toplevel,
- decoration,
+ surface_state,
appearance,
viewport,
client,
@@ -403,7 +506,7 @@ impl WaylandWindowStatePtr {
}
}
let mut state = self.state.borrow_mut();
- state.xdg_surface.ack_configure(serial);
+ state.surface_state.ack_configure(serial);
let window_geometry = inset_by_tiling(
state.bounds.map_origin(|_| px(0.0)),
@@ -413,7 +516,7 @@ impl WaylandWindowStatePtr {
.map(|v| v.0 as i32)
.map_size(|v| if v <= 0 { 1 } else { v });
- state.xdg_surface.set_window_geometry(
+ state.surface_state.set_geometry(
window_geometry.origin.x,
window_geometry.origin.y,
window_geometry.size.width,
@@ -578,6 +681,42 @@ impl WaylandWindowStatePtr {
}
}
+ pub fn handle_layersurface_event(&self, event: zwlr_layer_surface_v1::Event) -> bool {
+ match event {
+ zwlr_layer_surface_v1::Event::Configure {
+ width,
+ height,
+ serial,
+ } => {
+ let mut size = if width == 0 || height == 0 {
+ None
+ } else {
+ Some(size(px(width as f32), px(height as f32)))
+ };
+
+ let mut state = self.state.borrow_mut();
+ state.in_progress_configure = Some(InProgressConfigure {
+ size,
+ fullscreen: false,
+ maximized: false,
+ resizing: false,
+ tiling: Tiling::default(),
+ });
+ drop(state);
+
+ // just do the same thing we'd do as an xdg_surface
+ self.handle_xdg_surface_event(xdg_surface::Event::Configure { serial });
+
+ false
+ }
+ zwlr_layer_surface_v1::Event::Closed => {
+ // unlike xdg, we don't have a choice here: the surface is closing.
+ true
+ }
+ _ => false,
+ }
+ }
+
#[allow(clippy::mutable_key_type)]
pub fn handle_surface_event(
&self,
@@ -840,7 +979,7 @@ impl PlatformWindow for WaylandWindow {
let state_ptr = self.0.clone();
let dp_size = size.to_device_pixels(self.scale_factor());
- state.xdg_surface.set_window_geometry(
+ state.surface_state.set_geometry(
state.bounds.origin.x.0 as i32,
state.bounds.origin.y.0 as i32,
dp_size.width.0,
@@ -934,12 +1073,16 @@ impl PlatformWindow for WaylandWindow {
}
fn set_title(&mut self, title: &str) {
- self.borrow().toplevel.set_title(title.to_string());
+ if let Some(toplevel) = self.borrow().surface_state.toplevel() {
+ toplevel.set_title(title.to_string());
+ }
}
fn set_app_id(&mut self, app_id: &str) {
let mut state = self.borrow_mut();
- state.toplevel.set_app_id(app_id.to_owned());
+ if let Some(toplevel) = self.borrow().surface_state.toplevel() {
+ toplevel.set_app_id(app_id.to_owned());
+ }
state.app_id = Some(app_id.to_owned());
}
@@ -950,24 +1093,30 @@ impl PlatformWindow for WaylandWindow {
}
fn minimize(&self) {
- self.borrow().toplevel.set_minimized();
+ if let Some(toplevel) = self.borrow().surface_state.toplevel() {
+ toplevel.set_minimized();
+ }
}
fn zoom(&self) {
let state = self.borrow();
- if !state.maximized {
- state.toplevel.set_maximized();
- } else {
- state.toplevel.unset_maximized();
+ if let Some(toplevel) = state.surface_state.toplevel() {
+ if !state.maximized {
+ toplevel.set_maximized();
+ } else {
+ toplevel.unset_maximized();
+ }
}
}
fn toggle_fullscreen(&self) {
- let mut state = self.borrow_mut();
- if !state.fullscreen {
- state.toplevel.set_fullscreen(None);
- } else {
- state.toplevel.unset_fullscreen();
+ let mut state = self.borrow();
+ if let Some(toplevel) = state.surface_state.toplevel() {
+ if !state.fullscreen {
+ toplevel.set_fullscreen(None);
+ } else {
+ toplevel.unset_fullscreen();
+ }
}
}
@@ -1032,27 +1181,33 @@ impl PlatformWindow for WaylandWindow {
fn show_window_menu(&self, position: Point) {
let state = self.borrow();
let serial = state.client.get_serial(SerialKind::MousePress);
- state.toplevel.show_window_menu(
- &state.globals.seat,
- serial,
- position.x.0 as i32,
- position.y.0 as i32,
- );
+ if let Some(toplevel) = state.surface_state.toplevel() {
+ toplevel.show_window_menu(
+ &state.globals.seat,
+ serial,
+ position.x.0 as i32,
+ position.y.0 as i32,
+ );
+ }
}
fn start_window_move(&self) {
let state = self.borrow();
let serial = state.client.get_serial(SerialKind::MousePress);
- state.toplevel._move(&state.globals.seat, serial);
+ if let Some(toplevel) = state.surface_state.toplevel() {
+ toplevel._move(&state.globals.seat, serial);
+ }
}
fn start_window_resize(&self, edge: crate::ResizeEdge) {
let state = self.borrow();
- state.toplevel.resize(
- &state.globals.seat,
- state.client.get_serial(SerialKind::MousePress),
- edge.to_xdg(),
- )
+ if let Some(toplevel) = state.surface_state.toplevel() {
+ toplevel.resize(
+ &state.globals.seat,
+ state.client.get_serial(SerialKind::MousePress),
+ edge.to_xdg(),
+ )
+ }
}
fn window_decorations(&self) -> Decorations {
@@ -1068,7 +1223,7 @@ impl PlatformWindow for WaylandWindow {
fn request_decorations(&self, decorations: WindowDecorations) {
let mut state = self.borrow_mut();
state.decorations = decorations;
- if let Some(decoration) = state.decoration.as_ref() {
+ if let Some(decoration) = state.surface_state.decoration() {
decoration.set_mode(decorations.to_xdg());
update_window(state);
}
diff --git a/crates/gpui/src/platform/mac/window.rs b/crates/gpui/src/platform/mac/window.rs
index aedf131909..f01d33147b 100644
--- a/crates/gpui/src/platform/mac/window.rs
+++ b/crates/gpui/src/platform/mac/window.rs
@@ -559,7 +559,7 @@ impl MacWindow {
}
let native_window: id = match kind {
- WindowKind::Normal => msg_send![WINDOW_CLASS, alloc],
+ WindowKind::Normal | WindowKind::Overlay => msg_send![WINDOW_CLASS, alloc],
WindowKind::PopUp => {
style_mask |= NSWindowStyleMaskNonactivatingPanel;
msg_send![PANEL_CLASS, alloc]
@@ -711,7 +711,7 @@ impl MacWindow {
native_window.makeFirstResponder_(native_view);
match kind {
- WindowKind::Normal => {
+ WindowKind::Normal | WindowKind::Overlay => {
native_window.setLevel_(NSNormalWindowLevel);
native_window.setAcceptsMouseMovedEvents_(YES);
}
From 3378f02b7ee005d8116bbd17b99cb3196ed09d9e Mon Sep 17 00:00:00 2001
From: marius851000
Date: Tue, 29 Jul 2025 23:45:46 +0200
Subject: [PATCH 24/35] Fix link to panic location on GitHub (#35162)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
I had a panic, and it reported
``https://github.com/zed-industries/zed/blob/24c2a465bbbbb1be28259abef2f98d52184ff446/src/crates/assistant_tools/src/edit_agent.rs#L686
(may not be uploaded, line may be incorrect if files modified)``
The `/src` part seems superfluous, and result in a link that don’t work
(unlike
`https://github.com/zed-industries/zed/blob/24c2a465bbbbb1be28259abef2f98d52184ff446/src/crates/assistant_tools/src/edit_agent.rs#L686`).
I don’t know why it originally worked (of if it even actually originally
worked properly), but there seems to be no reason to keep that `/src`.
Release Notes:
- N/A
---
crates/zed/src/reliability.rs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/crates/zed/src/reliability.rs b/crates/zed/src/reliability.rs
index ccbe57e7b3..d7f1473288 100644
--- a/crates/zed/src/reliability.rs
+++ b/crates/zed/src/reliability.rs
@@ -63,7 +63,7 @@ pub fn init_panic_hook(
location.column(),
match app_commit_sha.as_ref() {
Some(commit_sha) => format!(
- "https://github.com/zed-industries/zed/blob/{}/src/{}#L{} \
+ "https://github.com/zed-industries/zed/blob/{}/{}#L{} \
(may not be uploaded, line may be incorrect if files modified)\n",
commit_sha.full(),
location.file(),
From 48e085a5236b993390a31fb4304512d187b1145f Mon Sep 17 00:00:00 2001
From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com>
Date: Tue, 29 Jul 2025 17:54:58 -0400
Subject: [PATCH 25/35] onboarding ui: Add editing page to onboarding page
(#35298)
I added buttons for inlay values, showing the mini map, git blame, and
controlling the UI/Editor Font/Font size. The only thing left for this
page is some UI clean up and adding buttons for setting import from
VSCode/cursor.
I also added Numeric Stepper as a component preview.
Current state:
Release Notes:
- N/A
---
Cargo.lock | 3 +
crates/editor/src/editor.rs | 2 +-
crates/onboarding/Cargo.toml | 5 +-
crates/onboarding/src/editing_page.rs | 287 ++++++++++++++++++++
crates/onboarding/src/onboarding.rs | 10 +-
crates/ui/src/components/numeric_stepper.rs | 33 ++-
6 files changed, 331 insertions(+), 9 deletions(-)
create mode 100644 crates/onboarding/src/editing_page.rs
diff --git a/Cargo.lock b/Cargo.lock
index 7f09342879..f171901e29 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -10942,9 +10942,12 @@ dependencies = [
"anyhow",
"command_palette_hooks",
"db",
+ "editor",
"feature_flags",
"fs",
"gpui",
+ "language",
+ "project",
"settings",
"theme",
"ui",
diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs
index 3c877873a0..a2f2310144 100644
--- a/crates/editor/src/editor.rs
+++ b/crates/editor/src/editor.rs
@@ -65,7 +65,7 @@ use display_map::*;
pub use display_map::{ChunkRenderer, ChunkRendererContext, DisplayPoint, FoldPlaceholder};
pub use editor_settings::{
CurrentLineHighlight, DocumentColorsRenderMode, EditorSettings, HideMouseMode,
- ScrollBeyondLastLine, ScrollbarAxes, SearchSettings, ShowScrollbar,
+ ScrollBeyondLastLine, ScrollbarAxes, SearchSettings, ShowMinimap, ShowScrollbar,
};
use editor_settings::{GoToDefinitionFallback, Minimap as MinimapSettings};
pub use editor_settings_controls::*;
diff --git a/crates/onboarding/Cargo.toml b/crates/onboarding/Cargo.toml
index 6ec8f8b162..da009b4e4e 100644
--- a/crates/onboarding/Cargo.toml
+++ b/crates/onboarding/Cargo.toml
@@ -18,12 +18,15 @@ default = []
anyhow.workspace = true
command_palette_hooks.workspace = true
db.workspace = true
+editor.workspace = true
feature_flags.workspace = true
fs.workspace = true
gpui.workspace = true
+language.workspace = true
+project.workspace = true
settings.workspace = true
theme.workspace = true
ui.workspace = true
-workspace.workspace = true
workspace-hack.workspace = true
+workspace.workspace = true
zed_actions.workspace = true
diff --git a/crates/onboarding/src/editing_page.rs b/crates/onboarding/src/editing_page.rs
new file mode 100644
index 0000000000..c07d8fef4d
--- /dev/null
+++ b/crates/onboarding/src/editing_page.rs
@@ -0,0 +1,287 @@
+use editor::{EditorSettings, ShowMinimap};
+use fs::Fs;
+use gpui::{App, IntoElement, Pixels, Window};
+use language::language_settings::AllLanguageSettings;
+use project::project_settings::ProjectSettings;
+use settings::{Settings as _, update_settings_file};
+use theme::{FontFamilyCache, FontFamilyName, ThemeSettings};
+use ui::{
+ ContextMenu, DropdownMenu, IconButton, Label, LabelCommon, LabelSize, NumericStepper,
+ ParentElement, SharedString, Styled, SwitchColor, SwitchField, ToggleButtonGroup,
+ ToggleButtonGroupStyle, ToggleButtonSimple, ToggleState, div, h_flex, px, v_flex,
+};
+
+fn read_show_mini_map(cx: &App) -> ShowMinimap {
+ editor::EditorSettings::get_global(cx).minimap.show
+}
+
+fn write_show_mini_map(show: ShowMinimap, cx: &mut App) {
+ let fs = ::global(cx);
+
+ update_settings_file::(fs, cx, move |editor_settings, _| {
+ editor_settings.minimap.get_or_insert_default().show = Some(show);
+ });
+}
+
+fn read_inlay_hints(cx: &App) -> bool {
+ AllLanguageSettings::get_global(cx)
+ .defaults
+ .inlay_hints
+ .enabled
+}
+
+fn write_inlay_hints(enabled: bool, cx: &mut App) {
+ let fs = ::global(cx);
+
+ update_settings_file::(fs, cx, move |all_language_settings, cx| {
+ all_language_settings
+ .defaults
+ .inlay_hints
+ .get_or_insert_with(|| {
+ AllLanguageSettings::get_global(cx)
+ .clone()
+ .defaults
+ .inlay_hints
+ })
+ .enabled = enabled;
+ });
+}
+
+fn read_git_blame(cx: &App) -> bool {
+ ProjectSettings::get_global(cx).git.inline_blame_enabled()
+}
+
+fn set_git_blame(enabled: bool, cx: &mut App) {
+ let fs = ::global(cx);
+
+ update_settings_file::(fs, cx, move |project_settings, _| {
+ project_settings
+ .git
+ .inline_blame
+ .get_or_insert_default()
+ .enabled = enabled;
+ });
+}
+
+fn write_ui_font_family(font: SharedString, cx: &mut App) {
+ let fs = ::global(cx);
+
+ update_settings_file::(fs, cx, move |theme_settings, _| {
+ theme_settings.ui_font_family = Some(FontFamilyName(font.into()));
+ });
+}
+
+fn write_ui_font_size(size: Pixels, cx: &mut App) {
+ let fs = ::global(cx);
+
+ update_settings_file::(fs, cx, move |theme_settings, _| {
+ theme_settings.ui_font_size = Some(size.into());
+ });
+}
+
+fn write_buffer_font_size(size: Pixels, cx: &mut App) {
+ let fs = ::global(cx);
+
+ update_settings_file::(fs, cx, move |theme_settings, _| {
+ theme_settings.buffer_font_size = Some(size.into());
+ });
+}
+
+fn write_buffer_font_family(font_family: SharedString, cx: &mut App) {
+ let fs = ::global(cx);
+
+ update_settings_file::(fs, cx, move |theme_settings, _| {
+ theme_settings.buffer_font_family = Some(FontFamilyName(font_family.into()));
+ });
+}
+
+pub(crate) fn render_editing_page(window: &mut Window, cx: &mut App) -> impl IntoElement {
+ let theme_settings = ThemeSettings::get_global(cx);
+ let ui_font_size = theme_settings.ui_font_size(cx);
+ let font_family = theme_settings.buffer_font.family.clone();
+ let buffer_font_size = theme_settings.buffer_font_size(cx);
+
+ v_flex()
+ .gap_4()
+ .child(Label::new("Import Settings").size(LabelSize::Large))
+ .child(
+ Label::new("Automatically pull your settings from other editors.")
+ .size(LabelSize::Small),
+ )
+ .child(
+ h_flex()
+ .child(IconButton::new(
+ "import-vs-code-settings",
+ ui::IconName::Code,
+ ))
+ .child(IconButton::new(
+ "import-cursor-settings",
+ ui::IconName::CursorIBeam,
+ )),
+ )
+ .child(Label::new("Popular Settings").size(LabelSize::Large))
+ .child(
+ h_flex()
+ .gap_4()
+ .justify_between()
+ .child(
+ v_flex()
+ .justify_between()
+ .gap_1()
+ .child(Label::new("UI Font"))
+ .child(
+ h_flex()
+ .justify_between()
+ .gap_2()
+ .child(div().min_w(px(120.)).child(DropdownMenu::new(
+ "ui-font-family",
+ theme_settings.ui_font.family.clone(),
+ ContextMenu::build(window, cx, |mut menu, _, cx| {
+ let font_family_cache = FontFamilyCache::global(cx);
+
+ for font_name in font_family_cache.list_font_families(cx) {
+ menu = menu.custom_entry(
+ {
+ let font_name = font_name.clone();
+ move |_window, _cx| {
+ Label::new(font_name.clone())
+ .into_any_element()
+ }
+ },
+ {
+ let font_name = font_name.clone();
+ move |_window, cx| {
+ write_ui_font_family(font_name.clone(), cx);
+ }
+ },
+ )
+ }
+
+ menu
+ }),
+ )))
+ .child(NumericStepper::new(
+ "ui-font-size",
+ ui_font_size.to_string(),
+ move |_, _, cx| {
+ write_ui_font_size(ui_font_size - px(1.), cx);
+ },
+ move |_, _, cx| {
+ write_ui_font_size(ui_font_size + px(1.), cx);
+ },
+ )),
+ ),
+ )
+ .child(
+ v_flex()
+ .justify_between()
+ .gap_1()
+ .child(Label::new("Editor Font"))
+ .child(
+ h_flex()
+ .justify_between()
+ .gap_2()
+ .child(DropdownMenu::new(
+ "buffer-font-family",
+ font_family,
+ ContextMenu::build(window, cx, |mut menu, _, cx| {
+ let font_family_cache = FontFamilyCache::global(cx);
+
+ for font_name in font_family_cache.list_font_families(cx) {
+ menu = menu.custom_entry(
+ {
+ let font_name = font_name.clone();
+ move |_window, _cx| {
+ Label::new(font_name.clone())
+ .into_any_element()
+ }
+ },
+ {
+ let font_name = font_name.clone();
+ move |_window, cx| {
+ write_buffer_font_family(
+ font_name.clone(),
+ cx,
+ );
+ }
+ },
+ )
+ }
+
+ menu
+ }),
+ ))
+ .child(NumericStepper::new(
+ "buffer-font-size",
+ buffer_font_size.to_string(),
+ move |_, _, cx| {
+ write_buffer_font_size(buffer_font_size - px(1.), cx);
+ },
+ move |_, _, cx| {
+ write_buffer_font_size(buffer_font_size + px(1.), cx);
+ },
+ )),
+ ),
+ ),
+ )
+ .child(
+ h_flex()
+ .justify_between()
+ .child(Label::new("Mini Map"))
+ .child(
+ ToggleButtonGroup::single_row(
+ "onboarding-show-mini-map",
+ [
+ ToggleButtonSimple::new("Auto", |_, _, cx| {
+ write_show_mini_map(ShowMinimap::Auto, cx);
+ }),
+ ToggleButtonSimple::new("Always", |_, _, cx| {
+ write_show_mini_map(ShowMinimap::Always, cx);
+ }),
+ ToggleButtonSimple::new("Never", |_, _, cx| {
+ write_show_mini_map(ShowMinimap::Never, cx);
+ }),
+ ],
+ )
+ .selected_index(match read_show_mini_map(cx) {
+ ShowMinimap::Auto => 0,
+ ShowMinimap::Always => 1,
+ ShowMinimap::Never => 2,
+ })
+ .style(ToggleButtonGroupStyle::Outlined)
+ .button_width(ui::rems_from_px(64.)),
+ ),
+ )
+ .child(
+ SwitchField::new(
+ "onboarding-enable-inlay-hints",
+ "Inlay Hints",
+ "See parameter names for function and method calls inline.",
+ if read_inlay_hints(cx) {
+ ui::ToggleState::Selected
+ } else {
+ ui::ToggleState::Unselected
+ },
+ |toggle_state, _, cx| {
+ write_inlay_hints(toggle_state == &ToggleState::Selected, cx);
+ },
+ )
+ .color(SwitchColor::Accent),
+ )
+ .child(
+ SwitchField::new(
+ "onboarding-git-blame-switch",
+ "Git Blame",
+ "See who committed each line on a given file.",
+ if read_git_blame(cx) {
+ ui::ToggleState::Selected
+ } else {
+ ui::ToggleState::Unselected
+ },
+ |toggle_state, _, cx| {
+ set_git_blame(toggle_state == &ToggleState::Selected, cx);
+ },
+ )
+ .color(SwitchColor::Accent),
+ )
+}
diff --git a/crates/onboarding/src/onboarding.rs b/crates/onboarding/src/onboarding.rs
index b675ed2dd7..cc0c47ca71 100644
--- a/crates/onboarding/src/onboarding.rs
+++ b/crates/onboarding/src/onboarding.rs
@@ -21,6 +21,7 @@ use workspace::{
open_new, with_active_or_new_workspace,
};
+mod editing_page;
mod welcome;
pub struct OnBoardingFeatureFlag {}
@@ -246,7 +247,9 @@ impl Onboarding {
fn render_page(&mut self, window: &mut Window, cx: &mut Context) -> AnyElement {
match self.selected_page {
SelectedPage::Basics => self.render_basics_page(window, cx).into_any_element(),
- SelectedPage::Editing => self.render_editing_page(window, cx).into_any_element(),
+ SelectedPage::Editing => {
+ crate::editing_page::render_editing_page(window, cx).into_any_element()
+ }
SelectedPage::AiSetup => self.render_ai_setup_page(window, cx).into_any_element(),
}
}
@@ -281,11 +284,6 @@ impl Onboarding {
)
}
- fn render_editing_page(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement {
- // div().child("editing page")
- "Right"
- }
-
fn render_ai_setup_page(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement {
div().child("ai setup page")
}
diff --git a/crates/ui/src/components/numeric_stepper.rs b/crates/ui/src/components/numeric_stepper.rs
index f9e6e88f01..05d368f427 100644
--- a/crates/ui/src/components/numeric_stepper.rs
+++ b/crates/ui/src/components/numeric_stepper.rs
@@ -2,7 +2,7 @@ use gpui::ClickEvent;
use crate::{IconButtonShape, prelude::*};
-#[derive(IntoElement)]
+#[derive(IntoElement, RegisterComponent)]
pub struct NumericStepper {
id: ElementId,
value: SharedString,
@@ -93,3 +93,34 @@ impl RenderOnce for NumericStepper {
)
}
}
+
+impl Component for NumericStepper {
+ fn scope() -> ComponentScope {
+ ComponentScope::Input
+ }
+
+ fn name() -> &'static str {
+ "NumericStepper"
+ }
+
+ fn sort_name() -> &'static str {
+ Self::name()
+ }
+
+ fn description() -> Option<&'static str> {
+ Some("A button used to increment or decrement a numeric value. ")
+ }
+
+ fn preview(_window: &mut Window, _cx: &mut App) -> Option {
+ Some(
+ div()
+ .child(NumericStepper::new(
+ "numeric-stepper-component-preview",
+ "10",
+ move |_, _, _| {},
+ move |_, _, _| {},
+ ))
+ .into_any_element(),
+ )
+ }
+}
From 9f69b538692156fd04e564d85ef7e2d9984ba403 Mon Sep 17 00:00:00 2001
From: Ben Kunkle