diff --git a/Cargo.lock b/Cargo.lock index 907bb049b5e067e1ad7dd98c825a4dfa74a6d839..160041f2d71997d54cf297e07b8d1b28b8523e55 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 87c0ed97cd85418b88b207c00444f997ddfd6370..1b6b3e95ed32b9e0976449ba3a6588b92cf5c192 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index f09ac0a812c3e875618c57da15bcf16e1f983d6e..ec21bc152edf969f57ac341e4b92f78c9e5da11a 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -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", diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 1d489771febc770e300b63e265024ffca3d14a90..fd2605a6ad99177c887d6f804ec2ac70724f16f8 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -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", diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index 9154cc43afb86c287329229c6f0d699f59a82b36..4a700e2c9190a8ae23ed53edaa075703fa07b855 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -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", diff --git a/assets/settings/default.json b/assets/settings/default.json index d98416e60f47550709791ae3bc1b4036209115a3..46324130ecfefefab74c26c6b48683015f766d08 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -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", diff --git a/crates/agent/src/history_store.rs b/crates/agent/src/history_store.rs index 5a1b923d139060ed7df679a69d96928d03559c9d..c455f73316e3fc7a641fa8a31ac0ad766a2ae584 100644 --- a/crates/agent/src/history_store.rs +++ b/crates/agent/src/history_store.rs @@ -216,14 +216,10 @@ impl HistoryStore { } pub fn reload(&self, cx: &mut Context) { - 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>> { 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)? diff --git a/crates/agent_ui/Cargo.toml b/crates/agent_ui/Cargo.toml index 38580b4d2c61597718d9fb718a20e52e84222481..8a9633e578a85323f2a289bd83c169a1f5d7f272 100644 --- a/crates/agent_ui/Cargo.toml +++ b/crates/agent_ui/Cargo.toml @@ -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] diff --git a/crates/agent_ui_v2/Cargo.toml b/crates/agent_ui_v2/Cargo.toml index f24ef47471cdcfe0910cf36c5e220c5276d5f6ae..2b2cf337adf578432d594ce14f2f58e5911c45fb 100644 --- a/crates/agent_ui_v2/Cargo.toml +++ b/crates/agent_ui_v2/Cargo.toml @@ -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"] } diff --git a/crates/agent_ui_v2/LICENSE-GPL b/crates/agent_ui_v2/LICENSE-GPL index e0f9dbd5d63fef1630c297edc4ceba4790be6f02..89e542f750cd3860a0598eff0dc34b56d7336dc4 120000 --- a/crates/agent_ui_v2/LICENSE-GPL +++ b/crates/agent_ui_v2/LICENSE-GPL @@ -1 +1 @@ -LICENSE-GPL \ No newline at end of file +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 1b84197471bd9ad65dc0ac31bf42c6ddc5ee3bf5..48e59f7b7420473054214572a2908215f98ffded 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -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::() - ); + // 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::()); } #[gpui::test] diff --git a/crates/fs/src/fs.rs b/crates/fs/src/fs.rs index e6f69a14593a0246ae8ccb4aa4673f4e1f5a1e8e..2cbbf61a21e145464e9dbec01ace3b5510709d0d 100644 --- a/crates/fs/src/fs.rs +++ b/crates/fs/src/fs.rs @@ -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()); diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index cf73406b3851b46ad1a7d056d6cb335666b9ac65..7216e1fc46e9d1240d23d8bd18202aa0963f846a 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -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, ) { 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 { @@ -894,9 +945,22 @@ impl GitPanel { } fn scroll_to_selected_entry(&mut self, cx: &mut Context) { - 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) { + fn select_first( + &mut self, + _: &menu::SelectFirst, + _window: &mut Window, + cx: &mut Context, + ) { 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, ) { @@ -1016,7 +1085,7 @@ impl GitPanel { self.scroll_to_selected_entry(cx); } - fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context) { + fn select_next(&mut self, _: &menu::SelectNext, _window: &mut Window, cx: &mut Context) { 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) { + fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context) { 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) { + maybe!({ + let workspace = self.workspace.upgrade()?; + + if let Some(project_diff) = workspace.read(cx).item_of_type::(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.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.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.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.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.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) { diff --git a/crates/git_ui/src/worktree_picker.rs b/crates/git_ui/src/worktree_picker.rs index 875ae55eefae19e24aa26fe75f80d70f8316c82b..fef5e16c80ddd26ae6dd0b2a5c0ad1d8e5b21b2c 100644 --- a/crates/git_ui/src/worktree_picker.rs +++ b/crates/git_ui/src/worktree_picker.rs @@ -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>, ) { - 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, diff --git a/crates/google_ai/src/google_ai.rs b/crates/google_ai/src/google_ai.rs index 3eff860e16f15fae76d8f9cb2523d2b91b611125..b6bba48c4b04608b502932787cfcdcd429276b5b 100644 --- a/crates/google_ai/src/google_ai.rs +++ b/crates/google_ai/src/google_ai.rs @@ -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, } } diff --git a/crates/gpui/src/key_dispatch.rs b/crates/gpui/src/key_dispatch.rs index 85aa550fa96ca76e46f8d75ab84e91a7e9ba43cd..1b92b9fe3ffabdbeec4bc7450adc1439e8e223eb 100644 --- a/crates/gpui/src/key_dispatch.rs +++ b/crates/gpui/src/key_dispatch.rs @@ -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 { + 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: diff --git a/crates/gpui/src/keymap.rs b/crates/gpui/src/keymap.rs index 33d956917055942cce365e9069cbb007e202eaf2..d5398ff0447849ca5bfcdbbb5a838af0cbc22836 100644 --- a/crates/gpui/src/keymap.rs +++ b/crates/gpui/src/keymap.rs @@ -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 { + 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::>(); + + 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::>() + } } #[cfg(test)] diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index dd20f71c22e388e0c739083d45941270ac8eac8e..840f2223fcc4a62b6e522f38b967a3fe4ad3209e 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -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 { + self.rendered_frame + .dispatch_tree + .possible_next_bindings_for_input(input, &self.context_stack()) + } + fn context_stack_for_focus_handle( &self, focus_handle: &FocusHandle, diff --git a/crates/language/Cargo.toml b/crates/language/Cargo.toml index 3ba93476d2a9fa5371b9d146cfc0c5833a748842..06d41e729bfabbf4f7e050409d2675dd909941d6 100644 --- a/crates/language/Cargo.toml +++ b/crates/language/Cargo.toml @@ -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 diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index 39003773f83718c6c61d4cfda55b9528f7c6eb2a..abf4d9b10a761b9c0247145e8ddb0664127756d2 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -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>>, _subscriptions: Vec, tree_sitter_data: Arc, + 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>, cx: &mut Context) { self.set_language_(language, cfg!(any(test, feature = "test-support")), cx); diff --git a/crates/project/Cargo.toml b/crates/project/Cargo.toml index f39c368218511b6ddf560dda1198ef5c06bd0a2e..0d264f9e58363f5e8d8e23dff565d512f118a8d1 100644 --- a/crates/project/Cargo.toml +++ b/crates/project/Cargo.toml @@ -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 diff --git a/crates/project/src/buffer_store.rs b/crates/project/src/buffer_store.rs index aea2482c83edb952f3b0dba03a510085c7c4d3f6..22106fa368904d91a5c3da4338e1a79cef7f0fd0 100644 --- a/crates/project/src/buffer_store.rs +++ b/crates/project/src/buffer_store.rs @@ -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| { diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 8b57413b22ac95a16e35a95d70a04b3ae49d4b31..5e31f2a90cf137f1e4d788952832e1eb2ee0ec35 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -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::(|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")?; diff --git a/crates/search/src/buffer_search.rs b/crates/search/src/buffer_search.rs index 66641e91a882b0b994e16673e3c65a1d51f27650..12b283ab22937b7952d18d63b1378d2914211f9b 100644 --- a/crates/search/src/buffer_search.rs +++ b/crates/search/src/buffer_search.rs @@ -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); diff --git a/crates/settings/src/settings_content.rs b/crates/settings/src/settings_content.rs index 3d7e6b5948b1db4d375814d6969ddabe95fc3e58..a00daaab1b9a93e1ec20b173dd6864849880d55e 100644 --- a/crates/settings/src/settings_content.rs +++ b/crates/settings/src/settings_content.rs @@ -158,6 +158,9 @@ pub struct SettingsContent { /// Default: false pub disable_ai: Option, + /// Settings for the which-key popup. + pub which_key: Option, + /// Settings related to Vim mode in Zed. pub vim: Option, } @@ -976,6 +979,19 @@ pub struct ReplSettingsContent { pub max_columns: Option, } +/// 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, + /// Delay in milliseconds before showing the which-key popup. + /// + /// Default: 700 + pub delay_ms: Option, +} + #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] /// An ExtendingVec in the settings can only accumulate new values. /// diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index 587850303f13649fcc4adf8cf4ddbb8dc7181dcb..d77754f611e8eb1746ee9061ce5b5e1dfdbdafdb 100644 --- a/crates/settings/src/vscode_import.rs +++ b/crates/settings/src/vscode_import.rs @@ -215,6 +215,7 @@ impl VsCodeSettings { vim: None, vim_mode: None, workspace: self.workspace_settings_content(), + which_key: None, } } diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index 34373a964f6ad163796905e93ae4e8a7816491b9..8e4fb7e478e8f0524e0258b1b62340f9e947c641 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -1233,6 +1233,49 @@ pub(crate) fn settings_data(cx: &App) -> Vec { } }).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", diff --git a/crates/vim/src/command.rs b/crates/vim/src/command.rs index 5bf0fca041cf274f38c84031e35903c9e339cc24..205097130d152fe255feb02a449956124586d8e6 100644 --- a/crates/vim/src/command.rs +++ b/crates/vim/src/command.rs @@ -330,10 +330,12 @@ pub fn register(editor: &mut Editor, cx: &mut Context) { 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) { 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); }); }) diff --git a/crates/which_key/Cargo.toml b/crates/which_key/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..f53ba45dd71abc972ce23efb8871f485dfe47207 --- /dev/null +++ b/crates/which_key/Cargo.toml @@ -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 diff --git a/crates/which_key/LICENSE-GPL b/crates/which_key/LICENSE-GPL new file mode 120000 index 0000000000000000000000000000000000000000..89e542f750cd3860a0598eff0dc34b56d7336dc4 --- /dev/null +++ b/crates/which_key/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/which_key/src/which_key.rs b/crates/which_key/src/which_key.rs new file mode 100644 index 0000000000000000000000000000000000000000..70889c100f33020a3ceaa8af1ba8812d5e7d4adb --- /dev/null +++ b/crates/which_key/src/which_key.rs @@ -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::(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::(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>> = 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, _> = s + .split(' ') + .map(|keystroke_str| Keystroke::parse(keystroke_str)) + .collect(); + keystrokes.ok() + }) + .collect() +}); diff --git a/crates/which_key/src/which_key_modal.rs b/crates/which_key/src/which_key_modal.rs new file mode 100644 index 0000000000000000000000000000000000000000..238431b90a8eafdd0e085a3f109e8f812fbe709b --- /dev/null +++ b/crates/which_key/src/which_key_modal.rs @@ -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, + 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, + window: &mut Window, + cx: &mut Context, + ) -> 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) { + cx.emit(DismissEvent) + } + + fn update_pending_keys(&mut self, window: &mut Window, cx: &mut Context) { + 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::>(), + 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) -> 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 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, SharedString)>, +) -> Vec<(Vec, SharedString)> { + let mut groups: HashMap, Vec<(Vec, 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 +} diff --git a/crates/which_key/src/which_key_settings.rs b/crates/which_key/src/which_key_settings.rs new file mode 100644 index 0000000000000000000000000000000000000000..be19ab1521f4793305efca79b7026f79fd9064e2 --- /dev/null +++ b/crates/which_key/src/which_key_settings.rs @@ -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(), + } + } +} diff --git a/crates/workspace/src/dock.rs b/crates/workspace/src/dock.rs index b358cf7b53ff16bae3756499470a2a55211618a8..7f4b09df0f94fa421c399ed9d70163f7cc2ba203 100644 --- a/crates/workspace/src/dock.rs +++ b/crates/workspace/src/dock.rs @@ -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, window: &mut Window, cx: &mut Context, - ) { + ) -> 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 } } diff --git a/crates/workspace/src/modal_layer.rs b/crates/workspace/src/modal_layer.rs index 10b24497a28faf68ed0820211f0d8860da558786..db4d85752835299117dba7fc2aeb1833383a390a 100644 --- a/crates/workspace/src/modal_layer.rs +++ b/crates/workspace/src/modal_layer.rs @@ -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 ModalViewHandle for Entity { @@ -42,6 +47,10 @@ impl ModalViewHandle for Entity { 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) -> 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() } } diff --git a/crates/workspace/src/security_modal.rs b/crates/workspace/src/security_modal.rs index e3b9ab6e72481048d0f78eb07afb72af53810279..bb1482d7cce2a9849a78a9512598e389a6e5eea0 100644 --- a/crates/workspace/src/security_modal.rs +++ b/crates/workspace/src/security_modal.rs @@ -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( diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index d358834f80770f26d3b70a88976ccea266f20bca..031e22e93942b3ab8308b4fd0c045ea63441ea17 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -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 { + 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()); + ::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, ) { + 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); } } diff --git a/crates/worktree/Cargo.toml b/crates/worktree/Cargo.toml index 6d132fbd2cb8c7a1282bffcea6577260a15c4572..e7d3ac34e1886bd76e0a0f5d23ea981b6626909a 100644 --- a/crates/worktree/Cargo.toml +++ b/crates/worktree/Cargo.toml @@ -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 diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index 6ec19493840da0b9de3eb55ac483488339ec5e8d..7145bccd514fbb5d6093efda765a826162c91260 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -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, pub text: String, + pub encoding: &'static Encoding, + pub has_bom: bool, } pub struct LoadedBinaryFile { @@ -741,10 +745,14 @@ impl Worktree { path: Arc, text: Rope, line_ending: LineEnding, + encoding: &'static Encoding, + has_bom: bool, cx: &Context, ) -> Task>> { 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, text: Rope, line_ending: LineEnding, + encoding: &'static Encoding, + has_bom: bool, cx: &Context, ) -> Task>> { 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) -> (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) -> (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) + } + } +} diff --git a/crates/worktree/src/worktree_tests.rs b/crates/worktree/src/worktree_tests.rs index 12f2863aab6c4b4376157f3499fa332051a4822f..094a6d52ea4168752578eab06cea511a57e65c10 100644 --- a/crates/worktree/src/worktree_tests.rs +++ b/crates/worktree/src/worktree_tests.rs @@ -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::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"); +} diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 955540843489ac21d79042854eb6fcebf5f64318..fd160759f4440e2736d57cea62abb6bdb138ae72 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -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"] diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index f3553473316d446ae5b1d831cbef07b179525532..988aa45fe29ce1fc2cde6e3ecb4edfc5e46a9eb3 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -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::({ let http = app_state.client.http_client(); diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index f6218c97c31b98db76a2ae46b3f89876d426ac33..d088df00839814e32a9c246a3486ac5ad5ca4b9e 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -707,7 +707,6 @@ fn setup_or_teardown_ai_panel( .disable_ai || cfg!(test); let existing_panel = workspace.panel::

(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);