Detailed changes
@@ -2667,9 +2667,9 @@ dependencies = [
[[package]]
name = "cap-fs-ext"
-version = "3.4.5"
+version = "3.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d5528f85b1e134ae811704e41ef80930f56e795923f866813255bc342cc20654"
+checksum = "e41cc18551193fe8fa6f15c1e3c799bc5ec9e2cfbfaa8ed46f37013e3e6c173c"
dependencies = [
"cap-primitives",
"cap-std",
@@ -2679,9 +2679,9 @@ dependencies = [
[[package]]
name = "cap-net-ext"
-version = "3.4.5"
+version = "3.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "20a158160765c6a7d0d8c072a53d772e4cb243f38b04bfcf6b4939cfbe7482e7"
+checksum = "9f83833816c66c986e913b22ac887cec216ea09301802054316fc5301809702c"
dependencies = [
"cap-primitives",
"cap-std",
@@ -2691,9 +2691,9 @@ dependencies = [
[[package]]
name = "cap-primitives"
-version = "3.4.5"
+version = "3.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b6cf3aea8a5081171859ef57bc1606b1df6999df4f1110f8eef68b30098d1d3a"
+checksum = "0a1e394ed14f39f8bc26f59d4c0c010dbe7f0a1b9bafff451b1f98b67c8af62a"
dependencies = [
"ambient-authority",
"fs-set-times",
@@ -2709,9 +2709,9 @@ dependencies = [
[[package]]
name = "cap-rand"
-version = "3.4.5"
+version = "3.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d8144c22e24bbcf26ade86cb6501a0916c46b7e4787abdb0045a467eb1645a1d"
+checksum = "0acb89ccf798a28683f00089d0630dfaceec087234eae0d308c05ddeaa941b40"
dependencies = [
"ambient-authority",
"rand 0.8.5",
@@ -2719,9 +2719,9 @@ dependencies = [
[[package]]
name = "cap-std"
-version = "3.4.5"
+version = "3.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b6dc3090992a735d23219de5c204927163d922f42f575a0189b005c62d37549a"
+checksum = "07c0355ca583dd58f176c3c12489d684163861ede3c9efa6fd8bba314c984189"
dependencies = [
"cap-primitives",
"io-extras",
@@ -2731,9 +2731,9 @@ dependencies = [
[[package]]
name = "cap-time-ext"
-version = "3.4.5"
+version = "3.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "def102506ce40c11710a9b16e614af0cde8e76ae51b1f48c04b8d79f4b671a80"
+checksum = "491af520b8770085daa0466978c75db90368c71896523f2464214e38359b1a5b"
dependencies = [
"ambient-authority",
"cap-primitives",
@@ -2896,6 +2896,17 @@ dependencies = [
"util",
]
+[[package]]
+name = "chardetng"
+version = "0.1.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "14b8f0b65b7b08ae3c8187e8d77174de20cb6777864c6b832d8ad365999cf1ea"
+dependencies = [
+ "cfg-if",
+ "encoding_rs",
+ "memchr",
+]
+
[[package]]
name = "chrono"
version = "0.4.42"
@@ -8803,6 +8814,7 @@ dependencies = [
"ctor",
"diffy",
"ec4rs",
+ "encoding_rs",
"fs",
"futures 0.3.31",
"fuzzy",
@@ -12473,6 +12485,7 @@ dependencies = [
"dap",
"dap_adapters",
"db",
+ "encoding_rs",
"extension",
"fancy-regex",
"fs",
@@ -19087,6 +19100,20 @@ dependencies = [
"winsafe",
]
+[[package]]
+name = "which_key"
+version = "0.1.0"
+dependencies = [
+ "command_palette",
+ "gpui",
+ "serde",
+ "settings",
+ "theme",
+ "ui",
+ "util",
+ "workspace",
+]
+
[[package]]
name = "whoami"
version = "1.6.1"
@@ -20092,8 +20119,10 @@ version = "0.1.0"
dependencies = [
"anyhow",
"async-lock 2.8.0",
+ "chardetng",
"clock",
"collections",
+ "encoding_rs",
"fs",
"futures 0.3.31",
"fuzzy",
@@ -20605,6 +20634,7 @@ dependencies = [
"watch",
"web_search",
"web_search_providers",
+ "which_key",
"windows 0.61.3",
"winresource",
"workspace",
@@ -192,6 +192,7 @@ members = [
"crates/vercel",
"crates/vim",
"crates/vim_mode_setting",
+ "crates/which_key",
"crates/watch",
"crates/web_search",
"crates/web_search_providers",
@@ -405,6 +406,7 @@ util_macros = { path = "crates/util_macros" }
vercel = { path = "crates/vercel" }
vim = { path = "crates/vim" }
vim_mode_setting = { path = "crates/vim_mode_setting" }
+which_key = { path = "crates/which_key" }
watch = { path = "crates/watch" }
web_search = { path = "crates/web_search" }
@@ -466,6 +468,7 @@ bytes = "1.0"
cargo_metadata = "0.19"
cargo_toml = "0.21"
cfg-if = "1.0.3"
+chardetng = "0.1"
chrono = { version = "0.4", features = ["serde"] }
ciborium = "0.2"
circular-buffer = "1.0"
@@ -489,6 +492,7 @@ dotenvy = "0.15.0"
ec4rs = "1.1"
emojis = "0.6.1"
env_logger = "0.11"
+encoding_rs = "0.8"
exec = "0.3.1"
fancy-regex = "0.16.0"
fork = "0.4.0"
@@ -905,8 +905,8 @@
"bindings": {
"left": "git_panel::CollapseSelectedEntry",
"right": "git_panel::ExpandSelectedEntry",
- "up": "menu::SelectPrevious",
- "down": "menu::SelectNext",
+ "up": "git_panel::PreviousEntry",
+ "down": "git_panel::NextEntry",
"enter": "menu::Confirm",
"alt-y": "git::StageFile",
"alt-shift-y": "git::UnstageFile",
@@ -981,12 +981,12 @@
"context": "GitPanel && ChangesList",
"use_key_equivalents": true,
"bindings": {
+ "up": "git_panel::PreviousEntry",
+ "down": "git_panel::NextEntry",
+ "cmd-up": "git_panel::FirstEntry",
+ "cmd-down": "git_panel::LastEntry",
"left": "git_panel::CollapseSelectedEntry",
"right": "git_panel::ExpandSelectedEntry",
- "up": "menu::SelectPrevious",
- "down": "menu::SelectNext",
- "cmd-up": "menu::SelectFirst",
- "cmd-down": "menu::SelectLast",
"enter": "menu::Confirm",
"cmd-alt-y": "git::ToggleStaged",
"space": "git::ToggleStaged",
@@ -908,10 +908,10 @@
"context": "GitPanel && ChangesList",
"use_key_equivalents": true,
"bindings": {
+ "up": "git_panel::PreviousEntry",
+ "down": "git_panel::NextEntry",
"left": "git_panel::CollapseSelectedEntry",
"right": "git_panel::ExpandSelectedEntry",
- "up": "menu::SelectPrevious",
- "down": "menu::SelectNext",
"enter": "menu::Confirm",
"alt-y": "git::StageFile",
"shift-alt-y": "git::UnstageFile",
@@ -2152,6 +2152,13 @@
// The shape can be one of the following: "block", "bar", "underline", "hollow".
"cursor_shape": {},
},
+ // Which-key popup settings
+ "which_key": {
+ // Whether to show the which-key popup when holding down key combinations.
+ "enabled": false,
+ // Delay in milliseconds before showing the which-key popup.
+ "delay_ms": 1000,
+ },
// The server to connect to. If the environment variable
// ZED_SERVER_URL is set, it will override this setting.
"server_url": "https://zed.dev",
@@ -216,14 +216,10 @@ impl HistoryStore {
}
pub fn reload(&self, cx: &mut Context<Self>) {
- let database_future = ThreadsDatabase::connect(cx);
+ let database_connection = ThreadsDatabase::connect(cx);
cx.spawn(async move |this, cx| {
- let threads = database_future
- .await
- .map_err(|err| anyhow!(err))?
- .list_threads()
- .await?;
-
+ let database = database_connection.await;
+ let threads = database.map_err(|err| anyhow!(err))?.list_threads().await?;
this.update(cx, |this, cx| {
if this.recently_opened_entries.len() < MAX_RECENTLY_OPENED_ENTRIES {
for thread in threads
@@ -344,7 +340,8 @@ impl HistoryStore {
fn load_recently_opened_entries(cx: &AsyncApp) -> Task<Result<VecDeque<HistoryEntryId>>> {
cx.background_spawn(async move {
if cfg!(any(feature = "test-support", test)) {
- anyhow::bail!("history store does not persist in tests");
+ log::warn!("history store does not persist in tests");
+ return Ok(VecDeque::new());
}
let json = KEY_VALUE_STORE
.read_kvp(RECENTLY_OPENED_THREADS_KEY)?
@@ -13,7 +13,7 @@ path = "src/agent_ui.rs"
doctest = false
[features]
-test-support = ["assistant_text_thread/test-support", "eval_utils", "gpui/test-support", "language/test-support", "reqwest_client", "workspace/test-support"]
+test-support = ["assistant_text_thread/test-support", "eval_utils", "gpui/test-support", "language/test-support", "reqwest_client", "workspace/test-support", "agent/test-support"]
unit-eval = []
[dependencies]
@@ -12,6 +12,10 @@ workspace = true
path = "src/agent_ui_v2.rs"
doctest = false
+[features]
+test-support = ["agent/test-support"]
+
+
[dependencies]
agent.workspace = true
agent_servers.workspace = true
@@ -38,3 +42,6 @@ time_format.workspace = true
ui.workspace = true
util.workspace = true
workspace.workspace = true
+
+[dev-dependencies]
+agent = { workspace = true, features = ["test-support"] }
@@ -1 +1 @@
-LICENSE-GPL
+../../LICENSE-GPL
@@ -69,7 +69,6 @@ use util::{
use workspace::{
CloseActiveItem, CloseAllItems, CloseOtherItems, MoveItemToPaneInDirection, NavigationEntry,
OpenOptions, ViewId,
- invalid_item_view::InvalidItemView,
item::{FollowEvent, FollowableItem, Item, ItemHandle, SaveOptions},
register_project_item,
};
@@ -27667,11 +27666,10 @@ async fn test_non_utf_8_opens(cx: &mut TestAppContext) {
})
.await
.unwrap();
-
- assert_eq!(
- handle.to_any_view().entity_type(),
- TypeId::of::<InvalidItemView>()
- );
+ // The test file content `vec![0xff, 0xfe, ...]` starts with a UTF-16 LE BOM.
+ // Previously, this fell back to `InvalidItemView` because it wasn't valid UTF-8.
+ // With auto-detection enabled, this is now recognized as UTF-16 and opens in the Editor.
+ assert_eq!(handle.to_any_view().entity_type(), TypeId::of::<Editor>());
}
#[gpui::test]
@@ -434,7 +434,18 @@ impl RealFs {
for component in path.components() {
match component {
std::path::Component::Prefix(_) => {
- let canonicalized = std::fs::canonicalize(component)?;
+ let component = component.as_os_str();
+ let canonicalized = if component
+ .to_str()
+ .map(|e| e.ends_with("\\"))
+ .unwrap_or(false)
+ {
+ std::fs::canonicalize(component)
+ } else {
+ let mut component = component.to_os_string();
+ component.push("\\");
+ std::fs::canonicalize(component)
+ }?;
let mut strip = PathBuf::new();
for component in canonicalized.components() {
@@ -3394,6 +3405,26 @@ mod tests {
assert_eq!(content, "Hello");
}
+ #[gpui::test]
+ #[cfg(target_os = "windows")]
+ async fn test_realfs_canonicalize(executor: BackgroundExecutor) {
+ use util::paths::SanitizedPath;
+
+ let fs = RealFs {
+ bundled_git_binary_path: None,
+ executor,
+ next_job_id: Arc::new(AtomicUsize::new(0)),
+ job_event_subscribers: Arc::new(Mutex::new(Vec::new())),
+ };
+ let temp_dir = TempDir::new().unwrap();
+ let file = temp_dir.path().join("test (1).txt");
+ let file = SanitizedPath::new(&file);
+ std::fs::write(&file, "test").unwrap();
+
+ let canonicalized = fs.canonicalize(file.as_path()).await;
+ assert!(canonicalized.is_ok());
+ }
+
#[gpui::test]
async fn test_rename(executor: BackgroundExecutor) {
let fs = FakeFs::new(executor.clone());
@@ -46,7 +46,7 @@ use language_model::{
ConfiguredModel, LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage,
Role, ZED_CLOUD_PROVIDER_ID,
};
-use menu::{Confirm, SecondaryConfirm, SelectFirst, SelectLast, SelectNext, SelectPrevious};
+use menu;
use multi_buffer::ExcerptInfo;
use notifications::status_toast::{StatusToast, ToastIcon};
use panel::{
@@ -93,6 +93,14 @@ actions!(
FocusEditor,
/// Focuses on the changes list.
FocusChanges,
+ /// Select next git panel menu item, and show it in the diff view
+ NextEntry,
+ /// Select previous git panel menu item, and show it in the diff view
+ PreviousEntry,
+ /// Select first git panel menu item, and show it in the diff view
+ FirstEntry,
+ /// Select last git panel menu item, and show it in the diff view
+ LastEntry,
/// Toggles automatic co-author suggestions.
ToggleFillCoAuthors,
/// Toggles sorting entries by path vs status.
@@ -793,20 +801,63 @@ impl GitPanel {
pub fn select_entry_by_path(
&mut self,
path: ProjectPath,
- _: &mut Window,
+ window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(git_repo) = self.active_repository.as_ref() else {
return;
};
- let Some(repo_path) = git_repo.read(cx).project_path_to_repo_path(&path, cx) else {
- return;
+
+ let (repo_path, section) = {
+ let repo = git_repo.read(cx);
+ let Some(repo_path) = repo.project_path_to_repo_path(&path, cx) else {
+ return;
+ };
+
+ let section = repo
+ .status_for_path(&repo_path)
+ .map(|status| status.status)
+ .map(|status| {
+ if repo.had_conflict_on_last_merge_head_change(&repo_path) {
+ Section::Conflict
+ } else if status.is_created() {
+ Section::New
+ } else {
+ Section::Tracked
+ }
+ });
+
+ (repo_path, section)
};
+
+ let mut needs_rebuild = false;
+ if let (Some(section), Some(tree_state)) = (section, self.view_mode.tree_state_mut()) {
+ let mut current_dir = repo_path.parent();
+ while let Some(dir) = current_dir {
+ let key = TreeKey {
+ section,
+ path: RepoPath::from_rel_path(dir),
+ };
+
+ if tree_state.expanded_dirs.get(&key) == Some(&false) {
+ tree_state.expanded_dirs.insert(key, true);
+ needs_rebuild = true;
+ }
+
+ current_dir = dir.parent();
+ }
+ }
+
+ if needs_rebuild {
+ self.update_visible_entries(window, cx);
+ }
+
let Some(ix) = self.entry_by_path(&repo_path) else {
return;
};
+
self.selected_entry = Some(ix);
- cx.notify();
+ self.scroll_to_selected_entry(cx);
}
fn serialization_key(workspace: &Workspace) -> Option<String> {
@@ -894,9 +945,22 @@ impl GitPanel {
}
fn scroll_to_selected_entry(&mut self, cx: &mut Context<Self>) {
- if let Some(selected_entry) = self.selected_entry {
+ let Some(selected_entry) = self.selected_entry else {
+ cx.notify();
+ return;
+ };
+
+ let visible_index = match &self.view_mode {
+ GitPanelViewMode::Flat => Some(selected_entry),
+ GitPanelViewMode::Tree(state) => state
+ .logical_indices
+ .iter()
+ .position(|&ix| ix == selected_entry),
+ };
+
+ if let Some(visible_index) = visible_index {
self.scroll_handle
- .scroll_to_item(selected_entry, ScrollStrategy::Center);
+ .scroll_to_item(visible_index, ScrollStrategy::Center);
}
cx.notify();
@@ -914,12 +978,12 @@ impl GitPanel {
if let GitListEntry::Directory(dir_entry) = entry {
if dir_entry.expanded {
- self.select_next(&SelectNext, window, cx);
+ self.select_next(&menu::SelectNext, window, cx);
} else {
self.toggle_directory(&dir_entry.key, window, cx);
}
} else {
- self.select_next(&SelectNext, window, cx);
+ self.select_next(&menu::SelectNext, window, cx);
}
}
@@ -937,14 +1001,19 @@ impl GitPanel {
if dir_entry.expanded {
self.toggle_directory(&dir_entry.key, window, cx);
} else {
- self.select_previous(&SelectPrevious, window, cx);
+ self.select_previous(&menu::SelectPrevious, window, cx);
}
} else {
- self.select_previous(&SelectPrevious, window, cx);
+ self.select_previous(&menu::SelectPrevious, window, cx);
}
}
- fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context<Self>) {
+ fn select_first(
+ &mut self,
+ _: &menu::SelectFirst,
+ _window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
let first_entry = match &self.view_mode {
GitPanelViewMode::Flat => self
.entries
@@ -967,7 +1036,7 @@ impl GitPanel {
fn select_previous(
&mut self,
- _: &SelectPrevious,
+ _: &menu::SelectPrevious,
_window: &mut Window,
cx: &mut Context<Self>,
) {
@@ -1016,7 +1085,7 @@ impl GitPanel {
self.scroll_to_selected_entry(cx);
}
- fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
+ fn select_next(&mut self, _: &menu::SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
let item_count = self.entries.len();
if item_count == 0 {
return;
@@ -1054,13 +1123,50 @@ impl GitPanel {
self.scroll_to_selected_entry(cx);
}
- fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
+ fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
if self.entries.last().is_some() {
self.selected_entry = Some(self.entries.len() - 1);
self.scroll_to_selected_entry(cx);
}
}
+ /// Show diff view at selected entry, only if the diff view is open
+ fn move_diff_to_entry(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+ maybe!({
+ let workspace = self.workspace.upgrade()?;
+
+ if let Some(project_diff) = workspace.read(cx).item_of_type::<ProjectDiff>(cx) {
+ let entry = self.entries.get(self.selected_entry?)?.status_entry()?;
+
+ project_diff.update(cx, |project_diff, cx| {
+ project_diff.move_to_entry(entry.clone(), window, cx);
+ });
+ }
+
+ Some(())
+ });
+ }
+
+ fn first_entry(&mut self, _: &FirstEntry, window: &mut Window, cx: &mut Context<Self>) {
+ self.select_first(&menu::SelectFirst, window, cx);
+ self.move_diff_to_entry(window, cx);
+ }
+
+ fn last_entry(&mut self, _: &LastEntry, window: &mut Window, cx: &mut Context<Self>) {
+ self.select_last(&menu::SelectLast, window, cx);
+ self.move_diff_to_entry(window, cx);
+ }
+
+ fn next_entry(&mut self, _: &NextEntry, window: &mut Window, cx: &mut Context<Self>) {
+ self.select_next(&menu::SelectNext, window, cx);
+ self.move_diff_to_entry(window, cx);
+ }
+
+ fn previous_entry(&mut self, _: &PreviousEntry, window: &mut Window, cx: &mut Context<Self>) {
+ self.select_previous(&menu::SelectPrevious, window, cx);
+ self.move_diff_to_entry(window, cx);
+ }
+
fn focus_editor(&mut self, _: &FocusEditor, window: &mut Window, cx: &mut Context<Self>) {
self.commit_editor.update(cx, |editor, cx| {
window.focus(&editor.focus_handle(cx), cx);
@@ -1074,7 +1180,7 @@ impl GitPanel {
.as_ref()
.is_some_and(|active_repository| active_repository.read(cx).status_summary().count > 0);
if have_entries && self.selected_entry.is_none() {
- self.select_first(&SelectFirst, window, cx);
+ self.select_first(&menu::SelectFirst, window, cx);
}
}
@@ -4726,8 +4832,8 @@ impl GitPanel {
git::AddToGitignore.boxed_clone(),
)
.separator()
- .action("Open Diff", Confirm.boxed_clone())
- .action("Open File", SecondaryConfirm.boxed_clone())
+ .action("Open Diff", menu::Confirm.boxed_clone())
+ .action("Open File", menu::SecondaryConfirm.boxed_clone())
.separator()
.action_disabled_when(is_created, "View File History", Box::new(git::FileHistory))
});
@@ -5390,6 +5496,10 @@ impl Render for GitPanel {
.on_action(cx.listener(Self::select_next))
.on_action(cx.listener(Self::select_previous))
.on_action(cx.listener(Self::select_last))
+ .on_action(cx.listener(Self::first_entry))
+ .on_action(cx.listener(Self::next_entry))
+ .on_action(cx.listener(Self::previous_entry))
+ .on_action(cx.listener(Self::last_entry))
.on_action(cx.listener(Self::close_panel))
.on_action(cx.listener(Self::open_diff))
.on_action(cx.listener(Self::open_file))
@@ -6855,7 +6965,7 @@ mod tests {
// the Project Diff's active path.
panel.update_in(cx, |panel, window, cx| {
panel.selected_entry = Some(1);
- panel.open_diff(&Confirm, window, cx);
+ panel.open_diff(&menu::Confirm, window, cx);
});
cx.run_until_parked();
@@ -6871,6 +6981,128 @@ mod tests {
});
}
+ #[gpui::test]
+ async fn test_tree_view_reveals_collapsed_parent_on_select_entry_by_path(
+ cx: &mut TestAppContext,
+ ) {
+ init_test(cx);
+
+ let fs = FakeFs::new(cx.background_executor.clone());
+ fs.insert_tree(
+ path!("/project"),
+ json!({
+ ".git": {},
+ "src": {
+ "a": {
+ "foo.rs": "fn foo() {}",
+ },
+ "b": {
+ "bar.rs": "fn bar() {}",
+ },
+ },
+ }),
+ )
+ .await;
+
+ fs.set_status_for_repo(
+ path!("/project/.git").as_ref(),
+ &[
+ ("src/a/foo.rs", StatusCode::Modified.worktree()),
+ ("src/b/bar.rs", StatusCode::Modified.worktree()),
+ ],
+ );
+
+ let project = Project::test(fs.clone(), [Path::new(path!("/project"))], cx).await;
+ let workspace =
+ cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
+ let cx = &mut VisualTestContext::from_window(*workspace, cx);
+
+ cx.read(|cx| {
+ project
+ .read(cx)
+ .worktrees(cx)
+ .next()
+ .unwrap()
+ .read(cx)
+ .as_local()
+ .unwrap()
+ .scan_complete()
+ })
+ .await;
+
+ cx.executor().run_until_parked();
+
+ cx.update(|_window, cx| {
+ SettingsStore::update_global(cx, |store, cx| {
+ store.update_user_settings(cx, |settings| {
+ settings.git_panel.get_or_insert_default().tree_view = Some(true);
+ })
+ });
+ });
+
+ let panel = workspace.update(cx, GitPanel::new).unwrap();
+
+ let handle = cx.update_window_entity(&panel, |panel, _, _| {
+ std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
+ });
+ cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
+ handle.await;
+
+ let src_key = panel.read_with(cx, |panel, _| {
+ panel
+ .entries
+ .iter()
+ .find_map(|entry| match entry {
+ GitListEntry::Directory(dir) if dir.key.path == repo_path("src") => {
+ Some(dir.key.clone())
+ }
+ _ => None,
+ })
+ .expect("src directory should exist in tree view")
+ });
+
+ panel.update_in(cx, |panel, window, cx| {
+ panel.toggle_directory(&src_key, window, cx);
+ });
+
+ panel.read_with(cx, |panel, _| {
+ let state = panel
+ .view_mode
+ .tree_state()
+ .expect("tree view state should exist");
+ assert_eq!(state.expanded_dirs.get(&src_key).copied(), Some(false));
+ });
+
+ let worktree_id =
+ cx.read(|cx| project.read(cx).worktrees(cx).next().unwrap().read(cx).id());
+ let project_path = ProjectPath {
+ worktree_id,
+ path: RelPath::unix("src/a/foo.rs").unwrap().into_arc(),
+ };
+
+ panel.update_in(cx, |panel, window, cx| {
+ panel.select_entry_by_path(project_path, window, cx);
+ });
+
+ panel.read_with(cx, |panel, _| {
+ let state = panel
+ .view_mode
+ .tree_state()
+ .expect("tree view state should exist");
+ assert_eq!(state.expanded_dirs.get(&src_key).copied(), Some(true));
+
+ let selected_ix = panel.selected_entry.expect("selection should be set");
+ assert!(state.logical_indices.contains(&selected_ix));
+
+ let selected_entry = panel
+ .entries
+ .get(selected_ix)
+ .and_then(|entry| entry.status_entry())
+ .expect("selected entry should be a status entry");
+ assert_eq!(selected_entry.repo_path, repo_path("src/a/foo.rs"));
+ });
+ }
+
fn assert_entry_paths(entries: &[GitListEntry], expected_paths: &[Option<&str>]) {
assert_eq!(entries.len(), expected_paths.len());
for (entry, expected_path) in entries.iter().zip(expected_paths) {
@@ -1,4 +1,5 @@
use anyhow::Context as _;
+use collections::HashSet;
use fuzzy::StringMatchCandidate;
use git::repository::Worktree as GitWorktree;
@@ -9,7 +10,11 @@ use gpui::{
actions, rems,
};
use picker::{Picker, PickerDelegate, PickerEditorPosition};
-use project::{DirectoryLister, git_store::Repository};
+use project::{
+ DirectoryLister,
+ git_store::Repository,
+ trusted_worktrees::{PathTrust, RemoteHostLocation, TrustedWorktrees},
+};
use recent_projects::{RemoteConnectionModal, connect};
use remote::{RemoteConnectionOptions, remote_client::ConnectionIdentifier};
use std::{path::PathBuf, sync::Arc};
@@ -219,7 +224,6 @@ impl WorktreeListDelegate {
window: &mut Window,
cx: &mut Context<Picker<Self>>,
) {
- let workspace = self.workspace.clone();
let Some(repo) = self.repo.clone() else {
return;
};
@@ -247,6 +251,7 @@ impl WorktreeListDelegate {
let branch = worktree_branch.to_string();
let window_handle = window.window_handle();
+ let workspace = self.workspace.clone();
cx.spawn_in(window, async move |_, cx| {
let Some(paths) = worktree_path.await? else {
return anyhow::Ok(());
@@ -257,8 +262,32 @@ impl WorktreeListDelegate {
repo.create_worktree(branch.clone(), path.clone(), commit)
})?
.await??;
-
- let final_path = path.join(branch);
+ let new_worktree_path = path.join(branch);
+
+ workspace.update(cx, |workspace, cx| {
+ if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) {
+ let repo_path = &repo.read(cx).snapshot().work_directory_abs_path;
+ let project = workspace.project();
+ if let Some((parent_worktree, _)) =
+ project.read(cx).find_worktree(repo_path, cx)
+ {
+ trusted_worktrees.update(cx, |trusted_worktrees, cx| {
+ if trusted_worktrees.can_trust(parent_worktree.read(cx).id(), cx) {
+ trusted_worktrees.trust(
+ HashSet::from_iter([PathTrust::AbsPath(
+ new_worktree_path.clone(),
+ )]),
+ project
+ .read(cx)
+ .remote_connection_options(cx)
+ .map(RemoteHostLocation::from),
+ cx,
+ );
+ }
+ });
+ }
+ }
+ })?;
let (connection_options, app_state, is_local) =
workspace.update(cx, |workspace, cx| {
@@ -274,7 +303,7 @@ impl WorktreeListDelegate {
.update_in(cx, |workspace, window, cx| {
workspace.open_workspace_for_paths(
replace_current_window,
- vec![final_path],
+ vec![new_worktree_path],
window,
cx,
)
@@ -283,7 +312,7 @@ impl WorktreeListDelegate {
} else if let Some(connection_options) = connection_options {
open_remote_worktree(
connection_options,
- vec![final_path],
+ vec![new_worktree_path],
app_state,
window_handle,
replace_current_window,
@@ -512,6 +512,8 @@ pub enum Model {
Gemini25Pro,
#[serde(rename = "gemini-3-pro-preview")]
Gemini3Pro,
+ #[serde(rename = "gemini-3-flash-preview")]
+ Gemini3Flash,
#[serde(rename = "custom")]
Custom {
name: String,
@@ -534,6 +536,7 @@ impl Model {
Self::Gemini25Flash => "gemini-2.5-flash",
Self::Gemini25Pro => "gemini-2.5-pro",
Self::Gemini3Pro => "gemini-3-pro-preview",
+ Self::Gemini3Flash => "gemini-3-flash-preview",
Self::Custom { name, .. } => name,
}
}
@@ -543,6 +546,7 @@ impl Model {
Self::Gemini25Flash => "gemini-2.5-flash",
Self::Gemini25Pro => "gemini-2.5-pro",
Self::Gemini3Pro => "gemini-3-pro-preview",
+ Self::Gemini3Flash => "gemini-3-flash-preview",
Self::Custom { name, .. } => name,
}
}
@@ -553,6 +557,7 @@ impl Model {
Self::Gemini25Flash => "Gemini 2.5 Flash",
Self::Gemini25Pro => "Gemini 2.5 Pro",
Self::Gemini3Pro => "Gemini 3 Pro",
+ Self::Gemini3Flash => "Gemini 3 Flash",
Self::Custom {
name, display_name, ..
} => display_name.as_ref().unwrap_or(name),
@@ -565,6 +570,7 @@ impl Model {
Self::Gemini25Flash => 1_048_576,
Self::Gemini25Pro => 1_048_576,
Self::Gemini3Pro => 1_048_576,
+ Self::Gemini3Flash => 1_048_576,
Self::Custom { max_tokens, .. } => *max_tokens,
}
}
@@ -575,6 +581,7 @@ impl Model {
Model::Gemini25Flash => Some(65_536),
Model::Gemini25Pro => Some(65_536),
Model::Gemini3Pro => Some(65_536),
+ Model::Gemini3Flash => Some(65_536),
Model::Custom { .. } => None,
}
}
@@ -599,6 +606,7 @@ impl Model {
budget_tokens: None,
}
}
+ Self::Gemini3Flash => GoogleModelMode::Default,
Self::Custom { mode, .. } => *mode,
}
}
@@ -462,6 +462,17 @@ impl DispatchTree {
(bindings, partial, context_stack)
}
+ /// Find the bindings that can follow the current input sequence.
+ pub fn possible_next_bindings_for_input(
+ &self,
+ input: &[Keystroke],
+ context_stack: &[KeyContext],
+ ) -> Vec<KeyBinding> {
+ self.keymap
+ .borrow()
+ .possible_next_bindings_for_input(input, context_stack)
+ }
+
/// dispatch_key processes the keystroke
/// input should be set to the value of `pending` from the previous call to dispatch_key.
/// This returns three instructions to the input handler:
@@ -215,6 +215,41 @@ impl Keymap {
Some(contexts.len())
}
}
+
+ /// Find the bindings that can follow the current input sequence.
+ pub fn possible_next_bindings_for_input(
+ &self,
+ input: &[Keystroke],
+ context_stack: &[KeyContext],
+ ) -> Vec<KeyBinding> {
+ let mut bindings = self
+ .bindings()
+ .enumerate()
+ .rev()
+ .filter_map(|(ix, binding)| {
+ let depth = self.binding_enabled(binding, context_stack)?;
+ let pending = binding.match_keystrokes(input);
+ match pending {
+ None => None,
+ Some(is_pending) => {
+ if !is_pending || is_no_action(&*binding.action) {
+ return None;
+ }
+ Some((depth, BindingIndex(ix), binding))
+ }
+ }
+ })
+ .collect::<Vec<_>>();
+
+ bindings.sort_by(|(depth_a, ix_a, _), (depth_b, ix_b, _)| {
+ depth_b.cmp(depth_a).then(ix_b.cmp(ix_a))
+ });
+
+ bindings
+ .into_iter()
+ .map(|(_, _, binding)| binding.clone())
+ .collect::<Vec<_>>()
+ }
}
#[cfg(test)]
@@ -4450,6 +4450,13 @@ impl Window {
dispatch_tree.highest_precedence_binding_for_action(action, &context_stack)
}
+ /// Find the bindings that can follow the current input sequence for the current context stack.
+ pub fn possible_bindings_for_input(&self, input: &[Keystroke]) -> Vec<KeyBinding> {
+ self.rendered_frame
+ .dispatch_tree
+ .possible_next_bindings_for_input(input, &self.context_stack())
+ }
+
fn context_stack_for_focus_handle(
&self,
focus_handle: &FocusHandle,
@@ -32,6 +32,7 @@ async-trait.workspace = true
clock.workspace = true
collections.workspace = true
ec4rs.workspace = true
+encoding_rs.workspace = true
fs.workspace = true
futures.workspace = true
fuzzy.workspace = true
@@ -25,6 +25,7 @@ use anyhow::{Context as _, Result};
use clock::Lamport;
pub use clock::ReplicaId;
use collections::{HashMap, HashSet};
+use encoding_rs::Encoding;
use fs::MTime;
use futures::channel::oneshot;
use gpui::{
@@ -131,6 +132,8 @@ pub struct Buffer {
change_bits: Vec<rc::Weak<Cell<bool>>>,
_subscriptions: Vec<gpui::Subscription>,
tree_sitter_data: Arc<TreeSitterData>,
+ encoding: &'static Encoding,
+ has_bom: bool,
}
#[derive(Debug)]
@@ -1100,6 +1103,8 @@ impl Buffer {
has_conflict: false,
change_bits: Default::default(),
_subscriptions: Vec::new(),
+ encoding: encoding_rs::UTF_8,
+ has_bom: false,
}
}
@@ -1383,6 +1388,26 @@ impl Buffer {
self.saved_mtime
}
+ /// Returns the character encoding of the buffer's file.
+ pub fn encoding(&self) -> &'static Encoding {
+ self.encoding
+ }
+
+ /// Sets the character encoding of the buffer.
+ pub fn set_encoding(&mut self, encoding: &'static Encoding) {
+ self.encoding = encoding;
+ }
+
+ /// Returns whether the buffer has a Byte Order Mark.
+ pub fn has_bom(&self) -> bool {
+ self.has_bom
+ }
+
+ /// Sets whether the buffer has a Byte Order Mark.
+ pub fn set_has_bom(&mut self, has_bom: bool) {
+ self.has_bom = has_bom;
+ }
+
/// Assign a language to the buffer.
pub fn set_language_async(&mut self, language: Option<Arc<Language>>, cx: &mut Context<Self>) {
self.set_language_(language, cfg!(any(test, feature = "test-support")), cx);
@@ -40,6 +40,7 @@ clock.workspace = true
collections.workspace = true
context_server.workspace = true
dap.workspace = true
+encoding_rs.workspace = true
extension.workspace = true
fancy-regex.workspace = true
fs.workspace = true
@@ -376,6 +376,8 @@ impl LocalBufferStore {
let text = buffer.as_rope().clone();
let line_ending = buffer.line_ending();
+ let encoding = buffer.encoding();
+ let has_bom = buffer.has_bom();
let version = buffer.version();
let buffer_id = buffer.remote_id();
let file = buffer.file().cloned();
@@ -387,7 +389,7 @@ impl LocalBufferStore {
}
let save = worktree.update(cx, |worktree, cx| {
- worktree.write_file(path, text, line_ending, cx)
+ worktree.write_file(path, text, line_ending, encoding, has_bom, cx)
});
cx.spawn(async move |this, cx| {
@@ -630,7 +632,11 @@ impl LocalBufferStore {
})
.await;
cx.insert_entity(reservation, |_| {
- Buffer::build(text_buffer, Some(loaded.file), Capability::ReadWrite)
+ let mut buffer =
+ Buffer::build(text_buffer, Some(loaded.file), Capability::ReadWrite);
+ buffer.set_encoding(loaded.encoding);
+ buffer.set_has_bom(loaded.has_bom);
+ buffer
})?
}
Err(error) if is_not_found_error(&error) => cx.new(|cx| {
@@ -65,6 +65,7 @@ use debugger::{
dap_store::{DapStore, DapStoreEvent},
session::Session,
};
+use encoding_rs;
pub use environment::ProjectEnvironment;
#[cfg(test)]
use futures::future::join_all;
@@ -5461,13 +5462,22 @@ impl Project {
.await
.context("Failed to load settings file")?;
+ let has_bom = file.has_bom;
+
let new_text = cx.read_global::<SettingsStore, _>(|store, cx| {
store.new_text_for_update(file.text, move |settings| update(settings, cx))
})?;
worktree
.update(cx, |worktree, cx| {
let line_ending = text::LineEnding::detect(&new_text);
- worktree.write_file(rel_path.clone(), new_text.into(), line_ending, cx)
+ worktree.write_file(
+ rel_path.clone(),
+ new_text.into(),
+ line_ending,
+ encoding_rs::UTF_8,
+ has_bom,
+ cx,
+ )
})?
.await
.context("Failed to write settings file")?;
@@ -7,7 +7,6 @@ use crate::{
search_bar::{ActionButtonState, input_base_styles, render_action_button, render_text_input},
};
use any_vec::AnyVec;
-use anyhow::Context as _;
use collections::HashMap;
use editor::{
DisplayPoint, Editor, EditorSettings, MultiBufferOffset,
@@ -634,15 +633,19 @@ impl BufferSearchBar {
.read(cx)
.as_singleton()
.expect("query editor should be backed by a singleton buffer");
+
query_buffer
.read(cx)
.set_language_registry(languages.clone());
cx.spawn(async move |buffer_search_bar, cx| {
+ use anyhow::Context as _;
+
let regex_language = languages
.language_for_name("regex")
.await
.context("loading regex language")?;
+
buffer_search_bar
.update(cx, |buffer_search_bar, cx| {
buffer_search_bar.regex_language = Some(regex_language);
@@ -158,6 +158,9 @@ pub struct SettingsContent {
/// Default: false
pub disable_ai: Option<SaturatingBool>,
+ /// Settings for the which-key popup.
+ pub which_key: Option<WhichKeySettingsContent>,
+
/// Settings related to Vim mode in Zed.
pub vim: Option<VimSettingsContent>,
}
@@ -976,6 +979,19 @@ pub struct ReplSettingsContent {
pub max_columns: Option<usize>,
}
+/// Settings for configuring the which-key popup behaviour.
+#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)]
+pub struct WhichKeySettingsContent {
+ /// Whether to show the which-key popup when holding down key combinations
+ ///
+ /// Default: false
+ pub enabled: Option<bool>,
+ /// Delay in milliseconds before showing the which-key popup.
+ ///
+ /// Default: 700
+ pub delay_ms: Option<u64>,
+}
+
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
/// An ExtendingVec in the settings can only accumulate new values.
///
@@ -215,6 +215,7 @@ impl VsCodeSettings {
vim: None,
vim_mode: None,
workspace: self.workspace_settings_content(),
+ which_key: None,
}
}
@@ -1233,6 +1233,49 @@ pub(crate) fn settings_data(cx: &App) -> Vec<SettingsPage> {
}
}).collect(),
}),
+ SettingsPageItem::SectionHeader("Which-key Menu"),
+ SettingsPageItem::SettingItem(SettingItem {
+ title: "Show Which-key Menu",
+ description: "Display the which-key menu with matching bindings while a multi-stroke binding is pending.",
+ field: Box::new(SettingField {
+ json_path: Some("which_key.enabled"),
+ pick: |settings_content| {
+ settings_content
+ .which_key
+ .as_ref()
+ .and_then(|settings| settings.enabled.as_ref())
+ },
+ write: |settings_content, value| {
+ settings_content
+ .which_key
+ .get_or_insert_default()
+ .enabled = value;
+ },
+ }),
+ metadata: None,
+ files: USER,
+ }),
+ SettingsPageItem::SettingItem(SettingItem {
+ title: "Menu Delay",
+ description: "Delay in milliseconds before the which-key menu appears.",
+ field: Box::new(SettingField {
+ json_path: Some("which_key.delay_ms"),
+ pick: |settings_content| {
+ settings_content
+ .which_key
+ .as_ref()
+ .and_then(|settings| settings.delay_ms.as_ref())
+ },
+ write: |settings_content, value| {
+ settings_content
+ .which_key
+ .get_or_insert_default()
+ .delay_ms = value;
+ },
+ }),
+ metadata: None,
+ files: USER,
+ }),
SettingsPageItem::SectionHeader("Multibuffer"),
SettingsPageItem::SettingItem(SettingItem {
title: "Double Click In Multibuffer",
@@ -330,10 +330,12 @@ pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
let Some(range) = range.buffer_range(vim, editor, window, cx).ok() else {
return;
};
- let Some((line_ending, text, whole_buffer)) = editor.buffer().update(cx, |multi, cx| {
+ let Some((line_ending, encoding, has_bom, text, whole_buffer)) = editor.buffer().update(cx, |multi, cx| {
Some(multi.as_singleton()?.update(cx, |buffer, _| {
(
buffer.line_ending(),
+ buffer.encoding(),
+ buffer.has_bom(),
buffer.as_rope().slice_rows(range.start.0..range.end.0 + 1),
range.start.0 == 0 && range.end.0 + 1 >= buffer.row_count(),
)
@@ -429,7 +431,7 @@ pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
return;
};
worktree
- .write_file(path.into_arc(), text.clone(), line_ending, cx)
+ .write_file(path.into_arc(), text.clone(), line_ending, encoding, has_bom, cx)
.detach_and_prompt_err("Failed to write lines", window, cx, |_, _, _| None);
});
})
@@ -0,0 +1,23 @@
+[package]
+name = "which_key"
+version = "0.1.0"
+edition.workspace = true
+publish.workspace = true
+license = "GPL-3.0-or-later"
+
+[lints]
+workspace = true
+
+[lib]
+path = "src/which_key.rs"
+doctest = false
+
+[dependencies]
+command_palette.workspace = true
+gpui.workspace = true
+serde.workspace = true
+settings.workspace = true
+theme.workspace = true
+ui.workspace = true
+util.workspace = true
+workspace.workspace = true
@@ -0,0 +1 @@
+../../LICENSE-GPL
@@ -0,0 +1,98 @@
+//! Which-key support for Zed.
+
+mod which_key_modal;
+mod which_key_settings;
+
+use gpui::{App, Keystroke};
+use settings::Settings;
+use std::{sync::LazyLock, time::Duration};
+use util::ResultExt;
+use which_key_modal::WhichKeyModal;
+use which_key_settings::WhichKeySettings;
+use workspace::Workspace;
+
+pub fn init(cx: &mut App) {
+ WhichKeySettings::register(cx);
+
+ cx.observe_new(|_: &mut Workspace, window, cx| {
+ let Some(window) = window else {
+ return;
+ };
+ let mut timer = None;
+ cx.observe_pending_input(window, move |workspace, window, cx| {
+ if window.pending_input_keystrokes().is_none() {
+ if let Some(modal) = workspace.active_modal::<WhichKeyModal>(cx) {
+ modal.update(cx, |modal, cx| modal.dismiss(cx));
+ };
+ timer.take();
+ return;
+ }
+
+ let which_key_settings = WhichKeySettings::get_global(cx);
+ if !which_key_settings.enabled {
+ return;
+ }
+
+ let delay_ms = which_key_settings.delay_ms;
+
+ timer.replace(cx.spawn_in(window, async move |workspace_handle, cx| {
+ cx.background_executor()
+ .timer(Duration::from_millis(delay_ms))
+ .await;
+ workspace_handle
+ .update_in(cx, |workspace, window, cx| {
+ if workspace.active_modal::<WhichKeyModal>(cx).is_some() {
+ return;
+ };
+
+ workspace.toggle_modal(window, cx, |window, cx| {
+ WhichKeyModal::new(workspace_handle.clone(), window, cx)
+ });
+ })
+ .log_err();
+ }));
+ })
+ .detach();
+ })
+ .detach();
+}
+
+// Hard-coded list of keystrokes to filter out from which-key display
+pub static FILTERED_KEYSTROKES: LazyLock<Vec<Vec<Keystroke>>> = LazyLock::new(|| {
+ [
+ // Modifiers on normal vim commands
+ "g h",
+ "g j",
+ "g k",
+ "g l",
+ "g $",
+ "g ^",
+ // Duplicate keys with "ctrl" held, e.g. "ctrl-w ctrl-a" is duplicate of "ctrl-w a"
+ "ctrl-w ctrl-a",
+ "ctrl-w ctrl-c",
+ "ctrl-w ctrl-h",
+ "ctrl-w ctrl-j",
+ "ctrl-w ctrl-k",
+ "ctrl-w ctrl-l",
+ "ctrl-w ctrl-n",
+ "ctrl-w ctrl-o",
+ "ctrl-w ctrl-p",
+ "ctrl-w ctrl-q",
+ "ctrl-w ctrl-s",
+ "ctrl-w ctrl-v",
+ "ctrl-w ctrl-w",
+ "ctrl-w ctrl-]",
+ "ctrl-w ctrl-shift-w",
+ "ctrl-w ctrl-g t",
+ "ctrl-w ctrl-g shift-t",
+ ]
+ .iter()
+ .filter_map(|s| {
+ let keystrokes: Result<Vec<_>, _> = s
+ .split(' ')
+ .map(|keystroke_str| Keystroke::parse(keystroke_str))
+ .collect();
+ keystrokes.ok()
+ })
+ .collect()
+});
@@ -0,0 +1,308 @@
+//! Modal implementation for the which-key display.
+
+use gpui::prelude::FluentBuilder;
+use gpui::{
+ App, Context, DismissEvent, EventEmitter, FocusHandle, Focusable, FontWeight, Keystroke,
+ ScrollHandle, Subscription, WeakEntity, Window,
+};
+use settings::Settings;
+use std::collections::HashMap;
+use theme::ThemeSettings;
+use ui::{
+ Divider, DividerColor, DynamicSpacing, LabelSize, WithScrollbar, prelude::*,
+ text_for_keystrokes,
+};
+use workspace::{ModalView, Workspace};
+
+use crate::FILTERED_KEYSTROKES;
+
+pub struct WhichKeyModal {
+ _workspace: WeakEntity<Workspace>,
+ focus_handle: FocusHandle,
+ scroll_handle: ScrollHandle,
+ bindings: Vec<(SharedString, SharedString)>,
+ pending_keys: SharedString,
+ _pending_input_subscription: Subscription,
+ _focus_out_subscription: Subscription,
+}
+
+impl WhichKeyModal {
+ pub fn new(
+ workspace: WeakEntity<Workspace>,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> Self {
+ // Keep focus where it currently is
+ let focus_handle = window.focused(cx).unwrap_or(cx.focus_handle());
+
+ let handle = cx.weak_entity();
+ let mut this = Self {
+ _workspace: workspace,
+ focus_handle: focus_handle.clone(),
+ scroll_handle: ScrollHandle::new(),
+ bindings: Vec::new(),
+ pending_keys: SharedString::new_static(""),
+ _pending_input_subscription: cx.observe_pending_input(
+ window,
+ |this: &mut Self, window, cx| {
+ this.update_pending_keys(window, cx);
+ },
+ ),
+ _focus_out_subscription: window.on_focus_out(&focus_handle, cx, move |_, _, cx| {
+ handle.update(cx, |_, cx| cx.emit(DismissEvent)).ok();
+ }),
+ };
+ this.update_pending_keys(window, cx);
+ this
+ }
+
+ pub fn dismiss(&self, cx: &mut Context<Self>) {
+ cx.emit(DismissEvent)
+ }
+
+ fn update_pending_keys(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+ let Some(pending_keys) = window.pending_input_keystrokes() else {
+ cx.emit(DismissEvent);
+ return;
+ };
+ let bindings = window.possible_bindings_for_input(pending_keys);
+
+ let mut binding_data = bindings
+ .iter()
+ .map(|binding| {
+ // Map to keystrokes
+ (
+ binding
+ .keystrokes()
+ .iter()
+ .map(|k| k.inner().to_owned())
+ .collect::<Vec<_>>(),
+ binding.action(),
+ )
+ })
+ .filter(|(keystrokes, _action)| {
+ // Check if this binding matches any filtered keystroke pattern
+ !FILTERED_KEYSTROKES.iter().any(|filtered| {
+ keystrokes.len() >= filtered.len()
+ && keystrokes[..filtered.len()] == filtered[..]
+ })
+ })
+ .map(|(keystrokes, action)| {
+ // Map to remaining keystrokes and action name
+ let remaining_keystrokes = keystrokes[pending_keys.len()..].to_vec();
+ let action_name: SharedString =
+ command_palette::humanize_action_name(action.name()).into();
+ (remaining_keystrokes, action_name)
+ })
+ .collect();
+
+ binding_data = group_bindings(binding_data);
+
+ // Sort bindings from shortest to longest, with groups last
+ // Using stable sort to preserve relative order of equal elements
+ binding_data.sort_by(|(keystrokes_a, action_a), (keystrokes_b, action_b)| {
+ // Groups (actions starting with "+") should go last
+ let is_group_a = action_a.starts_with('+');
+ let is_group_b = action_b.starts_with('+');
+
+ // First, separate groups from non-groups
+ let group_cmp = is_group_a.cmp(&is_group_b);
+ if group_cmp != std::cmp::Ordering::Equal {
+ return group_cmp;
+ }
+
+ // Then sort by keystroke count
+ let keystroke_cmp = keystrokes_a.len().cmp(&keystrokes_b.len());
+ if keystroke_cmp != std::cmp::Ordering::Equal {
+ return keystroke_cmp;
+ }
+
+ // Finally sort by text length, then lexicographically for full stability
+ let text_a = text_for_keystrokes(keystrokes_a, cx);
+ let text_b = text_for_keystrokes(keystrokes_b, cx);
+ let text_len_cmp = text_a.len().cmp(&text_b.len());
+ if text_len_cmp != std::cmp::Ordering::Equal {
+ return text_len_cmp;
+ }
+ text_a.cmp(&text_b)
+ });
+ binding_data.dedup();
+ self.pending_keys = text_for_keystrokes(&pending_keys, cx).into();
+ self.bindings = binding_data
+ .into_iter()
+ .map(|(keystrokes, action)| (text_for_keystrokes(&keystrokes, cx).into(), action))
+ .collect();
+ }
+}
+
+impl Render for WhichKeyModal {
+ fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+ let has_rows = !self.bindings.is_empty();
+ let viewport_size = window.viewport_size();
+
+ let max_panel_width = px((f32::from(viewport_size.width) * 0.5).min(480.0));
+ let max_content_height = px(f32::from(viewport_size.height) * 0.4);
+
+ // Push above status bar when visible
+ let status_height = self
+ ._workspace
+ .upgrade()
+ .and_then(|workspace| {
+ workspace.read_with(cx, |workspace, cx| {
+ if workspace.status_bar_visible(cx) {
+ Some(
+ DynamicSpacing::Base04.px(cx) * 2.0
+ + ThemeSettings::get_global(cx).ui_font_size(cx),
+ )
+ } else {
+ None
+ }
+ })
+ })
+ .unwrap_or(px(0.));
+
+ let margin_bottom = px(16.);
+ let bottom_offset = margin_bottom + status_height;
+
+ // Title section
+ let title_section = {
+ let mut column = v_flex().gap(px(0.)).child(
+ div()
+ .child(
+ Label::new(self.pending_keys.clone())
+ .size(LabelSize::Default)
+ .weight(FontWeight::MEDIUM)
+ .color(Color::Accent),
+ )
+ .mb(px(2.)),
+ );
+
+ if has_rows {
+ column = column.child(
+ div()
+ .child(Divider::horizontal().color(DividerColor::BorderFaded))
+ .mb(px(2.)),
+ );
+ }
+
+ column
+ };
+
+ let content = h_flex()
+ .items_start()
+ .id("which-key-content")
+ .gap(px(8.))
+ .overflow_y_scroll()
+ .track_scroll(&self.scroll_handle)
+ .h_full()
+ .max_h(max_content_height)
+ .child(
+ // Keystrokes column
+ v_flex()
+ .gap(px(4.))
+ .flex_shrink_0()
+ .children(self.bindings.iter().map(|(keystrokes, _)| {
+ div()
+ .child(
+ Label::new(keystrokes.clone())
+ .size(LabelSize::Default)
+ .color(Color::Accent),
+ )
+ .text_align(gpui::TextAlign::Right)
+ })),
+ )
+ .child(
+ // Actions column
+ v_flex()
+ .gap(px(4.))
+ .flex_1()
+ .min_w_0()
+ .children(self.bindings.iter().map(|(_, action_name)| {
+ let is_group = action_name.starts_with('+');
+ let label_color = if is_group {
+ Color::Success
+ } else {
+ Color::Default
+ };
+
+ div().child(
+ Label::new(action_name.clone())
+ .size(LabelSize::Default)
+ .color(label_color)
+ .single_line()
+ .truncate(),
+ )
+ })),
+ );
+
+ div()
+ .id("which-key-buffer-panel-scroll")
+ .occlude()
+ .absolute()
+ .bottom(bottom_offset)
+ .right(px(16.))
+ .min_w(px(220.))
+ .max_w(max_panel_width)
+ .elevation_3(cx)
+ .px(px(12.))
+ .child(v_flex().child(title_section).when(has_rows, |el| {
+ el.child(
+ div()
+ .max_h(max_content_height)
+ .child(content)
+ .vertical_scrollbar_for(&self.scroll_handle, window, cx),
+ )
+ }))
+ }
+}
+
+impl EventEmitter<DismissEvent> for WhichKeyModal {}
+
+impl Focusable for WhichKeyModal {
+ fn focus_handle(&self, _cx: &App) -> gpui::FocusHandle {
+ self.focus_handle.clone()
+ }
+}
+
+impl ModalView for WhichKeyModal {
+ fn render_bare(&self) -> bool {
+ true
+ }
+}
+
+fn group_bindings(
+ binding_data: Vec<(Vec<Keystroke>, SharedString)>,
+) -> Vec<(Vec<Keystroke>, SharedString)> {
+ let mut groups: HashMap<Option<Keystroke>, Vec<(Vec<Keystroke>, SharedString)>> =
+ HashMap::new();
+
+ // Group bindings by their first keystroke
+ for (remaining_keystrokes, action_name) in binding_data {
+ let first_key = remaining_keystrokes.first().cloned();
+ groups
+ .entry(first_key)
+ .or_default()
+ .push((remaining_keystrokes, action_name));
+ }
+
+ let mut result = Vec::new();
+
+ for (first_key, mut group_bindings) in groups {
+ // Remove duplicates within each group
+ group_bindings.dedup_by_key(|(keystrokes, _)| keystrokes.clone());
+
+ if let Some(first_key) = first_key
+ && group_bindings.len() > 1
+ {
+ // This is a group - create a single entry with just the first keystroke
+ let first_keystroke = vec![first_key];
+ let count = group_bindings.len();
+ result.push((first_keystroke, format!("+{} keybinds", count).into()));
+ } else {
+ // Not a group or empty keystrokes - add all bindings as-is
+ result.append(&mut group_bindings);
+ }
+ }
+
+ result
+}
@@ -0,0 +1,18 @@
+use settings::{RegisterSetting, Settings, SettingsContent, WhichKeySettingsContent};
+
+#[derive(Debug, Clone, Copy, RegisterSetting)]
+pub struct WhichKeySettings {
+ pub enabled: bool,
+ pub delay_ms: u64,
+}
+
+impl Settings for WhichKeySettings {
+ fn from_settings(content: &SettingsContent) -> Self {
+ let which_key: &WhichKeySettingsContent = content.which_key.as_ref().unwrap();
+
+ Self {
+ enabled: which_key.enabled.unwrap(),
+ delay_ms: which_key.delay_ms.unwrap(),
+ }
+ }
+}
@@ -1,5 +1,4 @@
use crate::persistence::model::DockData;
-use crate::utility_pane::utility_slot_for_dock_position;
use crate::{DraggedDock, Event, ModalLayer, Pane};
use crate::{Workspace, status_bar::StatusItemView};
use anyhow::Context as _;
@@ -705,7 +704,7 @@ impl Dock {
panel: &Entity<T>,
window: &mut Window,
cx: &mut Context<Self>,
- ) {
+ ) -> bool {
if let Some(panel_ix) = self
.panel_entries
.iter()
@@ -724,15 +723,12 @@ impl Dock {
}
}
- let slot = utility_slot_for_dock_position(self.position);
- if let Some(workspace) = self.workspace.upgrade() {
- workspace.update(cx, |workspace, cx| {
- workspace.clear_utility_pane_if_provider(slot, Entity::entity_id(panel), cx);
- });
- }
-
self.panel_entries.remove(panel_ix);
cx.notify();
+
+ true
+ } else {
+ false
}
}
@@ -22,12 +22,17 @@ pub trait ModalView: ManagedView {
fn fade_out_background(&self) -> bool {
false
}
+
+ fn render_bare(&self) -> bool {
+ false
+ }
}
trait ModalViewHandle {
fn on_before_dismiss(&mut self, window: &mut Window, cx: &mut App) -> DismissDecision;
fn view(&self) -> AnyView;
fn fade_out_background(&self, cx: &mut App) -> bool;
+ fn render_bare(&self, cx: &mut App) -> bool;
}
impl<V: ModalView> ModalViewHandle for Entity<V> {
@@ -42,6 +47,10 @@ impl<V: ModalView> ModalViewHandle for Entity<V> {
fn fade_out_background(&self, cx: &mut App) -> bool {
self.read(cx).fade_out_background()
}
+
+ fn render_bare(&self, cx: &mut App) -> bool {
+ self.read(cx).render_bare()
+ }
}
pub struct ActiveModal {
@@ -167,9 +176,13 @@ impl ModalLayer {
impl Render for ModalLayer {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let Some(active_modal) = &self.active_modal else {
- return div();
+ return div().into_any_element();
};
+ if active_modal.modal.render_bare(cx) {
+ return active_modal.modal.view().into_any_element();
+ }
+
div()
.absolute()
.size_full()
@@ -195,5 +208,6 @@ impl Render for ModalLayer {
}),
),
)
+ .into_any_element()
}
}
@@ -102,46 +102,31 @@ impl Render for SecurityModal {
.child(Icon::new(IconName::Warning).color(Color::Warning))
.child(Label::new(header_label)),
)
- .children(self.restricted_paths.values().map(|restricted_path| {
+ .children(self.restricted_paths.values().filter_map(|restricted_path| {
let abs_path = if restricted_path.is_file {
restricted_path.abs_path.parent()
} else {
Some(restricted_path.abs_path.as_ref())
- };
-
- let label = match abs_path {
- Some(abs_path) => match &restricted_path.host {
- Some(remote_host) => match &remote_host.user_name {
- Some(user_name) => format!(
- "{} ({}@{})",
- self.shorten_path(abs_path).display(),
- user_name,
- remote_host.host_identifier
- ),
- None => format!(
- "{} ({})",
- self.shorten_path(abs_path).display(),
- remote_host.host_identifier
- ),
- },
- None => self.shorten_path(abs_path).display().to_string(),
- },
- None => match &restricted_path.host {
- Some(remote_host) => match &remote_host.user_name {
- Some(user_name) => format!(
- "Workspace trust ({}@{})",
- user_name, remote_host.host_identifier
- ),
- None => {
- format!("Workspace trust ({})", remote_host.host_identifier)
- }
- },
- None => "Workspace trust".to_string(),
+ }?;
+ let label = match &restricted_path.host {
+ Some(remote_host) => match &remote_host.user_name {
+ Some(user_name) => format!(
+ "{} ({}@{})",
+ self.shorten_path(abs_path).display(),
+ user_name,
+ remote_host.host_identifier
+ ),
+ None => format!(
+ "{} ({})",
+ self.shorten_path(abs_path).display(),
+ remote_host.host_identifier
+ ),
},
+ None => self.shorten_path(abs_path).display().to_string(),
};
- h_flex()
+ Some(h_flex()
.pl(IconSize::default().rems() + rems(0.5))
- .child(Label::new(label).color(Color::Muted))
+ .child(Label::new(label).color(Color::Muted)))
})),
)
.child(
@@ -136,7 +136,9 @@ pub use workspace_settings::{
use zed_actions::{Spawn, feedback::FileBugReport};
use crate::{
- item::ItemBufferKind, notifications::NotificationId, utility_pane::UTILITY_PANE_MIN_WIDTH,
+ item::ItemBufferKind,
+ notifications::NotificationId,
+ utility_pane::{UTILITY_PANE_MIN_WIDTH, utility_slot_for_dock_position},
};
use crate::{
persistence::{
@@ -987,6 +989,7 @@ impl AppState {
#[cfg(any(test, feature = "test-support"))]
pub fn test(cx: &mut App) -> Arc<Self> {
+ use fs::Fs;
use node_runtime::NodeRuntime;
use session::Session;
use settings::SettingsStore;
@@ -997,6 +1000,7 @@ impl AppState {
}
let fs = fs::FakeFs::new(cx.background_executor().clone());
+ <dyn Fs>::set_global(fs.clone(), cx);
let languages = Arc::new(LanguageRegistry::test(cx.background_executor().clone()));
let clock = Arc::new(clock::FakeSystemClock::new());
let http_client = http_client::FakeHttpClient::with_404_response();
@@ -1891,10 +1895,18 @@ impl Workspace {
window: &mut Window,
cx: &mut Context<Self>,
) {
+ let mut found_in_dock = None;
for dock in [&self.left_dock, &self.bottom_dock, &self.right_dock] {
- dock.update(cx, |dock, cx| {
- dock.remove_panel(panel, window, cx);
- })
+ let found = dock.update(cx, |dock, cx| dock.remove_panel(panel, window, cx));
+
+ if found {
+ found_in_dock = Some(dock.clone());
+ }
+ }
+ if let Some(found_in_dock) = found_in_dock {
+ let position = found_in_dock.read(cx).position();
+ let slot = utility_slot_for_dock_position(position);
+ self.clear_utility_pane_if_provider(slot, Entity::entity_id(panel), cx);
}
}
@@ -25,8 +25,10 @@ test-support = [
[dependencies]
anyhow.workspace = true
async-lock.workspace = true
+chardetng.workspace = true
clock.workspace = true
collections.workspace = true
+encoding_rs.workspace = true
fs.workspace = true
futures.workspace = true
fuzzy.workspace = true
@@ -5,8 +5,10 @@ mod worktree_tests;
use ::ignore::gitignore::{Gitignore, GitignoreBuilder};
use anyhow::{Context as _, Result, anyhow};
+use chardetng::EncodingDetector;
use clock::ReplicaId;
use collections::{HashMap, HashSet, VecDeque};
+use encoding_rs::Encoding;
use fs::{Fs, MTime, PathEvent, RemoveOptions, Watcher, copy_recursive, read_dir_items};
use futures::{
FutureExt as _, Stream, StreamExt,
@@ -105,6 +107,8 @@ pub enum CreatedEntry {
pub struct LoadedFile {
pub file: Arc<File>,
pub text: String,
+ pub encoding: &'static Encoding,
+ pub has_bom: bool,
}
pub struct LoadedBinaryFile {
@@ -741,10 +745,14 @@ impl Worktree {
path: Arc<RelPath>,
text: Rope,
line_ending: LineEnding,
+ encoding: &'static Encoding,
+ has_bom: bool,
cx: &Context<Worktree>,
) -> Task<Result<Arc<File>>> {
match self {
- Worktree::Local(this) => this.write_file(path, text, line_ending, cx),
+ Worktree::Local(this) => {
+ this.write_file(path, text, line_ending, encoding, has_bom, cx)
+ }
Worktree::Remote(_) => {
Task::ready(Err(anyhow!("remote worktree can't yet write files")))
}
@@ -1351,7 +1359,9 @@ impl LocalWorktree {
anyhow::bail!("File is too large to load");
}
}
- let text = fs.load(&abs_path).await?;
+
+ let content = fs.load_bytes(&abs_path).await?;
+ let (text, encoding, has_bom) = decode_byte(content);
let worktree = this.upgrade().context("worktree was dropped")?;
let file = match entry.await? {
@@ -1379,7 +1389,12 @@ impl LocalWorktree {
}
};
- Ok(LoadedFile { file, text })
+ Ok(LoadedFile {
+ file,
+ text,
+ encoding,
+ has_bom,
+ })
})
}
@@ -1462,6 +1477,8 @@ impl LocalWorktree {
path: Arc<RelPath>,
text: Rope,
line_ending: LineEnding,
+ encoding: &'static Encoding,
+ has_bom: bool,
cx: &Context<Worktree>,
) -> Task<Result<Arc<File>>> {
let fs = self.fs.clone();
@@ -1471,7 +1488,49 @@ impl LocalWorktree {
let write = cx.background_spawn({
let fs = fs.clone();
let abs_path = abs_path.clone();
- async move { fs.save(&abs_path, &text, line_ending).await }
+ async move {
+ let bom_bytes = if has_bom {
+ if encoding == encoding_rs::UTF_16LE {
+ vec![0xFF, 0xFE]
+ } else if encoding == encoding_rs::UTF_16BE {
+ vec![0xFE, 0xFF]
+ } else if encoding == encoding_rs::UTF_8 {
+ vec![0xEF, 0xBB, 0xBF]
+ } else {
+ vec![]
+ }
+ } else {
+ vec![]
+ };
+
+ // For UTF-8, use the optimized `fs.save` which writes Rope chunks directly to disk
+ // without allocating a contiguous string.
+ if encoding == encoding_rs::UTF_8 && !has_bom {
+ return fs.save(&abs_path, &text, line_ending).await;
+ }
+ // For legacy encodings (e.g. Shift-JIS), we fall back to converting the entire Rope
+ // to a String/Bytes in memory before writing.
+ //
+ // Note: This is inefficient for very large files compared to the streaming approach above,
+ // but supporting streaming writes for arbitrary encodings would require a significant
+ // refactor of the `fs` crate to expose a Writer interface.
+ let text_string = text.to_string();
+ let normalized_text = match line_ending {
+ LineEnding::Unix => text_string,
+ LineEnding::Windows => text_string.replace('\n', "\r\n"),
+ };
+
+ let (cow, _, _) = encoding.encode(&normalized_text);
+ let bytes = if !bom_bytes.is_empty() {
+ let mut bytes = bom_bytes;
+ bytes.extend_from_slice(&cow);
+ bytes.into()
+ } else {
+ cow
+ };
+
+ fs.write(&abs_path, &bytes).await
+ }
});
cx.spawn(async move |this, cx| {
@@ -5782,3 +5841,40 @@ impl fs::Watcher for NullWatcher {
Ok(())
}
}
+
+fn decode_byte(bytes: Vec<u8>) -> (String, &'static Encoding, bool) {
+ // check BOM
+ if let Some((encoding, _bom_len)) = Encoding::for_bom(&bytes) {
+ let (cow, _) = encoding.decode_with_bom_removal(&bytes);
+ return (cow.into_owned(), encoding, true);
+ }
+
+ fn detect_encoding(bytes: Vec<u8>) -> (String, &'static Encoding) {
+ let mut detector = EncodingDetector::new();
+ detector.feed(&bytes, true);
+
+ let encoding = detector.guess(None, true); // Use None for TLD hint to ensure neutral detection logic.
+
+ let (cow, _, _) = encoding.decode(&bytes);
+ (cow.into_owned(), encoding)
+ }
+
+ match String::from_utf8(bytes) {
+ Ok(text) => {
+ // ISO-2022-JP (and other ISO-2022 variants) consists entirely of 7-bit ASCII bytes,
+ // so it is valid UTF-8. However, it contains escape sequences starting with '\x1b'.
+ // If we find an escape character, we double-check the encoding to prevent
+ // displaying raw escape sequences instead of the correct characters.
+ if text.contains('\x1b') {
+ let (s, enc) = detect_encoding(text.into_bytes());
+ (s, enc, false)
+ } else {
+ (text, encoding_rs::UTF_8, false)
+ }
+ }
+ Err(e) => {
+ let (s, enc) = detect_encoding(e.into_bytes());
+ (s, enc, false)
+ }
+ }
+}
@@ -1,5 +1,6 @@
use crate::{Entry, EntryKind, Event, PathChange, Worktree, WorktreeModelHandle};
-use anyhow::Result;
+use anyhow::{Context as _, Result};
+use encoding_rs;
use fs::{FakeFs, Fs, RealFs, RemoveOptions};
use git::{DOT_GIT, GITIGNORE, REPO_EXCLUDE};
use gpui::{AppContext as _, BackgroundExecutor, BorrowAppContext, Context, Task, TestAppContext};
@@ -19,6 +20,7 @@ use std::{
};
use util::{
ResultExt, path,
+ paths::PathStyle,
rel_path::{RelPath, rel_path},
test::TempTree,
};
@@ -723,6 +725,8 @@ async fn test_write_file(cx: &mut TestAppContext) {
rel_path("tracked-dir/file.txt").into(),
"hello".into(),
Default::default(),
+ encoding_rs::UTF_8,
+ false,
cx,
)
})
@@ -734,6 +738,8 @@ async fn test_write_file(cx: &mut TestAppContext) {
rel_path("ignored-dir/file.txt").into(),
"world".into(),
Default::default(),
+ encoding_rs::UTF_8,
+ false,
cx,
)
})
@@ -2035,8 +2041,14 @@ fn randomly_mutate_worktree(
})
} else {
log::info!("overwriting file {:?} ({})", &entry.path, entry.id.0);
- let task =
- worktree.write_file(entry.path.clone(), "".into(), Default::default(), cx);
+ let task = worktree.write_file(
+ entry.path.clone(),
+ "".into(),
+ Default::default(),
+ encoding_rs::UTF_8,
+ false,
+ cx,
+ );
cx.background_spawn(async move {
task.await?;
Ok(())
@@ -2552,3 +2564,176 @@ fn init_test(cx: &mut gpui::TestAppContext) {
cx.set_global(settings_store);
});
}
+
+#[gpui::test]
+async fn test_load_file_encoding(cx: &mut TestAppContext) {
+ init_test(cx);
+ let test_cases: Vec<(&str, &[u8], &str)> = vec![
+ ("utf8.txt", "γγγ«γ‘γ―".as_bytes(), "γγγ«γ‘γ―"), // "γγγ«γ‘γ―" is Japanese "Hello"
+ (
+ "sjis.txt",
+ &[0x82, 0xb1, 0x82, 0xf1, 0x82, 0xc9, 0x82, 0xbf, 0x82, 0xcd],
+ "γγγ«γ‘γ―",
+ ),
+ (
+ "eucjp.txt",
+ &[0xa4, 0xb3, 0xa4, 0xf3, 0xa4, 0xcb, 0xa4, 0xc1, 0xa4, 0xcf],
+ "γγγ«γ‘γ―",
+ ),
+ (
+ "iso2022jp.txt",
+ &[
+ 0x1b, 0x24, 0x42, 0x24, 0x33, 0x24, 0x73, 0x24, 0x4b, 0x24, 0x41, 0x24, 0x4f, 0x1b,
+ 0x28, 0x42,
+ ],
+ "γγγ«γ‘γ―",
+ ),
+ // Western Europe (Windows-1252)
+ // "CafΓ©" -> 0xE9 is 'Γ©' in Windows-1252 (it is typically 0xC3 0xA9 in UTF-8)
+ ("win1252.txt", &[0x43, 0x61, 0x66, 0xe9], "CafΓ©"),
+ // Chinese Simplified (GBK)
+ // Note: We use a slightly longer string here because short byte sequences can be ambiguous
+ // in multi-byte encodings. Providing more context helps the heuristic detector guess correctly.
+ // Text: "δ»ε€©ε€©ζ°δΈι" (Today's weather is not bad / nice)
+ // Bytes:
+ // δ»: BD F1
+ // 倩: CC EC
+ // 倩: CC EC
+ // ζ°: C6 F8
+ // δΈ: B2 BB
+ // ι: B4 ED
+ (
+ "gbk.txt",
+ &[
+ 0xbd, 0xf1, 0xcc, 0xec, 0xcc, 0xec, 0xc6, 0xf8, 0xb2, 0xbb, 0xb4, 0xed,
+ ],
+ "δ»ε€©ε€©ζ°δΈι",
+ ),
+ (
+ "utf16le_bom.txt",
+ &[
+ 0xFF, 0xFE, // BOM
+ 0x53, 0x30, // γ
+ 0x93, 0x30, // γ
+ 0x6B, 0x30, // γ«
+ 0x61, 0x30, // γ‘
+ 0x6F, 0x30, // γ―
+ ],
+ "γγγ«γ‘γ―",
+ ),
+ (
+ "utf8_bom.txt",
+ &[
+ 0xEF, 0xBB, 0xBF, // UTF-8 BOM
+ 0xE3, 0x81, 0x93, // γ
+ 0xE3, 0x82, 0x93, // γ
+ 0xE3, 0x81, 0xAB, // γ«
+ 0xE3, 0x81, 0xA1, // γ‘
+ 0xE3, 0x81, 0xAF, // γ―
+ ],
+ "γγγ«γ‘γ―",
+ ),
+ ];
+
+ let root_path = if cfg!(windows) {
+ Path::new("C:\\root")
+ } else {
+ Path::new("/root")
+ };
+
+ let fs = FakeFs::new(cx.background_executor.clone());
+
+ let mut files_json = serde_json::Map::new();
+ for (name, _, _) in &test_cases {
+ files_json.insert(name.to_string(), serde_json::Value::String("".to_string()));
+ }
+
+ for (name, bytes, _) in &test_cases {
+ let path = root_path.join(name);
+ fs.write(&path, bytes).await.unwrap();
+ }
+
+ let tree = Worktree::local(
+ root_path,
+ true,
+ fs,
+ Default::default(),
+ true,
+ &mut cx.to_async(),
+ )
+ .await
+ .unwrap();
+
+ cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
+ .await;
+
+ for (name, _, expected) in test_cases {
+ let loaded = tree
+ .update(cx, |tree, cx| tree.load_file(rel_path(name), cx))
+ .await
+ .with_context(|| format!("Failed to load {}", name))
+ .unwrap();
+
+ assert_eq!(
+ loaded.text, expected,
+ "Encoding mismatch for file: {}",
+ name
+ );
+ }
+}
+
+#[gpui::test]
+async fn test_write_file_encoding(cx: &mut gpui::TestAppContext) {
+ init_test(cx);
+ let fs = FakeFs::new(cx.executor());
+ let root_path = if cfg!(windows) {
+ Path::new("C:\\root")
+ } else {
+ Path::new("/root")
+ };
+ fs.create_dir(root_path).await.unwrap();
+ let file_path = root_path.join("test.txt");
+
+ fs.insert_file(&file_path, "initial".into()).await;
+
+ let worktree = Worktree::local(
+ root_path,
+ true,
+ fs.clone(),
+ Default::default(),
+ true,
+ &mut cx.to_async(),
+ )
+ .await
+ .unwrap();
+
+ let path: Arc<Path> = Path::new("test.txt").into();
+ let rel_path = RelPath::new(&path, PathStyle::local()).unwrap().into_arc();
+
+ let text = text::Rope::from("γγγ«γ‘γ―");
+
+ let task = worktree.update(cx, |wt, cx| {
+ wt.write_file(
+ rel_path,
+ text,
+ text::LineEnding::Unix,
+ encoding_rs::SHIFT_JIS,
+ false,
+ cx,
+ )
+ });
+
+ task.await.unwrap();
+
+ let bytes = fs.load_bytes(&file_path).await.unwrap();
+
+ let expected_bytes = vec![
+ 0x82, 0xb1, // γ
+ 0x82, 0xf1, // γ
+ 0x82, 0xc9, // γ«
+ 0x82, 0xbf, // γ‘
+ 0x82, 0xcd, // γ―
+ ];
+
+ assert_eq!(bytes, expected_bytes, "Should be saved as Shift-JIS");
+}
@@ -163,6 +163,7 @@ vim_mode_setting.workspace = true
watch.workspace = true
web_search.workspace = true
web_search_providers.workspace = true
+which_key.workspace = true
workspace.workspace = true
zed_actions.workspace = true
zed_env_vars.workspace = true
@@ -195,6 +196,10 @@ terminal_view = { workspace = true, features = ["test-support"] }
tree-sitter-md.workspace = true
tree-sitter-rust.workspace = true
workspace = { workspace = true, features = ["test-support"] }
+agent_ui = { workspace = true, features = ["test-support"] }
+agent_ui_v2 = { workspace = true, features = ["test-support"] }
+search = { workspace = true, features = ["test-support"] }
+
[package.metadata.bundle-dev]
icon = ["resources/app-icon-dev@2x.png", "resources/app-icon-dev.png"]
@@ -660,6 +660,7 @@ pub fn main() {
inspector_ui::init(app_state.clone(), cx);
json_schema_store::init(cx);
miniprofiler_ui::init(*STARTUP_TIME.get().unwrap(), cx);
+ which_key::init(cx);
cx.observe_global::<SettingsStore>({
let http = app_state.client.http_client();
@@ -707,7 +707,6 @@ fn setup_or_teardown_ai_panel<P: Panel>(
.disable_ai
|| cfg!(test);
let existing_panel = workspace.panel::<P>(cx);
-
match (disable_ai, existing_panel) {
(false, None) => cx.spawn_in(window, async move |workspace, cx| {
let panel = load_panel(workspace.clone(), cx.clone()).await?;
@@ -2327,7 +2326,7 @@ mod tests {
use project::{Project, ProjectPath};
use semver::Version;
use serde_json::json;
- use settings::{SettingsStore, watch_config_file};
+ use settings::{SaturatingBool, SettingsStore, watch_config_file};
use std::{
path::{Path, PathBuf},
time::Duration,
@@ -5171,6 +5170,28 @@ mod tests {
);
}
+ #[gpui::test]
+ async fn test_disable_ai_crash(cx: &mut gpui::TestAppContext) {
+ let app_state = init_test(cx);
+ cx.update(init);
+ let project = Project::test(app_state.fs.clone(), [], cx).await;
+ let _window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx));
+
+ cx.run_until_parked();
+
+ cx.update(|cx| {
+ SettingsStore::update_global(cx, |settings_store, cx| {
+ settings_store.update_user_settings(cx, |settings| {
+ settings.disable_ai = Some(SaturatingBool(true));
+ });
+ });
+ });
+
+ cx.run_until_parked();
+
+ // If this panics, the test has failed
+ }
+
#[gpui::test]
async fn test_prefer_focused_window(cx: &mut gpui::TestAppContext) {
let app_state = init_test(cx);